feat: NFOGuard v1.7.2 - Complete media management system
Initial clean repository with full NFOGuard codebase: - Docker-based media file processing - Webhook integration for Radarr/Sonarr - NFO file management and organization - Emby/Jellyfin plugin integration - TMDB API integration for metadata - Smart episode and movie renaming - Comprehensive dual-repository development workflow Author: SBCrumb
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# NFOGuard Environment Variables
|
||||||
|
|
||||||
|
# User and Group IDs
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# Timezone
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
# Media path (for read-only access to scan NFO files)
|
||||||
|
MEDIA_PATH=/path/to/your/media
|
||||||
|
|
||||||
|
# Database settings (if using PostgreSQL)
|
||||||
|
# DB_USER=nfoguard
|
||||||
|
# DB_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# Emby Plugin Deployment - Bind Mount Method (Recommended)
|
||||||
|
# Set this to the path where Emby plugins are installed on your host
|
||||||
|
# Common paths:
|
||||||
|
# - /var/lib/emby/plugins (native Emby install)
|
||||||
|
# - /path/to/emby/config/plugins (Docker Emby)
|
||||||
|
# - /config/plugins (linuxserver.io Emby)
|
||||||
|
EMBY_PLUGINS_PATH=/path/to/emby/plugins
|
||||||
|
|
||||||
|
# NFOGuard specific settings
|
||||||
|
DEBUG=false
|
||||||
|
PATH_DEBUG=false
|
||||||
|
SUPPRESS_TVDB_WARNINGS=true
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# NFOGuard Secrets Configuration Template
|
||||||
|
# Copy this to .env.secrets and add your actual API keys
|
||||||
|
# This file should NOT be committed to version control
|
||||||
|
# Add .env.secrets to your .gitignore
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CREDENTIALS
|
||||||
|
# ===========================================
|
||||||
|
# Database password for PostgreSQL connection
|
||||||
|
RADARR_DB_PASSWORD=your_radarr_db_password
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# API KEYS (SENSITIVE DATA)
|
||||||
|
# ===========================================
|
||||||
|
# TMDB API key for digital release dates
|
||||||
|
TMDB_API_KEY=your_tmdb_api_key
|
||||||
|
|
||||||
|
# OMDb API key for DVD/digital release dates (optional)
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
|
||||||
|
# Radarr API key (optional - for fallback only)
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_sonarr_api_key
|
||||||
|
|
||||||
|
# Jellyseerr API key (optional)
|
||||||
|
JELLYSEERR_API_KEY=your_jellyseerr_api_key
|
||||||
|
|
||||||
|
# TVDB API key (RECOMMENDED for v0.6.1+ Emby TV NFO Compatibility)
|
||||||
|
# Required to convert IMDB IDs to TVDB IDs for proper Emby metadata loading
|
||||||
|
# Get free API key at: https://thetvdb.com/api-information
|
||||||
|
TVDB_API_KEY=your_tvdb_api_key
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
# ===========================================
|
||||||
|
# NFOGuard Configuration - CORRECTED VERSION
|
||||||
|
# ===========================================
|
||||||
|
# Main configuration file - safe to share for debugging
|
||||||
|
# Sensitive data (API keys, passwords) are in .env.secrets
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MEDIA PATHS (REQUIRED) - FIXED
|
||||||
|
# ===========================================
|
||||||
|
# Container paths (what NFOGuard sees inside container)
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
|
||||||
|
# Radarr paths (what Radarr sees on the host system)
|
||||||
|
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
|
||||||
|
|
||||||
|
# Sonarr paths (what Sonarr sees on the host system) - FIXED VARIABLE NAME AND PATHS
|
||||||
|
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
|
||||||
|
|
||||||
|
# Download detection paths (for identifying downloads vs existing files)
|
||||||
|
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# DATABASE CONFIGURATION
|
||||||
|
# ===========================================
|
||||||
|
# NFOGuard SQLite database location
|
||||||
|
DB_PATH=/app/data/media_dates.db
|
||||||
|
|
||||||
|
# Log file directory
|
||||||
|
LOG_DIR=/app/data/logs
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CONNECTION (RECOMMENDED)
|
||||||
|
# ===========================================
|
||||||
|
# Direct database access for better performance
|
||||||
|
RADARR_DB_TYPE=postgresql
|
||||||
|
RADARR_DB_HOST=192.168.255.50
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr-main
|
||||||
|
RADARR_DB_USER=postgres
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# API CONNECTIONS (OPTIONAL)
|
||||||
|
# ===========================================
|
||||||
|
# API keys are stored in .env.secrets for security
|
||||||
|
RADARR_URL=http://radarr:7878
|
||||||
|
SONARR_URL=http://sonarr:8989
|
||||||
|
JELLYSEERR_URL=http://jellyseerr:5055
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RELEASE DATE PROCESSING
|
||||||
|
# ===========================================
|
||||||
|
# Priority order for release date fallbacks (digital, physical, theatrical)
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
#RELEASE_DATE_PRIORITY=digital,theatrical,physical
|
||||||
|
ENABLE_SMART_DATE_VALIDATION=true
|
||||||
|
MAX_RELEASE_DATE_GAP_YEARS=10
|
||||||
|
|
||||||
|
# Prefer API release dates over file modification dates for manual imports
|
||||||
|
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||||
|
|
||||||
|
# Disable file date fallback completely (recommended for clean imports)
|
||||||
|
ALLOW_FILE_DATE_FALLBACK=false
|
||||||
|
|
||||||
|
# TMDB country for regional release date preferences
|
||||||
|
TMDB_COUNTRY=US
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NFO FILE MANAGEMENT
|
||||||
|
# ===========================================
|
||||||
|
# Create/update .nfo files
|
||||||
|
MANAGE_NFO=true
|
||||||
|
|
||||||
|
# Update file modification times to match import dates
|
||||||
|
FIX_DIR_MTIMES=true
|
||||||
|
|
||||||
|
# Add lockdata tags to prevent metadata overwrites
|
||||||
|
LOCK_METADATA=true
|
||||||
|
|
||||||
|
# Brand name in NFO comments
|
||||||
|
MANAGER_BRAND=NFOGuard
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# PROCESSING BEHAVIOR
|
||||||
|
# ===========================================
|
||||||
|
# Movie date update strategy
|
||||||
|
MOVIE_DATE_UPDATE_MODE=overwrite
|
||||||
|
MOVIE_PRESERVE_EXISTING=false
|
||||||
|
|
||||||
|
# When to update existing dates (always, missing_only, never)
|
||||||
|
UPDATE_MODE=always
|
||||||
|
|
||||||
|
# File modification time behavior (update, leave_alone)
|
||||||
|
MTIME_BEHAVIOR=update
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# PERFORMANCE & BATCHING
|
||||||
|
# ===========================================
|
||||||
|
# Delay before processing batched events (seconds)
|
||||||
|
BATCH_DELAY=5.0
|
||||||
|
|
||||||
|
# Maximum concurrent series processing
|
||||||
|
MAX_CONCURRENT_SERIES=3
|
||||||
|
|
||||||
|
# API timeout in seconds
|
||||||
|
TIMEOUT_SECONDS=45
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# DEBUGGING
|
||||||
|
# ===========================================
|
||||||
|
# Enable verbose logging (true/false)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# Enable path mapping debug output (true/false)
|
||||||
|
PATH_DEBUG=false
|
||||||
|
|
||||||
|
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
|
||||||
|
SUPPRESS_TVDB_WARNINGS=true
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SERVER CONFIGURATION
|
||||||
|
# ===========================================
|
||||||
|
# Port for webhook server
|
||||||
|
PORT=8080
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
name: Local Docker Build (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ dev ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ dev ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-dev:
|
||||||
|
runs-on: host
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code (DEV)
|
||||||
|
run: |
|
||||||
|
echo "Current workspace: $(pwd)"
|
||||||
|
|
||||||
|
# Clean workspace and temp directory first
|
||||||
|
rm -rf * .git* 2>/dev/null || true
|
||||||
|
rm -rf /tmp/repo 2>/dev/null || true
|
||||||
|
|
||||||
|
# Clone the repository since Gitea runner doesn't auto-checkout
|
||||||
|
echo "Cloning repository..."
|
||||||
|
git clone --branch dev http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/jskala/NFOguard.git /tmp/repo
|
||||||
|
cp -r /tmp/repo/* .
|
||||||
|
cp -r /tmp/repo/.* . 2>/dev/null || true
|
||||||
|
rm -rf /tmp/repo
|
||||||
|
|
||||||
|
echo "Repository cloned successfully"
|
||||||
|
ls -la
|
||||||
|
echo "Checking Dockerfile:"
|
||||||
|
head -30 Dockerfile
|
||||||
|
|
||||||
|
- name: Check Docker availability (DEV)
|
||||||
|
run: |
|
||||||
|
echo "Checking Docker installation..."
|
||||||
|
which docker || (echo "Docker not found in PATH" && exit 1)
|
||||||
|
docker --version || (echo "Docker not available" && exit 1)
|
||||||
|
docker info || (echo "Docker daemon not running" && exit 1)
|
||||||
|
|
||||||
|
- name: Configure Docker for local registry (DEV)
|
||||||
|
run: |
|
||||||
|
echo "Configuring Docker client for insecure local registry..."
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
|
||||||
|
export DOCKER_CONFIG=/tmp/docker-config-dev
|
||||||
|
mkdir -p $DOCKER_CONFIG
|
||||||
|
|
||||||
|
# Create Docker client config for insecure registry
|
||||||
|
cat > $DOCKER_CONFIG/config.json << EOF
|
||||||
|
{
|
||||||
|
"auths": {},
|
||||||
|
"HttpHeaders": {
|
||||||
|
"User-Agent": "Docker-Client/20.10.0 (linux)"
|
||||||
|
},
|
||||||
|
"credsStore": "",
|
||||||
|
"credHelpers": {},
|
||||||
|
"experimental": "disabled"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Docker client config created for DEV"
|
||||||
|
echo "Testing registry connectivity..."
|
||||||
|
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible"
|
||||||
|
|
||||||
|
- name: Build Docker image with caching (DEV)
|
||||||
|
run: |
|
||||||
|
echo "Building DEV Docker image with layer caching..."
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
CACHE_IMAGE="$LOCAL_REGISTRY/jskala/nfoguard:dev-buildcache"
|
||||||
|
|
||||||
|
# Clear any existing Docker configs to prevent permission issues
|
||||||
|
unset DOCKER_CONFIG
|
||||||
|
export HOME=/tmp/docker-home-dev
|
||||||
|
mkdir -p $HOME
|
||||||
|
|
||||||
|
# Enable Docker BuildKit for better caching
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
|
||||||
|
# Check if DEV cache image exists and pull it
|
||||||
|
echo "Checking for existing DEV cache image..."
|
||||||
|
CACHE_ARGS=""
|
||||||
|
if curl -s "http://$LOCAL_REGISTRY/v2/jskala/nfoguard/manifests/dev-buildcache" > /dev/null 2>&1; then
|
||||||
|
echo "✅ DEV cache manifest found, pulling image..."
|
||||||
|
if docker pull "$CACHE_IMAGE" 2>/dev/null; then
|
||||||
|
echo "✅ DEV cache image pulled successfully"
|
||||||
|
CACHE_ARGS="--cache-from=$CACHE_IMAGE"
|
||||||
|
else
|
||||||
|
echo "⚠️ DEV cache manifest exists but pull failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ No DEV cache image found (first build or cache expired)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build DEV image with cache (if available)
|
||||||
|
echo "Building DEV image with layer caching..."
|
||||||
|
docker build \
|
||||||
|
$CACHE_ARGS \
|
||||||
|
--build-arg GIT_BRANCH=dev \
|
||||||
|
--build-arg BUILD_SOURCE=gitea \
|
||||||
|
--tag nfoguard:dev \
|
||||||
|
--tag nfoguard:dev-${{ github.sha }} \
|
||||||
|
--tag "$CACHE_IMAGE" \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "DEV Docker image built and tagged successfully"
|
||||||
|
|
||||||
|
- name: Login and Push to Gitea registry (DEV tags)
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
echo "Using LOCAL IP ONLY for DEV push!"
|
||||||
|
export DOCKER_CONFIG=/tmp/docker-config-dev
|
||||||
|
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
echo "Registry: $LOCAL_REGISTRY (DEV TAGS)"
|
||||||
|
|
||||||
|
# Try login to local registry
|
||||||
|
if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then
|
||||||
|
echo "✅ DEV Login succeeded"
|
||||||
|
|
||||||
|
# Tag for local registry
|
||||||
|
docker tag nfoguard:dev "$LOCAL_REGISTRY/jskala/nfoguard:dev"
|
||||||
|
docker tag nfoguard:dev "$LOCAL_REGISTRY/jskala/nfoguard:dev-${{ github.sha }}"
|
||||||
|
|
||||||
|
# Push DEV images
|
||||||
|
echo "=== Pushing DEV cache image ==="
|
||||||
|
timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev-buildcache" && echo "✅ DEV cache pushed" || echo "⚠️ DEV cache push failed"
|
||||||
|
|
||||||
|
echo "=== Pushing DEV latest tag ==="
|
||||||
|
if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev"; then
|
||||||
|
echo "✅ DEV tag pushed successfully"
|
||||||
|
|
||||||
|
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:dev-${{ github.sha }}"; then
|
||||||
|
echo "✅ DEV SHA tag pushed successfully"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ DEV local registry login failed - continuing to Docker Hub"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 DEV build completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📦 Available DEV images:"
|
||||||
|
echo " Local only: nfoguard:dev"
|
||||||
|
echo " Local SHA: nfoguard:dev-${{ github.sha }}"
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
name: Local Docker Build (Main)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: host
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
echo "Current workspace: $(pwd)"
|
||||||
|
|
||||||
|
# Clean workspace and temp directory first
|
||||||
|
rm -rf * .git* 2>/dev/null || true
|
||||||
|
rm -rf /tmp/repo 2>/dev/null || true
|
||||||
|
|
||||||
|
# Clone the repository since Gitea runner doesn't auto-checkout
|
||||||
|
echo "Cloning repository..."
|
||||||
|
git clone --branch main http://${{ secrets.username }}:${{ secrets.token }}@192.168.253.221:3000/jskala/NFOguard.git /tmp/repo
|
||||||
|
cp -r /tmp/repo/* .
|
||||||
|
cp -r /tmp/repo/.* . 2>/dev/null || true
|
||||||
|
rm -rf /tmp/repo
|
||||||
|
|
||||||
|
echo "Repository cloned successfully"
|
||||||
|
ls -la
|
||||||
|
echo "Checking Dockerfile:"
|
||||||
|
head -30 Dockerfile
|
||||||
|
|
||||||
|
- name: Check Docker availability
|
||||||
|
run: |
|
||||||
|
echo "Checking Docker installation..."
|
||||||
|
which docker || (echo "Docker not found in PATH" && exit 1)
|
||||||
|
docker --version || (echo "Docker not available" && exit 1)
|
||||||
|
docker info || (echo "Docker daemon not running" && exit 1)
|
||||||
|
|
||||||
|
- name: Configure Docker for local registry
|
||||||
|
run: |
|
||||||
|
echo "Configuring Docker client for insecure local registry..."
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
|
||||||
|
# We can't use sudo in the container, so we'll configure the client differently
|
||||||
|
export DOCKER_CONFIG=/tmp/docker-config
|
||||||
|
mkdir -p $DOCKER_CONFIG
|
||||||
|
|
||||||
|
# Create Docker client config for insecure registry
|
||||||
|
cat > $DOCKER_CONFIG/config.json << EOF
|
||||||
|
{
|
||||||
|
"auths": {},
|
||||||
|
"HttpHeaders": {
|
||||||
|
"User-Agent": "Docker-Client/20.10.0 (linux)"
|
||||||
|
},
|
||||||
|
"credsStore": "",
|
||||||
|
"credHelpers": {},
|
||||||
|
"experimental": "disabled"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Docker client config created"
|
||||||
|
echo "Note: Since we can't modify daemon config in container, we'll use --insecure-registry flag"
|
||||||
|
|
||||||
|
echo "Testing registry connectivity..."
|
||||||
|
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Registry accessible via HTTP" || echo "❌ Registry not accessible"
|
||||||
|
|
||||||
|
- name: Build Docker image with caching
|
||||||
|
run: |
|
||||||
|
echo "Building Docker image with layer caching..."
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
CACHE_IMAGE="$LOCAL_REGISTRY/jskala/nfoguard:buildcache"
|
||||||
|
|
||||||
|
# Clear any existing Docker configs to prevent permission issues
|
||||||
|
unset DOCKER_CONFIG
|
||||||
|
export HOME=/tmp/docker-home
|
||||||
|
mkdir -p $HOME
|
||||||
|
|
||||||
|
# Enable Docker BuildKit for better caching
|
||||||
|
export DOCKER_BUILDKIT=1
|
||||||
|
|
||||||
|
# Check if cache image exists and pull it
|
||||||
|
echo "Checking for existing cache image..."
|
||||||
|
CACHE_ARGS=""
|
||||||
|
if curl -s "http://$LOCAL_REGISTRY/v2/jskala/nfoguard/manifests/buildcache" > /dev/null 2>&1; then
|
||||||
|
echo "✅ Cache manifest found, pulling image..."
|
||||||
|
if docker pull "$CACHE_IMAGE" 2>/dev/null; then
|
||||||
|
echo "✅ Cache image pulled successfully"
|
||||||
|
CACHE_ARGS="--cache-from=$CACHE_IMAGE"
|
||||||
|
else
|
||||||
|
echo "⚠️ Cache manifest exists but pull failed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ℹ️ No cache image found (first build or cache expired)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build with cache (if available)
|
||||||
|
echo "Building with layer caching..."
|
||||||
|
docker build \
|
||||||
|
$CACHE_ARGS \
|
||||||
|
--build-arg GIT_BRANCH=main \
|
||||||
|
--build-arg BUILD_SOURCE=gitea \
|
||||||
|
--tag nfoguard:latest \
|
||||||
|
--tag nfoguard:${{ github.sha }} \
|
||||||
|
--tag "$CACHE_IMAGE" \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo "Docker image built and tagged successfully"
|
||||||
|
|
||||||
|
# Show layer info for debugging (with error handling)
|
||||||
|
echo "=== Image layer history ==="
|
||||||
|
if docker history nfoguard:latest --format "table {{.CreatedBy}}\t{{.Size}}" 2>/dev/null; then
|
||||||
|
echo "✅ Image history displayed successfully"
|
||||||
|
else
|
||||||
|
echo "⚠️ Could not display image history (permissions issue, but build succeeded)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Login and Push to Gitea registry (local IP only)
|
||||||
|
continue-on-error: true # Don't fail the build if local registry fails
|
||||||
|
run: |
|
||||||
|
echo "Using LOCAL IP ONLY - no Cloudflare tunnel!"
|
||||||
|
export DOCKER_CONFIG=/tmp/docker-config
|
||||||
|
|
||||||
|
# Force local IP - never use domain
|
||||||
|
LOCAL_REGISTRY="192.168.253.221:3000"
|
||||||
|
echo "Registry: $LOCAL_REGISTRY (LOCAL IP ONLY)"
|
||||||
|
|
||||||
|
# Check image size
|
||||||
|
IMAGE_SIZE=$(docker images nfoguard:latest --format "table {{.Size}}" | tail -n1)
|
||||||
|
echo "Image size: $IMAGE_SIZE"
|
||||||
|
|
||||||
|
echo "Testing local registry connectivity..."
|
||||||
|
curl -s "http://$LOCAL_REGISTRY/v2/" && echo "✅ Local registry accessible via HTTP" || echo "❌ Local registry not accessible"
|
||||||
|
|
||||||
|
# Try multiple approaches to handle the HTTP issue
|
||||||
|
echo "Attempting login to LOCAL registry..."
|
||||||
|
|
||||||
|
# Approach 1: Try with explicit HTTP in auth
|
||||||
|
if echo "${{ secrets.token }}" | docker --config $DOCKER_CONFIG login "$LOCAL_REGISTRY" -u "${{ secrets.username }}" --password-stdin 2>/dev/null; then
|
||||||
|
echo "✅ Login succeeded with direct method"
|
||||||
|
|
||||||
|
# Tag for local registry (cache image already tagged in build step)
|
||||||
|
docker tag nfoguard:latest "$LOCAL_REGISTRY/jskala/nfoguard:latest"
|
||||||
|
docker tag nfoguard:latest "$LOCAL_REGISTRY/jskala/nfoguard:${{ github.sha }}"
|
||||||
|
|
||||||
|
echo "Pushing to LOCAL registry (gigabit speed!)..."
|
||||||
|
|
||||||
|
# Push with reasonable timeout for local network
|
||||||
|
echo "=== Pushing cache image (for faster future builds) ==="
|
||||||
|
timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:buildcache" && echo "✅ Cache image pushed" || echo "⚠️ Cache push failed"
|
||||||
|
|
||||||
|
echo "=== Pushing latest tag to LOCAL IP ==="
|
||||||
|
if timeout 120 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:latest"; then
|
||||||
|
echo "✅ Latest tag pushed successfully to LOCAL registry"
|
||||||
|
|
||||||
|
echo "=== Pushing SHA tag to LOCAL IP ==="
|
||||||
|
if timeout 60 docker --config $DOCKER_CONFIG push "$LOCAL_REGISTRY/jskala/nfoguard:${{ github.sha }}"; then
|
||||||
|
echo "✅ SHA tag pushed successfully to LOCAL registry"
|
||||||
|
else
|
||||||
|
echo "⚠️ SHA tag push failed but latest succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ Push to LOCAL registry failed"
|
||||||
|
echo "This is likely because Docker daemon needs insecure-registry configuration"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
echo "❌ Local registry login failed"
|
||||||
|
echo "This is expected - Docker daemon needs insecure-registry configuration"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 To fix, run on the host machine:"
|
||||||
|
echo "sudo mkdir -p /etc/docker"
|
||||||
|
echo 'echo '"'"'{"insecure-registries":["'$LOCAL_REGISTRY'"]}'"'"' | sudo tee /etc/docker/daemon.json'
|
||||||
|
echo "sudo systemctl restart docker"
|
||||||
|
echo ""
|
||||||
|
echo "⏭️ Continuing to Docker Hub push..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Local registry step completed!"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Local build completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📦 Available images:"
|
||||||
|
echo " Local Docker: nfoguard:latest"
|
||||||
|
echo " Local SHA: nfoguard:${{ github.sha }}"
|
||||||
|
echo " Local Gitea: 192.168.253.221:3000/jskala/nfoguard:latest"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: host
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy application
|
||||||
|
run: |
|
||||||
|
echo "Deploying application..."
|
||||||
|
# Add your deployment commands here
|
||||||
|
# Examples:
|
||||||
|
# docker-compose pull
|
||||||
|
# docker-compose up -d
|
||||||
|
# or
|
||||||
|
# docker stop nfoguard || true
|
||||||
|
# docker run -d --name nfoguard -p 8080:8080 nfoguard:latest
|
||||||
|
echo "Deployment completed"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help fixing issues
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots & Logs**
|
||||||
|
If applicable, add screenshots/logs to help explain your problem.
|
||||||
|
|
||||||
|
**Please complete the following information:**
|
||||||
|
- OS: [e.g. Fedora Linux 42 x86_64]
|
||||||
|
- Architecture [e.g. amd64, arm64]
|
||||||
|
- Docker Version
|
||||||
|
- Remediarr Version [e.g. 0.1.9]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here. Such as logs from Remediarr , Sonarr, Radarr. .env file(with API removed) docker compose file as well.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"template": "# Changelog for {{currentTag}}\n\n{{#conventionalCommits}}\n- {{#scope}}**{{scope}}:** {{/scope}}{{subject}}\n{{/conventionalCommits}}\n",
|
||||||
|
"categories": [
|
||||||
|
{ "title": "### 🚀 Features", "labels": ["feat", "feature"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🐛 Fixes", "labels": ["fix", "bug"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🧹 Chore", "labels": ["chore"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 📝 Docs", "labels": ["docs"], "collapseAfter": 0 },
|
||||||
|
{ "title": "### 🔧 Refactor", "labels": ["refactor"], "collapseAfter": 0 }
|
||||||
|
],
|
||||||
|
"replacers": [],
|
||||||
|
"sort": {
|
||||||
|
"order": "ASC",
|
||||||
|
"onProperty": "scope"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name-template: 'v$NEXT_PATCH_VERSION'
|
||||||
|
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||||
|
categories:
|
||||||
|
- title: '🚀 Features'
|
||||||
|
labels: ['feature', 'enhancement']
|
||||||
|
- title: '🐛 Fixes'
|
||||||
|
labels: ['fix', 'bug']
|
||||||
|
- title: '🧰 Maintenance'
|
||||||
|
labels: ['chore', 'maintenance', 'deps']
|
||||||
|
change-template: '- $TITLE (#$NUMBER) by @$AUTHOR'
|
||||||
|
no-changes-template: 'Minor internal improvements.'
|
||||||
|
template: |
|
||||||
|
|
||||||
|
## Container Images
|
||||||
|
- `sbcrumb/nfoguard:latest`
|
||||||
|
- `sbcrumb/nfoguard:$NEXT_PATCH_VERSION`
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
name: Generate Changelog on Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Build changelog from commits
|
||||||
|
id: build
|
||||||
|
uses: mikepenz/release-changelog-builder-action@v4
|
||||||
|
with:
|
||||||
|
configuration: .github/changelog-config.json
|
||||||
|
ignorePreReleases: false
|
||||||
|
toTag: ${{ github.ref_name }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create / Update GitHub Release notes
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
body: ${{ steps.build.outputs.changelog }}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Build & Push DEV to DockerHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ dev ]
|
||||||
|
push:
|
||||||
|
branches: [ dev ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
# PRs: single-arch build (faster). Push to dev: multi-arch.
|
||||||
|
PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read VERSION and compute DEV tags
|
||||||
|
run: |
|
||||||
|
if [[ ! -f VERSION ]]; then echo "VERSION file missing"; exit 1; fi
|
||||||
|
RAW=$(tr -d ' \n' < VERSION)
|
||||||
|
if [[ -z "$RAW" ]]; then echo "VERSION empty"; exit 1; fi
|
||||||
|
# Remove 'v' prefix if present for version number
|
||||||
|
if [[ "$RAW" =~ ^v ]]; then NUM="${RAW#v}"; else NUM="$RAW"; fi
|
||||||
|
|
||||||
|
# Get repository name (assumes format: owner/repo)
|
||||||
|
REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2)
|
||||||
|
|
||||||
|
echo "VERSION_NUM=$NUM" >> $GITHUB_ENV
|
||||||
|
echo "DEV_VERSION=${NUM}-dev" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_REPO=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}" >> $GITHUB_ENV
|
||||||
|
echo "DEV_TAGS=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:${NUM}-dev,${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:dev" >> $GITHUB_ENV
|
||||||
|
echo "Computed DEV_VERSION=${NUM}-dev"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: ${{ env.PLATFORMS == 'linux/amd64,linux/arm64' }}
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & (maybe) push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ env.DEV_TAGS }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.DEV_VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Build & Publish to DockerHub (from VERSION + Release)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write # create tag + release
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Read VERSION
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
if [[ ! -f VERSION ]]; then echo "VERSION missing"; exit 1; fi
|
||||||
|
RAW=$(tr -d ' \n' < VERSION)
|
||||||
|
if [[ -z "$RAW" ]]; then echo "VERSION empty"; exit 1; fi
|
||||||
|
if [[ "$RAW" =~ ^v ]]; then TAG="$RAW"; NUM="${RAW#v}"; else TAG="v${RAW}"; NUM="$RAW"; fi
|
||||||
|
|
||||||
|
# Get repository name for DockerHub
|
||||||
|
REPO_NAME=$(echo "${GITHUB_REPOSITORY}" | cut -d'/' -f2)
|
||||||
|
|
||||||
|
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
echo "NUM=$NUM" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_REPO=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}" >> $GITHUB_ENV
|
||||||
|
echo "DOCKER_TAGS=${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:${NUM},${{ secrets.DOCKER_HUB_USERNAME }}/${REPO_NAME}:latest" >> $GITHUB_ENV
|
||||||
|
echo "Resolved TAG=$TAG NUM=$NUM"
|
||||||
|
|
||||||
|
- name: Create tag (if missing)
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const tag = process.env.TAG;
|
||||||
|
const sha = context.sha;
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
try {
|
||||||
|
await github.rest.git.getRef({ owner, repo, ref: `tags/${tag}` });
|
||||||
|
core.info(`Tag ${tag} exists`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
core.info(`Creating tag ${tag} -> ${sha}`);
|
||||||
|
await github.rest.git.createRef({ owner, repo, ref: `refs/tags/${tag}`, sha });
|
||||||
|
} else { throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Update draft release notes
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
id: drafter
|
||||||
|
uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
config-name: release-drafter.yml
|
||||||
|
publish: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.TAG }}
|
||||||
|
name: NFOGuard ${{ env.TAG }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
body: ${{ steps.drafter.outputs.body }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
if: ${{ env.PLATFORMS == 'linux/amd64,linux/arm64' }}
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to DockerHub
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build & Push (main)
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: ${{ env.PLATFORMS }}
|
||||||
|
tags: ${{ env.DOCKER_TAGS }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ env.NUM }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
provenance: false
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Notify on new issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Validate required secrets
|
||||||
|
run: |
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_URL }}" ]]; then
|
||||||
|
echo "Error: GOTIFY_URL secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "${{ secrets.GOTIFY_TOKEN }}" ]]; then
|
||||||
|
echo "Error: GOTIFY_TOKEN secret is not set"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Secrets validation passed"
|
||||||
|
|
||||||
|
- name: Build and send Gotify notification
|
||||||
|
env:
|
||||||
|
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||||
|
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Remove trailing slash from URL if present
|
||||||
|
GOTIFY_URL="${GOTIFY_URL%/}"
|
||||||
|
|
||||||
|
# Validate URL format
|
||||||
|
if [[ ! "$GOTIFY_URL" =~ ^https?:// ]]; then
|
||||||
|
echo "Error: GOTIFY_URL must start with http:// or https://"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the notification payload
|
||||||
|
TITLE="New issue in ${{ github.repository }}"
|
||||||
|
MESSAGE="Issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}
|
||||||
|
By: ${{ github.actor }}
|
||||||
|
${{ github.event.issue.html_url }}"
|
||||||
|
|
||||||
|
# Create JSON payload
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg title "$TITLE" \
|
||||||
|
--arg message "$MESSAGE" \
|
||||||
|
--argjson priority 5 \
|
||||||
|
'{title: $title, message: $message, priority: $priority}')
|
||||||
|
|
||||||
|
echo "Sending notification to Gotify..."
|
||||||
|
echo "URL: ${GOTIFY_URL}/message"
|
||||||
|
echo "Payload: $PAYLOAD"
|
||||||
|
|
||||||
|
# Send the notification using header authentication
|
||||||
|
curl -X POST "${GOTIFY_URL}/message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gotify-Key: ${GOTIFY_TOKEN}" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
--fail \
|
||||||
|
--show-error \
|
||||||
|
--silent
|
||||||
|
|
||||||
|
echo "Notification sent successfully!"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, synchronize, closed]
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_release_draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: release-drafter/release-drafter@v6
|
||||||
|
with:
|
||||||
|
config-name: release-drafter.yml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Environment files with sensitive data
|
||||||
|
.env
|
||||||
|
.env.secrets
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Local development files (not for public repo)
|
||||||
|
*.local
|
||||||
|
CODE_STRUCTURE.local
|
||||||
|
sync-to-github.sh
|
||||||
|
|
||||||
|
# Ignore GitHub workflows when pushing to Gitea
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Ignore Gitea workflows when pushing to GitHub
|
||||||
|
.gitea/
|
||||||
|
|
||||||
|
# Local development documentation (Gitea only, not for GitHub)
|
||||||
|
.local/
|
||||||
|
|
||||||
|
# Temporary troubleshooting files - to be removed
|
||||||
|
Dockerfile.optimized
|
||||||
|
fix-gitea-packages.md
|
||||||
|
configure-docker-insecure.md
|
||||||
|
CLEANUP.md
|
||||||
|
README-OPTIMIZED.md
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# NFOGuard Environment Variables
|
||||||
|
|
||||||
|
# User and Group IDs
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# Timezone
|
||||||
|
TZ=UTC
|
||||||
|
|
||||||
|
# Media path (for read-only access to scan NFO files)
|
||||||
|
MEDIA_PATH=/path/to/your/media
|
||||||
|
|
||||||
|
# Database settings (if using PostgreSQL)
|
||||||
|
# DB_USER=nfoguard
|
||||||
|
# DB_PASSWORD=your_secure_password
|
||||||
|
|
||||||
|
# Emby Plugin Deployment - Bind Mount Method (Recommended)
|
||||||
|
# Set this to the path where Emby plugins are installed on your host
|
||||||
|
# Common paths:
|
||||||
|
# - /var/lib/emby/plugins (native Emby install)
|
||||||
|
# - /path/to/emby/config/plugins (Docker Emby)
|
||||||
|
# - /config/plugins (linuxserver.io Emby)
|
||||||
|
EMBY_PLUGINS_PATH=/path/to/emby/plugins
|
||||||
|
|
||||||
|
# NFOGuard specific settings
|
||||||
|
DEBUG=false
|
||||||
|
PATH_DEBUG=false
|
||||||
|
SUPPRESS_TVDB_WARNINGS=true
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# NFOGuard Environment Configuration Template
|
||||||
|
# Copy this to .env and customize for your setup
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# MEDIA PATHS (REQUIRED)
|
||||||
|
# ===========================================
|
||||||
|
# Paths where your movies and TV shows are stored inside the container
|
||||||
|
# Adjust these to match YOUR directory structure
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# DATABASE CONFIGURATION
|
||||||
|
# ===========================================
|
||||||
|
# NFOGuard SQLite database location
|
||||||
|
DB_PATH=/app/data/media_dates.db
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR DATABASE CONNECTION (REQUIRED)
|
||||||
|
# ===========================================
|
||||||
|
# Connection to Radarr's PostgreSQL database
|
||||||
|
# NOTE: Database password should be set in .env.secrets file
|
||||||
|
RADARR_DB_HOST=radarr-postgres
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr
|
||||||
|
RADARR_DB_USER=radarr
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# RADARR API (OPTIONAL - for fallback)
|
||||||
|
# ===========================================
|
||||||
|
# Only needed if you want API fallback (not recommended for performance)
|
||||||
|
# NOTE: API key should be set in .env.secrets file
|
||||||
|
RADARR_URL=http://radarr:7878
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SONARR API (REQUIRED for Enhanced TV NFOs - v0.6.0+)
|
||||||
|
# ===========================================
|
||||||
|
# Required for TV processing and enhanced NFO generation with metadata
|
||||||
|
# NOTE: API key should be set in .env.secrets file
|
||||||
|
SONARR_URL=http://sonarr:8989
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# EXTERNAL APIs FOR DIGITAL RELEASE DATES
|
||||||
|
# ===========================================
|
||||||
|
# NOTE: All API keys should be set in .env.secrets file
|
||||||
|
# See .env.secrets.template for required keys:
|
||||||
|
# - TMDB_API_KEY (recommended for digital release dates)
|
||||||
|
# - OMDB_API_KEY (optional for DVD/digital release dates)
|
||||||
|
# - JELLYSEERR_API_KEY (optional for additional digital release data)
|
||||||
|
|
||||||
|
# Jellyseerr URL (optional)
|
||||||
|
JELLYSEERR_URL=http://jellyseerr:5055
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# NFO MANAGEMENT
|
||||||
|
# ===========================================
|
||||||
|
# Whether to create/update .nfo files
|
||||||
|
MANAGE_NFO=true
|
||||||
|
|
||||||
|
# Whether to fix file modification times to match import dates
|
||||||
|
FIX_DIR_MTIMES=true
|
||||||
|
|
||||||
|
# Whether to add <lockdata> tags to prevent metadata drift
|
||||||
|
LOCK_METADATA=true
|
||||||
|
|
||||||
|
# Brand name to add to NFO files
|
||||||
|
MANAGER_BRAND=NFOGuard
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# PROCESSING SETTINGS
|
||||||
|
# ===========================================
|
||||||
|
# Delay before processing batched webhook events (seconds)
|
||||||
|
BATCH_DELAY=5.0
|
||||||
|
|
||||||
|
# Maximum concurrent series processing
|
||||||
|
MAX_CONCURRENT_SERIES=3
|
||||||
|
|
||||||
|
# Movie date strategy: import_then_digital or digital_then_import
|
||||||
|
MOVIE_PRIORITY=import_then_digital
|
||||||
|
|
||||||
|
# Smart fallback: prefer release dates over file dates for manual imports (true/false)
|
||||||
|
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||||
|
|
||||||
|
# Allow file dates as absolute last resort (true/false)
|
||||||
|
# If false, movies with no valid import AND no release dates will be skipped entirely
|
||||||
|
ALLOW_FILE_DATE_FALLBACK=false
|
||||||
|
|
||||||
|
# Fallback priority order when no valid Radarr import exists (comma-separated)
|
||||||
|
# Options: digital, physical, theatrical
|
||||||
|
# digital = VOD/streaming release, physical = DVD/Blu-ray, theatrical = cinema release
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
|
||||||
|
# Smart date validation: automatically prefer theatrical over unreasonably late digital/physical dates
|
||||||
|
# Example: "The Craft (1996)" theatrical vs "The Craft (2022)" physical → chooses 1996 theatrical
|
||||||
|
ENABLE_SMART_DATE_VALIDATION=true
|
||||||
|
|
||||||
|
# Maximum reasonable gap between theatrical and digital/physical release (in years)
|
||||||
|
# If digital/physical is more than this many years after theatrical, prefer theatrical instead
|
||||||
|
MAX_RELEASE_DATE_GAP_YEARS=10
|
||||||
|
|
||||||
|
# TMDB country for regional release date preferences
|
||||||
|
TMDB_COUNTRY=US
|
||||||
|
|
||||||
|
# TMDB release type priority (comma-separated numbers)
|
||||||
|
# 1=Premiere, 2=Limited Theatrical, 3=Theatrical, 4=Digital, 5=Physical, 6=TV Premiere
|
||||||
|
# Default: 4,5,3,2,6,1 (Digital → Physical → Theatrical → Limited → TV → Premiere)
|
||||||
|
TMDB_TYPE_PRIORITY=4,5,3,2,6,1
|
||||||
|
|
||||||
|
# When to query APIs: always, if_missing, never
|
||||||
|
MOVIE_POLL_MODE=always
|
||||||
|
|
||||||
|
# Update mode: backfill_only or overwrite
|
||||||
|
MOVIE_DATE_UPDATE_MODE=backfill_only
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# TV SERIES PROCESSING
|
||||||
|
# ===========================================
|
||||||
|
# Season directory naming format (supports {season} placeholder)
|
||||||
|
# Examples: "Season {season:02d}" -> "Season 01", "S{season:02d}" -> "S01"
|
||||||
|
TV_SEASON_DIR_FORMAT=Season {season:02d}
|
||||||
|
|
||||||
|
# Season directory detection pattern (lowercase, what to look for at start of dir name)
|
||||||
|
# Examples: "season " -> matches "Season 01", "s" -> matches "S01", "season" -> matches "Season01"
|
||||||
|
TV_SEASON_DIR_PATTERN=season
|
||||||
|
|
||||||
|
# TV webhook processing mode (v0.6.0+)
|
||||||
|
# targeted = Only process episodes mentioned in webhook (efficient, recommended)
|
||||||
|
# series = Process entire series directory (comprehensive, current default)
|
||||||
|
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# LOGGING
|
||||||
|
# ===========================================
|
||||||
|
# Enable debug logging
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SERVER
|
||||||
|
# ===========================================
|
||||||
|
# Port to run the webhook server on
|
||||||
|
PORT=8080
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
# NFOGuard Setup Guide
|
||||||
|
|
||||||
|
## 🔐 Secure Configuration Setup
|
||||||
|
|
||||||
|
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
|
||||||
|
|
||||||
|
- **`.env`** - Main configuration (safe to share for debugging)
|
||||||
|
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
|
||||||
|
|
||||||
|
### Step 1: Copy Configuration Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy main configuration
|
||||||
|
cp .env.template .env
|
||||||
|
|
||||||
|
# Copy secrets configuration
|
||||||
|
cp .env.secrets.template .env.secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Main Settings
|
||||||
|
|
||||||
|
Edit your `.env` file with your specific paths and preferences:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Media paths (adjust to your directory structure)
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
|
||||||
|
# Database connection details
|
||||||
|
RADARR_DB_HOST=radarr-postgres
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr
|
||||||
|
RADARR_DB_USER=radarr
|
||||||
|
|
||||||
|
# Processing preferences
|
||||||
|
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||||
|
ALLOW_FILE_DATE_FALLBACK=false
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
|
||||||
|
# TV webhook processing mode (v0.6.0+)
|
||||||
|
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure Secrets
|
||||||
|
|
||||||
|
Edit your `.env.secrets` file with your actual API keys and passwords:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database password
|
||||||
|
RADARR_DB_PASSWORD=your_actual_radarr_password
|
||||||
|
|
||||||
|
# TMDB API key (required for release date detection)
|
||||||
|
TMDB_API_KEY=your_actual_tmdb_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_actual_sonarr_api_key
|
||||||
|
|
||||||
|
# Optional API keys
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Configuration
|
||||||
|
|
||||||
|
Test your setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test database connections
|
||||||
|
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||||
|
|
||||||
|
# Test movie scanning
|
||||||
|
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||||
|
|
||||||
|
# Check system health
|
||||||
|
curl "http://localhost:8080/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
### API Key Masking
|
||||||
|
All API keys and passwords are automatically masked in logs:
|
||||||
|
```
|
||||||
|
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
|
||||||
|
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Data Separation
|
||||||
|
- **Main `.env`**: Paths, preferences, URLs (safe to share)
|
||||||
|
- **`.env.secrets`**: API keys, passwords (never commit to version control)
|
||||||
|
- **Automatic loading**: Both files loaded automatically at startup
|
||||||
|
|
||||||
|
### Git Protection
|
||||||
|
The `.gitignore` file prevents accidental commits:
|
||||||
|
```
|
||||||
|
.env
|
||||||
|
.env.secrets
|
||||||
|
.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠 Troubleshooting
|
||||||
|
|
||||||
|
### "Environment files not loaded" Warning
|
||||||
|
Install python-dotenv:
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv==1.0.0
|
||||||
|
# or
|
||||||
|
docker-compose build # rebuilds with updated requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing Configuration for Help
|
||||||
|
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
|
||||||
|
|
||||||
|
### Migration from Old Setup
|
||||||
|
If you have an existing `.env` with API keys:
|
||||||
|
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
|
||||||
|
2. Remove sensitive data from `.env`
|
||||||
|
3. Restart NFOGuard
|
||||||
|
|
||||||
|
## 🎯 Docker Compose Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
nfoguard:
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
container_name: nfoguard
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/media:/media:rw
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./.env:/app/.env:ro # Main configuration
|
||||||
|
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
depends_on:
|
||||||
|
- radarr-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Configuration Reference
|
||||||
|
|
||||||
|
### Main Configuration (.env)
|
||||||
|
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
|
||||||
|
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
|
||||||
|
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
|
||||||
|
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
|
||||||
|
|
||||||
|
### Secrets Configuration (.env.secrets)
|
||||||
|
- **Database**: `RADARR_DB_PASSWORD`
|
||||||
|
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
|
||||||
|
- **Optional**: `JELLYSEERR_API_KEY`
|
||||||
|
|
||||||
|
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# NFOGuard Development Summary - September 22, 2025
|
||||||
|
|
||||||
|
## 🔥 CRITICAL SECURITY & PRIVACY FIXES COMPLETED
|
||||||
|
|
||||||
|
### 🛡️ Privacy Protection (COMPLETED)
|
||||||
|
- **REDACTED**: Removed personal name "jskala" from all Gitea workflows (kept on private Gitea, removed from public GitHub)
|
||||||
|
- **SECURITY**: Personal information completely scrubbed from public repository
|
||||||
|
- **SEPARATION**: Gitea (private dev) vs GitHub (public release) properly isolated
|
||||||
|
|
||||||
|
### 🤖 Claude Attribution Removal (COMPLETED)
|
||||||
|
- **ELIMINATED**: All "Co-Authored-By: Claude" lines from commit history
|
||||||
|
- **CLEAN HISTORY**: Repository recreated with professional commit history
|
||||||
|
- **CONTRIBUTOR LIST**: GitHub now shows only sbcrumb as contributor (0 Claude references)
|
||||||
|
- **FRESH START**: Repository deleted and recreated to eliminate all AI attribution
|
||||||
|
|
||||||
|
### 📋 Repository Refresh (COMPLETED)
|
||||||
|
- **PROFESSIONAL**: Added clean "Repository Refresh" notice in README explaining fresh start
|
||||||
|
- **POSITIONING**: Framed as intentional improvement for "professional and streamlined experience"
|
||||||
|
- **NO AI MENTIONS**: Zero references to Claude or AI assistance anywhere
|
||||||
|
|
||||||
|
## 🐳 Docker Hub Integration (COMPLETED)
|
||||||
|
|
||||||
|
### 🔧 Workflow Fixes
|
||||||
|
- **FIXED**: Changed from GitHub Container Registry (GHCR) to Docker Hub
|
||||||
|
- **WORKING**: Builds now push to `sbcrumb/nfoguard:1.7.1` and `:latest`
|
||||||
|
- **AUTHENTICATION**: Uses DOCKER_HUB_USERNAME and DOCKER_HUB_TOKEN secrets
|
||||||
|
- **AUTOMATION**: Triggers on pushes to main branch with proper versioning
|
||||||
|
|
||||||
|
### 📦 Release Management
|
||||||
|
- **SYNCHRONIZED**: VERSION file matches Docker tags and GitHub releases
|
||||||
|
- **AUTOMATED**: Release drafter creates releases with Docker Hub links
|
||||||
|
- **VERSIONING**: Fixed version mismatches between workflows
|
||||||
|
|
||||||
|
## ✅ FINAL PRIVACY BREACH RESOLVED (September 23, 2025)
|
||||||
|
|
||||||
|
### 🚨 Attribution Issue Discovered & Fixed
|
||||||
|
User discovered I was still appearing as GitHub contributor despite repository recreation:
|
||||||
|
|
||||||
|
**ROOT CAUSE:**
|
||||||
|
- Two commits (`cb875d6` and `a0298ab`) had "jskala" as author/committer
|
||||||
|
- These commits were pushed to GitHub during sync, making personal name visible
|
||||||
|
- Original sync script only filtered commit messages, not author information
|
||||||
|
|
||||||
|
**COMPLETE SOLUTION IMPLEMENTED:**
|
||||||
|
- ✅ **Enhanced sync script** - Now uses `git filter-branch --env-filter` to rewrite author/committer names
|
||||||
|
- ✅ **Pre-commit hooks** - Block commits with jskala/Claude identity at commit time
|
||||||
|
- ✅ **Commit-msg hooks** - Strip AI attribution from commit messages automatically
|
||||||
|
- ✅ **Git identity protection** - Repository configured to use SBCrumb identity only
|
||||||
|
- ✅ **Complete GitHub cleanup** - All 220 commits rewritten with SBCrumb authorship
|
||||||
|
- ✅ **Prevention measures** - Multiple layers prevent future attribution issues
|
||||||
|
|
||||||
|
**VERIFICATION COMPLETED:**
|
||||||
|
- GitHub now shows 100% SBCrumb authorship (0 jskala, 0 Claude references)
|
||||||
|
- Pre-commit hook successfully blocks personal identity attempts
|
||||||
|
- Sync script automatically cleans any problematic commits before GitHub push
|
||||||
|
- All privacy protection working as intended
|
||||||
|
|
||||||
|
## 📝 SUMMARY.md PURPOSE & GUIDELINES
|
||||||
|
|
||||||
|
### 🎯 This File's Purpose
|
||||||
|
- **PRIVATE DEVELOPMENT DOCS**: This .local/SUMMARY.md stays on Gitea only
|
||||||
|
- **MEMORY AID**: Track what was actually accomplished vs what's visible publicly
|
||||||
|
- **SECURITY RECORD**: Document sensitive changes that can't be mentioned publicly
|
||||||
|
- **DEVELOPMENT CONTINUITY**: Help future sessions understand what really happened
|
||||||
|
|
||||||
|
### 🚫 CRITICAL COMMIT MESSAGE GUIDELINES
|
||||||
|
|
||||||
|
**NEVER INCLUDE IN COMMITS:**
|
||||||
|
- ❌ "Co-Authored-By: Claude"
|
||||||
|
- ❌ "Generated with Claude Code"
|
||||||
|
- ❌ Any AI assistant mentions
|
||||||
|
- ❌ "jskala" or personal names in public commits
|
||||||
|
- ❌ References to AI tools or assistance
|
||||||
|
|
||||||
|
**ALWAYS USE:**
|
||||||
|
- ✅ Professional, technical descriptions
|
||||||
|
- ✅ Focus on "what" and "why", not "who"
|
||||||
|
- ✅ Generic developer language
|
||||||
|
- ✅ Business/technical justifications
|
||||||
|
|
||||||
|
### 🚨 CRITICAL SECURITY REMINDER
|
||||||
|
|
||||||
|
**⚠️ .local/ DIRECTORY SECURITY:**
|
||||||
|
- **NEVER COMMIT .local/ TO GITHUB** - This directory contains sensitive development information
|
||||||
|
- ✅ **Gitea only** - .local/ stays on private Gitea repository forever
|
||||||
|
- ✅ **Protected by .gitignore** - .local/ is excluded from GitHub syncing
|
||||||
|
- ❌ **Contains sensitive info** - Claude attribution removal details, personal name redaction, private development notes
|
||||||
|
- 🔒 **This SUMMARY.md** - Contains confidential information that must never be public
|
||||||
|
|
||||||
|
**IF .local/ EVER APPEARS ON GITHUB:**
|
||||||
|
1. **IMMEDIATE ACTION REQUIRED** - Delete repository and recreate
|
||||||
|
2. **SECURITY BREACH** - All sensitive development information exposed
|
||||||
|
3. **PRIVACY VIOLATION** - Personal names and AI assistance details visible publicly
|
||||||
|
|
||||||
|
**GOOD EXAMPLES:**
|
||||||
|
```
|
||||||
|
fix: Resolve NFO file naming conflicts for long episode titles
|
||||||
|
feat: Add Docker Hub integration for automated builds
|
||||||
|
docs: Add repository refresh notice for improved maintenance
|
||||||
|
```
|
||||||
|
|
||||||
|
**BAD EXAMPLES:**
|
||||||
|
```
|
||||||
|
fix: Claude helped resolve NFO issues ❌
|
||||||
|
feat: AI-generated Docker improvements ❌
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com> ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Current Repository State
|
||||||
|
|
||||||
|
### 📍 Gitea (Private - Complete Code)
|
||||||
|
- ✅ All today's NFO fixes and improvements
|
||||||
|
- ✅ Complete development history
|
||||||
|
- ✅ .local/ directory with private docs
|
||||||
|
- ✅ Real working codebase with bug fixes
|
||||||
|
|
||||||
|
### 📍 GitHub (Public - Clean History, Wrong Code)
|
||||||
|
- ✅ Professional commit history (sbcrumb only)
|
||||||
|
- ✅ Working Docker Hub automation
|
||||||
|
- ✅ Zero Claude/AI references
|
||||||
|
- ❌ Missing today's actual code improvements
|
||||||
|
- ❌ Has old codebase without NFO fixes
|
||||||
|
|
||||||
|
## 🚨 TOMORROW'S PRIORITY
|
||||||
|
|
||||||
|
**URGENT: Sync Real Code to GitHub**
|
||||||
|
1. Copy actual working code from Gitea main to GitHub main
|
||||||
|
2. Preserve clean commit history on GitHub
|
||||||
|
3. Ensure NFO long name fixes are included
|
||||||
|
4. Maintain privacy/security improvements
|
||||||
|
5. Keep .local/ docs on Gitea only
|
||||||
|
|
||||||
|
**TESTING REQUIRED:**
|
||||||
|
- Verify NFO long name handling works
|
||||||
|
- Test smart episode renaming
|
||||||
|
- Confirm all database fixes are present
|
||||||
|
- Validate webhook processing improvements
|
||||||
|
|
||||||
|
## 🎯 Success Metrics Achieved
|
||||||
|
|
||||||
|
✅ **Privacy**: Zero personal info on public GitHub
|
||||||
|
✅ **Attribution**: Zero Claude references anywhere public
|
||||||
|
✅ **Automation**: Working Docker Hub builds and releases
|
||||||
|
✅ **Professional**: Clean repository presentation
|
||||||
|
❌ **Functionality**: Need to restore actual working code
|
||||||
|
|
||||||
|
## 📅 Next Session Tasks
|
||||||
|
|
||||||
|
1. **IMMEDIATE**: Sync Gitea main code to GitHub main
|
||||||
|
2. **VERIFY**: All NFO fixes are working on GitHub version
|
||||||
|
3. **TEST**: Docker builds include latest code improvements
|
||||||
|
4. **DOCUMENT**: Update public docs with any new features
|
||||||
|
5. **MAINTAIN**: Keep this .local/SUMMARY.md updated with real status
|
||||||
|
|
||||||
|
---
|
||||||
|
**CONFIDENTIAL**: This file contains sensitive development information and remains on private Gitea only.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# =� NFOGuard v1.6.9 - Enhanced NFO Migration & Workflow Automation
|
||||||
|
|
||||||
|
## <� Major Features
|
||||||
|
|
||||||
|
### Complete NFO Migration System
|
||||||
|
- **Smart Content Preservation**: Automatically migrates rich metadata (title, plot, runtime) from long-named Sonarr NFO files to standardized `S##E##.nfo` format
|
||||||
|
- **Intelligent File Detection**: Enhanced `find_existing_episode_nfo()` detects matching season/episode in any NFO file naming convention
|
||||||
|
- **Automatic Cleanup**: Long-named NFO files automatically deleted after successful migration
|
||||||
|
- **Priority Logic Fix**: Migration now correctly prioritizes long-named files over existing short files for content extraction
|
||||||
|
|
||||||
|
### Workflow Automation Overhaul
|
||||||
|
- **Local-Only Builds**: Removed all Docker Hub dependencies, builds now local-only for faster, more secure CI/CD
|
||||||
|
- **GitHub Integration**: Automated local � GitHub workflow for public testing and releases
|
||||||
|
- **Post-Merge Automation**: Git hooks automatically sync local main to GitHub dev branch with PR creation
|
||||||
|
- **Version Management**: Integrated semantic versioning with automatic increment options (patch/minor/major)
|
||||||
|
|
||||||
|
### Build Identification System
|
||||||
|
- **Branch Detection**: Automatic git branch detection during Docker builds
|
||||||
|
- **Environment Tagging**: Clear version identification (`1.6.9-dev-gitea` vs `1.6.9`)
|
||||||
|
- **Build Source Tracking**: Distinguishes local Gitea builds from public GitHub releases
|
||||||
|
|
||||||
|
## =' Technical Improvements
|
||||||
|
|
||||||
|
### Docker Enhancements
|
||||||
|
- **Graceful Shutdown**: Added proper SIGTERM/SIGINT signal handling for clean container stops
|
||||||
|
- **Branch-Specific Builds**: Workflow now correctly clones target branch instead of default
|
||||||
|
- **Build Arguments**: Proper `GIT_BRANCH` and `BUILD_SOURCE` parameter passing
|
||||||
|
|
||||||
|
### Debug System
|
||||||
|
- **Conditional Logging**: All debug statements now respect `DEBUG=true/false` environment variable
|
||||||
|
- **Migration Tracking**: Detailed logging for NFO content preservation process (when enabled)
|
||||||
|
- **Clean Production Logs**: Debug noise eliminated when `DEBUG=false`
|
||||||
|
|
||||||
|
## <� NFO Migration Details
|
||||||
|
|
||||||
|
**Before**: Long-named Sonarr NFO files existed alongside minimal short NFO files, content was lost
|
||||||
|
**After**: Rich metadata automatically preserved in standardized format, old files cleaned up
|
||||||
|
|
||||||
|
**Example Migration**:
|
||||||
|
```
|
||||||
|
Source: "Invasion (2021)-S03E04-The Mission[WEBDL-1080p][AAC2.0][h264].nfo"
|
||||||
|
Target: "S03E04.nfo"
|
||||||
|
Content: title, plot, runtime, premiered � All preserved
|
||||||
|
Cleanup: Original long file � Automatically deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
## =� New Release Workflow
|
||||||
|
|
||||||
|
1. **Local Development** � dev branch with local Gitea CI
|
||||||
|
2. **Local Release** � Merge dev to main triggers GitHub sync
|
||||||
|
3. **Public Testing** � GitHub dev branch for community testing
|
||||||
|
4. **Production Release** � Manual PR approval to GitHub main
|
||||||
|
|
||||||
|
## =� Developer Experience
|
||||||
|
|
||||||
|
- **Version Bumping**: Never forget with integrated `sync-to-github.sh` script
|
||||||
|
- **Build Clarity**: Always know which environment you're running
|
||||||
|
- **Fast Iteration**: Local-only builds eliminate external dependencies
|
||||||
|
- **Debug Control**: Toggle verbose logging with single environment variable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Breaking Changes**: None - fully backward compatible
|
||||||
|
**Migration**: Automatic - existing NFO files will be migrated on next scan
|
||||||
|
**Requirements**: No additional dependencies
|
||||||
|
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# NFOGuard Development Workflow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
NFOGuard uses a dual-repository system:
|
||||||
|
- **Gitea (Private)**: `origin` - Development, testing, messy commits
|
||||||
|
- **GitHub (Public)**: `github` - Clean releases, public testing
|
||||||
|
|
||||||
|
## Repository Setup
|
||||||
|
```bash
|
||||||
|
# Remotes should be configured as:
|
||||||
|
origin gitea@192.168.253.221:jskala/NFOguard.git (fetch/push)
|
||||||
|
github git@github.com:sbcrumb/nfoguard.git (fetch/push)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Daily Development Workflow
|
||||||
|
|
||||||
|
### 1. Local Development
|
||||||
|
```bash
|
||||||
|
# Work on dev branch (make as many commits as you want)
|
||||||
|
git checkout dev
|
||||||
|
# ... make changes, commit frequently ...
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: whatever you're working on"
|
||||||
|
|
||||||
|
# Push to Gitea regularly for backup
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Feature Complete - Merge to Main
|
||||||
|
```bash
|
||||||
|
# When feature/fix is complete, merge to main
|
||||||
|
git checkout main
|
||||||
|
git merge dev
|
||||||
|
|
||||||
|
# Push to Gitea main
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Prepare for GitHub Release
|
||||||
|
|
||||||
|
**Use the sync script for clean releases:**
|
||||||
|
```bash
|
||||||
|
# From main branch, run the sync script
|
||||||
|
./sync-to-github.sh
|
||||||
|
|
||||||
|
# Choose Option 3: "Create clean release branch for GitHub PR (RECOMMENDED)"
|
||||||
|
# This will:
|
||||||
|
# 1. Increment version (patch/minor/major)
|
||||||
|
# 2. Create clean release-vX.X.X branch from GitHub main
|
||||||
|
# 3. Copy your changes with single clean commit
|
||||||
|
# 4. Push to GitHub automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
**The script will output:**
|
||||||
|
```
|
||||||
|
🔗 Create PR at: https://github.com/sbcrumb/nfoguard/pull/new/release-v1.7.0
|
||||||
|
📝 PR will be: release-v1.7.0 → main (clean, single commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. GitHub Testing and Release
|
||||||
|
```bash
|
||||||
|
# 1. Create PR: release-vX.X.X → main on GitHub
|
||||||
|
# 2. Test the release branch thoroughly
|
||||||
|
# 3. Merge when satisfied (squash merge will be available)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Process (If Script Unavailable)
|
||||||
|
|
||||||
|
### Emergency Manual Release
|
||||||
|
If the sync script isn't available, here's the manual process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fetch latest GitHub main
|
||||||
|
git fetch github
|
||||||
|
|
||||||
|
# 2. Create clean release branch
|
||||||
|
git checkout -b release-v1.7.0 github/main
|
||||||
|
|
||||||
|
# 3. Copy key files from your local main
|
||||||
|
git checkout main -- VERSION Dockerfile core/nfo_manager.py nfoguard.py .env.template .env.secrets.template .gitea/
|
||||||
|
|
||||||
|
# 4. Increment version manually
|
||||||
|
echo "1.7.0" > VERSION
|
||||||
|
|
||||||
|
# 5. Commit and push
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: Release v1.7.0 - [describe key changes]"
|
||||||
|
git push github release-v1.7.0
|
||||||
|
|
||||||
|
# 6. Create PR on GitHub: release-v1.7.0 → main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Files and Their Purpose
|
||||||
|
|
||||||
|
### Local-Only Files (Never Commit to Gitea/GitHub)
|
||||||
|
- `sync-to-github.sh` - GitHub sync automation
|
||||||
|
- `.local/` directory - Local documentation and notes
|
||||||
|
- `.env.secrets` - API keys and passwords
|
||||||
|
|
||||||
|
### Repository-Specific Files
|
||||||
|
- `.gitea/workflows/` - Gitea CI/CD (ignored by GitHub)
|
||||||
|
- `.github/workflows/` - GitHub CI/CD (ignored by Gitea)
|
||||||
|
|
||||||
|
### Shared Files
|
||||||
|
- Core application code
|
||||||
|
- Dockerfile
|
||||||
|
- Configuration templates
|
||||||
|
- VERSION file
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
- **Patch (X.Y.Z+1)**: Bug fixes, small updates
|
||||||
|
- **Minor (X.Y+1.0)**: New features, enhancements
|
||||||
|
- **Major (X+1.0.0)**: Breaking changes, major releases
|
||||||
|
|
||||||
|
### Version Identification
|
||||||
|
- **Local Gitea builds**: `1.7.0-dev-gitea`
|
||||||
|
- **GitHub releases**: `1.7.0`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Messy Commit History" in GitHub PRs
|
||||||
|
**Problem**: Local dev has 20+ commits that make GitHub PRs hard to review
|
||||||
|
**Solution**: Always use sync script Option 3 to create clean release branches
|
||||||
|
|
||||||
|
### "No Common History" Error
|
||||||
|
**Problem**: GitHub and local repos have different histories
|
||||||
|
**Solution**: Use the clean release branch workflow (creates branches from GitHub main)
|
||||||
|
|
||||||
|
### "Sync Script Not Working"
|
||||||
|
**Fallback**: Use the manual process outlined above
|
||||||
|
|
||||||
|
### Local Branch "Ahead by X Commits"
|
||||||
|
**Normal**: Your local dev is ahead of Gitea dev - just push when ready
|
||||||
|
```bash
|
||||||
|
git push origin dev # Sync to Gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch Strategy
|
||||||
|
|
||||||
|
### Local Branches
|
||||||
|
- `dev` - Active development (tracks origin/dev)
|
||||||
|
- `main` - Stable local code (tracks origin/main)
|
||||||
|
|
||||||
|
### GitHub Branches
|
||||||
|
- `release-vX.X.X` - Clean release branches (created by sync script)
|
||||||
|
- `main` - Public stable releases
|
||||||
|
- `dev` - Optional testing branch
|
||||||
|
|
||||||
|
### Gitea Branches
|
||||||
|
- `dev` - Development history (all commits)
|
||||||
|
- `main` - Feature-complete code (merged from dev)
|
||||||
|
|
||||||
|
## CI/CD Behavior
|
||||||
|
|
||||||
|
### Gitea Workflows
|
||||||
|
- Build locally only (no Docker Hub)
|
||||||
|
- Version shows as "X.X.X-dev-gitea"
|
||||||
|
- Triggered on dev/main pushes
|
||||||
|
|
||||||
|
### GitHub Workflows
|
||||||
|
- Build to GitHub Container Registry
|
||||||
|
- Version shows as "X.X.X" (clean)
|
||||||
|
- Triggered on main pushes
|
||||||
|
|
||||||
|
## Emergency Procedures
|
||||||
|
|
||||||
|
### If Sync Script is Lost
|
||||||
|
1. Recreate from this workflow document
|
||||||
|
2. Use manual process for immediate needs
|
||||||
|
3. Script should be in `.local/` directory and ignored by git
|
||||||
|
|
||||||
|
### If GitHub Repo Gets Corrupted
|
||||||
|
1. Force push clean main from Gitea
|
||||||
|
2. Recreate release branches as needed
|
||||||
|
3. Your code is safe in Gitea
|
||||||
|
|
||||||
|
### If Gitea Goes Down
|
||||||
|
1. Continue development locally
|
||||||
|
2. Push to GitHub dev branch for backup
|
||||||
|
3. Sync back to Gitea when restored
|
||||||
|
|
||||||
|
## Success Indicators
|
||||||
|
|
||||||
|
### Good Workflow Signs
|
||||||
|
- ✅ GitHub PRs have 1-3 clean commits
|
||||||
|
- ✅ Local history is detailed and preserved
|
||||||
|
- ✅ Version numbers increment properly
|
||||||
|
- ✅ CI/CD builds succeed on both platforms
|
||||||
|
|
||||||
|
### Warning Signs
|
||||||
|
- ❌ GitHub PRs have 20+ messy commits
|
||||||
|
- ❌ "No common history" errors
|
||||||
|
- ❌ Version numbers out of sync
|
||||||
|
- ❌ Sync script getting committed to repos
|
||||||
|
|
||||||
|
## Quick Reference Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Daily development
|
||||||
|
git checkout dev && git pull origin dev
|
||||||
|
# ... work ...
|
||||||
|
git add . && git commit -m "fix: description"
|
||||||
|
git push origin dev
|
||||||
|
|
||||||
|
# Complete feature
|
||||||
|
git checkout main && git merge dev && git push origin main
|
||||||
|
|
||||||
|
# Release to GitHub
|
||||||
|
./sync-to-github.sh # Choose option 3
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
git status
|
||||||
|
git log --oneline -5
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember**: The sync script Option 3 is your best friend for clean GitHub releases!
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
#Udates for local builds
|
||||||
|
|
||||||
|
# Build argument for git branch and build source
|
||||||
|
ARG GIT_BRANCH=main
|
||||||
|
ARG BUILD_SOURCE=
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
GIT_BRANCH=${GIT_BRANCH} \
|
||||||
|
BUILD_SOURCE=${BUILD_SOURCE}
|
||||||
|
|
||||||
|
# Install system dependencies including PostgreSQL client libraries
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
libpq-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create app user and directory
|
||||||
|
RUN useradd --create-home --shell /bin/bash app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create git metadata for version detection based on build arg
|
||||||
|
RUN mkdir -p .git && \
|
||||||
|
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
|
||||||
|
|
||||||
|
# Copy DLL to a dedicated directory and create entrypoint script
|
||||||
|
RUN mkdir -p /app/emby-plugin && \
|
||||||
|
cp /app/Emby-DLL/NFOGuard.Emby.Plugin.dll /app/emby-plugin/ && \
|
||||||
|
echo '#!/bin/bash' > /app/deploy-plugin.sh && \
|
||||||
|
echo 'if [ -d "/emby-plugins" ]; then' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "Deploying NFOGuard Emby Plugin to mounted directory: /emby-plugins"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll /emby-plugins/' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo 'elif [ -n "$EMBY_PLUGINS_PATH" ] && [ -d "$EMBY_PLUGINS_PATH" ]; then' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "Deploying NFOGuard Emby Plugin to: $EMBY_PLUGINS_PATH"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll "$EMBY_PLUGINS_PATH/"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo 'else' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "No Emby plugins directory found - skipping plugin deployment"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo ' echo "To enable plugin deployment, bind mount your Emby plugins directory to /emby-plugins"' >> /app/deploy-plugin.sh && \
|
||||||
|
echo 'fi' >> /app/deploy-plugin.sh && \
|
||||||
|
echo 'exec python -u nfoguard.py' >> /app/deploy-plugin.sh && \
|
||||||
|
chmod +x /app/deploy-plugin.sh
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to app user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Run the application with plugin deployment
|
||||||
|
CMD ["/app/deploy-plugin.sh"]
|
||||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 NFOGuard
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commercial Licensing
|
||||||
|
|
||||||
|
For commercial use, enterprise features, or if the MIT license doesn't meet your needs,
|
||||||
|
commercial licenses are available. Contact us for more information.
|
||||||
|
|
||||||
|
Future premium features (such as web UI, advanced analytics, enterprise support)
|
||||||
|
may be offered under separate commercial licensing terms.
|
||||||
|
|
||||||
|
## Third-Party Licenses
|
||||||
|
|
||||||
|
This software includes third-party libraries with their own licenses.
|
||||||
|
See requirements.txt and the respective package documentation for details.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# NFOGuard
|
||||||
|
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
|
||||||
|
**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
||||||
|
>
|
||||||
|
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
||||||
|
>
|
||||||
|
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||||
|
>
|
||||||
|
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
||||||
|
>
|
||||||
|
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
|
||||||
|
>
|
||||||
|
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- 🎬 **Movie & TV Support** - Works with both Radarr and Sonarr
|
||||||
|
- 📅 **Smart Date Handling** - Prioritizes digital, physical, and theatrical release dates
|
||||||
|
- 🔄 **Webhook Integration** - Triggers automatically on import, upgrade, and rename
|
||||||
|
- 🗄️ **Database Integration** - Direct PostgreSQL access for better performance
|
||||||
|
- 📝 **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
|
||||||
|
- 🔒 **Metadata Locking** - Prevents overwrites with lockdata tags
|
||||||
|
- ⚡ **Batch Processing** - Efficient handling of multiple files
|
||||||
|
- 🐳 **Docker Ready** - Easy deployment with Docker Compose
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. Download Configuration Files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
|
||||||
|
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy and edit main configuration
|
||||||
|
cp .env.template .env
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# Copy and edit secrets (API keys, passwords)
|
||||||
|
cp .env.secrets.template .env.secrets
|
||||||
|
nano .env.secrets
|
||||||
|
chmod 600 .env.secrets
|
||||||
|
|
||||||
|
# Copy and edit Docker Compose
|
||||||
|
cp docker-compose.example.yml docker-compose.yml
|
||||||
|
nano docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create data directory
|
||||||
|
mkdir -p ./data
|
||||||
|
|
||||||
|
# Start NFOGuard
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f nfoguard
|
||||||
|
|
||||||
|
# Verify health
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
### Environment Files
|
||||||
|
|
||||||
|
| File | Purpose | Contains |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `.env` | Main configuration | Paths, behavior settings, non-sensitive options |
|
||||||
|
| `.env.secrets` | Sensitive data | API keys, passwords, database credentials |
|
||||||
|
|
||||||
|
### Key Configuration Options
|
||||||
|
|
||||||
|
**Media Paths** (Required):
|
||||||
|
```bash
|
||||||
|
# Container paths (what NFOGuard sees)
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
|
||||||
|
# *arr application paths (what your apps see)
|
||||||
|
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
|
||||||
|
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv
|
||||||
|
```
|
||||||
|
|
||||||
|
**Release Date Priority**:
|
||||||
|
```bash
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debug Mode**:
|
||||||
|
```bash
|
||||||
|
DEBUG=false # Clean production logs
|
||||||
|
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Images
|
||||||
|
|
||||||
|
### Production (Stable)
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development (Latest Features)
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Version
|
||||||
|
```yaml
|
||||||
|
image: sbcrumb/nfoguard:v1.5.5
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔗 Webhook Setup
|
||||||
|
|
||||||
|
Configure these webhook URLs in your applications:
|
||||||
|
|
||||||
|
**Radarr**: `http://nfoguard:8080/webhook/radarr`
|
||||||
|
**Sonarr**: `http://nfoguard:8080/webhook/sonarr`
|
||||||
|
|
||||||
|
**Triggers**: On Import, On Upgrade, On Rename
|
||||||
|
|
||||||
|
## 🔄 Manual Operations
|
||||||
|
|
||||||
|
### Manual Scanning
|
||||||
|
|
||||||
|
Trigger manual scans via API endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual scan all media (movies and TV)
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=both"
|
||||||
|
|
||||||
|
# Manual scan TV only
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=tv"
|
||||||
|
|
||||||
|
# Manual scan movies only
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||||
|
|
||||||
|
# Manual scan specific path
|
||||||
|
curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
||||||
|
|
||||||
|
# Bulk update all movies from Radarr database
|
||||||
|
curl -X POST "http://localhost:8080/bulk/update"
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
| `/webhook/radarr` | POST | Radarr webhook handler |
|
||||||
|
| `/webhook/sonarr` | POST | Sonarr webhook handler |
|
||||||
|
| `/manual/scan` | POST | Manual media scanning |
|
||||||
|
| `/bulk/update` | POST | Bulk movie updates from Radarr DB |
|
||||||
|
|
||||||
|
### Manual Scan Parameters
|
||||||
|
|
||||||
|
- `scan_type`: `both`, `movies`, `tv`
|
||||||
|
- `path`: Specific directory path to scan
|
||||||
|
- Use for initial setup or fixing existing media
|
||||||
|
|
||||||
|
## 📁 Volume Mapping
|
||||||
|
|
||||||
|
Critical: Update `docker-compose.yml` with your actual paths:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # NFOGuard data
|
||||||
|
- /your/movies/path:/media/Movies/movies:rw # Movie library
|
||||||
|
- /your/tv/path:/media/TV/tv:rw # TV library
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Examples
|
||||||
|
|
||||||
|
**Unraid**:
|
||||||
|
```yaml
|
||||||
|
- /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||||
|
- /mnt/user/Media/TV:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Synology**:
|
||||||
|
```yaml
|
||||||
|
- /volume1/Media/Movies:/media/Movies/movies:rw
|
||||||
|
- /volume1/Media/TV:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Standard Linux**:
|
||||||
|
```yaml
|
||||||
|
- /home/user/media/movies:/media/Movies/movies:rw
|
||||||
|
- /home/user/media/tv:/media/TV/tv:rw
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Check Logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f nfoguard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Debug Mode
|
||||||
|
```bash
|
||||||
|
# In .env file
|
||||||
|
DEBUG=true
|
||||||
|
PATH_DEBUG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Permission Errors**: Ensure NFOGuard can write to mounted directories
|
||||||
|
2. **Path Mapping**: Verify container paths match `.env` configuration
|
||||||
|
3. **Webhooks**: Check URLs and ensure port 8080 is accessible
|
||||||
|
4. **Database**: Verify PostgreSQL credentials in `.env.secrets`
|
||||||
|
|
||||||
|
## 📊 What NFOGuard Does
|
||||||
|
|
||||||
|
### Before
|
||||||
|
```xml
|
||||||
|
<movie>
|
||||||
|
<title>Movie Title</title>
|
||||||
|
<year>2023</year>
|
||||||
|
<!-- Existing Radarr metadata -->
|
||||||
|
</movie>
|
||||||
|
```
|
||||||
|
|
||||||
|
### After
|
||||||
|
```xml
|
||||||
|
<movie>
|
||||||
|
<title>Movie Title</title>
|
||||||
|
<year>2023</year>
|
||||||
|
<!-- Existing Radarr metadata preserved -->
|
||||||
|
|
||||||
|
<!-- NFOGuard additions at bottom -->
|
||||||
|
<digital_release_date>2023-03-15</digital_release_date>
|
||||||
|
<lockdata>true</lockdata>
|
||||||
|
<!-- Manager: NFOGuard -->
|
||||||
|
</movie>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Requirements
|
||||||
|
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Radarr and/or Sonarr
|
||||||
|
- Media files in accessible directories
|
||||||
|
- Network connectivity between services
|
||||||
|
|
||||||
|
## 📁 Directory Structure Requirements
|
||||||
|
|
||||||
|
NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions:
|
||||||
|
|
||||||
|
### 🎬 **Movies**
|
||||||
|
|
||||||
|
**Directory Structure:**
|
||||||
|
```
|
||||||
|
/movies/
|
||||||
|
└── Movie Title (2024) [tt1234567]/
|
||||||
|
├── movie.mkv
|
||||||
|
└── movie.nfo (created by NFOGuard)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identification Methods:**
|
||||||
|
1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]`
|
||||||
|
2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below)
|
||||||
|
3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`
|
||||||
|
4. **Case insensitive** - `[TT1234567]` works too
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
✅ Action Film (2024) [tt1234567]/
|
||||||
|
✅ Drama Movie [imdb-tt7654321]/
|
||||||
|
✅ SciFi Thriller (2023) [TT9876543]/
|
||||||
|
❌ Missing IMDB Directory/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📺 **TV Shows**
|
||||||
|
|
||||||
|
**Directory Structure:**
|
||||||
|
```
|
||||||
|
/tv/
|
||||||
|
└── Series Title (2024) [tt1234567]/
|
||||||
|
├── Season 01/
|
||||||
|
│ ├── Series S01E01.mkv
|
||||||
|
│ ├── Series S01E02.mkv
|
||||||
|
│ ├── S01E01.nfo (created by NFOGuard)
|
||||||
|
│ └── S01E02.nfo (created by NFOGuard)
|
||||||
|
├── Season 02/
|
||||||
|
└── tvshow.nfo (created by NFOGuard)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Identification Methods:**
|
||||||
|
1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]`
|
||||||
|
2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below)
|
||||||
|
3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc.
|
||||||
|
4. **Episode files** must contain season/episode info:
|
||||||
|
- **SxxExx format**: `S01E01`, `S1E1`, `s01e01`
|
||||||
|
- **Dot format**: `1.1`, `01.01`
|
||||||
|
5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts`
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
✅ Drama Series (2024) [tt1234567]/
|
||||||
|
├── Season 01/
|
||||||
|
│ ├── Drama S01E01.mkv
|
||||||
|
│ └── Drama S01E02.mkv
|
||||||
|
└── Season 02/
|
||||||
|
|
||||||
|
✅ Comedy Show [tt7654321]/
|
||||||
|
└── Season 1/
|
||||||
|
├── Comedy 1.1.mkv
|
||||||
|
└── Comedy 1.2.mkv
|
||||||
|
|
||||||
|
❌ Series Without IMDB []/
|
||||||
|
❌ Series [tt1234567]/Episode01.mkv (no season directory)
|
||||||
|
❌ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📄 **NFO File Identification Format**
|
||||||
|
|
||||||
|
NFOGuard can extract IMDb IDs from existing NFO files using these XML tags:
|
||||||
|
|
||||||
|
**Movie NFO (movie.nfo):**
|
||||||
|
```xml
|
||||||
|
<!-- Method 1: uniqueid tag (preferred) -->
|
||||||
|
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||||
|
|
||||||
|
<!-- Method 2: imdbid tag -->
|
||||||
|
<imdbid>tt1234567</imdbid>
|
||||||
|
|
||||||
|
<!-- Method 3: imdb tag -->
|
||||||
|
<imdb>tt1234567</imdb>
|
||||||
|
```
|
||||||
|
|
||||||
|
**TV Show NFO (tvshow.nfo):**
|
||||||
|
```xml
|
||||||
|
<!-- Same format as movies -->
|
||||||
|
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||||
|
<imdbid>tt1234567</imdbid>
|
||||||
|
<imdb>tt1234567</imdb>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Episode NFO Files:**
|
||||||
|
NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`:
|
||||||
|
- `S01E01.nfo` for Season 1, Episode 1
|
||||||
|
- `S02E05.nfo` for Season 2, Episode 5
|
||||||
|
- Always zero-padded format (S01E01, not S1E1)
|
||||||
|
- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format
|
||||||
|
|
||||||
|
### 🚫 **What Gets Skipped**
|
||||||
|
NFOGuard will ignore:
|
||||||
|
- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs
|
||||||
|
- Directories without video files
|
||||||
|
- TV episodes without recognizable season/episode patterns
|
||||||
|
- Season directories that don't match "Season X" format
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
||||||
|
- **Documentation**: See `SETUP.md` for detailed instructions
|
||||||
|
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
Commercial licensing and enterprise features may be available separately. Contact us for more information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**NFOGuard** - Keeping your media metadata clean and organized! 🎯
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# NFOGuard Setup Guide
|
||||||
|
|
||||||
|
## 🔐 Secure Configuration Setup
|
||||||
|
|
||||||
|
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
|
||||||
|
|
||||||
|
- **`.env`** - Main configuration (safe to share for debugging)
|
||||||
|
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
|
||||||
|
|
||||||
|
### Step 1: Copy Configuration Templates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy main configuration
|
||||||
|
cp .env.template .env
|
||||||
|
|
||||||
|
# Copy secrets configuration
|
||||||
|
cp .env.secrets.template .env.secrets
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Configure Main Settings
|
||||||
|
|
||||||
|
Edit your `.env` file with your specific paths and preferences:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Media paths (adjust to your directory structure)
|
||||||
|
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||||
|
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||||
|
|
||||||
|
# Database connection details
|
||||||
|
RADARR_DB_HOST=radarr-postgres
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr
|
||||||
|
RADARR_DB_USER=radarr
|
||||||
|
|
||||||
|
# Processing preferences
|
||||||
|
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||||
|
ALLOW_FILE_DATE_FALLBACK=false
|
||||||
|
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||||
|
|
||||||
|
# TV webhook processing mode (v0.6.0+)
|
||||||
|
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Configure Secrets
|
||||||
|
|
||||||
|
Edit your `.env.secrets` file with your actual API keys and passwords:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database password
|
||||||
|
RADARR_DB_PASSWORD=your_actual_radarr_password
|
||||||
|
|
||||||
|
# TMDB API key (required for release date detection)
|
||||||
|
TMDB_API_KEY=your_actual_tmdb_api_key
|
||||||
|
|
||||||
|
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||||
|
SONARR_API_KEY=your_actual_sonarr_api_key
|
||||||
|
|
||||||
|
# Optional API keys
|
||||||
|
RADARR_API_KEY=your_radarr_api_key
|
||||||
|
OMDB_API_KEY=your_omdb_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Configuration
|
||||||
|
|
||||||
|
Test your setup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test database connections
|
||||||
|
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||||
|
|
||||||
|
# Test movie scanning
|
||||||
|
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||||
|
|
||||||
|
# Check system health
|
||||||
|
curl "http://localhost:8080/health"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
### API Key Masking
|
||||||
|
All API keys and passwords are automatically masked in logs:
|
||||||
|
```
|
||||||
|
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
|
||||||
|
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Data Separation
|
||||||
|
- **Main `.env`**: Paths, preferences, URLs (safe to share)
|
||||||
|
- **`.env.secrets`**: API keys, passwords (never commit to version control)
|
||||||
|
- **Automatic loading**: Both files loaded automatically at startup
|
||||||
|
|
||||||
|
### Git Protection
|
||||||
|
The `.gitignore` file prevents accidental commits:
|
||||||
|
```
|
||||||
|
.env
|
||||||
|
.env.secrets
|
||||||
|
.env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠 Troubleshooting
|
||||||
|
|
||||||
|
### "Environment files not loaded" Warning
|
||||||
|
Install python-dotenv:
|
||||||
|
```bash
|
||||||
|
pip install python-dotenv==1.0.0
|
||||||
|
# or
|
||||||
|
docker-compose build # rebuilds with updated requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sharing Configuration for Help
|
||||||
|
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
|
||||||
|
|
||||||
|
### Migration from Old Setup
|
||||||
|
If you have an existing `.env` with API keys:
|
||||||
|
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
|
||||||
|
2. Remove sensitive data from `.env`
|
||||||
|
3. Restart NFOGuard
|
||||||
|
|
||||||
|
## 🎯 Docker Compose Example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
nfoguard:
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
container_name: nfoguard
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- /path/to/your/media:/media:rw
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./.env:/app/.env:ro # Main configuration
|
||||||
|
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
depends_on:
|
||||||
|
- radarr-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Configuration Reference
|
||||||
|
|
||||||
|
### Main Configuration (.env)
|
||||||
|
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
|
||||||
|
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
|
||||||
|
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
|
||||||
|
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
|
||||||
|
|
||||||
|
### Secrets Configuration (.env.secrets)
|
||||||
|
- **Database**: `RADARR_DB_PASSWORD`
|
||||||
|
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
|
||||||
|
- **Optional**: `JELLYSEERR_API_KEY`
|
||||||
|
|
||||||
|
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
External API clients for TMDB, OMDb, and Jellyseerr
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from urllib.parse import urlencode, quote
|
||||||
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from core.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Make GET request and return JSON"""
|
||||||
|
try:
|
||||||
|
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
||||||
|
with urlopen(req, timeout=timeout) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as e:
|
||||||
|
# Handle specific HTTP errors more gracefully
|
||||||
|
if suppress_404 and e.code in [400, 404]:
|
||||||
|
_log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"GET {url} failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_to_iso(date_str: str) -> Optional[str]:
|
||||||
|
"""Parse various date formats to ISO string"""
|
||||||
|
if not date_str or date_str == "N/A":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
|
||||||
|
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||||
|
return dt.isoformat(timespec="seconds")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TVDBClient:
|
||||||
|
"""The TV Database API client for IMDB to TVDB ID conversion"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
self.api_key = api_key or os.environ.get("TVDB_API_KEY", "")
|
||||||
|
self.base_url = "https://api4.thetvdb.com/v4"
|
||||||
|
self._token = None
|
||||||
|
self._token_expires = 0
|
||||||
|
|
||||||
|
def _get_token(self) -> Optional[str]:
|
||||||
|
"""Get TVDB auth token (cached)"""
|
||||||
|
if not self.api_key:
|
||||||
|
_log("DEBUG", "TVDB: No API key provided")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if time.time() < self._token_expires and self._token:
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...")
|
||||||
|
req = UrlRequest(
|
||||||
|
f"{self.base_url}/login",
|
||||||
|
data=json.dumps({"apikey": self.api_key}).encode('utf-8'),
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
_log("DEBUG", f"TVDB login response: {data}")
|
||||||
|
if data.get("status") == "success":
|
||||||
|
self._token = data["data"]["token"]
|
||||||
|
self._token_expires = time.time() + 3600 # 1 hour
|
||||||
|
_log("INFO", f"✅ TVDB: Authentication successful")
|
||||||
|
return self._token
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"TVDB login failed: {data}")
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"TVDB login failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Convert IMDB ID to TVDB series ID using TVDB v4 API"""
|
||||||
|
token = self._get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try the official v4 search endpoint first
|
||||||
|
# According to docs: /search?query=imdb_id&type=series
|
||||||
|
url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations"
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
_log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint")
|
||||||
|
data = _get_json(url, headers=headers, suppress_404=True)
|
||||||
|
|
||||||
|
if data and data.get("status") == "success" and data.get("data"):
|
||||||
|
series_list = data["data"]
|
||||||
|
_log("DEBUG", f"TVDB search response: found {len(series_list)} results")
|
||||||
|
|
||||||
|
# Look for exact IMDB match in results
|
||||||
|
for series in series_list:
|
||||||
|
# Check if this series has the IMDB ID we're looking for
|
||||||
|
remote_ids = series.get("remote_ids", [])
|
||||||
|
for remote in remote_ids:
|
||||||
|
if (remote.get("source_name") == "IMDB" and
|
||||||
|
remote.get("remote_id") == imdb_id):
|
||||||
|
tvdb_id = series.get("tvdb_id") or series.get("id")
|
||||||
|
if tvdb_id:
|
||||||
|
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}")
|
||||||
|
return str(tvdb_id)
|
||||||
|
|
||||||
|
# If no exact match, try the first result if it looks promising
|
||||||
|
if series_list:
|
||||||
|
first_result = series_list[0]
|
||||||
|
tvdb_id = first_result.get("tvdb_id") or first_result.get("id")
|
||||||
|
if tvdb_id:
|
||||||
|
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (first result)")
|
||||||
|
return str(tvdb_id)
|
||||||
|
|
||||||
|
# If search didn't work, try the legacy remoteid endpoint
|
||||||
|
_log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}")
|
||||||
|
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
||||||
|
data = _get_json(url, headers=headers, suppress_404=True)
|
||||||
|
|
||||||
|
if data and data.get("status") == "success" and data.get("data"):
|
||||||
|
series_list = data["data"]
|
||||||
|
if series_list and len(series_list) > 0:
|
||||||
|
tvdb_id = series_list[0].get("id")
|
||||||
|
if tvdb_id:
|
||||||
|
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (legacy endpoint)")
|
||||||
|
return str(tvdb_id)
|
||||||
|
|
||||||
|
# If we get here, the series wasn't found in TVDB
|
||||||
|
_log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"TVDB API error for {imdb_id}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TMDBClient:
|
||||||
|
"""The Movie Database API client"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None, primary_country: str = "US"):
|
||||||
|
self.api_key = api_key or os.environ.get("TMDB_API_KEY", "")
|
||||||
|
self.primary_country = primary_country.upper()
|
||||||
|
self.enabled = bool(self.api_key)
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Make GET request to TMDB API"""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
params = params or {}
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}"
|
||||||
|
return _get_json(url, timeout=20)
|
||||||
|
|
||||||
|
def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find movie by IMDb ID"""
|
||||||
|
result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"})
|
||||||
|
if result and result.get("movie_results"):
|
||||||
|
return result["movie_results"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get detailed movie information"""
|
||||||
|
return self._get(f"/movie/{tmdb_id}")
|
||||||
|
|
||||||
|
def get_digital_release_date(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Get digital release date for a movie"""
|
||||||
|
_log("INFO", f"🔍 TMDB: Looking for digital release date for {imdb_id}")
|
||||||
|
movie = self.find_by_imdb(imdb_id)
|
||||||
|
if not movie:
|
||||||
|
_log("WARNING", f"❌ TMDB: Movie not found for {imdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmdb_id = movie.get("id")
|
||||||
|
_log("INFO", f"✅ TMDB: Found movie ID {tmdb_id} for {imdb_id}")
|
||||||
|
if not tmdb_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||||
|
if not release_dates:
|
||||||
|
_log("WARNING", f"❌ TMDB: No release dates data for movie {tmdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("INFO", f"🔍 TMDB: Got release dates data, looking for {self.primary_country} digital releases")
|
||||||
|
|
||||||
|
# Debug: Show all available countries
|
||||||
|
countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])]
|
||||||
|
_log("INFO", f"📍 TMDB: Available countries: {countries}")
|
||||||
|
|
||||||
|
for entry in release_dates.get("results", []):
|
||||||
|
country = entry.get("iso_3166_1", "").upper()
|
||||||
|
if country != self.primary_country:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_log("INFO", f"🎯 TMDB: Found {country} release data")
|
||||||
|
releases = entry.get("release_dates", [])
|
||||||
|
_log("INFO", f"🎬 TMDB: Release types available: {[r.get('type') for r in releases]}")
|
||||||
|
|
||||||
|
# Collect all available releases with their types and dates
|
||||||
|
available_releases = []
|
||||||
|
for release in releases:
|
||||||
|
release_type = release.get("type")
|
||||||
|
release_date = release.get("release_date")
|
||||||
|
_log("INFO", f"📅 TMDB: Type {release_type}, Date: {release_date}")
|
||||||
|
|
||||||
|
if release_date:
|
||||||
|
parsed_date = _parse_date_to_iso(release_date)
|
||||||
|
if parsed_date:
|
||||||
|
available_releases.append((release_type, parsed_date))
|
||||||
|
|
||||||
|
# Apply TMDB release type priority order
|
||||||
|
tmdb_priority = self._get_tmdb_type_priority()
|
||||||
|
for preferred_type in tmdb_priority:
|
||||||
|
for release_type, parsed_date in available_releases:
|
||||||
|
if release_type == preferred_type:
|
||||||
|
release_type_names = {
|
||||||
|
1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical",
|
||||||
|
4: "Digital", 5: "Physical", 6: "TV Premiere"
|
||||||
|
}
|
||||||
|
type_name = release_type_names.get(release_type, f"Type {release_type}")
|
||||||
|
_log("INFO", f"✅ TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})")
|
||||||
|
return parsed_date
|
||||||
|
|
||||||
|
_log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in {self.primary_country}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_tmdb_type_priority(self) -> List[int]:
|
||||||
|
"""Get TMDB release type priority order from environment"""
|
||||||
|
# Default priority: Digital first, then Physical, then Theatrical, then others
|
||||||
|
default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere
|
||||||
|
|
||||||
|
priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority)
|
||||||
|
try:
|
||||||
|
# Parse comma-separated numbers
|
||||||
|
priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()]
|
||||||
|
if priority_list:
|
||||||
|
_log("DEBUG", f"TMDB type priority: {priority_list}")
|
||||||
|
return priority_list
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default")
|
||||||
|
return [4, 5, 3, 2, 6, 1]
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default")
|
||||||
|
return [4, 5, 3, 2, 6, 1]
|
||||||
|
|
||||||
|
def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Get theatrical release date for a movie"""
|
||||||
|
movie = self.find_by_imdb(imdb_id)
|
||||||
|
if not movie:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmdb_id = movie.get("id")
|
||||||
|
if not tmdb_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||||
|
if not release_dates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for entry in release_dates.get("results", []):
|
||||||
|
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for release in entry.get("release_dates", []):
|
||||||
|
if release.get("type") == 3 and release.get("release_date"): # Theatrical release
|
||||||
|
return _parse_date_to_iso(release["release_date"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_physical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Get physical release date (DVD/Blu-ray) for a movie"""
|
||||||
|
movie = self.find_by_imdb(imdb_id)
|
||||||
|
if not movie:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tmdb_id = movie.get("id")
|
||||||
|
if not tmdb_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||||
|
if not release_dates:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for entry in release_dates.get("results", []):
|
||||||
|
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for release in entry.get("release_dates", []):
|
||||||
|
if release.get("type") == 5 and release.get("release_date"): # Physical release
|
||||||
|
return _parse_date_to_iso(release["release_date"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]:
|
||||||
|
"""Get episode air dates for a TV season"""
|
||||||
|
result = self._get(f"/tv/{tv_id}/season/{season_number}")
|
||||||
|
episodes = {}
|
||||||
|
|
||||||
|
if result:
|
||||||
|
for episode in result.get("episodes", []):
|
||||||
|
ep_num = episode.get("episode_number")
|
||||||
|
air_date = episode.get("air_date")
|
||||||
|
if isinstance(ep_num, int) and air_date:
|
||||||
|
episodes[ep_num] = air_date
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
class OMDbClient:
|
||||||
|
"""Open Movie Database API client"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
self.api_key = api_key or os.environ.get("OMDB_API_KEY", "")
|
||||||
|
self.enabled = bool(self.api_key)
|
||||||
|
|
||||||
|
def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get movie details from OMDb"""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
params = {"i": imdb_id, "apikey": self.api_key}
|
||||||
|
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||||
|
result = _get_json(url, timeout=15)
|
||||||
|
|
||||||
|
if result and result.get("Response") == "True":
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_dvd_release_date(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Get DVD/digital release date"""
|
||||||
|
details = self.get_movie_details(imdb_id)
|
||||||
|
if not details:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dvd_date = details.get("DVD") or details.get("Released")
|
||||||
|
if not dvd_date or dvd_date == "N/A":
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Try to parse various date formats
|
||||||
|
for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc)
|
||||||
|
return dt.isoformat(timespec="seconds")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]:
|
||||||
|
"""Get episode release dates for a TV season"""
|
||||||
|
if not self.enabled:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key}
|
||||||
|
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||||
|
result = _get_json(url, timeout=15)
|
||||||
|
|
||||||
|
episodes = {}
|
||||||
|
if result and result.get("Response") == "True":
|
||||||
|
for episode in result.get("Episodes", []):
|
||||||
|
try:
|
||||||
|
ep_num = int(episode.get("Episode", 0))
|
||||||
|
released = episode.get("Released")
|
||||||
|
if ep_num and released and released != "N/A":
|
||||||
|
episodes[ep_num] = released
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return episodes
|
||||||
|
|
||||||
|
|
||||||
|
class JellyseerrClient:
|
||||||
|
"""Jellyseerr API client"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = None, api_key: str = None):
|
||||||
|
self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/")
|
||||||
|
self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "")
|
||||||
|
self.enabled = bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
|
def _get(self, path: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Make GET request to Jellyseerr API"""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self.base_url}/api/v1{path}"
|
||||||
|
headers = {"X-Api-Key": self.api_key, "Accept": "application/json"}
|
||||||
|
return _get_json(url, timeout=20, headers=headers)
|
||||||
|
|
||||||
|
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get movie details from Jellyseerr"""
|
||||||
|
return self._get(f"/movie/{tmdb_id}")
|
||||||
|
|
||||||
|
def get_digital_release_dates(self, tmdb_id: int) -> List[str]:
|
||||||
|
"""Get digital release date candidates from Jellyseerr"""
|
||||||
|
details = self.get_movie_details(tmdb_id)
|
||||||
|
if not details:
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# Check direct fields
|
||||||
|
for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"):
|
||||||
|
value = details.get(field)
|
||||||
|
if value:
|
||||||
|
iso_date = _parse_date_to_iso(value)
|
||||||
|
if iso_date:
|
||||||
|
candidates.append(iso_date)
|
||||||
|
|
||||||
|
# Check release dates array
|
||||||
|
for array_field in ("releaseDates", "releases", "dates"):
|
||||||
|
release_array = details.get(array_field)
|
||||||
|
if not isinstance(release_array, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for release in release_array:
|
||||||
|
if not isinstance(release, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
release_type = (release.get("type") or release.get("label") or "").lower()
|
||||||
|
release_date = release.get("date") or release.get("releaseDate")
|
||||||
|
|
||||||
|
if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type):
|
||||||
|
iso_date = _parse_date_to_iso(release_date)
|
||||||
|
if iso_date:
|
||||||
|
candidates.append(iso_date)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalClientManager:
|
||||||
|
"""Manager for all external API clients"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Get country from environment, default to US
|
||||||
|
tmdb_country = os.environ.get("TMDB_COUNTRY", "US")
|
||||||
|
_log("INFO", f"🌍 TMDB: Initializing with country: {tmdb_country}")
|
||||||
|
|
||||||
|
self.tmdb = TMDBClient(primary_country=tmdb_country)
|
||||||
|
self.omdb = OMDbClient()
|
||||||
|
self.jellyseerr = JellyseerrClient()
|
||||||
|
self.tvdb = TVDBClient()
|
||||||
|
|
||||||
|
def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Get release date using configurable priority order with smart date validation"""
|
||||||
|
|
||||||
|
# Get all possible release dates
|
||||||
|
release_options = {}
|
||||||
|
|
||||||
|
if self.tmdb.enabled:
|
||||||
|
# Digital release
|
||||||
|
digital_date = self.tmdb.get_digital_release_date(imdb_id)
|
||||||
|
if digital_date:
|
||||||
|
release_options["digital"] = (digital_date, "tmdb:digital")
|
||||||
|
|
||||||
|
# Physical release
|
||||||
|
physical_date = self.tmdb.get_physical_release_date(imdb_id)
|
||||||
|
if physical_date:
|
||||||
|
release_options["physical"] = (physical_date, "tmdb:physical")
|
||||||
|
|
||||||
|
# Theatrical release
|
||||||
|
theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id)
|
||||||
|
if theatrical_date:
|
||||||
|
release_options["theatrical"] = (theatrical_date, "tmdb:theatrical")
|
||||||
|
|
||||||
|
# Add OMDb options
|
||||||
|
if self.omdb.enabled:
|
||||||
|
omdb_date = self.omdb.get_dvd_release_date(imdb_id)
|
||||||
|
if omdb_date and "physical" not in release_options:
|
||||||
|
release_options["physical"] = (omdb_date, "omdb:dvd")
|
||||||
|
|
||||||
|
# Add Jellyseerr digital releases
|
||||||
|
if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options:
|
||||||
|
tmdb_movie = self.tmdb.find_by_imdb(imdb_id)
|
||||||
|
if tmdb_movie:
|
||||||
|
tmdb_id = tmdb_movie.get("id")
|
||||||
|
if tmdb_id:
|
||||||
|
jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id)
|
||||||
|
if jellyseerr_dates:
|
||||||
|
earliest_jellyseerr = min(jellyseerr_dates)
|
||||||
|
release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital")
|
||||||
|
|
||||||
|
# Smart date validation: Check if priority order makes sense given the actual dates
|
||||||
|
if enable_smart_validation and len(release_options) > 1:
|
||||||
|
validated_choice = self._validate_date_choice(release_options, priority_order)
|
||||||
|
if validated_choice:
|
||||||
|
return validated_choice
|
||||||
|
|
||||||
|
# Return first available option according to priority (fallback behavior)
|
||||||
|
for priority in priority_order:
|
||||||
|
if priority in release_options:
|
||||||
|
return release_options[priority]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Validate date choice and prefer theatrical if digital/physical are unreasonably late"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Get configuration for maximum gap (default: 10 years)
|
||||||
|
max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10"))
|
||||||
|
|
||||||
|
# Parse all available dates
|
||||||
|
parsed_dates = {}
|
||||||
|
for release_type, (date_str, source) in release_options.items():
|
||||||
|
try:
|
||||||
|
parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not parsed_dates or "theatrical" not in parsed_dates:
|
||||||
|
return None # No smart validation possible without theatrical date
|
||||||
|
|
||||||
|
theatrical_date, theatrical_source = parsed_dates["theatrical"]
|
||||||
|
|
||||||
|
# Check each priority option against theatrical date
|
||||||
|
for priority in priority_order:
|
||||||
|
if priority == "theatrical":
|
||||||
|
continue # Skip theatrical in this validation
|
||||||
|
|
||||||
|
if priority in parsed_dates:
|
||||||
|
priority_date, priority_source = parsed_dates[priority]
|
||||||
|
|
||||||
|
# Calculate the gap in years
|
||||||
|
gap = (priority_date - theatrical_date).days / 365.25
|
||||||
|
|
||||||
|
# If the gap is too large, skip this priority and continue
|
||||||
|
if gap > max_reasonable_gap_years:
|
||||||
|
print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# This priority option is reasonable, use it
|
||||||
|
return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)")
|
||||||
|
|
||||||
|
# If all priority options are unreasonable, fall back to theatrical
|
||||||
|
if "theatrical" in release_options:
|
||||||
|
theatrical_date_str, theatrical_source = release_options["theatrical"]
|
||||||
|
return (theatrical_date_str, f"{theatrical_source} (smart fallback)")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]:
|
||||||
|
"""Get digital release date candidates from all sources (legacy method)"""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# Try the new priority system with digital-first fallback
|
||||||
|
result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"])
|
||||||
|
if result:
|
||||||
|
candidates.append(result)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Get the earliest digital release date (legacy method)"""
|
||||||
|
candidates = self.get_digital_release_candidates(imdb_id)
|
||||||
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||||
|
"""Get TVDB series ID from IMDB ID"""
|
||||||
|
# Check if TVDB lookups are disabled
|
||||||
|
if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]:
|
||||||
|
_log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self.tvdb.api_key:
|
||||||
|
_log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.tvdb.imdb_to_tvdb_series_id(imdb_id)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the clients
|
||||||
|
manager = ExternalClientManager()
|
||||||
|
|
||||||
|
test_imdb = "tt1596343" # Example IMDb ID
|
||||||
|
digital_candidates = manager.get_digital_release_candidates(test_imdb)
|
||||||
|
print(f"Digital release candidates for {test_imdb}: {digital_candidates}")
|
||||||
|
|
||||||
|
earliest = manager.get_earliest_digital_release(test_imdb)
|
||||||
|
if earliest:
|
||||||
|
print(f"Earliest digital release: {earliest[0]} ({earliest[1]})")
|
||||||
|
else:
|
||||||
|
print("No digital release dates found")
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enhanced Radarr API client with improved import date detection"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
|
from urllib.parse import urlencode, urljoin
|
||||||
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from core.logging import _log
|
||||||
|
|
||||||
|
# Import path mapper for proper path handling
|
||||||
|
try:
|
||||||
|
from core.path_mapper import path_mapper
|
||||||
|
except ImportError:
|
||||||
|
# Fallback for standalone testing
|
||||||
|
class DummyPathMapper:
|
||||||
|
def analyze_import_source_path(self, path):
|
||||||
|
return "/downloads/" in path.lower(), "basic_check"
|
||||||
|
|
||||||
|
def is_download_path(self, path):
|
||||||
|
return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
|
||||||
|
|
||||||
|
path_mapper = DummyPathMapper()
|
||||||
|
|
||||||
|
# Import database client for enhanced performance
|
||||||
|
try:
|
||||||
|
from clients.radarr_db_client import RadarrDbClient
|
||||||
|
except ImportError:
|
||||||
|
RadarrDbClient = None
|
||||||
|
|
||||||
|
|
||||||
|
class RadarrClient:
|
||||||
|
"""Enhanced Radarr API client with improved import date detection"""
|
||||||
|
|
||||||
|
# Radarr History API event types (HistoryEventType enum)
|
||||||
|
# From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
|
||||||
|
EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
|
||||||
|
EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
|
||||||
|
EVENT_TYPE_FAILED = 4 # Download or import failed
|
||||||
|
EVENT_TYPE_RETAGGED = 6 # Files were tagged
|
||||||
|
EVENT_TYPE_RENAMED = 8 # Files were renamed
|
||||||
|
|
||||||
|
# Event types that indicate real imports
|
||||||
|
REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
|
||||||
|
|
||||||
|
# These are now handled by path_mapper, but keeping for backward compatibility
|
||||||
|
DOWNLOAD_PATH_INDICATORS = [
|
||||||
|
'/downloads/', '/download/', '/completed/', '/importing/',
|
||||||
|
'/nzbs/', '/torrents/', '/temp/', '/tmp/',
|
||||||
|
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
|
||||||
|
'usenet', 'torrent', 'radarr', 'completed', 'processing'
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.retries = max(0, retries)
|
||||||
|
|
||||||
|
# Initialize database client - REQUIRED for operation
|
||||||
|
self.db_client = None
|
||||||
|
if RadarrDbClient:
|
||||||
|
try:
|
||||||
|
self.db_client = RadarrDbClient.from_env()
|
||||||
|
if self.db_client:
|
||||||
|
_log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled")
|
||||||
|
else:
|
||||||
|
_log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled")
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}")
|
||||||
|
self.db_client = None
|
||||||
|
else:
|
||||||
|
_log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||||
|
"""Make GET request to Radarr API with retries"""
|
||||||
|
if not self.api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
last_err = None
|
||||||
|
|
||||||
|
while attempt <= self.retries:
|
||||||
|
try:
|
||||||
|
params = params or {}
|
||||||
|
params["apikey"] = self.api_key
|
||||||
|
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||||
|
|
||||||
|
if params:
|
||||||
|
url = url + ("&" if "?" in url else "?") + urlencode(params)
|
||||||
|
|
||||||
|
_log("DEBUG", f"Radarr API Request: {url}")
|
||||||
|
req = UrlRequest(url, headers={"Accept": "application/json"})
|
||||||
|
|
||||||
|
with urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
data = resp.read().decode("utf-8")
|
||||||
|
result = json.loads(data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||||
|
last_err = e
|
||||||
|
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
|
||||||
|
time.sleep(min(2 ** attempt, 5)) # Exponential backoff
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
_log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find movie by IMDb ID - DATABASE ONLY mode"""
|
||||||
|
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||||
|
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
|
||||||
|
|
||||||
|
# Database required - no API fallback
|
||||||
|
if self.db_client:
|
||||||
|
try:
|
||||||
|
movie = self.db_client.get_movie_by_imdb(imdb_id)
|
||||||
|
if movie:
|
||||||
|
_log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})")
|
||||||
|
return movie
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database lookup failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# No database client available
|
||||||
|
_log("ERROR", "Database client required for movie lookup - API mode disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Analyze a history event to determine if it's a real import.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The history event to analyze
|
||||||
|
movie_info: Optional movie information to validate paths against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_real_import, reason, date_iso)
|
||||||
|
"""
|
||||||
|
event_type = event.get("eventType")
|
||||||
|
date_str = event.get("date")
|
||||||
|
event_data = event.get("data", {})
|
||||||
|
|
||||||
|
# Parse date
|
||||||
|
date_iso = None
|
||||||
|
if date_str:
|
||||||
|
try:
|
||||||
|
date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
except Exception:
|
||||||
|
date_iso = None
|
||||||
|
|
||||||
|
if not date_iso:
|
||||||
|
return False, "no_valid_date", None
|
||||||
|
|
||||||
|
# Convert event type to int if needed
|
||||||
|
try:
|
||||||
|
event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
event_type_int = None
|
||||||
|
|
||||||
|
# Check if event type indicates import
|
||||||
|
if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
|
||||||
|
return False, f"event_type_not_import({event_type})", date_iso
|
||||||
|
|
||||||
|
# Get all possible source paths/titles
|
||||||
|
source_items = []
|
||||||
|
|
||||||
|
# Get both sourcePath and importedPath if available
|
||||||
|
if event_data:
|
||||||
|
for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
|
||||||
|
if event_data.get(key):
|
||||||
|
source_items.append(event_data[key])
|
||||||
|
|
||||||
|
# Also check event root for these fields
|
||||||
|
for key in ['sourcePath', 'sourceTitle', 'importedPath']:
|
||||||
|
if event.get(key):
|
||||||
|
source_items.append(event[key])
|
||||||
|
|
||||||
|
# Clean up and make unique
|
||||||
|
source_items = [str(s).lower().strip() for s in source_items if s]
|
||||||
|
source_items = list(set(source_items)) # Remove duplicates
|
||||||
|
|
||||||
|
if not source_items:
|
||||||
|
return False, "no_source_paths", date_iso
|
||||||
|
|
||||||
|
# If we have movie info, look for title/year match
|
||||||
|
if movie_info:
|
||||||
|
movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
|
||||||
|
movie_year = str(movie_info.get('year', ''))
|
||||||
|
|
||||||
|
for source in source_items:
|
||||||
|
# Clean up source text for comparison
|
||||||
|
source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
|
||||||
|
|
||||||
|
# Check if both title and year are in the source
|
||||||
|
if movie_title and movie_year:
|
||||||
|
if movie_title in source_clean and movie_year in source_clean:
|
||||||
|
_log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}")
|
||||||
|
return True, "matched_title_and_year", date_iso
|
||||||
|
|
||||||
|
# Also check for downloads path as secondary validation
|
||||||
|
if path_mapper.is_download_path(source):
|
||||||
|
_log("DEBUG", f"Source is from downloads: {source}")
|
||||||
|
return True, "from_downloads_path", date_iso
|
||||||
|
|
||||||
|
_log("DEBUG", f"⚠️ No match found in sources: {source_items}")
|
||||||
|
return False, "no_title_year_match", date_iso
|
||||||
|
|
||||||
|
# Fallback to basic path validation if no movie info
|
||||||
|
for source in source_items:
|
||||||
|
if path_mapper.is_download_path(source):
|
||||||
|
return True, "basic_download_path_match", date_iso
|
||||||
|
|
||||||
|
return False, "no_download_path_match", date_iso
|
||||||
|
|
||||||
|
def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Find earliest real import event with optimized querying.
|
||||||
|
Stops as soon as we find valid import events instead of loading everything.
|
||||||
|
"""
|
||||||
|
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||||
|
|
||||||
|
# Get movie info for path validation
|
||||||
|
movie_info = self._get(f"/api/v3/movie/{movie_id}")
|
||||||
|
if not movie_info or not isinstance(movie_info, dict):
|
||||||
|
_log("ERROR", f"Could not get movie info for ID {movie_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
earliest_real_import = None
|
||||||
|
first_grab = None
|
||||||
|
page = 1
|
||||||
|
page_size = 50 # Smaller pages for faster iteration
|
||||||
|
total_processed = 0
|
||||||
|
|
||||||
|
while page <= 20: # Safety limit
|
||||||
|
# Get history in chronological order
|
||||||
|
data = self._get("/api/v3/history", {
|
||||||
|
"movieId": str(movie_id),
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"sortKey": "date",
|
||||||
|
"sortDirection": "ascending"
|
||||||
|
})
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
|
||||||
|
items = data if isinstance(data, list) else data.get("records", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
|
||||||
|
_log("DEBUG", f"Page {page}: Processing {len(items)} events")
|
||||||
|
|
||||||
|
for event in items:
|
||||||
|
total_processed += 1
|
||||||
|
event_type = event.get("eventType")
|
||||||
|
if not event_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert event type to int or handle string types
|
||||||
|
try:
|
||||||
|
if isinstance(event_type, str):
|
||||||
|
# Map string event types to numeric values
|
||||||
|
string_to_numeric = {
|
||||||
|
"grabbed": self.EVENT_TYPE_GRABBED,
|
||||||
|
"downloadFolderImported": self.EVENT_TYPE_IMPORTED,
|
||||||
|
"movieFileImported": self.EVENT_TYPE_IMPORTED,
|
||||||
|
"downloadFailed": self.EVENT_TYPE_FAILED,
|
||||||
|
"movieFileRenamed": self.EVENT_TYPE_RENAMED,
|
||||||
|
"movieFileDeleted": 5 # Not in our constants but common
|
||||||
|
}
|
||||||
|
event_type = string_to_numeric.get(event_type, 0)
|
||||||
|
else:
|
||||||
|
event_type = int(event_type)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
_log("DEBUG", f"Unknown event type: {event_type}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for grab events (type 1) - but validate it's a real download
|
||||||
|
if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
|
||||||
|
if event.get("date"):
|
||||||
|
try:
|
||||||
|
# Get event data to check if this is a real grab with download info
|
||||||
|
event_data = event.get("data", {})
|
||||||
|
if isinstance(event_data, str):
|
||||||
|
try:
|
||||||
|
event_data = json.loads(event_data)
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
event_data = {}
|
||||||
|
|
||||||
|
# Check if this grab has actual download/indexer info
|
||||||
|
source_title = event_data.get("sourceTitle", "")
|
||||||
|
indexer = event_data.get("indexer", "")
|
||||||
|
|
||||||
|
# Only count grabs that have actual download metadata
|
||||||
|
if source_title or indexer:
|
||||||
|
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
_log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
|
||||||
|
else:
|
||||||
|
_log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Only process import events (type 3)
|
||||||
|
if event_type != self.EVENT_TYPE_IMPORTED:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get imported path from event data
|
||||||
|
imported_path = None
|
||||||
|
event_data = event.get("data", {})
|
||||||
|
|
||||||
|
# Handle both string and dict data
|
||||||
|
if isinstance(event_data, str):
|
||||||
|
try:
|
||||||
|
event_data = json.loads(event_data)
|
||||||
|
except (json.JSONDecodeError, AttributeError) as e:
|
||||||
|
_log("DEBUG", f"Failed to parse event data JSON: {e}")
|
||||||
|
continue
|
||||||
|
elif not isinstance(event_data, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
imported_path = event_data.get("importedPath", "")
|
||||||
|
if not imported_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
imported_path = imported_path.lower()
|
||||||
|
|
||||||
|
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||||
|
movie_title = (movie_info.get("title", "") or "").lower()
|
||||||
|
movie_year = str(movie_info.get("year", ""))
|
||||||
|
|
||||||
|
# First try IMDb ID match
|
||||||
|
# First try IMDb ID match
|
||||||
|
if movie_imdb and (
|
||||||
|
f"[imdb-{movie_imdb}]" in imported_path or
|
||||||
|
f"[{movie_imdb}]" in imported_path or
|
||||||
|
movie_imdb in imported_path
|
||||||
|
):
|
||||||
|
_log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
|
||||||
|
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
_log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}")
|
||||||
|
earliest_real_import = date_iso
|
||||||
|
break
|
||||||
|
|
||||||
|
# Then try title/year match with fuzzy path cleaning
|
||||||
|
if movie_title and movie_year:
|
||||||
|
# Clean strings for comparison
|
||||||
|
clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
|
||||||
|
clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
|
||||||
|
|
||||||
|
# Look for both title and year in the path
|
||||||
|
if clean_title in clean_path and movie_year in clean_path:
|
||||||
|
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
_log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
|
||||||
|
_log("INFO", f"✅ FOUND IMPORT at {date_iso}")
|
||||||
|
earliest_real_import = date_iso
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to normal import analysis
|
||||||
|
is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
|
||||||
|
if is_real and date_iso:
|
||||||
|
source_path = (event.get("data", {}).get("sourcePath", "") or
|
||||||
|
event.get("data", {}).get("droppedPath", "") or
|
||||||
|
event.get("sourcePath", "") or
|
||||||
|
event.get("data", {}).get("importedPath", "") or
|
||||||
|
event.get("importedPath", "") or "").lower()
|
||||||
|
|
||||||
|
if source_path:
|
||||||
|
# Check for path match
|
||||||
|
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||||
|
if movie_imdb and (
|
||||||
|
f"[imdb-{movie_imdb}]" in source_path or
|
||||||
|
f"[{movie_imdb}]" in source_path or
|
||||||
|
movie_imdb in source_path
|
||||||
|
):
|
||||||
|
_log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}")
|
||||||
|
earliest_real_import = date_iso
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for title/year match
|
||||||
|
movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
|
||||||
|
movie_year = str(movie_info.get("year", ""))
|
||||||
|
if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
|
||||||
|
_log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}")
|
||||||
|
earliest_real_import = date_iso
|
||||||
|
break
|
||||||
|
elif event_type == 3:
|
||||||
|
_log("DEBUG", f"⚠️ Skipped import event: {reason}")
|
||||||
|
|
||||||
|
# If we found a real import, no need to continue
|
||||||
|
if earliest_real_import:
|
||||||
|
break
|
||||||
|
|
||||||
|
# If we got less than page size, we've seen all events
|
||||||
|
if len(items) < page_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
_log("INFO", f"Processed {total_processed} events across {page-1} pages")
|
||||||
|
|
||||||
|
if earliest_real_import:
|
||||||
|
_log("INFO", f"✅ Using earliest real import: {earliest_real_import}")
|
||||||
|
return earliest_real_import
|
||||||
|
|
||||||
|
if first_grab:
|
||||||
|
_log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}")
|
||||||
|
return first_grab
|
||||||
|
|
||||||
|
_log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get movie files for a movie - DATABASE ONLY mode"""
|
||||||
|
if self.db_client:
|
||||||
|
_log("INFO", "Using database for movie files lookup")
|
||||||
|
# Database handles this internally in get_movie_file_date()
|
||||||
|
return []
|
||||||
|
|
||||||
|
_log("ERROR", "Database client required for movie files - API mode disabled")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
|
||||||
|
"""Get earliest file dateAdded - DATABASE ONLY mode"""
|
||||||
|
if self.db_client:
|
||||||
|
try:
|
||||||
|
return self.db_client.get_movie_file_date(movie_id)
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database file date query failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("ERROR", "Database client required for file dates - API mode disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
Get the best import date for a movie - DATABASE ONLY mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(date_iso, source_description)
|
||||||
|
"""
|
||||||
|
# Database required - no API fallback
|
||||||
|
if self.db_client:
|
||||||
|
try:
|
||||||
|
date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
|
||||||
|
if date_iso:
|
||||||
|
return date_iso, source
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"No import date found in database for movie_id {movie_id}")
|
||||||
|
return None, "radarr:db.no_date_found"
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database import date query failed: {e}")
|
||||||
|
return None, "radarr:db.error"
|
||||||
|
|
||||||
|
# No database client available
|
||||||
|
_log("ERROR", "Database client required for import date detection - API mode disabled")
|
||||||
|
return None, "radarr:db.not_configured"
|
||||||
|
|
||||||
|
def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
|
||||||
|
"""Get the earliest import date from Radarr history."""
|
||||||
|
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||||
|
|
||||||
|
earliest_real_import = None
|
||||||
|
earliest_grab_date = None
|
||||||
|
page = 1
|
||||||
|
page_size = 50
|
||||||
|
total_events = 0
|
||||||
|
|
||||||
|
# Get full movie history
|
||||||
|
while True:
|
||||||
|
# Get page of history
|
||||||
|
history_data = self._get_movie_history_page(movie_id, page, page_size)
|
||||||
|
if not history_data:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Process events on this page
|
||||||
|
for event in history_data:
|
||||||
|
event_type = event.get("eventType")
|
||||||
|
if not event_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse event date
|
||||||
|
event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
# Track earliest grab date as fallback (EventType 1)
|
||||||
|
if event_type == 1 and not earliest_grab_date:
|
||||||
|
earliest_grab_date = event_date
|
||||||
|
_log("DEBUG", f"Found first grab event at {earliest_grab_date}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Look for import events (EventType 3)
|
||||||
|
if event_type == 3:
|
||||||
|
try:
|
||||||
|
data = json.loads(event.get("data", "{}"))
|
||||||
|
if data.get("importedPath"):
|
||||||
|
_log("INFO", f"✅ FOUND IMPORT at {event_date}")
|
||||||
|
earliest_real_import = event_date
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Break if we found an import
|
||||||
|
if earliest_real_import:
|
||||||
|
break
|
||||||
|
|
||||||
|
total_events += len(history_data)
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
_log("INFO", f"Processed {total_events} events across {page}")
|
||||||
|
|
||||||
|
if earliest_real_import:
|
||||||
|
return earliest_real_import
|
||||||
|
if earliest_grab_date:
|
||||||
|
_log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
|
||||||
|
return earliest_grab_date
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get a page of movie history."""
|
||||||
|
data = self._get("/api/v3/history", {
|
||||||
|
"movieId": str(movie_id),
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size,
|
||||||
|
"sortKey": "date",
|
||||||
|
"sortDirection": "ascending"
|
||||||
|
})
|
||||||
|
return data if isinstance(data, list) else data.get("records", [])
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Direct Radarr Database Client for NFOGuard
|
||||||
|
Provides high-performance access to Radarr's SQLite/PostgreSQL database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from core.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
class RadarrDbClient:
|
||||||
|
"""Direct database client for Radarr's SQLite or PostgreSQL database"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
db_type: str = "sqlite",
|
||||||
|
db_path: Optional[str] = None,
|
||||||
|
db_host: Optional[str] = None,
|
||||||
|
db_port: Optional[int] = None,
|
||||||
|
db_name: Optional[str] = None,
|
||||||
|
db_user: Optional[str] = None,
|
||||||
|
db_password: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize Radarr database client
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_type: "sqlite" or "postgresql"
|
||||||
|
db_path: Path to SQLite database file
|
||||||
|
db_host: PostgreSQL host
|
||||||
|
db_port: PostgreSQL port
|
||||||
|
db_name: PostgreSQL database name
|
||||||
|
db_user: PostgreSQL username
|
||||||
|
db_password: PostgreSQL password
|
||||||
|
"""
|
||||||
|
self.db_type = db_type.lower()
|
||||||
|
self.db_path = db_path
|
||||||
|
self.db_host = db_host
|
||||||
|
self.db_port = db_port or 5432
|
||||||
|
self.db_name = db_name
|
||||||
|
self.db_user = db_user
|
||||||
|
self.db_password = db_password
|
||||||
|
|
||||||
|
self._test_connection()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> Optional['RadarrDbClient']:
|
||||||
|
"""Create client from environment variables"""
|
||||||
|
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||||
|
|
||||||
|
if not db_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if db_type == "sqlite":
|
||||||
|
db_path = os.environ.get("RADARR_DB_PATH")
|
||||||
|
if not db_path or not Path(db_path).exists():
|
||||||
|
_log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
|
||||||
|
return None
|
||||||
|
return cls(db_type="sqlite", db_path=db_path)
|
||||||
|
|
||||||
|
elif db_type == "postgresql":
|
||||||
|
# Support both individual vars and connection string
|
||||||
|
db_url = os.environ.get("RADARR_DB_URL")
|
||||||
|
if db_url:
|
||||||
|
parsed = urlparse(db_url)
|
||||||
|
return cls(
|
||||||
|
db_type="postgresql",
|
||||||
|
db_host=parsed.hostname,
|
||||||
|
db_port=parsed.port or 5432,
|
||||||
|
db_name=parsed.path.lstrip('/'),
|
||||||
|
db_user=parsed.username,
|
||||||
|
db_password=parsed.password
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return cls(
|
||||||
|
db_type="postgresql",
|
||||||
|
db_host=os.environ.get("RADARR_DB_HOST"),
|
||||||
|
db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
|
||||||
|
db_name=os.environ.get("RADARR_DB_NAME"),
|
||||||
|
db_user=os.environ.get("RADARR_DB_USER"),
|
||||||
|
db_password=os.environ.get("RADARR_DB_PASSWORD")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _test_connection(self) -> None:
|
||||||
|
"""Test database connection on initialization"""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
_log("INFO", f"Connected to Radarr {self.db_type} database successfully")
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to create connection")
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Failed to connect to Radarr database: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||||
|
"""Get database connection"""
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
elif self.db_type == "postgresql":
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
host=self.db_host,
|
||||||
|
port=self.db_port,
|
||||||
|
database=self.db_name,
|
||||||
|
user=self.db_user,
|
||||||
|
password=self.db_password
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||||
|
|
||||||
|
def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Find movie by IMDb ID using database query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with movie info including id, imdbId, title, year, path
|
||||||
|
"""
|
||||||
|
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
m."Id" as id,
|
||||||
|
m."Path" as path,
|
||||||
|
m."Added" as added,
|
||||||
|
mm."ImdbId" as imdb_id,
|
||||||
|
mm."Title" as title,
|
||||||
|
mm."Year" as year,
|
||||||
|
mm."DigitalRelease" as digital_release
|
||||||
|
FROM "Movies" m
|
||||||
|
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||||
|
WHERE mm."ImdbId" = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
query = query.replace("%s", "?")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(query, (imdb_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row) if self.db_type == "sqlite" else row
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
Get earliest import date from History table, accounting for upgrade scenarios
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_id: Radarr movie ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(date_iso, source_description)
|
||||||
|
"""
|
||||||
|
# If first event is rename, all subsequent imports are upgrades - skip them
|
||||||
|
if self.is_first_event_rename_based(movie_id):
|
||||||
|
_log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
|
||||||
|
return None, "radarr:db.upgrade_imports_skipped"
|
||||||
|
|
||||||
|
# Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
|
||||||
|
import_query = """
|
||||||
|
SELECT
|
||||||
|
h."Date" as event_date,
|
||||||
|
h."Data" as event_data,
|
||||||
|
h."EventType" as event_type
|
||||||
|
FROM "History" h
|
||||||
|
WHERE h."MovieId" = %s
|
||||||
|
AND h."EventType" = 3
|
||||||
|
ORDER BY h."Date" ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
|
||||||
|
grab_query = """
|
||||||
|
SELECT
|
||||||
|
h."Date" as event_date,
|
||||||
|
h."Data" as event_data,
|
||||||
|
h."EventType" as event_type
|
||||||
|
FROM "History" h
|
||||||
|
WHERE h."MovieId" = %s
|
||||||
|
AND h."EventType" = 1
|
||||||
|
AND h."Data" IS NOT NULL
|
||||||
|
ORDER BY h."Date" ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
import_query = import_query.replace("%s", "?")
|
||||||
|
grab_query = grab_query.replace("%s", "?")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Try import events first
|
||||||
|
cursor.execute(import_query, (movie_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||||
|
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||||
|
if isinstance(event_date, str):
|
||||||
|
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
dt = event_date.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
_log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||||
|
return date_iso, "radarr:db.history.import"
|
||||||
|
|
||||||
|
# Fallback to grab events
|
||||||
|
cursor.execute(grab_query, (movie_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||||
|
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||||
|
if isinstance(event_date, str):
|
||||||
|
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
dt = event_date.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
_log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||||
|
return date_iso, "radarr:db.history.grab"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database query error for movie {movie_id}: {e}")
|
||||||
|
|
||||||
|
return None, "radarr:db.no_date_found"
|
||||||
|
|
||||||
|
def is_first_event_rename_based(self, movie_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the first event in history is rename-based (not a true import)
|
||||||
|
|
||||||
|
This helps identify movies where:
|
||||||
|
- First event: movieFileRenamed (EventType = 8)
|
||||||
|
- Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
|
||||||
|
|
||||||
|
In such cases, we should prefer release dates over the upgrade date
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
SELECT h."EventType" as event_type, h."Date" as event_date
|
||||||
|
FROM "History" h
|
||||||
|
WHERE h."MovieId" = %s
|
||||||
|
ORDER BY h."Date" ASC
|
||||||
|
LIMIT 5
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
query = query.replace("%s", "?")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(query, (movie_id,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
_log("INFO", f"Movie {movie_id} history debug - first 5 events:")
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
|
||||||
|
event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
|
||||||
|
_log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
|
||||||
|
|
||||||
|
first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
|
||||||
|
# EventType 8 = movieFileRenamed
|
||||||
|
# Also check for EventType 7 = movieFileRenamed in some Radarr versions
|
||||||
|
is_rename_first = first_event_type in [7, 8]
|
||||||
|
_log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
|
||||||
|
|
||||||
|
if is_rename_first:
|
||||||
|
_log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
|
||||||
|
|
||||||
|
return is_rename_first
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_movie_file_date(self, movie_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get earliest file dateAdded as fallback
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_id: Radarr movie ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ISO date string or None
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
SELECT MIN(mf."DateAdded") as earliest_date
|
||||||
|
FROM "MovieFiles" mf
|
||||||
|
WHERE mf."MovieId" = %s
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
query = query.replace("%s", "?")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(query, (movie_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
|
||||||
|
if date_value:
|
||||||
|
if isinstance(date_value, str):
|
||||||
|
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
dt = date_value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||||
|
"""
|
||||||
|
Get the best import date for a movie using optimized database queries
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_id: Radarr movie ID
|
||||||
|
fallback_to_file_date: Whether to fall back to file dateAdded
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(date_iso, source_description)
|
||||||
|
"""
|
||||||
|
# Try history first - this handles upgrade detection internally
|
||||||
|
date_iso, source = self.get_earliest_import_date(movie_id)
|
||||||
|
if date_iso:
|
||||||
|
return date_iso, source
|
||||||
|
|
||||||
|
# Check if we skipped upgrades and should prefer release dates
|
||||||
|
if source == "radarr:db.upgrade_imports_skipped":
|
||||||
|
_log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
|
||||||
|
return None, "radarr:db.prefer_release_dates"
|
||||||
|
|
||||||
|
# Fallback to file date if requested
|
||||||
|
if fallback_to_file_date:
|
||||||
|
file_date = self.get_movie_file_date(movie_id)
|
||||||
|
if file_date:
|
||||||
|
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||||
|
return file_date, "radarr:db.file.dateAdded"
|
||||||
|
|
||||||
|
return None, "radarr:db.no_date_found"
|
||||||
|
|
||||||
|
def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
|
||||||
|
"""
|
||||||
|
Get import dates for multiple movies in a single query
|
||||||
|
|
||||||
|
Args:
|
||||||
|
imdb_ids: List of IMDb IDs
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping imdb_id -> (date_iso, source)
|
||||||
|
"""
|
||||||
|
if not imdb_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Ensure all IMDb IDs have tt prefix
|
||||||
|
clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
|
||||||
|
|
||||||
|
placeholders = ",".join(["%s"] * len(clean_imdb_ids))
|
||||||
|
if self.db_type == "sqlite":
|
||||||
|
placeholders = ",".join(["?"] * len(clean_imdb_ids))
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
mm."ImdbId" as imdb_id,
|
||||||
|
m."Id" as movie_id,
|
||||||
|
MIN(h."Date") as earliest_import
|
||||||
|
FROM "Movies" m
|
||||||
|
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||||
|
LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
|
||||||
|
WHERE mm."ImdbId" IN ({placeholders})
|
||||||
|
GROUP BY mm."ImdbId", m."Id"
|
||||||
|
"""
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(query, clean_imdb_ids)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
|
||||||
|
else:
|
||||||
|
imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
|
||||||
|
|
||||||
|
if earliest_import:
|
||||||
|
if isinstance(earliest_import, str):
|
||||||
|
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
results[imdb_id] = (date_iso, "radarr:db.bulk.import")
|
||||||
|
else:
|
||||||
|
results[imdb_id] = (None, "radarr:db.bulk.no_import")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Bulk query error: {e}")
|
||||||
|
# Return empty results for failed queries
|
||||||
|
for imdb_id in clean_imdb_ids:
|
||||||
|
if imdb_id not in results:
|
||||||
|
results[imdb_id] = (None, "radarr:db.bulk.error")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def get_database_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get basic statistics about the Radarr database"""
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
queries = {
|
||||||
|
"total_movies": 'SELECT COUNT(*) FROM "Movies"',
|
||||||
|
"total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
|
||||||
|
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||||
|
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||||
|
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
for stat_name, query in queries.items():
|
||||||
|
cursor.execute(query)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
stats[stat_name] = result[0] if result else 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("ERROR", f"Stats query error: {e}")
|
||||||
|
stats["error"] = str(e)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Comprehensive health check for the Radarr database connection
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with health status, connection info, and basic functionality tests
|
||||||
|
"""
|
||||||
|
health = {
|
||||||
|
"status": "healthy",
|
||||||
|
"database_type": self.db_type,
|
||||||
|
"connection": "ok",
|
||||||
|
"readable": False,
|
||||||
|
"writable": False,
|
||||||
|
"tables_exist": False,
|
||||||
|
"sample_data": False,
|
||||||
|
"issues": [],
|
||||||
|
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test 1: Basic connection
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Test 2: Check if we can read (basic query)
|
||||||
|
try:
|
||||||
|
cursor.execute('SELECT 1')
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result and result[0] == 1:
|
||||||
|
health["readable"] = True
|
||||||
|
health["connection"] = "readable"
|
||||||
|
else:
|
||||||
|
health["issues"].append("Basic SELECT query failed")
|
||||||
|
except Exception as e:
|
||||||
|
health["issues"].append(f"Read test failed: {e}")
|
||||||
|
health["status"] = "degraded"
|
||||||
|
|
||||||
|
# Test 3: Check required tables exist
|
||||||
|
required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
|
||||||
|
existing_tables = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.db_type == "postgresql":
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||||
|
""")
|
||||||
|
else: # SQLite
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type='table'
|
||||||
|
AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||||
|
""")
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
existing_tables = [row[0] for row in rows]
|
||||||
|
|
||||||
|
if len(existing_tables) == len(required_tables):
|
||||||
|
health["tables_exist"] = True
|
||||||
|
else:
|
||||||
|
missing = set(required_tables) - set(existing_tables)
|
||||||
|
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||||
|
health["status"] = "degraded"
|
||||||
|
|
||||||
|
health["existing_tables"] = existing_tables
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
health["issues"].append(f"Table check failed: {e}")
|
||||||
|
health["status"] = "degraded"
|
||||||
|
|
||||||
|
# Test 4: Check for sample data
|
||||||
|
if health["tables_exist"]:
|
||||||
|
try:
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM "Movies"')
|
||||||
|
movie_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||||
|
history_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if movie_count > 0 and history_count > 0:
|
||||||
|
health["sample_data"] = True
|
||||||
|
health["movie_count"] = movie_count
|
||||||
|
health["history_count"] = history_count
|
||||||
|
else:
|
||||||
|
health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
health["issues"].append(f"Sample data check failed: {e}")
|
||||||
|
|
||||||
|
# Test 5: Test a real query (movie with IMDb lookup)
|
||||||
|
if health["sample_data"]:
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM "Movies" m
|
||||||
|
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||||
|
WHERE mm."ImdbId" IS NOT NULL
|
||||||
|
""")
|
||||||
|
imdb_movies = cursor.fetchone()[0]
|
||||||
|
health["movies_with_imdb"] = imdb_movies
|
||||||
|
|
||||||
|
if imdb_movies > 0:
|
||||||
|
health["functional"] = True
|
||||||
|
else:
|
||||||
|
health["issues"].append("No movies with IMDb IDs found")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
health["issues"].append(f"Functional test failed: {e}")
|
||||||
|
health["status"] = "degraded"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
health["status"] = "error"
|
||||||
|
health["connection"] = "failed"
|
||||||
|
health["issues"].append(f"Connection failed: {e}")
|
||||||
|
_log("ERROR", f"Database health check failed: {e}")
|
||||||
|
|
||||||
|
# Overall status determination
|
||||||
|
if health["issues"]:
|
||||||
|
if health["status"] == "healthy":
|
||||||
|
health["status"] = "degraded"
|
||||||
|
|
||||||
|
# Add connection details (safe info only)
|
||||||
|
health["connection_info"] = {
|
||||||
|
"type": self.db_type,
|
||||||
|
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||||
|
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||||
|
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||||
|
"path": self.db_path if self.db_type == "sqlite" else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return health
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the database client
|
||||||
|
print("Testing RadarrDbClient...")
|
||||||
|
|
||||||
|
# Test with environment variables
|
||||||
|
client = RadarrDbClient.from_env()
|
||||||
|
if client:
|
||||||
|
print("✅ Connected to Radarr database")
|
||||||
|
|
||||||
|
# Test stats
|
||||||
|
stats = client.get_database_stats()
|
||||||
|
print(f"Database stats: {stats}")
|
||||||
|
|
||||||
|
# Test movie lookup
|
||||||
|
test_movie = client.get_movie_by_imdb("tt1596343")
|
||||||
|
if test_movie:
|
||||||
|
print(f"Found test movie: {test_movie}")
|
||||||
|
|
||||||
|
# Test import date
|
||||||
|
movie_id = test_movie['id']
|
||||||
|
date_iso, source = client.get_movie_import_date_optimized(movie_id)
|
||||||
|
print(f"Import date: {date_iso} (source: {source})")
|
||||||
|
else:
|
||||||
|
print("Test movie not found")
|
||||||
|
else:
|
||||||
|
print("❌ Could not connect to database - check environment variables")
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Enhanced Sonarr API client for TV show metadata and episode management
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request as UrlRequest, urlopen
|
||||||
|
from urllib.error import URLError, HTTPError
|
||||||
|
|
||||||
|
from core.logging import _log
|
||||||
|
|
||||||
|
|
||||||
|
class SonarrClient:
|
||||||
|
"""Enhanced Sonarr API client for TV series and episode management"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
self.retries = max(0, retries)
|
||||||
|
self.enabled = bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||||
|
"""Make GET request to Sonarr API with retries"""
|
||||||
|
if not self.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self.base_url}/api/v3{path}"
|
||||||
|
if params:
|
||||||
|
url += "?" + urlencode(params)
|
||||||
|
|
||||||
|
headers = {"X-Api-Key": self.api_key}
|
||||||
|
|
||||||
|
for attempt in range(self.retries):
|
||||||
|
try:
|
||||||
|
_log("DEBUG", f"Sonarr API Request: {url}")
|
||||||
|
req = UrlRequest(url, headers=headers)
|
||||||
|
|
||||||
|
with urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
data = resp.read().decode("utf-8")
|
||||||
|
result = json.loads(data) if data else None
|
||||||
|
return result
|
||||||
|
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 401:
|
||||||
|
_log("ERROR", "Sonarr authentication failed - check API key")
|
||||||
|
return None
|
||||||
|
elif e.code == 429:
|
||||||
|
wait_time = (attempt + 1) * 2
|
||||||
|
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
else:
|
||||||
|
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
|
||||||
|
|
||||||
|
if attempt < self.retries - 1:
|
||||||
|
time.sleep(0.5 * (attempt + 1))
|
||||||
|
|
||||||
|
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find series by IMDb ID using lookup endpoint"""
|
||||||
|
search_term = f"imdbid:{imdb_id}"
|
||||||
|
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
|
||||||
|
|
||||||
|
result = self._get("/series/lookup", {"term": search_term})
|
||||||
|
if not result:
|
||||||
|
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
|
||||||
|
|
||||||
|
# Log all results for debugging
|
||||||
|
for i, series in enumerate(result):
|
||||||
|
series_imdb = series.get("imdbId", "")
|
||||||
|
series_title = series.get("title", "")
|
||||||
|
series_id = series.get("id", "")
|
||||||
|
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
|
||||||
|
|
||||||
|
# Find exact IMDb match (case insensitive)
|
||||||
|
target_imdb = imdb_id.lower()
|
||||||
|
for series in result:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if series_imdb == target_imdb:
|
||||||
|
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
# Try partial match as fallback
|
||||||
|
for series in result:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if target_imdb in series_imdb or series_imdb in target_imdb:
|
||||||
|
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Search for series by title as fallback when IMDb lookup fails"""
|
||||||
|
_log("DEBUG", f"Searching Sonarr by title: {title}")
|
||||||
|
|
||||||
|
result = self._get("/series/lookup", {"term": title})
|
||||||
|
if not result:
|
||||||
|
_log("WARNING", f"No results from Sonarr title search for: {title}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
|
||||||
|
|
||||||
|
title_lower = title.lower()
|
||||||
|
|
||||||
|
# Look for exact title match
|
||||||
|
for series in result:
|
||||||
|
series_title = (series.get("title") or "").lower()
|
||||||
|
if series_title == title_lower:
|
||||||
|
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
# Look for partial title match
|
||||||
|
for series in result:
|
||||||
|
series_title = (series.get("title") or "").lower()
|
||||||
|
if title_lower in series_title or series_title in title_lower:
|
||||||
|
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No title match found for: {title}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_series(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all series from Sonarr"""
|
||||||
|
return self._get("/series") or []
|
||||||
|
|
||||||
|
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
|
||||||
|
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
|
||||||
|
all_series = self.get_all_series()
|
||||||
|
|
||||||
|
target_imdb = imdb_id.lower()
|
||||||
|
for series in all_series:
|
||||||
|
series_imdb = (series.get("imdbId") or "").lower()
|
||||||
|
if series_imdb == target_imdb:
|
||||||
|
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
|
||||||
|
return series
|
||||||
|
|
||||||
|
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all episodes for a series"""
|
||||||
|
return self._get("/episode", {"seriesId": series_id}) or []
|
||||||
|
|
||||||
|
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get episode file details"""
|
||||||
|
return self._get(f"/episodefile/{episode_file_id}")
|
||||||
|
|
||||||
|
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the original import date from history with enhanced detection.
|
||||||
|
Focuses on finding the earliest REAL import, not upgrades.
|
||||||
|
"""
|
||||||
|
all_records = []
|
||||||
|
page = 1
|
||||||
|
page_size = 100
|
||||||
|
|
||||||
|
# Collect all history records for this episode
|
||||||
|
while True:
|
||||||
|
history = self._get("/history", {
|
||||||
|
"episodeId": episode_id,
|
||||||
|
"sortKey": "date",
|
||||||
|
"sortDir": "asc",
|
||||||
|
"page": page,
|
||||||
|
"pageSize": page_size
|
||||||
|
})
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
break
|
||||||
|
|
||||||
|
records = history.get("records", []) if isinstance(history, dict) else []
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
|
||||||
|
all_records.extend(records)
|
||||||
|
|
||||||
|
if len(records) < page_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
if page > 10: # Safety valve
|
||||||
|
break
|
||||||
|
|
||||||
|
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
|
||||||
|
|
||||||
|
# Categorize events
|
||||||
|
import_events = []
|
||||||
|
grabbed_events = []
|
||||||
|
rename_events = []
|
||||||
|
|
||||||
|
for event in all_records:
|
||||||
|
event_type = event.get("eventType", "").lower()
|
||||||
|
date = event.get("date")
|
||||||
|
|
||||||
|
if not date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
_log("DEBUG", f"History event: {event_type} at {date}")
|
||||||
|
|
||||||
|
if event_type == "downloadfolderimported":
|
||||||
|
import_events.append({"date": date, "event": event})
|
||||||
|
elif event_type == "grabbed":
|
||||||
|
grabbed_events.append({"date": date, "event": event})
|
||||||
|
elif event_type == "episodefilerenamed":
|
||||||
|
rename_events.append({"date": date, "event": event})
|
||||||
|
|
||||||
|
# Use the earliest real import event
|
||||||
|
if import_events:
|
||||||
|
earliest_import = min(import_events, key=lambda x: x["date"])
|
||||||
|
import_date = earliest_import["date"]
|
||||||
|
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||||
|
|
||||||
|
# Check if this looks like an upgrade by comparing to renames
|
||||||
|
if rename_events:
|
||||||
|
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||||
|
rename_date = earliest_rename["date"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||||
|
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||||
|
days_diff = (import_dt - rename_dt).days
|
||||||
|
|
||||||
|
# If import is significantly after rename, prefer rename date
|
||||||
|
if days_diff > 30:
|
||||||
|
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||||
|
return rename_date
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log("DEBUG", f"Error comparing dates: {e}")
|
||||||
|
|
||||||
|
return import_date
|
||||||
|
|
||||||
|
# Fallback to grab event
|
||||||
|
if grabbed_events:
|
||||||
|
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
|
||||||
|
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
|
||||||
|
return earliest_grab["date"]
|
||||||
|
|
||||||
|
_log("WARNING", f"No reliable import events found for episode {episode_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the client
|
||||||
|
import os
|
||||||
|
|
||||||
|
base_url = os.environ.get("SONARR_URL", "")
|
||||||
|
api_key = os.environ.get("SONARR_API_KEY", "")
|
||||||
|
|
||||||
|
if base_url and api_key:
|
||||||
|
client = SonarrClient(base_url, api_key)
|
||||||
|
|
||||||
|
# Test with a known series
|
||||||
|
test_imdb = "tt2085059" # Example
|
||||||
|
series = client.series_by_imdb(test_imdb)
|
||||||
|
|
||||||
|
if series:
|
||||||
|
series_id = series.get("id")
|
||||||
|
print(f"Found series: {series.get('title')} (ID: {series_id})")
|
||||||
|
|
||||||
|
# Get episodes
|
||||||
|
episodes = client.episodes_for_series(series_id)
|
||||||
|
print(f"Found {len(episodes)} episodes")
|
||||||
|
|
||||||
|
# Test import date for first episode
|
||||||
|
if episodes:
|
||||||
|
first_episode = episodes[0]
|
||||||
|
episode_id = first_episode.get("id")
|
||||||
|
import_date = client.get_episode_import_history(episode_id)
|
||||||
|
print(f"First episode import date: {import_date}")
|
||||||
|
else:
|
||||||
|
print(f"Series not found: {test_imdb}")
|
||||||
|
else:
|
||||||
|
print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing")
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database management for NFOGuard
|
||||||
|
Handles SQLite database operations for tracking media dates and processing history
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, List, Any
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
class NFOGuardDatabase:
|
||||||
|
"""Manages NFOGuard SQLite database operations"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: Path):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._local = threading.local()
|
||||||
|
self._init_database()
|
||||||
|
|
||||||
|
def _get_connection(self) -> sqlite3.Connection:
|
||||||
|
"""Get thread-local database connection"""
|
||||||
|
if not hasattr(self._local, 'connection'):
|
||||||
|
self._local.connection = sqlite3.connect(
|
||||||
|
self.db_path,
|
||||||
|
check_same_thread=False,
|
||||||
|
timeout=30.0
|
||||||
|
)
|
||||||
|
self._local.connection.row_factory = sqlite3.Row
|
||||||
|
return self._local.connection
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection(self):
|
||||||
|
"""Context manager for database connections"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _init_database(self):
|
||||||
|
"""Initialize database tables with migration support"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Series table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
|
imdb_id TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
last_updated TEXT NOT NULL,
|
||||||
|
metadata TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Episodes table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
imdb_id TEXT NOT NULL,
|
||||||
|
season INTEGER NOT NULL,
|
||||||
|
episode INTEGER NOT NULL,
|
||||||
|
aired TEXT,
|
||||||
|
dateadded TEXT,
|
||||||
|
source TEXT,
|
||||||
|
last_updated TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (imdb_id, season, episode),
|
||||||
|
FOREIGN KEY (imdb_id) REFERENCES series(imdb_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Movies table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS movies (
|
||||||
|
imdb_id TEXT PRIMARY KEY,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
released TEXT,
|
||||||
|
dateadded TEXT,
|
||||||
|
source TEXT,
|
||||||
|
last_updated TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Processing history table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS processing_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
imdb_id TEXT NOT NULL,
|
||||||
|
media_type TEXT NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
processed_at TEXT NOT NULL,
|
||||||
|
details TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Add missing columns if they don't exist (migration)
|
||||||
|
# Check current schema and add missing columns
|
||||||
|
cursor.execute("PRAGMA table_info(movies)")
|
||||||
|
movie_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(episodes)")
|
||||||
|
episode_columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Add missing columns to movies table
|
||||||
|
if 'path' not in movie_columns:
|
||||||
|
cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT")
|
||||||
|
cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL")
|
||||||
|
|
||||||
|
if 'has_video_file' not in movie_columns:
|
||||||
|
cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||||
|
|
||||||
|
if 'last_updated' not in movie_columns:
|
||||||
|
cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT")
|
||||||
|
cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||||
|
|
||||||
|
# Add missing columns to episodes table
|
||||||
|
if 'has_video_file' not in episode_columns:
|
||||||
|
cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||||
|
|
||||||
|
if 'last_updated' not in episode_columns:
|
||||||
|
cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT")
|
||||||
|
cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||||
|
|
||||||
|
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||||
|
"""Insert or update series record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
|
||||||
|
|
||||||
|
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
|
||||||
|
aired: Optional[str], dateadded: Optional[str],
|
||||||
|
source: str, has_video_file: bool = False):
|
||||||
|
"""Insert or update episode date record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO episodes
|
||||||
|
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||||
|
|
||||||
|
def upsert_movie(self, imdb_id: str, path: str):
|
||||||
|
"""Insert or update movie record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (imdb_id, path, datetime.utcnow().isoformat()))
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
if "no column named path" in str(e):
|
||||||
|
# Fallback for databases without path column - just insert imdb_id
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO movies (imdb_id, last_updated)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""", (imdb_id, datetime.utcnow().isoformat()))
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||||
|
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||||
|
"""Insert or update movie date record"""
|
||||||
|
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
# Use INSERT OR REPLACE to ensure we always update the dates properly
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'),
|
||||||
|
?, ?, ?, ?, ?
|
||||||
|
)
|
||||||
|
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||||
|
|
||||||
|
# Debug: Check what was actually saved
|
||||||
|
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}")
|
||||||
|
|
||||||
|
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||||
|
"""Get all episodes for a series"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT * FROM episodes WHERE imdb_id = ?"
|
||||||
|
params = [imdb_id]
|
||||||
|
|
||||||
|
if has_video_file_only:
|
||||||
|
query += " AND has_video_file = TRUE"
|
||||||
|
|
||||||
|
cursor.execute(query, params)
|
||||||
|
return [dict(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||||
|
"""Get episode date record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT * FROM episodes
|
||||||
|
WHERE imdb_id = ? AND season = ? AND episode = ?
|
||||||
|
""", (imdb_id, season, episode))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||||
|
"""Get movie date record"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
|
||||||
|
"""Add processing history entry"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||||
|
json.dumps(details) if details else None))
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get database statistics"""
|
||||||
|
with self.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Series stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM series")
|
||||||
|
series_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Episode stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||||
|
episodes_total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
|
||||||
|
episodes_with_video = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Movie stats
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||||
|
movies_total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
|
||||||
|
movies_with_video = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Processing history
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM processing_history")
|
||||||
|
history_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"series_count": series_count,
|
||||||
|
"episodes_total": episodes_total,
|
||||||
|
"episodes_with_video": episodes_with_video,
|
||||||
|
"movies_total": movies_total,
|
||||||
|
"movies_with_video": movies_with_video,
|
||||||
|
"processing_history_count": history_count,
|
||||||
|
"database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Logging utilities for NFOguard"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import os
|
||||||
|
|
||||||
|
def _get_local_timezone():
|
||||||
|
"""Get the local timezone, respecting TZ environment variable"""
|
||||||
|
tz_name = os.environ.get('TZ', 'UTC')
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try zoneinfo first (Python 3.9+)
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return ZoneInfo(tz_name)
|
||||||
|
except ImportError:
|
||||||
|
# Fallback for older Python versions
|
||||||
|
try:
|
||||||
|
import pytz
|
||||||
|
return pytz.timezone(tz_name)
|
||||||
|
except:
|
||||||
|
# Final fallback to UTC
|
||||||
|
return timezone.utc
|
||||||
|
except:
|
||||||
|
# If zone name is invalid, fallback to UTC
|
||||||
|
return timezone.utc
|
||||||
|
|
||||||
|
def _log(level: str, msg: str):
|
||||||
|
"""Basic logging function that writes to console"""
|
||||||
|
tz = _get_local_timezone()
|
||||||
|
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
|
||||||
|
|
||||||
|
def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||||
|
"""Convert UTC ISO timestamp to local timezone timestamp"""
|
||||||
|
if not utc_iso_string:
|
||||||
|
return utc_iso_string
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse UTC timestamp
|
||||||
|
if utc_iso_string.endswith('Z'):
|
||||||
|
dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
|
||||||
|
elif '+00:00' in utc_iso_string:
|
||||||
|
dt_utc = datetime.fromisoformat(utc_iso_string)
|
||||||
|
else:
|
||||||
|
# Assume UTC if no timezone info
|
||||||
|
dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Convert to local timezone
|
||||||
|
local_tz = _get_local_timezone()
|
||||||
|
dt_local = dt_utc.astimezone(local_tz)
|
||||||
|
|
||||||
|
return dt_local.isoformat(timespec='seconds')
|
||||||
|
except Exception:
|
||||||
|
# If conversion fails, return original
|
||||||
|
return utc_iso_string
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
NFO Manager for creating and managing metadata files
|
||||||
|
Handles NFO creation for movies, TV shows, seasons, and episodes
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class NFOManager:
|
||||||
|
"""Manages NFO file creation and updates"""
|
||||||
|
|
||||||
|
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
|
||||||
|
self.manager_brand = manager_brand
|
||||||
|
self.debug = debug
|
||||||
|
|
||||||
|
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from directory path or filename"""
|
||||||
|
# Look for various IMDb patterns in both directory and file names
|
||||||
|
path_str = str(path).lower()
|
||||||
|
|
||||||
|
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||||
|
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try standalone [ttXXXXXXX] format in brackets
|
||||||
|
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||||
|
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||||
|
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||||
|
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||||
|
"""Extract IMDb ID from NFO file content"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
|
||||||
|
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
|
||||||
|
if imdb_uniqueid is not None and imdb_uniqueid.text:
|
||||||
|
imdb_id = imdb_uniqueid.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Check for legacy <imdbid>ttXXXXXX</imdbid>
|
||||||
|
imdbid_elem = root.find('.//imdbid')
|
||||||
|
if imdbid_elem is not None and imdbid_elem.text:
|
||||||
|
imdb_id = imdbid_elem.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Check for legacy <imdb>ttXXXXXX</imdb>
|
||||||
|
imdb_elem = root.find('.//imdb')
|
||||||
|
if imdb_elem is not None and imdb_elem.text:
|
||||||
|
imdb_id = imdb_elem.text.strip()
|
||||||
|
if imdb_id.startswith('tt'):
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception):
|
||||||
|
# Skip corrupted or non-XML files
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||||
|
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||||
|
# First try directory name
|
||||||
|
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Try all files in the directory for IMDb ID patterns
|
||||||
|
for file_path in movie_dir.iterdir():
|
||||||
|
if file_path.is_file():
|
||||||
|
imdb_id = self.parse_imdb_from_path(file_path)
|
||||||
|
if imdb_id:
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
# Finally, try NFO file content
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||||
|
if imdb_id:
|
||||||
|
print(f"🔍 Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
|
||||||
|
return imdb_id
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_nfo_with_tolerance(self, nfo_path: Path):
|
||||||
|
"""Parse NFO file with tolerance for URLs appended after XML"""
|
||||||
|
try:
|
||||||
|
# First try normal parsing
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
return tree.getroot()
|
||||||
|
except ET.ParseError as e:
|
||||||
|
# If parsing fails, try to extract just the XML part
|
||||||
|
try:
|
||||||
|
with open(nfo_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find the last </movie> tag and truncate after it
|
||||||
|
last_movie_end = content.rfind('</movie>')
|
||||||
|
if last_movie_end != -1:
|
||||||
|
xml_content = content[:last_movie_end + 8] # +8 for </movie>
|
||||||
|
|
||||||
|
# Try parsing the truncated content
|
||||||
|
root = ET.fromstring(xml_content)
|
||||||
|
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
|
||||||
|
return root
|
||||||
|
else:
|
||||||
|
# Re-raise original error if we can't find </movie>
|
||||||
|
raise e
|
||||||
|
except Exception:
|
||||||
|
# Re-raise original error if our fix attempt fails
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||||
|
released: Optional[str] = None, source: str = "unknown",
|
||||||
|
lock_metadata: bool = True) -> None:
|
||||||
|
"""Create or update movie.nfo file preserving existing content"""
|
||||||
|
nfo_path = movie_dir / "movie.nfo"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to load existing NFO file
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
# Try to parse the XML, handling URLs appended after </movie>
|
||||||
|
movie = self._parse_nfo_with_tolerance(nfo_path)
|
||||||
|
|
||||||
|
# Ensure root element is <movie>
|
||||||
|
if movie.tag != "movie":
|
||||||
|
raise ValueError("Root element is not <movie>")
|
||||||
|
|
||||||
|
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||||
|
# These will be re-added at the very bottom
|
||||||
|
nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"]
|
||||||
|
for tag in nfoguard_fields:
|
||||||
|
existing = movie.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
# Store the value before removing (for premiered/year)
|
||||||
|
if tag == "premiered" and not released:
|
||||||
|
released = existing.text # Preserve existing premiered date
|
||||||
|
movie.remove(existing)
|
||||||
|
|
||||||
|
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||||
|
# We'll add a clean one at the bottom
|
||||||
|
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||||
|
movie.remove(uniqueid)
|
||||||
|
|
||||||
|
except (ET.ParseError, ValueError) as e:
|
||||||
|
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean NFO file to replace corrupted one")
|
||||||
|
movie = ET.Element("movie")
|
||||||
|
else:
|
||||||
|
# Create new NFO structure
|
||||||
|
movie = ET.Element("movie")
|
||||||
|
|
||||||
|
# Now append ALL NFOGuard and date fields at the VERY END of the file
|
||||||
|
# This ensures they appear after all existing content including actors
|
||||||
|
|
||||||
|
# Add IMDb uniqueid at the end (after all existing content)
|
||||||
|
uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true")
|
||||||
|
uniqueid.text = imdb_id
|
||||||
|
|
||||||
|
# Add premiered date at the bottom if we have it
|
||||||
|
if released:
|
||||||
|
premiered_elem = ET.SubElement(movie, "premiered")
|
||||||
|
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||||
|
|
||||||
|
# Extract year from premiered date for consistency
|
||||||
|
try:
|
||||||
|
year_value = released[:4] if len(released) >= 4 else None
|
||||||
|
if year_value and year_value.isdigit():
|
||||||
|
year_elem = ET.SubElement(movie, "year")
|
||||||
|
year_elem.text = year_value
|
||||||
|
except:
|
||||||
|
pass # Skip year if we can't extract it
|
||||||
|
|
||||||
|
# Add dateadded at the end
|
||||||
|
if dateadded:
|
||||||
|
dateadded_elem = ET.SubElement(movie, "dateadded")
|
||||||
|
dateadded_elem.text = dateadded
|
||||||
|
|
||||||
|
# Add lockdata at the very end
|
||||||
|
if lock_metadata:
|
||||||
|
lockdata = ET.SubElement(movie, "lockdata")
|
||||||
|
lockdata.text = "true"
|
||||||
|
|
||||||
|
# Add NFOGuard comment at the beginning
|
||||||
|
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||||
|
|
||||||
|
# Write file with proper formatting
|
||||||
|
tree = ET.ElementTree(movie)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
|
||||||
|
# Write to string first to add comment
|
||||||
|
import xml.etree.ElementTree as ET_temp
|
||||||
|
xml_str = ET.tostring(movie, encoding='unicode')
|
||||||
|
|
||||||
|
# Add XML declaration and comment
|
||||||
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
||||||
|
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
||||||
|
"""Create or update tvshow.nfo file preserving existing content"""
|
||||||
|
nfo_path = series_dir / "tvshow.nfo"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to load existing NFO file
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
tvshow = tree.getroot()
|
||||||
|
|
||||||
|
# Ensure root element is <tvshow>
|
||||||
|
if tvshow.tag != "tvshow":
|
||||||
|
raise ValueError("Root element is not <tvshow>")
|
||||||
|
|
||||||
|
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||||
|
# These will be re-added at the bottom
|
||||||
|
for tag in ["lockdata"]:
|
||||||
|
existing = tvshow.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
tvshow.remove(existing)
|
||||||
|
|
||||||
|
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||||
|
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
|
||||||
|
tvshow.remove(uniqueid)
|
||||||
|
|
||||||
|
except (ET.ParseError, ValueError) as e:
|
||||||
|
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
||||||
|
tvshow = ET.Element("tvshow")
|
||||||
|
else:
|
||||||
|
# Create new NFO structure
|
||||||
|
tvshow = ET.Element("tvshow")
|
||||||
|
|
||||||
|
# Add NFOGuard fields at the bottom
|
||||||
|
|
||||||
|
# Add IMDb uniqueid at the end
|
||||||
|
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
|
||||||
|
imdb_uniqueid.text = imdb_id
|
||||||
|
|
||||||
|
# Add TVDB ID if available (preserve existing or add new)
|
||||||
|
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
|
||||||
|
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
||||||
|
tvdb_uniqueid.text = tvdb_id
|
||||||
|
|
||||||
|
# Add lockdata at the very end
|
||||||
|
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||||
|
lockdata.text = "true"
|
||||||
|
|
||||||
|
# Add NFOGuard comment at the beginning
|
||||||
|
comment_text = f" Created by {self.manager_brand} "
|
||||||
|
|
||||||
|
# Write file with proper formatting
|
||||||
|
tree = ET.ElementTree(tvshow)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
|
||||||
|
# Write to string first to add comment
|
||||||
|
xml_str = ET.tostring(tvshow, encoding='unicode')
|
||||||
|
|
||||||
|
# Add XML declaration and comment
|
||||||
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
||||||
|
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
||||||
|
"""Create or update season.nfo file preserving existing content"""
|
||||||
|
nfo_path = season_dir / "season.nfo"
|
||||||
|
|
||||||
|
try:
|
||||||
|
season_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Try to load existing NFO file
|
||||||
|
if nfo_path.exists():
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_path)
|
||||||
|
season = tree.getroot()
|
||||||
|
|
||||||
|
# Ensure root element is <season>
|
||||||
|
if season.tag != "season":
|
||||||
|
raise ValueError("Root element is not <season>")
|
||||||
|
|
||||||
|
# Remove existing NFOGuard-managed elements
|
||||||
|
for tag in ["seasonnumber", "lockdata"]:
|
||||||
|
existing = season.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
season.remove(existing)
|
||||||
|
|
||||||
|
except (ET.ParseError, ValueError) as e:
|
||||||
|
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean season.nfo file to replace corrupted one")
|
||||||
|
season = ET.Element("season")
|
||||||
|
else:
|
||||||
|
# Create new NFO structure
|
||||||
|
season = ET.Element("season")
|
||||||
|
|
||||||
|
# Add NFOGuard fields at the bottom
|
||||||
|
seasonnumber = ET.SubElement(season, "seasonnumber")
|
||||||
|
seasonnumber.text = str(season_number)
|
||||||
|
|
||||||
|
# Add lockdata at the end
|
||||||
|
lockdata = ET.SubElement(season, "lockdata")
|
||||||
|
lockdata.text = "true"
|
||||||
|
|
||||||
|
# Add NFOGuard comment at the beginning
|
||||||
|
comment_text = f" Created by {self.manager_brand} "
|
||||||
|
|
||||||
|
# Write file with proper formatting
|
||||||
|
tree = ET.ElementTree(season)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
|
||||||
|
# Write to string first to add comment
|
||||||
|
xml_str = ET.tostring(season, encoding='unicode')
|
||||||
|
|
||||||
|
# Add XML declaration and comment
|
||||||
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||||
|
print(f" Season: {season_number}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||||
|
"""Find existing episode NFO file that matches season/episode but isn't standardized name"""
|
||||||
|
if not season_dir.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Standard filename pattern we're looking to create
|
||||||
|
standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
|
||||||
|
# Look for NFO files in the season directory
|
||||||
|
for nfo_file in season_dir.glob("*.nfo"):
|
||||||
|
# Skip if it's already the standard format
|
||||||
|
if nfo_file.name == standard_pattern:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if this NFO contains the right season/episode
|
||||||
|
try:
|
||||||
|
tree = ET.parse(nfo_file)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
if root.tag == "episodedetails":
|
||||||
|
# Check for season/episode elements
|
||||||
|
season_elem = root.find("season")
|
||||||
|
episode_elem = root.find("episode")
|
||||||
|
|
||||||
|
if (season_elem is not None and episode_elem is not None and
|
||||||
|
season_elem.text and episode_elem.text):
|
||||||
|
try:
|
||||||
|
file_season = int(season_elem.text)
|
||||||
|
file_episode = int(episode_elem.text)
|
||||||
|
|
||||||
|
if file_season == season_num and file_episode == episode_num:
|
||||||
|
print(f"🔍 Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}")
|
||||||
|
return nfo_file
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except (ET.ParseError, Exception):
|
||||||
|
# Skip corrupted or non-XML files
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||||
|
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||||
|
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||||
|
"""Create or update episode NFO file preserving existing content"""
|
||||||
|
# Generate episode filename pattern
|
||||||
|
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||||
|
nfo_path = season_dir / episode_filename
|
||||||
|
|
||||||
|
# Track if we need to delete an old long-named NFO file
|
||||||
|
old_nfo_to_delete = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First, check for existing long-named NFO files that need migration
|
||||||
|
existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||||
|
|
||||||
|
# Prioritize long-named file for migration, otherwise use standard file
|
||||||
|
source_nfo_path = existing_long_nfo if existing_long_nfo else nfo_path if nfo_path.exists() else None
|
||||||
|
|
||||||
|
if source_nfo_path:
|
||||||
|
try:
|
||||||
|
tree = ET.parse(source_nfo_path)
|
||||||
|
episode = tree.getroot()
|
||||||
|
|
||||||
|
# Ensure root element is <episodedetails>
|
||||||
|
if episode.tag != "episodedetails":
|
||||||
|
raise ValueError("Root element is not <episodedetails>")
|
||||||
|
|
||||||
|
# If we're migrating from a long-named file, mark it for deletion
|
||||||
|
if existing_long_nfo and source_nfo_path == existing_long_nfo:
|
||||||
|
old_nfo_to_delete = existing_long_nfo
|
||||||
|
print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
|
||||||
|
|
||||||
|
# Show what content fields are being preserved
|
||||||
|
content_fields = ["title", "plot", "runtime", "premiered"]
|
||||||
|
preserved_content = []
|
||||||
|
for field in content_fields:
|
||||||
|
elem = episode.find(field)
|
||||||
|
if elem is not None and elem.text:
|
||||||
|
preserved_content.append(field)
|
||||||
|
if preserved_content:
|
||||||
|
print(f" 📄 Preserving content: {', '.join(preserved_content)}")
|
||||||
|
|
||||||
|
# Preserve existing content fields and store NFOGuard-managed fields for re-adding
|
||||||
|
preserved_values = {}
|
||||||
|
|
||||||
|
# Extract and preserve NFOGuard-managed fields (we'll re-add these at the bottom)
|
||||||
|
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||||
|
for tag in nfoguard_fields:
|
||||||
|
existing = episode.find(tag)
|
||||||
|
if existing is not None:
|
||||||
|
# Store the value before removing
|
||||||
|
if tag == "aired" and not aired:
|
||||||
|
aired = existing.text # Preserve existing aired date
|
||||||
|
preserved_values[tag] = existing.text
|
||||||
|
episode.remove(existing)
|
||||||
|
|
||||||
|
# Important: DO NOT remove content fields like title, plot, runtime, premiered, etc.
|
||||||
|
# These should be preserved from the long-named NFO files
|
||||||
|
|
||||||
|
# Debug: Show what fields are preserved after removing NFOGuard fields (if DEBUG enabled)
|
||||||
|
if self.debug:
|
||||||
|
preserved_fields = [elem.tag for elem in episode]
|
||||||
|
if preserved_fields:
|
||||||
|
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ No content fields found after cleanup!")
|
||||||
|
|
||||||
|
except (ET.ParseError, ValueError) as e:
|
||||||
|
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||||
|
print(f" Creating new clean episode NFO file to replace corrupted one")
|
||||||
|
episode = ET.Element("episodedetails")
|
||||||
|
else:
|
||||||
|
# Create new NFO structure
|
||||||
|
episode = ET.Element("episodedetails")
|
||||||
|
|
||||||
|
# Add enhanced metadata only if not already present (preserve existing from long-named NFO)
|
||||||
|
if enhanced_metadata:
|
||||||
|
if enhanced_metadata.get("title") and not episode.find("title"):
|
||||||
|
title_elem = ET.SubElement(episode, "title")
|
||||||
|
title_elem.text = enhanced_metadata["title"]
|
||||||
|
|
||||||
|
if enhanced_metadata.get("overview") and not episode.find("plot"):
|
||||||
|
plot_elem = ET.SubElement(episode, "plot")
|
||||||
|
plot_elem.text = enhanced_metadata["overview"]
|
||||||
|
|
||||||
|
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
|
||||||
|
runtime_elem = ET.SubElement(episode, "runtime")
|
||||||
|
runtime_elem.text = str(enhanced_metadata["runtime"])
|
||||||
|
|
||||||
|
# Add NFOGuard fields at the bottom
|
||||||
|
|
||||||
|
# Basic episode info at the end
|
||||||
|
season_elem = ET.SubElement(episode, "season")
|
||||||
|
season_elem.text = str(season_num)
|
||||||
|
|
||||||
|
episode_elem = ET.SubElement(episode, "episode")
|
||||||
|
episode_elem.text = str(episode_num)
|
||||||
|
|
||||||
|
# Dates at the end
|
||||||
|
if aired:
|
||||||
|
aired_elem = ET.SubElement(episode, "aired")
|
||||||
|
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||||
|
|
||||||
|
if dateadded:
|
||||||
|
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||||
|
dateadded_elem.text = dateadded
|
||||||
|
|
||||||
|
# Add lockdata at the very end
|
||||||
|
if lock_metadata:
|
||||||
|
lockdata = ET.SubElement(episode, "lockdata")
|
||||||
|
lockdata.text = "true"
|
||||||
|
|
||||||
|
# Add NFOGuard comment at the beginning
|
||||||
|
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||||
|
|
||||||
|
# Write file with proper formatting
|
||||||
|
tree = ET.ElementTree(episode)
|
||||||
|
ET.indent(tree, space=" ", level=0)
|
||||||
|
|
||||||
|
# Write to string first to add comment
|
||||||
|
xml_str = ET.tostring(episode, encoding='unicode')
|
||||||
|
|
||||||
|
# Add XML declaration and comment
|
||||||
|
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||||
|
|
||||||
|
# Write to file
|
||||||
|
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(full_xml)
|
||||||
|
|
||||||
|
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||||
|
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||||
|
|
||||||
|
# Clean up old long-named NFO file if we migrated from it
|
||||||
|
if old_nfo_to_delete and old_nfo_to_delete.exists():
|
||||||
|
try:
|
||||||
|
old_nfo_to_delete.unlink()
|
||||||
|
print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}")
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||||
|
|
||||||
|
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
||||||
|
"""Set file modification time to match import date"""
|
||||||
|
try:
|
||||||
|
# Parse ISO timestamp
|
||||||
|
if iso_timestamp.endswith('Z'):
|
||||||
|
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||||
|
elif '+' in iso_timestamp or 'T' in iso_timestamp:
|
||||||
|
dt = datetime.fromisoformat(iso_timestamp)
|
||||||
|
else:
|
||||||
|
# Assume it's already a simple date
|
||||||
|
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
||||||
|
|
||||||
|
# Convert to timestamp
|
||||||
|
timestamp = dt.timestamp()
|
||||||
|
|
||||||
|
# Set both access and modification times
|
||||||
|
os.utime(file_path, (timestamp, timestamp))
|
||||||
|
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error setting mtime for {file_path}: {e}")
|
||||||
|
|
||||||
|
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
||||||
|
"""Update modification times for all video files in movie directory"""
|
||||||
|
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||||
|
updated_files = []
|
||||||
|
|
||||||
|
for file_path in movie_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||||
|
self.set_file_mtime(file_path, iso_timestamp)
|
||||||
|
updated_files.append(file_path.name)
|
||||||
|
|
||||||
|
if updated_files:
|
||||||
|
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Path mapping utilities for NFOGuard
|
||||||
|
Handles conversion between external service paths and container paths
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
class PathMapper:
|
||||||
|
"""Handles path mapping between different environments"""
|
||||||
|
|
||||||
|
def __init__(self, config):
|
||||||
|
"""Initialize path mapper with configuration"""
|
||||||
|
# Use environment variables directly since config attribute names are unclear
|
||||||
|
import os
|
||||||
|
|
||||||
|
radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
|
||||||
|
sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
|
||||||
|
movie_paths_str = os.getenv('MOVIE_PATHS', '')
|
||||||
|
tv_paths_str = os.getenv('TV_PATHS', '')
|
||||||
|
|
||||||
|
self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
|
||||||
|
self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
|
||||||
|
self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
|
||||||
|
self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
|
||||||
|
|
||||||
|
# Check if path debugging is enabled
|
||||||
|
self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: PathMapper initialized with:")
|
||||||
|
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||||
|
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||||
|
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||||
|
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||||
|
|
||||||
|
def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
|
||||||
|
"""Convert Sonarr path to container path using environment mappings"""
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
|
||||||
|
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||||
|
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||||
|
|
||||||
|
# Sort roots by length (longest first) to avoid substring matching issues
|
||||||
|
indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
|
||||||
|
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||||
|
|
||||||
|
# Try to match against configured Sonarr root folders (longest first)
|
||||||
|
for original_index, sonarr_root in indexed_roots:
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
|
||||||
|
if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||||
|
# Map to corresponding TV path
|
||||||
|
if original_index < len(self.tv_paths):
|
||||||
|
container_root = self.tv_paths[original_index]
|
||||||
|
relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
|
||||||
|
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
|
||||||
|
# No fallback - if path mapping fails, return original and let validation catch it
|
||||||
|
return sonarr_path
|
||||||
|
|
||||||
|
def radarr_path_to_container_path(self, radarr_path: str) -> str:
|
||||||
|
"""Convert Radarr path to container path using environment mappings"""
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
|
||||||
|
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||||
|
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||||
|
|
||||||
|
# Sort roots by length (longest first) to avoid substring matching issues
|
||||||
|
indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
|
||||||
|
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||||
|
|
||||||
|
# Try to match against configured Radarr root folders (longest first)
|
||||||
|
for original_index, radarr_root in indexed_roots:
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
|
||||||
|
if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||||
|
# Map to corresponding movie path
|
||||||
|
if original_index < len(self.movie_paths):
|
||||||
|
container_root = self.movie_paths[original_index]
|
||||||
|
relative_path = radarr_path[len(radarr_root):].lstrip('/')
|
||||||
|
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if self.path_debug:
|
||||||
|
print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
|
||||||
|
# No fallback - if path mapping fails, return original and let validation catch it
|
||||||
|
return radarr_path
|
||||||
|
|
||||||
|
def container_path_to_host_path(self, container_path: str) -> str:
|
||||||
|
"""Convert container path back to host path if needed"""
|
||||||
|
# This might be needed for file operations
|
||||||
|
return container_path
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
nfoguard:
|
||||||
|
# Use the official image from Docker Hub
|
||||||
|
image: sbcrumb/nfoguard:latest
|
||||||
|
|
||||||
|
# Alternative: Use specific version
|
||||||
|
# image: sbcrumb/nfoguard:v1.5.5
|
||||||
|
|
||||||
|
# Alternative: Use development version
|
||||||
|
# image: sbcrumb/nfoguard:dev
|
||||||
|
|
||||||
|
container_name: nfoguard
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
- .env.secrets
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
environment:
|
||||||
|
- TZ=America/New_York # Set to your local timezone
|
||||||
|
# Alternative examples:
|
||||||
|
# - TZ=Europe/London
|
||||||
|
# - TZ=America/Los_Angeles
|
||||||
|
# - TZ=Australia/Sydney
|
||||||
|
# - TZ=UTC
|
||||||
|
|
||||||
|
# Port mapping (adjust if needed)
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
# Volume mounts - CRITICAL: These must match your media setup
|
||||||
|
volumes:
|
||||||
|
# NFOGuard data directory (database, logs, cache)
|
||||||
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# Bind mount your Emby plugins directory for automatic plugin deployment
|
||||||
|
- ${EMBY_PLUGINS_PATH}:/emby-plugins
|
||||||
|
|
||||||
|
# Movie libraries - Mount your movie directories here
|
||||||
|
# Format: host_path:container_path:rw
|
||||||
|
- /path/to/your/movies:/media/Movies/movies:rw
|
||||||
|
- /path/to/your/movies2:/media/Movies/movies6:rw
|
||||||
|
|
||||||
|
# TV libraries - Mount your TV show directories here
|
||||||
|
- /path/to/your/tv:/media/TV/tv:rw
|
||||||
|
- /path/to/your/tv2:/media/TV/tv6:rw
|
||||||
|
|
||||||
|
# Optional: Mount download directories for detection
|
||||||
|
# - /path/to/downloads:/downloads:ro
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Resource limits (optional)
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
# Network (optional - use if you have a custom network)
|
||||||
|
# networks:
|
||||||
|
# - media-network
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# Optional: Custom network for media services
|
||||||
|
# networks:
|
||||||
|
# media-network:
|
||||||
|
# external: true
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SETUP INSTRUCTIONS
|
||||||
|
# ===========================================
|
||||||
|
# 1. Copy this file to docker-compose.yml
|
||||||
|
# 2. Update volume mounts to match your actual media paths
|
||||||
|
# 3. Copy .env.template to .env and configure
|
||||||
|
# 4. Copy .env.secrets.template to .env.secrets and add credentials
|
||||||
|
# 5. Create data directory: mkdir -p ./data
|
||||||
|
# 6. Run: docker-compose up -d
|
||||||
|
# 7. Check logs: docker-compose logs -f nfoguard
|
||||||
|
# 8. Access health check: curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# VOLUME MAPPING EXAMPLES
|
||||||
|
# ===========================================
|
||||||
|
# Common setups:
|
||||||
|
#
|
||||||
|
# Unraid:
|
||||||
|
# - /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||||
|
# - /mnt/user/Media/TV:/media/TV/tv:rw
|
||||||
|
#
|
||||||
|
# Synology:
|
||||||
|
# - /volume1/Media/Movies:/media/Movies/movies:rw
|
||||||
|
# - /volume1/Media/TV:/media/TV/tv:rw
|
||||||
|
#
|
||||||
|
# Standard Linux:
|
||||||
|
# - /home/user/media/movies:/media/Movies/movies:rw
|
||||||
|
# - /home/user/media/tv:/media/TV/tv:rw
|
||||||
|
#
|
||||||
|
# Windows (Docker Desktop):
|
||||||
|
# - C:\Media\Movies:/media/Movies/movies:rw
|
||||||
|
# - C:\Media\TV:/media/TV/tv:rw
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# WEBHOOK CONFIGURATION
|
||||||
|
# ===========================================
|
||||||
|
# Configure these webhook URLs in your *arr applications:
|
||||||
|
#
|
||||||
|
# Radarr webhook URL:
|
||||||
|
# http://nfoguard:8080/webhook/radarr
|
||||||
|
# (or http://localhost:8080/webhook/radarr if not using Docker network)
|
||||||
|
#
|
||||||
|
# Sonarr webhook URL:
|
||||||
|
# http://nfoguard:8080/webhook/sonarr
|
||||||
|
# (or http://localhost:8080/webhook/sonarr if not using Docker network)
|
||||||
|
#
|
||||||
|
# Webhook triggers:
|
||||||
|
# - On Import
|
||||||
|
# - On Upgrade
|
||||||
|
# - On Rename (recommended)
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# TROUBLESHOOTING
|
||||||
|
# ===========================================
|
||||||
|
# Common issues and solutions:
|
||||||
|
#
|
||||||
|
# 1. Permission denied errors:
|
||||||
|
# - Ensure NFOGuard container can write to mounted directories
|
||||||
|
# - Check file ownership and permissions
|
||||||
|
# - Consider using PUID/PGID environment variables
|
||||||
|
#
|
||||||
|
# 2. Path mapping issues:
|
||||||
|
# - Verify container paths match .env configuration
|
||||||
|
# - Ensure *arr applications can see the same files
|
||||||
|
# - Check RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS in .env
|
||||||
|
#
|
||||||
|
# 3. Database connection issues:
|
||||||
|
# - Verify PostgreSQL credentials in .env.secrets
|
||||||
|
# - Check network connectivity to database
|
||||||
|
# - Ensure database exists and user has permissions
|
||||||
|
#
|
||||||
|
# 4. Webhook not triggering:
|
||||||
|
# - Verify webhook URLs in *arr applications
|
||||||
|
# - Check NFOGuard logs for incoming requests
|
||||||
|
# - Ensure port 8080 is accessible
|
||||||
+2241
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
psycopg2-binary==2.9.7
|
||||||
|
requests==2.31.0
|
||||||
|
python-multipart==0.0.6
|
||||||
|
aiofiles==23.2.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
Reference in New Issue
Block a user