feat: NFOGuard v1.7.2 - Complete media management system
Local Docker Build (Dev) / build-dev (push) Failing after 0s
Local Docker Build (Main) / build (push) Failing after 0s
Local Docker Build (Main) / deploy (push) Has been skipped

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:
2025-09-23 16:12:58 -04:00
commit 7ad0dd8ca2
38 changed files with 7915 additions and 0 deletions
+28
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
+146
View File
@@ -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 }}"
+207
View File
@@ -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"
+33
View File
@@ -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.
+20
View File
@@ -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.
+15
View File
@@ -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"
}
}
+17
View File
@@ -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`
+35
View File
@@ -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 }}
+67
View File
@@ -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
+107
View File
@@ -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
+66
View File
@@ -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!"
+21
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
+141
View File
@@ -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
View File
@@ -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.
+156
View File
@@ -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.
+66
View File
@@ -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
+221
View File
@@ -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
View File
@@ -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.
+36
View File
@@ -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.
+390
View File
@@ -0,0 +1,390 @@
# NFOGuard
[![Docker Pulls](https://img.shields.io/docker/pulls/sbcrumb/nfoguard.svg)](https://hub.docker.com/r/sbcrumb/nfoguard)
[![Docker Image Version](https://img.shields.io/docker/v/sbcrumb/nfoguard?sort=semver)](https://hub.docker.com/r/sbcrumb/nfoguard)
[![Docker Image Size](https://img.shields.io/docker/image-size/sbcrumb/nfoguard/latest)](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 youd 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! 🎯
+153
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
1.7.2
+597
View File
@@ -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")
+534
View File
@@ -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", [])
+646
View File
@@ -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")
+286
View File
@@ -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")
+274
View File
@@ -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)
}
+53
View File
@@ -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
+590
View File
@@ -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}")
+105
View File
@@ -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
+165
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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