NFOGuard v2.6.7 - Production ready release

Complete media management solution with PostgreSQL, web interface,
and 88% scan optimization for efficient library management.

Key Features:
- PostgreSQL database with production-grade performance
- Web interface for episode and movie management
- Smart scan optimization reducing scan time by 88%
- Database cleanup tools for orphaned episodes/movies
- Graceful Docker shutdown for container orchestration
- Comprehensive configuration validation
- Real-time health monitoring and metrics
This commit is contained in:
2025-10-20 14:57:36 -04:00
commit 60f7063175
66 changed files with 22575 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# ===========================================
# 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 Database Configuration (PostgreSQL - v2.6+)
# NOTE: For new installations, PostgreSQL is strongly recommended
# Set these values in .env.secrets for security:
# - DB_PASSWORD=your_secure_password
# - DB_USER=nfoguard (default)
# PostgreSQL Database Settings
DB_TYPE=postgresql
DB_HOST=nfoguard-db # Container name from docker-compose
DB_PORT=5432
DB_NAME=nfoguard
# Legacy SQLite Configuration (Deprecated in v2.6+)
# Only use for existing SQLite installations
# 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
# Manual scan priority: use existing NFO dates for speed vs check APIs for accuracy
# false (default) = Check external APIs first, use NFO as fallback (slower but accurate)
# true = Use NFO dates immediately without API checks (faster but may use wrong dates)
MANUAL_SCAN_PRIORITIZE_NFO=false
# ===========================================
# 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
+41
View File
@@ -0,0 +1,41 @@
# 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
# ===========================================
# NFOGUARD DATABASE CREDENTIALS (REQUIRED)
# ===========================================
# NFOGuard PostgreSQL database password (REQUIRED for v2.6+)
DB_PASSWORD=your_secure_nfoguard_password
# Optional: Override default database user (defaults to 'nfoguard')
# DB_USER=nfoguard
# ===========================================
# RADARR DATABASE CREDENTIALS (OPTIONAL)
# ===========================================
# Database password for external Radarr PostgreSQL connection (optional optimization)
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
+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
+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
+75
View File
@@ -0,0 +1,75 @@
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 and tini
RUN apt-get update && apt-get install -y \
curl \
libpq-dev \
gcc \
tini \
&& 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 main.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
# Use tini as init process to handle signals and zombie processes properly
ENTRYPOINT ["tini", "--"]
# 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.
+514
View File
@@ -0,0 +1,514 @@
# 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
### **Core Media Management**
- **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
- **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
- **Metadata Locking** - Prevents overwrites with lockdata tags
### **Performance & Scalability**
- **Async I/O Operations** - High-performance concurrent file processing
- **Batch Processing** - Efficient handling of multiple files simultaneously
- **PostgreSQL Database** - Production-ready database with optimized queries
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
- **88% Scan Optimization** - TV library scans reduced from hours to minutes
### **Web Interface & Management**
- **Complete Web UI** - Episode and movie management with filtering and search
- **Database Cleanup Tools** - Delete orphaned episodes with confirmation dialogs
- **Real-time Statistics** - Live episode counts and source mapping
- **Manual Scan Control** - Smart, full, and incomplete scan modes
- **Health Monitoring** - System status and performance metrics
### **Configuration & Validation**
- **Comprehensive Config Validation** - Validates all settings before startup
- **Runtime Health Checks** - Monitors system health and dependencies
- **Path Validation** - Ensures media directories are accessible
- **Database Connectivity Tests** - Validates database connections
### **Production Ready**
- **Docker & Kubernetes** - Health checks for orchestration platforms
- **Graceful Shutdown** - Proper signal handling for container management
- **Configuration CLI** - Validation tools for troubleshooting
- **Modular Architecture** - Clean separation of concerns for maintainability
## 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:v2.0.0 # Latest with monitoring & validation
```
> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
## 🔗 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
#### **Core Operations**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/webhook/radarr` | POST | Radarr webhook handler |
| `/webhook/sonarr` | POST | Sonarr webhook handler |
| `/manual/scan` | POST | Manual media scanning |
| `/bulk/update` | POST | Bulk update from Radarr database |
#### **Health & Monitoring**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/health` | GET | Comprehensive health status |
| `/api/v1/health/ready` | GET | Kubernetes readiness probe |
| `/api/v1/health/live` | GET | Kubernetes liveness probe |
| `/api/v1/status` | GET | Complete system status |
| `/api/v1/status/brief` | GET | Quick system overview |
#### **Metrics & Performance**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/v1/metrics` | GET | Prometheus metrics (text format) |
| `/api/v1/metrics/json` | GET | Metrics in JSON format |
| `/api/v1/metrics/processing` | GET | Processing-specific metrics |
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
| `/api/v1/metrics/system` | GET | System resource metrics |
#### **Configuration & Debugging**
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/debug/movie/{imdb_id}/priority` | GET | Debug movie priority logic |
| `/debug/tmdb/{imdb_id}` | GET | Debug TMDB lookup |
| `/ping` | GET | Simple connectivity test |
### 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
```
## Monitoring & Observability
NFOGuard provides comprehensive monitoring capabilities for production deployments:
### **Health Checks**
```bash
# Basic health status
curl http://localhost:8080/api/v1/health
# Kubernetes readiness probe
curl http://localhost:8080/api/v1/health/ready
# Kubernetes liveness probe
curl http://localhost:8080/api/v1/health/live
# Quick system overview
curl http://localhost:8080/api/v1/status/brief
```
### **Metrics for Grafana/Prometheus**
```bash
# Prometheus metrics format
curl http://localhost:8080/api/v1/metrics
# JSON metrics for dashboards
curl http://localhost:8080/api/v1/metrics/json
# Processing performance metrics
curl http://localhost:8080/api/v1/metrics/processing
# Error rates and recent failures
curl http://localhost:8080/api/v1/metrics/errors
# System resource usage
curl http://localhost:8080/api/v1/metrics/system
```
### **Configuration Validation**
```bash
# Validate configuration before deployment
python config/validation_cli.py
# Include runtime tests (database, APIs, file access)
python config/validation_cli.py --runtime
# JSON output for automation
python config/validation_cli.py --runtime --json
```
### **Docker Health Checks**
Add to your `docker-compose.yml`:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/health/live"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
```
### **Structured Logging**
NFOGuard uses structured JSON logging with correlation IDs for request tracing:
```bash
# Enable structured logging
STRUCTURED_LOGGING=true
# Log level configuration
LOG_LEVEL=INFO
# Example log output with correlation ID
{"timestamp": "2024-01-01T12:00:00Z", "level": "INFO", "correlation_id": "req_123", "message": "Webhook received", "context": {"webhook_type": "radarr", "media_type": "movie"}}
```
### 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 @@
2.6.7
View File
+110
View File
@@ -0,0 +1,110 @@
"""
Pydantic models for NFOGuard API
"""
from pydantic import BaseModel
from typing import Optional, Dict, Any, List
class SonarrWebhook(BaseModel):
"""Sonarr webhook payload model"""
eventType: str
series: Optional[Dict[str, Any]] = None
episodes: Optional[list] = []
episodeFile: Optional[Dict[str, Any]] = None
isUpgrade: Optional[bool] = False
class Config:
extra = "allow"
class RadarrWebhook(BaseModel):
"""Radarr webhook payload model"""
eventType: str
movie: Optional[Dict[str, Any]] = None
movieFile: Optional[Dict[str, Any]] = None
isUpgrade: Optional[bool] = False
deletedFiles: Optional[list] = []
remoteMovie: Optional[Dict[str, Any]] = None
renamedMovieFiles: Optional[List[Dict[str, Any]]] = None
class Config:
extra = "allow"
class HealthResponse(BaseModel):
"""Health check response model"""
status: str
version: str
uptime: str
database_status: str
radarr_database: Optional[Dict[str, Any]] = None
class TVSeasonRequest(BaseModel):
"""TV season processing request model"""
series_path: str
season_name: str
class TVEpisodeRequest(BaseModel):
"""TV episode processing request model"""
series_path: str
season: int
episode: int
# Web interface models
class MovieUpdateRequest(BaseModel):
"""Request to update movie dateadded"""
dateadded: Optional[str]
source: str
class EpisodeUpdateRequest(BaseModel):
"""Request to update episode dateadded"""
dateadded: Optional[str]
source: str
class BulkUpdateRequest(BaseModel):
"""Request for bulk source updates"""
media_type: str # "movies" or "episodes"
old_source: str
new_source: str
class MovieResponse(BaseModel):
"""Movie data response"""
imdb_id: str
title: str
path: str
released: Optional[str]
dateadded: Optional[str]
source: Optional[str]
has_video_file: bool
last_updated: str
class SeriesResponse(BaseModel):
"""TV series data response"""
imdb_id: str
title: str
path: str
last_updated: str
total_episodes: int
episodes_with_dates: int
episodes_with_video: int
class EpisodeResponse(BaseModel):
"""TV episode data response"""
season: int
episode: int
aired: Optional[str]
dateadded: Optional[str]
source: Optional[str]
has_video_file: bool
last_updated: str
series_path: str
season_name: str
episode_name: str
+351
View File
@@ -0,0 +1,351 @@
"""
Monitoring API Routes for NFOGuard
Provides health checks, metrics, and system status endpoints
"""
from fastapi import APIRouter, Response, HTTPException
from typing import Dict, Any, Optional
import time
from monitoring.health import health_checker, HealthStatus
from monitoring.metrics import metrics
try:
from config.validator import get_configuration_summary
except ImportError:
def get_configuration_summary():
return {"status": "Configuration validator not available"}
router = APIRouter(prefix="/api/v1", tags=["monitoring"])
@router.get("/health")
async def get_health_status():
"""
Get comprehensive health status
Returns detailed health information including:
- Overall system health
- Individual component health checks
- Performance metrics
- Error status
"""
try:
health_status = await health_checker.get_full_health_status()
# Set appropriate HTTP status code
if health_status.status == HealthStatus.HEALTHY:
status_code = 200
elif health_status.status == HealthStatus.DEGRADED:
status_code = 200 # Still operational
else: # UNHEALTHY
status_code = 503 # Service unavailable
return Response(
content=health_status.to_dict(),
status_code=status_code,
media_type="application/json"
)
except Exception as e:
# Return unhealthy status if health check itself fails
return Response(
content={
"status": "unhealthy",
"message": f"Health check failed: {e}",
"timestamp": time.time()
},
status_code=500,
media_type="application/json"
)
@router.get("/health/ready")
async def get_readiness_status():
"""
Kubernetes readiness probe endpoint
Returns 200 if service is ready to accept traffic
Returns 503 if service is not ready
"""
try:
readiness = await health_checker.get_readiness_status()
status_code = 200 if readiness["ready"] else 503
return Response(
content=readiness,
status_code=status_code,
media_type="application/json"
)
except Exception as e:
return Response(
content={
"ready": False,
"message": f"Readiness check failed: {e}",
"timestamp": time.time()
},
status_code=503,
media_type="application/json"
)
@router.get("/health/live")
async def get_liveness_status():
"""
Kubernetes liveness probe endpoint
Returns 200 if service is alive and responsive
Returns 500 if service should be restarted
"""
try:
liveness = await health_checker.get_liveness_status()
status_code = 200 if liveness["alive"] else 500
return Response(
content=liveness,
status_code=status_code,
media_type="application/json"
)
except Exception as e:
return Response(
content={
"alive": False,
"message": f"Liveness check failed: {e}",
"timestamp": time.time()
},
status_code=500,
media_type="application/json"
)
@router.get("/metrics")
async def get_prometheus_metrics():
"""
Prometheus-compatible metrics endpoint
Returns metrics in Prometheus text format for scraping
"""
try:
prometheus_metrics = metrics.get_prometheus_metrics()
return Response(
content=prometheus_metrics,
media_type="text/plain; charset=utf-8"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to generate metrics: {e}"
)
@router.get("/metrics/json")
async def get_metrics_json():
"""
Get all metrics in JSON format
Returns structured metrics data including:
- System metrics (CPU, memory, disk)
- Processing metrics (rates, durations)
- Error metrics (counts, recent errors)
"""
try:
all_metrics = metrics.get_all_metrics()
return all_metrics
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get metrics: {e}"
)
@router.get("/status")
async def get_system_status():
"""
Get comprehensive system status
Returns detailed system information including:
- Health status
- Configuration summary
- Performance metrics
- Recent activity
"""
try:
# Get health status
health_status = await health_checker.get_full_health_status()
# Get metrics
all_metrics = metrics.get_all_metrics()
# Get configuration summary
config_summary = get_configuration_summary()
# Combine into comprehensive status
status_response = {
"overall_status": health_status.status.value,
"timestamp": time.time(),
"uptime_seconds": health_status.uptime_seconds,
"version": health_status.version,
"health": health_status.to_dict(),
"metrics": all_metrics,
"configuration": config_summary,
"summary": {
"service_healthy": health_status.status in [HealthStatus.HEALTHY, HealthStatus.DEGRADED],
"total_webhooks_processed": all_metrics["processing"]["total_webhooks"],
"total_nfo_files_created": all_metrics["processing"]["total_nfo_created"],
"total_errors": all_metrics["processing"]["total_errors"],
"current_processing_rate": all_metrics["processing"]["webhooks_received_per_minute"],
"average_processing_time": all_metrics["processing"]["average_processing_time_seconds"]
}
}
return status_response
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get system status: {e}"
)
@router.get("/status/brief")
async def get_brief_status():
"""
Get brief system status for quick monitoring
Returns essential status information without detailed metrics
"""
try:
# Get basic health
liveness = await health_checker.get_liveness_status()
readiness = await health_checker.get_readiness_status()
# Get basic metrics
processing_metrics = metrics.get_processing_metrics()
system_metrics = metrics.get_system_metrics()
return {
"status": "healthy" if liveness["alive"] and readiness["ready"] else "unhealthy",
"alive": liveness["alive"],
"ready": readiness["ready"],
"uptime_seconds": liveness["uptime_seconds"],
"webhooks_per_minute": processing_metrics["webhooks_received_per_minute"],
"active_operations": processing_metrics["active_operations"],
"total_errors": processing_metrics["total_errors"],
"cpu_percent": system_metrics.get("cpu_percent", 0),
"memory_percent": system_metrics.get("memory_percent", 0),
"timestamp": time.time()
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get brief status: {e}"
)
@router.get("/metrics/processing")
async def get_processing_metrics():
"""
Get processing-specific metrics
Returns metrics focused on NFO processing performance
"""
try:
processing_metrics = metrics.get_processing_metrics()
return processing_metrics
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get processing metrics: {e}"
)
@router.get("/metrics/errors")
async def get_error_metrics():
"""
Get error-specific metrics
Returns error counts, types, and recent error information
"""
try:
error_metrics = metrics.get_error_metrics()
return error_metrics
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get error metrics: {e}"
)
@router.get("/metrics/system")
async def get_system_metrics():
"""
Get system resource metrics
Returns CPU, memory, disk, and process information
"""
try:
system_metrics = metrics.get_system_metrics()
return system_metrics
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get system metrics: {e}"
)
@router.post("/metrics/reset")
async def reset_metrics(metric_types: Optional[str] = None):
"""
Reset specific metric types
Parameters:
- metric_types: Comma-separated list of metric types to reset
(counters, histograms, errors, timeseries)
If not specified, resets all metrics
"""
try:
reset_types = None
if metric_types:
reset_types = [t.strip() for t in metric_types.split(",")]
metrics.reset_metrics(reset_types)
return {
"message": "Metrics reset successfully",
"reset_types": reset_types or "all",
"timestamp": time.time()
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to reset metrics: {e}"
)
# Legacy endpoints for backwards compatibility
@router.get("/health-check")
async def legacy_health_check():
"""Legacy health check endpoint (redirects to /health)"""
return await get_brief_status()
@router.get("/ping")
async def ping():
"""Simple ping endpoint for basic connectivity testing"""
return {
"message": "pong",
"timestamp": time.time(),
"service": "nfoguard",
"version": "2.0.0"
}
+1442
View File
File diff suppressed because it is too large Load Diff
+1216
View File
File diff suppressed because it is too large Load Diff
+706
View File
@@ -0,0 +1,706 @@
#!/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}")
# Fallback: First try English-speaking countries, then any country
english_speaking_countries = {"GB", "CA", "AU", "NZ", "IE"} # UK, Canada, Australia, New Zealand, Ireland
# First pass: Try English-speaking countries
_log("INFO", f"🇺🇸 TMDB: Trying English-speaking countries fallback for {imdb_id}")
for entry in release_dates.get("results", []):
country = entry.get("iso_3166_1", "").upper()
if country not in english_speaking_countries:
continue
_log("INFO", f"🎯 TMDB: Checking English-speaking country {country}")
releases = entry.get("release_dates", [])
if not releases:
continue
# 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")
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: Using English-speaking {country} {type_name} release date: {parsed_date}")
return parsed_date
# Second pass: Try any remaining country as last resort
_log("INFO", f"🌍 TMDB: Trying any available country as last resort for {imdb_id}")
for entry in release_dates.get("results", []):
country = entry.get("iso_3166_1", "").upper()
if country in english_speaking_countries or country == self.primary_country:
continue # Already tried these
_log("INFO", f"🎯 TMDB: Checking fallback country {country}")
releases = entry.get("release_dates", [])
if not releases:
continue
# 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")
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: Using fallback {country} {type_name} release date: {parsed_date}")
return parsed_date
_log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in any 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)
def get_episode_air_date(self, imdb_id: str, season: int, episode: int) -> Optional[str]:
"""Get episode air date from external sources"""
_log("DEBUG", f"Looking for air date for {imdb_id} S{season:02d}E{episode:02d}")
# Try TMDB first if available
if self.tmdb.enabled:
# Find TV show by IMDB ID
tv_find_result = self.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
if tv_find_result and tv_find_result.get("tv_results"):
tv_show = tv_find_result["tv_results"][0]
tv_id = tv_show.get("id")
if tv_id:
_log("DEBUG", f"Found TMDB TV ID {tv_id} for {imdb_id}")
episodes = self.tmdb.get_tv_season_episodes(tv_id, season)
if episode in episodes:
air_date = episodes[episode]
_log("INFO", f"Found TMDB air date for {imdb_id} S{season:02d}E{episode:02d}: {air_date}")
return _parse_date_to_iso(air_date)
# Try OMDb as fallback
if self.omdb.enabled:
episodes = self.omdb.get_tv_season_episodes(imdb_id, season)
if episode in episodes:
air_date = episodes[episode]
_log("INFO", f"Found OMDb air date for {imdb_id} S{season:02d}E{episode:02d}: {air_date}")
return _parse_date_to_iso(air_date)
_log("WARNING", f"No air date found for {imdb_id} S{season:02d}E{episode:02d}")
return None
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} - should use air date instead")
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")
View File
+547
View File
@@ -0,0 +1,547 @@
"""
Runtime Configuration Validator for NFOGuard
Provides validation of configuration at runtime with health checks
"""
import asyncio
import time
from pathlib import Path
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass, field
from contextlib import asynccontextmanager
import aiohttp
import sqlite3
import psycopg2
from config.validator import ValidationIssue, ValidationResult, ValidationSeverity
from utils.exceptions import ConfigurationError, NetworkRetryableError, DatabaseError
@dataclass
class HealthCheckResult:
"""Result of a runtime health check"""
component: str
is_healthy: bool
response_time_ms: Optional[float] = None
message: str = ""
details: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"component": self.component,
"is_healthy": self.is_healthy,
"response_time_ms": self.response_time_ms,
"message": self.message,
"details": self.details,
"timestamp": time.time()
}
class RuntimeValidator:
"""Runtime configuration and system health validator"""
def __init__(self, config):
self.config = config
self._health_cache = {}
self._cache_ttl = 60 # Cache health results for 60 seconds
async def validate_runtime_config(self) -> ValidationResult:
"""Perform runtime validation of configuration"""
result = ValidationResult(is_valid=True)
# Validate filesystem access
await self._validate_filesystem_access(result)
# Validate database connectivity
await self._validate_database_connectivity(result)
# Validate external API connectivity
await self._validate_api_connectivity(result)
# Validate permissions
await self._validate_permissions(result)
# Validate resource availability
await self._validate_resources(result)
return result
async def _validate_filesystem_access(self, result: ValidationResult) -> None:
"""Validate filesystem access for media paths"""
all_paths = []
# Collect all media paths
all_paths.extend(self.config.tv_paths)
all_paths.extend(self.config.movie_paths)
for path in all_paths:
try:
# Check if path exists and is accessible
if not path.exists():
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.ERROR,
message=f"Media path does not exist: {path}",
current_value=str(path)
))
continue
# Check if path is readable
test_file = None
try:
# Try to list directory contents
list(path.iterdir())
except PermissionError:
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.ERROR,
message=f"No read permission for media path: {path}",
current_value=str(path)
))
continue
except OSError as e:
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.WARNING,
message=f"Error accessing media path: {e}",
current_value=str(path)
))
continue
# Check write permissions (needed for NFO files)
try:
test_file = path / ".nfoguard_write_test"
test_file.write_text("test")
test_file.unlink()
except PermissionError:
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.ERROR,
message=f"No write permission for media path: {path}",
current_value=str(path),
details={"required_for": "NFO file creation"}
))
except OSError as e:
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.WARNING,
message=f"Write test failed for media path: {e}",
current_value=str(path)
))
finally:
# Cleanup test file if it exists
if test_file and test_file.exists():
try:
test_file.unlink()
except:
pass
except Exception as e:
result.add_issue(ValidationIssue(
setting="media_paths",
severity=ValidationSeverity.ERROR,
message=f"Unexpected error accessing path: {e}",
current_value=str(path)
))
# Validate database directory
db_path = Path(self.config.db_path)
db_dir = db_path.parent
if not db_dir.exists():
try:
db_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Cannot create database directory: {db_dir}",
current_value=str(db_path)
))
except OSError as e:
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Error creating database directory: {e}",
current_value=str(db_path)
))
# Test database file access
if not db_path.exists():
try:
# Try to create the database
with sqlite3.connect(str(db_path)) as conn:
conn.execute("SELECT 1")
except PermissionError:
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Cannot create database file: {db_path}",
current_value=str(db_path)
))
except sqlite3.Error as e:
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Database error: {e}",
current_value=str(db_path)
))
async def _validate_database_connectivity(self, result: ValidationResult) -> None:
"""Validate database connectivity"""
# Test Radarr database if configured
if hasattr(self.config, 'db_type') or 'RADARR_DB_TYPE' in os.environ:
import os
db_type = getattr(self.config, 'db_type', os.environ.get('RADARR_DB_TYPE', '')).lower()
if db_type == 'postgresql':
await self._test_postgresql_connection(result)
elif db_type == 'sqlite':
await self._test_sqlite_connection(result)
# Test local SQLite database
await self._test_local_database(result)
async def _test_postgresql_connection(self, result: ValidationResult) -> None:
"""Test PostgreSQL connection"""
import os
try:
host = os.environ.get('RADARR_DB_HOST')
port = int(os.environ.get('RADARR_DB_PORT', 5432))
database = os.environ.get('RADARR_DB_NAME')
user = os.environ.get('RADARR_DB_USER')
password = os.environ.get('RADARR_DB_PASSWORD', '')
start_time = time.time()
# Use asyncio to run blocking DB call
def test_connection():
conn = psycopg2.connect(
host=host, port=port, database=database,
user=user, password=password,
connect_timeout=10
)
with conn:
with conn.cursor() as cur:
cur.execute("SELECT 1")
return conn
conn = await asyncio.get_event_loop().run_in_executor(None, test_connection)
conn.close()
response_time = (time.time() - start_time) * 1000
if response_time > 5000: # 5 seconds
result.add_issue(ValidationIssue(
setting="RADARR_DB",
severity=ValidationSeverity.WARNING,
message=f"Slow database connection ({response_time:.0f}ms)",
details={"response_time_ms": response_time}
))
except psycopg2.OperationalError as e:
result.add_issue(ValidationIssue(
setting="RADARR_DB",
severity=ValidationSeverity.ERROR,
message=f"Cannot connect to PostgreSQL database: {e}",
details={"error_type": "connection_failed"}
))
except Exception as e:
result.add_issue(ValidationIssue(
setting="RADARR_DB",
severity=ValidationSeverity.ERROR,
message=f"Database connection error: {e}",
details={"error_type": "unexpected_error"}
))
async def _test_local_database(self, result: ValidationResult) -> None:
"""Test local SQLite database"""
try:
db_path = Path(self.config.db_path)
start_time = time.time()
def test_db():
with sqlite3.connect(str(db_path), timeout=10) as conn:
conn.execute("SELECT 1")
return True
await asyncio.get_event_loop().run_in_executor(None, test_db)
response_time = (time.time() - start_time) * 1000
if response_time > 1000: # 1 second is slow for SQLite
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.WARNING,
message=f"Slow local database access ({response_time:.0f}ms)",
current_value=str(db_path),
details={"response_time_ms": response_time}
))
except sqlite3.OperationalError as e:
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Local database error: {e}",
current_value=str(self.config.db_path)
))
async def _validate_api_connectivity(self, result: ValidationResult) -> None:
"""Validate external API connectivity"""
apis = []
if hasattr(self.config, 'radarr_url') and self.config.radarr_url:
apis.append(("Radarr", self.config.radarr_url))
if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url:
apis.append(("Sonarr", self.config.sonarr_url))
for api_name, base_url in apis:
await self._test_api_connectivity(result, api_name, base_url)
async def _test_api_connectivity(self, result: ValidationResult, api_name: str, base_url: str) -> None:
"""Test connectivity to a specific API"""
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
start_time = time.time()
# Test basic connectivity
test_url = f"{base_url.rstrip('/')}/api/v1/health"
async with session.get(test_url) as response:
response_time = (time.time() - start_time) * 1000
if response.status == 200:
if response_time > 5000: # 5 seconds
result.add_issue(ValidationIssue(
setting=f"{api_name.upper()}_URL",
severity=ValidationSeverity.WARNING,
message=f"Slow {api_name} API response ({response_time:.0f}ms)",
current_value=base_url,
details={"response_time_ms": response_time}
))
else:
result.add_issue(ValidationIssue(
setting=f"{api_name.upper()}_URL",
severity=ValidationSeverity.WARNING,
message=f"{api_name} API returned HTTP {response.status}",
current_value=base_url,
details={"status_code": response.status}
))
except asyncio.TimeoutError:
result.add_issue(ValidationIssue(
setting=f"{api_name.upper()}_URL",
severity=ValidationSeverity.WARNING,
message=f"{api_name} API connection timeout",
current_value=base_url,
details={"error_type": "timeout"}
))
except Exception as e:
result.add_issue(ValidationIssue(
setting=f"{api_name.upper()}_URL",
severity=ValidationSeverity.WARNING,
message=f"{api_name} API connection error: {e}",
current_value=base_url,
details={"error_type": "connection_error"}
))
async def _validate_permissions(self, result: ValidationResult) -> None:
"""Validate file system permissions"""
# This is partially covered in filesystem validation
# Additional permission checks can be added here
pass
async def _validate_resources(self, result: ValidationResult) -> None:
"""Validate system resources"""
import shutil
try:
# Check disk space for database directory
db_path = Path(self.config.db_path)
db_dir = db_path.parent if not db_path.is_dir() else db_path
if db_dir.exists():
free_space = shutil.disk_usage(str(db_dir)).free
free_space_mb = free_space / (1024 * 1024)
if free_space_mb < 100: # Less than 100MB
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.ERROR,
message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)",
current_value=str(db_path),
details={"free_space_mb": free_space_mb}
))
elif free_space_mb < 1000: # Less than 1GB
result.add_issue(ValidationIssue(
setting="DB_PATH",
severity=ValidationSeverity.WARNING,
message=f"Low disk space in database directory ({free_space_mb:.1f}MB free)",
current_value=str(db_path),
details={"free_space_mb": free_space_mb}
))
except Exception as e:
result.add_issue(ValidationIssue(
setting="system_resources",
severity=ValidationSeverity.WARNING,
message=f"Could not check disk space: {e}",
details={"error_type": "resource_check_failed"}
))
async def get_system_health(self) -> Dict[str, HealthCheckResult]:
"""Get comprehensive system health status"""
health_checks = {}
# Database health
health_checks["database"] = await self._check_database_health()
# Filesystem health
health_checks["filesystem"] = await self._check_filesystem_health()
# API health
health_checks["external_apis"] = await self._check_api_health()
return health_checks
async def _check_database_health(self) -> HealthCheckResult:
"""Check database health"""
try:
start_time = time.time()
def test_db():
with sqlite3.connect(str(self.config.db_path), timeout=5) as conn:
conn.execute("SELECT COUNT(*) FROM sqlite_master")
return True
await asyncio.get_event_loop().run_in_executor(None, test_db)
response_time = (time.time() - start_time) * 1000
return HealthCheckResult(
component="database",
is_healthy=True,
response_time_ms=response_time,
message="Database accessible",
details={"db_path": str(self.config.db_path)}
)
except Exception as e:
return HealthCheckResult(
component="database",
is_healthy=False,
message=f"Database error: {e}",
details={"db_path": str(self.config.db_path), "error": str(e)}
)
async def _check_filesystem_health(self) -> HealthCheckResult:
"""Check filesystem health"""
try:
accessible_paths = 0
total_paths = len(self.config.tv_paths) + len(self.config.movie_paths)
for path in list(self.config.tv_paths) + list(self.config.movie_paths):
if path.exists() and path.is_dir():
try:
# Quick access test
next(path.iterdir(), None)
accessible_paths += 1
except:
pass
is_healthy = accessible_paths == total_paths
health_percentage = (accessible_paths / total_paths * 100) if total_paths > 0 else 0
return HealthCheckResult(
component="filesystem",
is_healthy=is_healthy,
message=f"{accessible_paths}/{total_paths} media paths accessible ({health_percentage:.1f}%)",
details={
"accessible_paths": accessible_paths,
"total_paths": total_paths,
"health_percentage": health_percentage
}
)
except Exception as e:
return HealthCheckResult(
component="filesystem",
is_healthy=False,
message=f"Filesystem check error: {e}",
details={"error": str(e)}
)
async def _check_api_health(self) -> HealthCheckResult:
"""Check external API health"""
apis_tested = 0
apis_healthy = 0
api_details = {}
try:
# Test configured APIs
if hasattr(self.config, 'radarr_url') and self.config.radarr_url:
apis_tested += 1
healthy = await self._test_single_api("Radarr", self.config.radarr_url)
if healthy:
apis_healthy += 1
api_details["radarr"] = {"healthy": healthy}
if hasattr(self.config, 'sonarr_url') and self.config.sonarr_url:
apis_tested += 1
healthy = await self._test_single_api("Sonarr", self.config.sonarr_url)
if healthy:
apis_healthy += 1
api_details["sonarr"] = {"healthy": healthy}
if apis_tested == 0:
return HealthCheckResult(
component="external_apis",
is_healthy=True,
message="No external APIs configured",
details={"apis_configured": 0}
)
is_healthy = apis_healthy == apis_tested
health_percentage = (apis_healthy / apis_tested * 100) if apis_tested > 0 else 0
return HealthCheckResult(
component="external_apis",
is_healthy=is_healthy,
message=f"{apis_healthy}/{apis_tested} APIs healthy ({health_percentage:.1f}%)",
details={
"healthy_apis": apis_healthy,
"total_apis": apis_tested,
"health_percentage": health_percentage,
"api_status": api_details
}
)
except Exception as e:
return HealthCheckResult(
component="external_apis",
is_healthy=False,
message=f"API health check error: {e}",
details={"error": str(e)}
)
async def _test_single_api(self, api_name: str, base_url: str) -> bool:
"""Test a single API for health"""
try:
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
test_url = f"{base_url.rstrip('/')}/api/v1/health"
async with session.get(test_url) as response:
return response.status == 200
except:
return False
# Import needed for database validation
import os
+302
View File
@@ -0,0 +1,302 @@
"""
NFOGuard Configuration Module
Handles all configuration loading and validation with comprehensive error reporting
"""
import os
import sys
import logging
from pathlib import Path
from typing import List, Optional, Dict, Any
from utils.exceptions import ConfigurationError
logger = logging.getLogger(__name__)
def _bool_env(name: str, default: bool) -> bool:
"""Convert environment variable to boolean"""
v = os.environ.get(name)
if v is None:
return default
return v.lower() in ("1", "true", "yes", "y", "on")
class NFOGuardConfig:
"""Configuration class for NFOGuard with integrated validation"""
def __init__(self, validate_on_init: bool = True, strict_validation: bool = False):
"""
Initialize NFOGuard configuration
Args:
validate_on_init: Run validation during initialization
strict_validation: Treat warnings as errors
"""
self.strict_validation = strict_validation
self._validation_issues = []
# Initialize configuration
self._load_configuration()
# Run validation if requested
if validate_on_init:
self._validate_configuration()
def _load_configuration(self) -> None:
"""Load all configuration from environment variables"""
# Core paths - Required
self._load_paths()
# Core settings
self.manage_nfo = _bool_env("MANAGE_NFO", True)
self.fix_dir_mtimes = _bool_env("FIX_DIR_MTIMES", True)
self.lock_metadata = _bool_env("LOCK_METADATA", True)
self.debug = _bool_env("DEBUG", False)
self.manager_brand = os.environ.get("MANAGER_BRAND", "NFOGuard")
# Batching and performance
self.batch_delay = self._get_float_env("BATCH_DELAY", 5.0, 0.1, 300.0)
self.max_concurrent = self._get_int_env("MAX_CONCURRENT_SERIES", 3, 1, 10)
# Database
self.db_type = os.environ.get("DB_TYPE", "sqlite").lower()
self.db_path = Path(os.environ.get("DB_PATH", "/app/data/media_dates.db"))
# PostgreSQL database settings
if self.db_type == "postgresql":
self.db_host = os.environ.get("DB_HOST", "localhost")
self.db_port = self._get_int_env("DB_PORT", 5432, 1, 65535)
self.db_name = os.environ.get("DB_NAME", "nfoguard")
self.db_user = os.environ.get("DB_USER", "nfoguard")
self.db_password = os.environ.get("DB_PASSWORD", "")
# External connections
self._load_external_connections()
# Movie processing
self._load_movie_settings()
# TV processing
self._load_tv_settings()
def _load_paths(self) -> None:
"""Load and validate path configuration"""
tv_paths_env = os.environ.get("TV_PATHS", "")
movie_paths_env = os.environ.get("MOVIE_PATHS", "")
if not tv_paths_env:
raise ConfigurationError(
setting="TV_PATHS",
reason="TV_PATHS environment variable is required but not set"
)
if not movie_paths_env:
raise ConfigurationError(
setting="MOVIE_PATHS",
reason="MOVIE_PATHS environment variable is required but not set"
)
# Parse paths
self.tv_paths = [Path(p.strip()) for p in tv_paths_env.split(",") if p.strip()]
self.movie_paths = [Path(p.strip()) for p in movie_paths_env.split(",") if p.strip()]
if not self.tv_paths:
raise ConfigurationError(
setting="TV_PATHS",
reason="No valid TV paths found after parsing",
current_value=tv_paths_env
)
if not self.movie_paths:
raise ConfigurationError(
setting="MOVIE_PATHS",
reason="No valid movie paths found after parsing",
current_value=movie_paths_env
)
def _load_external_connections(self) -> None:
"""Load external API and database connection settings"""
# API URLs
self.radarr_url = os.environ.get("RADARR_URL", "")
self.sonarr_url = os.environ.get("SONARR_URL", "")
self.jellyseerr_url = os.environ.get("JELLYSEERR_URL", "")
# Radarr database settings
self.radarr_db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
self.radarr_db_host = os.environ.get("RADARR_DB_HOST", "")
self.radarr_db_port = self._get_int_env("RADARR_DB_PORT", 5432, 1, 65535)
self.radarr_db_name = os.environ.get("RADARR_DB_NAME", "")
self.radarr_db_user = os.environ.get("RADARR_DB_USER", "")
# Timeout settings
self.timeout_seconds = self._get_int_env("TIMEOUT_SECONDS", 45, 10, 300)
def _load_movie_settings(self) -> None:
"""Load movie processing settings"""
self.movie_priority = os.environ.get("MOVIE_PRIORITY", "import_then_digital").lower()
self.prefer_release_dates_over_file_dates = _bool_env("PREFER_RELEASE_DATES_OVER_FILE_DATES", True)
self.allow_file_date_fallback = _bool_env("ALLOW_FILE_DATE_FALLBACK", False)
# Manual scan behavior
self.manual_scan_prioritize_nfo = _bool_env("MANUAL_SCAN_PRIORITIZE_NFO", False)
# Release date settings
release_priority_env = os.environ.get("RELEASE_DATE_PRIORITY", "digital,physical,theatrical")
self.release_date_priority = [p.strip() for p in release_priority_env.split(",") if p.strip()]
self.enable_smart_date_validation = _bool_env("ENABLE_SMART_DATE_VALIDATION", True)
self.max_release_date_gap_years = self._get_int_env("MAX_RELEASE_DATE_GAP_YEARS", 10, 1, 50)
self.movie_poll_mode = os.environ.get("MOVIE_POLL_MODE", "always").lower()
self.movie_update_mode = os.environ.get("MOVIE_DATE_UPDATE_MODE", "backfill_only").lower()
def _load_tv_settings(self) -> None:
"""Load TV processing settings"""
self.tv_season_dir_format = os.environ.get("TV_SEASON_DIR_FORMAT", "Season {season:02d}")
self.tv_season_dir_pattern = os.environ.get("TV_SEASON_DIR_PATTERN", "season ").lower()
self.tv_webhook_processing_mode = os.environ.get("TV_WEBHOOK_PROCESSING_MODE", "targeted").lower()
def _get_int_env(self, name: str, default: int, min_val: int, max_val: int) -> int:
"""Get integer environment variable with validation"""
value_str = os.environ.get(name)
if not value_str:
return default
try:
value = int(value_str)
if value < min_val or value > max_val:
raise ConfigurationError(
setting=name,
reason=f"Value must be between {min_val} and {max_val}",
current_value=value_str
)
return value
except ValueError:
raise ConfigurationError(
setting=name,
reason=f"Invalid integer value",
current_value=value_str
)
def _get_float_env(self, name: str, default: float, min_val: float, max_val: float) -> float:
"""Get float environment variable with validation"""
value_str = os.environ.get(name)
if not value_str:
return default
try:
value = float(value_str)
if value < min_val or value > max_val:
raise ConfigurationError(
setting=name,
reason=f"Value must be between {min_val} and {max_val}",
current_value=value_str
)
return value
except ValueError:
raise ConfigurationError(
setting=name,
reason=f"Invalid float value",
current_value=value_str
)
def _validate_configuration(self) -> None:
"""Validate configuration using the validator"""
try:
# Import here to avoid circular imports
from config.validator import validate_configuration_and_raise
validate_configuration_and_raise()
except ImportError:
# Fallback to basic validation if validator not available
logger.warning("Configuration validator not available, using basic validation")
self._basic_validation()
except ConfigurationError:
if self.strict_validation:
raise
else:
# Log warning but continue
logger.warning("Configuration validation found issues", exc_info=True)
def _basic_validation(self) -> None:
"""Basic fallback validation"""
# Validate that paths exist (basic check)
for path_list, path_type in [(self.tv_paths, "TV"), (self.movie_paths, "Movie")]:
for path in path_list:
if not path.is_absolute():
logger.warning(f"{path_type} path should be absolute: {path}")
def get_configuration_summary(self) -> Dict[str, Any]:
"""Get a summary of current configuration"""
return {
"tv_paths": [str(p) for p in self.tv_paths],
"movie_paths": [str(p) for p in self.movie_paths],
"database": {
"type": self.db_type,
"path": str(self.db_path) if self.db_type == "sqlite" else None,
"host": getattr(self, 'db_host', None) if self.db_type == "postgresql" else None,
"port": getattr(self, 'db_port', None) if self.db_type == "postgresql" else None,
"name": getattr(self, 'db_name', None) if self.db_type == "postgresql" else None
},
"external_apis": {
"radarr": bool(self.radarr_url),
"sonarr": bool(self.sonarr_url),
"jellyseerr": bool(self.jellyseerr_url)
},
"radarr_database": {
"type": getattr(self, 'radarr_db_type', None),
"configured": bool(getattr(self, 'radarr_db_type', None) and getattr(self, 'radarr_db_host', None))
},
"performance": {
"batch_delay": self.batch_delay,
"max_concurrent": self.max_concurrent,
"timeout_seconds": self.timeout_seconds
},
"features": {
"manage_nfo": self.manage_nfo,
"fix_dir_mtimes": self.fix_dir_mtimes,
"lock_metadata": self.lock_metadata,
"debug": self.debug,
"manual_scan_prioritize_nfo": self.manual_scan_prioritize_nfo
}
}
def validate_runtime_access(self) -> Dict[str, bool]:
"""Quick runtime validation of critical paths"""
results = {
"tv_paths_accessible": True,
"movie_paths_accessible": True,
"database_writable": True
}
# Test TV paths
for path in self.tv_paths:
if not path.exists() or not path.is_dir():
results["tv_paths_accessible"] = False
break
# Test movie paths
for path in self.movie_paths:
if not path.exists() or not path.is_dir():
results["movie_paths_accessible"] = False
break
# Test database directory
db_dir = self.db_path.parent
try:
if not db_dir.exists():
db_dir.mkdir(parents=True, exist_ok=True)
# Test write access
test_file = db_dir / ".nfoguard_write_test"
test_file.write_text("test")
test_file.unlink()
except (PermissionError, OSError):
results["database_writable"] = False
return results
# Global config instance - Initialize with validation disabled by default for backwards compatibility
# Applications can enable validation by creating their own instance with validate_on_init=True
config = NFOGuardConfig(validate_on_init=False)
+293
View File
@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
Configuration Validation CLI for NFOGuard
Provides command-line validation and reporting of configuration issues
"""
import sys
import json
import argparse
from pathlib import Path
from typing import Dict, Any, List
from datetime import datetime
from config.validator import validate_configuration, ValidationSeverity
from config.runtime_validator import RuntimeValidator
from config.settings import NFOGuardConfig
class ValidationReporter:
"""Formats and displays validation results"""
def __init__(self, verbose: bool = False, json_output: bool = False):
self.verbose = verbose
self.json_output = json_output
# Color codes for terminal output
self.colors = {
'error': '\033[91m', # Red
'warning': '\033[93m', # Yellow
'info': '\033[94m', # Blue
'success': '\033[92m', # Green
'reset': '\033[0m', # Reset
'bold': '\033[1m' # Bold
}
def report_validation_results(self, result, runtime_result=None) -> int:
"""
Report validation results
Returns:
Exit code (0 for success, 1 for warnings, 2 for errors)
"""
if self.json_output:
return self._report_json(result, runtime_result)
else:
return self._report_human_readable(result, runtime_result)
def _report_json(self, result, runtime_result=None) -> int:
"""Report results in JSON format"""
output = {
"timestamp": datetime.now().isoformat(),
"validation": result.to_dict()
}
if runtime_result:
output["runtime_validation"] = runtime_result.to_dict()
print(json.dumps(output, indent=2))
if result.errors_count > 0:
return 2
elif result.warnings_count > 0:
return 1
return 0
def _report_human_readable(self, result, runtime_result=None) -> int:
"""Report results in human-readable format"""
print(f"{self.colors['bold']}NFOGuard Configuration Validation Report{self.colors['reset']}")
print("=" * 50)
# Overall status
if result.is_valid:
status_color = self.colors['success']
status_text = "✓ VALID"
else:
status_color = self.colors['error']
status_text = "✗ INVALID"
print(f"Status: {status_color}{status_text}{self.colors['reset']}")
print(f"Errors: {result.errors_count}")
print(f"Warnings: {result.warnings_count}")
print(f"Total Issues: {len(result.issues)}")
print()
# Report issues by severity
if result.issues:
self._report_issues_by_severity(result.issues)
# Report runtime validation if available
if runtime_result:
print(f"\n{self.colors['bold']}Runtime Validation{self.colors['reset']}")
print("-" * 20)
if runtime_result.issues:
self._report_issues_by_severity(runtime_result.issues, "Runtime")
else:
print(f"{self.colors['success']}✓ All runtime checks passed{self.colors['reset']}")
# Summary and recommendations
self._report_summary_and_recommendations(result)
# Return appropriate exit code
if result.errors_count > 0 or (runtime_result and runtime_result.errors_count > 0):
return 2
elif result.warnings_count > 0 or (runtime_result and runtime_result.warnings_count > 0):
return 1
return 0
def _report_issues_by_severity(self, issues: List, context: str = "Configuration") -> None:
"""Report issues grouped by severity"""
errors = [issue for issue in issues if issue.severity == ValidationSeverity.ERROR]
warnings = [issue for issue in issues if issue.severity == ValidationSeverity.WARNING]
info_issues = [issue for issue in issues if issue.severity == ValidationSeverity.INFO]
if errors:
print(f"{self.colors['error']}{self.colors['bold']}ERRORS ({len(errors)}):{self.colors['reset']}")
for issue in errors:
self._format_issue(issue)
print()
if warnings:
print(f"{self.colors['warning']}{self.colors['bold']}WARNINGS ({len(warnings)}):{self.colors['reset']}")
for issue in warnings:
self._format_issue(issue)
print()
if info_issues and self.verbose:
print(f"{self.colors['info']}{self.colors['bold']}INFO ({len(info_issues)}):{self.colors['reset']}")
for issue in info_issues:
self._format_issue(issue)
print()
def _format_issue(self, issue) -> None:
"""Format a single validation issue"""
severity_colors = {
ValidationSeverity.ERROR: self.colors['error'],
ValidationSeverity.WARNING: self.colors['warning'],
ValidationSeverity.INFO: self.colors['info']
}
color = severity_colors.get(issue.severity, '')
print(f" {color}{issue.setting}:{self.colors['reset']} {issue.message}")
if issue.current_value is not None:
print(f" Current: {issue.current_value}")
if issue.suggested_value is not None:
print(f" Suggested: {issue.suggested_value}")
if self.verbose and issue.details:
print(f" Details: {issue.details}")
def _report_summary_and_recommendations(self, result) -> None:
"""Report summary and general recommendations"""
print(f"\n{self.colors['bold']}Summary{self.colors['reset']}")
print("-" * 10)
if result.is_valid:
print(f"{self.colors['success']}✓ Your configuration is valid and ready to use!{self.colors['reset']}")
else:
print(f"{self.colors['error']}✗ Your configuration has issues that need to be resolved.{self.colors['reset']}")
# Provide specific recommendations based on issue types
recommendations = self._generate_recommendations(result)
if recommendations:
print(f"\n{self.colors['bold']}Recommendations{self.colors['reset']}")
print("-" * 15)
for rec in recommendations:
print(f" {self.colors['info']}{rec}{self.colors['reset']}")
def _generate_recommendations(self, result) -> List[str]:
"""Generate recommendations based on validation results"""
recommendations = []
# Check for common patterns
path_issues = [issue for issue in result.issues if 'path' in issue.setting.lower()]
if path_issues:
recommendations.append("Verify all file paths are correct for your environment")
recommendations.append("Ensure paths are absolute when using Docker")
db_issues = [issue for issue in result.issues if 'db' in issue.setting.lower()]
if db_issues:
recommendations.append("Check database connection settings and credentials")
url_issues = [issue for issue in result.issues if 'url' in issue.setting.lower()]
if url_issues:
recommendations.append("Verify API URLs are reachable and include the correct port")
perf_issues = [issue for issue in result.issues if any(perf in issue.setting.lower()
for perf in ['concurrent', 'delay', 'timeout'])]
if perf_issues:
recommendations.append("Consider adjusting performance settings based on your system resources")
# General recommendations
if result.errors_count > 0:
recommendations.append("Fix all ERROR-level issues before starting NFOGuard")
if result.warnings_count > 0:
recommendations.append("Review WARNING-level issues to optimize performance and reliability")
return recommendations
async def run_validation(args) -> int:
"""Run configuration validation"""
reporter = ValidationReporter(verbose=args.verbose, json_output=args.json)
try:
# Run static validation
print("Running configuration validation..." if not args.json else "", file=sys.stderr)
result = validate_configuration()
runtime_result = None
# Run runtime validation if requested
if args.runtime:
print("Running runtime validation..." if not args.json else "", file=sys.stderr)
try:
config = NFOGuardConfig()
runtime_validator = RuntimeValidator(config)
runtime_result = await runtime_validator.validate_runtime_config()
except Exception as e:
if not args.json:
print(f"Runtime validation failed: {e}", file=sys.stderr)
# Continue with static validation results
# Report results
return reporter.report_validation_results(result, runtime_result)
except Exception as e:
if args.json:
error_output = {
"timestamp": datetime.now().isoformat(),
"error": {
"message": str(e),
"type": type(e).__name__
}
}
print(json.dumps(error_output, indent=2))
else:
print(f"Validation failed: {e}", file=sys.stderr)
return 2
def main():
"""Main CLI entry point"""
parser = argparse.ArgumentParser(
description="Validate NFOGuard configuration",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s # Basic validation
%(prog)s --runtime # Include runtime checks
%(prog)s --verbose # Show detailed information
%(prog)s --json # Output JSON format
%(prog)s --runtime --json # Runtime validation with JSON output
"""
)
parser.add_argument(
"--runtime",
action="store_true",
help="Perform runtime validation (tests actual connectivity and permissions)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show verbose output including info-level messages"
)
parser.add_argument(
"--json",
action="store_true",
help="Output results in JSON format"
)
args = parser.parse_args()
# Run validation
import asyncio
try:
exit_code = asyncio.run(run_validation(args))
sys.exit(exit_code)
except KeyboardInterrupt:
print("Validation interrupted", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(2)
if __name__ == "__main__":
main()
+498
View File
@@ -0,0 +1,498 @@
"""
Configuration Validator for NFOGuard
Provides comprehensive validation of all configuration settings with detailed error reporting
"""
import os
import re
from pathlib import Path
from typing import Dict, List, Any, Optional, Union, Type, Callable
from dataclasses import dataclass, field
from enum import Enum
from utils.exceptions import ConfigurationError
from utils.validation import validate_url_format
class ValidationSeverity(Enum):
"""Severity levels for validation issues"""
ERROR = "error" # Configuration is invalid, will cause failures
WARNING = "warning" # Configuration may cause issues but is workable
INFO = "info" # Configuration could be improved
@dataclass
class ValidationIssue:
"""Represents a configuration validation issue"""
setting: str
severity: ValidationSeverity
message: str
current_value: Any = None
suggested_value: Any = None
details: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for structured logging"""
return {
"setting": self.setting,
"severity": self.severity.value,
"message": self.message,
"current_value": str(self.current_value) if self.current_value is not None else None,
"suggested_value": str(self.suggested_value) if self.suggested_value is not None else None,
"details": self.details
}
@dataclass
class ValidationResult:
"""Results of configuration validation"""
is_valid: bool
issues: List[ValidationIssue] = field(default_factory=list)
warnings_count: int = 0
errors_count: int = 0
def add_issue(self, issue: ValidationIssue) -> None:
"""Add a validation issue"""
self.issues.append(issue)
if issue.severity == ValidationSeverity.ERROR:
self.errors_count += 1
self.is_valid = False
elif issue.severity == ValidationSeverity.WARNING:
self.warnings_count += 1
def get_errors(self) -> List[ValidationIssue]:
"""Get only error-level issues"""
return [issue for issue in self.issues if issue.severity == ValidationSeverity.ERROR]
def get_warnings(self) -> List[ValidationIssue]:
"""Get only warning-level issues"""
return [issue for issue in self.issues if issue.severity == ValidationSeverity.WARNING]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for structured logging"""
return {
"is_valid": self.is_valid,
"errors_count": self.errors_count,
"warnings_count": self.warnings_count,
"issues": [issue.to_dict() for issue in self.issues]
}
class ConfigValidator:
"""Comprehensive configuration validator for NFOGuard"""
def __init__(self):
self.result = ValidationResult(is_valid=True)
# Define validation rules
self._path_settings = {
"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS",
"SONARR_ROOT_FOLDERS", "DB_PATH", "LOG_DIR"
}
self._url_settings = {
"RADARR_URL", "SONARR_URL", "JELLYSEERR_URL"
}
self._required_settings = {
"TV_PATHS", "MOVIE_PATHS"
}
self._numeric_settings = {
"BATCH_DELAY": (float, 0.1, 300.0),
"MAX_CONCURRENT_SERIES": (int, 1, 10),
"TIMEOUT_SECONDS": (int, 10, 300),
"PORT": (int, 1024, 65535),
"RADARR_DB_PORT": (int, 1, 65535),
"MAX_RELEASE_DATE_GAP_YEARS": (int, 1, 50)
}
self._boolean_settings = {
"MANAGE_NFO", "FIX_DIR_MTIMES", "LOCK_METADATA", "DEBUG",
"PREFER_RELEASE_DATES_OVER_FILE_DATES", "ALLOW_FILE_DATE_FALLBACK",
"ENABLE_SMART_DATE_VALIDATION", "PATH_DEBUG", "SUPPRESS_TVDB_WARNINGS"
}
self._choice_settings = {
"MOVIE_PRIORITY": ["import_then_digital", "digital_first", "file_date_only"],
"MOVIE_POLL_MODE": ["always", "missing_only", "never"],
"MOVIE_DATE_UPDATE_MODE": ["overwrite", "backfill_only", "preserve_existing"],
"TV_WEBHOOK_PROCESSING_MODE": ["targeted", "full_scan", "hybrid"],
"UPDATE_MODE": ["always", "missing_only", "never"],
"MTIME_BEHAVIOR": ["update", "leave_alone"],
"RADARR_DB_TYPE": ["postgresql", "sqlite"]
}
def validate_all(self) -> ValidationResult:
"""Validate all configuration settings"""
self.result = ValidationResult(is_valid=True)
# Validate required settings
self._validate_required_settings()
# Validate paths
self._validate_paths()
# Validate URLs
self._validate_urls()
# Validate numeric settings
self._validate_numeric_settings()
# Validate boolean settings
self._validate_boolean_settings()
# Validate choice settings
self._validate_choice_settings()
# Validate database configuration
self._validate_database_config()
# Validate release date configuration
self._validate_release_date_config()
# Validate performance settings
self._validate_performance_settings()
# Validate cross-setting dependencies
self._validate_dependencies()
return self.result
def _validate_required_settings(self) -> None:
"""Validate required environment variables are set"""
for setting in self._required_settings:
value = os.environ.get(setting)
if not value:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Required setting {setting} is not set",
current_value=value
))
elif not value.strip():
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Required setting {setting} is empty",
current_value=value
))
def _validate_paths(self) -> None:
"""Validate all path-related settings"""
for setting in self._path_settings:
value = os.environ.get(setting)
if not value:
if setting in self._required_settings:
continue # Already handled in required validation
else:
# Optional path settings
continue
if setting in {"TV_PATHS", "MOVIE_PATHS", "RADARR_ROOT_FOLDERS", "SONARR_ROOT_FOLDERS"}:
# Multi-path settings
paths = [p.strip() for p in value.split(",") if p.strip()]
if not paths:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"No valid paths found in {setting}",
current_value=value
))
continue
for path_str in paths:
self._validate_single_path(setting, path_str)
else:
# Single path settings
self._validate_single_path(setting, value)
def _validate_single_path(self, setting: str, path_str: str) -> None:
"""Validate a single path"""
try:
path = Path(path_str)
# Check if path is absolute (recommended for Docker)
if not path.is_absolute():
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.WARNING,
message=f"Path should be absolute for reliable Docker operation",
current_value=path_str,
suggested_value=f"Use absolute path like /media/..."
))
# For media paths, check existence if not in container
if setting in {"TV_PATHS", "MOVIE_PATHS"} and not self._is_likely_container_path(path_str):
if not path.exists():
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.WARNING,
message=f"Path does not exist (may be valid in container)",
current_value=path_str,
details={"path_type": "media"}
))
elif not path.is_dir():
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Path exists but is not a directory",
current_value=path_str
))
# Check for database directory
if setting == "DB_PATH":
parent_dir = path.parent
if not self._is_likely_container_path(str(parent_dir)) and not parent_dir.exists():
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.WARNING,
message=f"Database directory does not exist: {parent_dir}",
current_value=path_str
))
except (OSError, ValueError) as e:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Invalid path format: {e}",
current_value=path_str
))
def _is_likely_container_path(self, path: str) -> bool:
"""Check if path looks like a container path"""
container_indicators = ["/app/", "/media/", "/config/", "/data/"]
return any(indicator in path for indicator in container_indicators)
def _validate_urls(self) -> None:
"""Validate URL settings"""
for setting in self._url_settings:
value = os.environ.get(setting)
if value and not validate_url_format(value):
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Invalid URL format",
current_value=value,
suggested_value="Use format: http://hostname:port or https://hostname:port"
))
def _validate_numeric_settings(self) -> None:
"""Validate numeric settings"""
for setting, (type_class, min_val, max_val) in self._numeric_settings.items():
value = os.environ.get(setting)
if not value:
continue
try:
numeric_value = type_class(value)
if numeric_value < min_val or numeric_value > max_val:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Value must be between {min_val} and {max_val}",
current_value=value,
suggested_value=f"Use value between {min_val}-{max_val}"
))
except (ValueError, TypeError):
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Invalid {type_class.__name__} value",
current_value=value,
suggested_value=f"Use a valid {type_class.__name__} between {min_val}-{max_val}"
))
def _validate_boolean_settings(self) -> None:
"""Validate boolean settings"""
valid_true = {"1", "true", "yes", "y", "on"}
valid_false = {"0", "false", "no", "n", "off"}
valid_values = valid_true | valid_false
for setting in self._boolean_settings:
value = os.environ.get(setting)
if value and value.lower() not in valid_values:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Invalid boolean value",
current_value=value,
suggested_value="Use: true/false, 1/0, yes/no, y/n, on/off"
))
def _validate_choice_settings(self) -> None:
"""Validate settings with predefined choices"""
for setting, valid_choices in self._choice_settings.items():
value = os.environ.get(setting)
if value and value.lower() not in [choice.lower() for choice in valid_choices]:
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Invalid choice",
current_value=value,
suggested_value=f"Use one of: {', '.join(valid_choices)}"
))
def _validate_database_config(self) -> None:
"""Validate database configuration"""
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
if db_type == "postgresql":
# Check required PostgreSQL settings
required_pg_settings = ["RADARR_DB_HOST", "RADARR_DB_NAME", "RADARR_DB_USER"]
for setting in required_pg_settings:
if not os.environ.get(setting):
self.result.add_issue(ValidationIssue(
setting=setting,
severity=ValidationSeverity.ERROR,
message=f"Required for PostgreSQL database connection",
current_value=os.environ.get(setting)
))
def _validate_release_date_config(self) -> None:
"""Validate release date processing configuration"""
priority = os.environ.get("RELEASE_DATE_PRIORITY", "")
if priority:
priorities = [p.strip().lower() for p in priority.split(",")]
valid_priorities = {"digital", "physical", "theatrical"}
invalid_priorities = [p for p in priorities if p not in valid_priorities]
if invalid_priorities:
self.result.add_issue(ValidationIssue(
setting="RELEASE_DATE_PRIORITY",
severity=ValidationSeverity.ERROR,
message=f"Invalid release date priorities: {', '.join(invalid_priorities)}",
current_value=priority,
suggested_value="Use: digital, physical, theatrical"
))
if len(set(priorities)) != len(priorities):
self.result.add_issue(ValidationIssue(
setting="RELEASE_DATE_PRIORITY",
severity=ValidationSeverity.WARNING,
message="Duplicate priorities found",
current_value=priority
))
def _validate_performance_settings(self) -> None:
"""Validate performance-related settings"""
batch_delay = os.environ.get("BATCH_DELAY")
max_concurrent = os.environ.get("MAX_CONCURRENT_SERIES")
# Performance recommendations
if batch_delay:
try:
delay = float(batch_delay)
if delay < 1.0:
self.result.add_issue(ValidationIssue(
setting="BATCH_DELAY",
severity=ValidationSeverity.WARNING,
message="Very low batch delay may increase system load",
current_value=batch_delay,
suggested_value="Consider using 1.0 or higher for better stability"
))
except ValueError:
pass # Already caught in numeric validation
if max_concurrent:
try:
concurrent = int(max_concurrent)
if concurrent > 5:
self.result.add_issue(ValidationIssue(
setting="MAX_CONCURRENT_SERIES",
severity=ValidationSeverity.WARNING,
message="High concurrency may overload system resources",
current_value=max_concurrent,
suggested_value="Consider using 3-5 for optimal balance"
))
except ValueError:
pass # Already caught in numeric validation
def _validate_dependencies(self) -> None:
"""Validate cross-setting dependencies"""
# If database is configured, recommend using database over API
db_type = os.environ.get("RADARR_DB_TYPE")
radarr_url = os.environ.get("RADARR_URL")
if db_type and radarr_url:
self.result.add_issue(ValidationIssue(
setting="RADARR_DB_TYPE",
severity=ValidationSeverity.INFO,
message="Database connection preferred over API for better performance",
details={"recommendation": "Database access is faster and more reliable"}
))
# Check path mapping consistency
tv_paths = os.environ.get("TV_PATHS", "").split(",")
sonarr_paths = os.environ.get("SONARR_ROOT_FOLDERS", "").split(",")
if len([p for p in tv_paths if p.strip()]) != len([p for p in sonarr_paths if p.strip()]):
self.result.add_issue(ValidationIssue(
setting="TV_PATHS",
severity=ValidationSeverity.WARNING,
message="TV_PATHS and SONARR_ROOT_FOLDERS should have matching number of paths",
details={
"tv_paths_count": len([p for p in tv_paths if p.strip()]),
"sonarr_paths_count": len([p for p in sonarr_paths if p.strip()])
}
))
def validate_configuration() -> ValidationResult:
"""
Validate the complete NFOGuard configuration
Returns:
ValidationResult with all validation issues found
"""
validator = ConfigValidator()
return validator.validate_all()
def validate_configuration_and_raise() -> None:
"""
Validate configuration and raise ConfigurationError if invalid
Raises:
ConfigurationError: If configuration validation fails
"""
result = validate_configuration()
if not result.is_valid:
error_messages = []
for error in result.get_errors():
error_messages.append(f"{error.setting}: {error.message}")
raise ConfigurationError(
setting="configuration",
reason=f"Configuration validation failed with {result.errors_count} errors",
current_value={
"errors": error_messages,
"warnings_count": result.warnings_count,
"validation_details": result.to_dict()
}
)
def get_configuration_summary() -> Dict[str, Any]:
"""
Get a summary of current configuration status
Returns:
Dictionary with configuration summary
"""
result = validate_configuration()
return {
"is_valid": result.is_valid,
"errors_count": result.errors_count,
"warnings_count": result.warnings_count,
"total_issues": len(result.issues),
"critical_issues": [
issue.to_dict() for issue in result.issues
if issue.severity == ValidationSeverity.ERROR
],
"recommendations": [
issue.to_dict() for issue in result.issues
if issue.severity == ValidationSeverity.WARNING
]
}
+423
View File
@@ -0,0 +1,423 @@
"""
Async NFO Manager for NFOGuard
High-performance async NFO file operations with concurrent processing
"""
import asyncio
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime
import xml.etree.ElementTree as ET
from utils.logging import _log
from utils.async_file_utils import (
async_read_nfo_file,
async_write_nfo_file,
async_file_exists,
async_set_file_mtime,
async_batch_nfo_operations,
async_concurrent_episode_processing
)
from utils.nfo_patterns import (
create_basic_nfo_structure,
extract_imdb_from_nfo_content,
extract_dates_from_nfo,
extract_imdb_id_from_text
)
from utils.validation import validate_date_string
class AsyncNFOManager:
"""Async NFO file manager with concurrent processing capabilities"""
def __init__(self, manager_brand: str = "NFOGuard", debug: bool = False):
self.manager_brand = manager_brand
self.debug = debug
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
"""
Async extract IMDb ID from directory path or filename
Args:
path: Path to examine
Returns:
IMDb ID if found, None otherwise
"""
# Use the sync version since it's just string processing
return extract_imdb_id_from_text(str(path))
async def async_parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
"""
Async extract IMDb ID from NFO file content
Args:
nfo_path: Path to NFO file
Returns:
IMDb ID if found, None otherwise
"""
root = await async_read_nfo_file(nfo_path)
if root is None:
return None
return extract_imdb_from_nfo_content(root)
async def async_find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
"""
Async find IMDb ID from directory name, filenames, or NFO file
Args:
movie_dir: Path to movie directory
Returns:
IMDb ID if found, None otherwise
"""
if self.debug:
_log("DEBUG", f"Async searching for IMDb ID in: {movie_dir.name}")
# First try directory name
imdb_id = await self.async_parse_imdb_from_path(movie_dir)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in directory name: {imdb_id}")
return imdb_id
# Try all files in the directory concurrently
if await async_file_exists(movie_dir):
try:
from utils.async_file_utils import aiofiles
entries = await aiofiles.os.listdir(movie_dir)
# Create tasks to check all files
async def check_file(filename: str) -> Optional[str]:
file_path = movie_dir / filename
if await aiofiles.os.path.isfile(file_path):
return await self.async_parse_imdb_from_path(file_path)
return None
# Check all files concurrently
results = await asyncio.gather(
*[check_file(filename) for filename in entries],
return_exceptions=True
)
# Find first valid IMDb ID
for result in results:
if isinstance(result, str) and result:
if self.debug:
_log("DEBUG", f"Found IMDb ID in filename: {result}")
return result
except Exception as e:
_log("WARNING", f"Failed to scan directory {movie_dir}: {e}")
# Finally, try NFO file content
nfo_path = movie_dir / "movie.nfo"
imdb_id = await self.async_parse_imdb_from_nfo(nfo_path)
if imdb_id:
if self.debug:
_log("DEBUG", f"Found IMDb ID in NFO file: {imdb_id}")
return imdb_id
if self.debug:
_log("DEBUG", f"No IMDb ID found for: {movie_dir.name}")
return None
async def async_create_movie_nfo(
self,
movie_dir: Path,
imdb_id: str,
dateadded: str,
premiered: Optional[str] = None,
lock_metadata: bool = True
) -> bool:
"""
Async create movie NFO file
Args:
movie_dir: Path to movie directory
imdb_id: IMDb ID
dateadded: Date added
premiered: Optional premiere date
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_path = movie_dir / "movie.nfo"
# Prepare dates
dates = {"dateadded": dateadded}
if premiered and validate_date_string(premiered):
dates["premiered"] = premiered
# Create NFO structure
root = create_basic_nfo_structure(
media_type="movie",
title=movie_dir.name,
imdb_id=imdb_id,
dates=dates,
additional_fields={"source": self.manager_brand}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created movie NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create movie NFO for {movie_dir}: {e}")
return False
async def async_create_episode_nfo(
self,
season_dir: Path,
season: int,
episode: int,
aired: Optional[str] = None,
dateadded: Optional[str] = None,
source: str = "unknown",
lock_metadata: bool = True
) -> bool:
"""
Async create episode NFO file
Args:
season_dir: Path to season directory
season: Season number
episode: Episode number
aired: Optional air date
dateadded: Optional date added
source: Source of the data
lock_metadata: Whether to lock metadata
Returns:
True if successful, False otherwise
"""
try:
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
nfo_path = season_dir / nfo_filename
# Prepare dates
dates = {}
if aired and validate_date_string(aired):
dates["aired"] = aired
if dateadded and validate_date_string(dateadded):
dates["dateadded"] = dateadded
# Create NFO structure
root = create_basic_nfo_structure(
media_type="episodedetails",
title=f"S{season:02d}E{episode:02d}",
dates=dates,
additional_fields={
"season": str(season),
"episode": str(episode),
"source": source
}
)
# Write NFO file asynchronously
success = await async_write_nfo_file(nfo_path, root, lock_metadata)
if success and self.debug:
_log("DEBUG", f"Created episode NFO: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to create episode NFO S{season:02d}E{episode:02d}: {e}")
return False
async def async_batch_create_episode_nfos(
self,
episode_data_list: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch create multiple episode NFOs concurrently
Args:
episode_data_list: List of episode data dictionaries
max_concurrent: Maximum concurrent NFO operations
Returns:
List of success/failure results
"""
async def _create_episode_nfo(episode_data: Dict[str, Any]) -> bool:
return await self.async_create_episode_nfo(
season_dir=episode_data.get('season_dir'),
season=episode_data.get('season'),
episode=episode_data.get('episode'),
aired=episode_data.get('aired'),
dateadded=episode_data.get('dateadded'),
source=episode_data.get('source', 'unknown'),
lock_metadata=episode_data.get('lock_metadata', True)
)
return await async_concurrent_episode_processing(
episode_data_list,
_create_episode_nfo,
max_concurrent
)
async def async_set_file_mtime(self, file_path: Path, date_str: str) -> bool:
"""
Async set file modification time from date string
Args:
file_path: Path to file
date_str: Date string in ISO format
Returns:
True if successful, False otherwise
"""
try:
if not validate_date_string(date_str):
_log("WARNING", f"Invalid date format for mtime: {date_str}")
return False
# Parse date string to timestamp
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
timestamp = dt.timestamp()
# Set file mtime asynchronously
success = await async_set_file_mtime(file_path, timestamp)
if success and self.debug:
_log("DEBUG", f"Set mtime for {file_path}: {date_str}")
return success
except Exception as e:
_log("ERROR", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_batch_set_file_mtimes(
self,
file_mtime_pairs: List[Tuple[Path, str]],
max_concurrent: int = 10
) -> List[bool]:
"""
Batch set file modification times concurrently
Args:
file_mtime_pairs: List of (file_path, date_str) tuples
max_concurrent: Maximum concurrent operations
Returns:
List of success/failure results
"""
async def _set_single_mtime(file_path: Path, date_str: str) -> bool:
return await self.async_set_file_mtime(file_path, date_str)
# Create tasks for all mtime operations
semaphore = asyncio.Semaphore(max_concurrent)
async def _set_mtime_with_semaphore(file_path: Path, date_str: str) -> bool:
async with semaphore:
return await _set_single_mtime(file_path, date_str)
tasks = [
_set_mtime_with_semaphore(file_path, date_str)
for file_path, date_str in file_mtime_pairs
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return [result if isinstance(result, bool) else False for result in results]
async def async_validate_nfo_integrity(
self,
nfo_paths: List[Path],
max_concurrent: int = 10
) -> Dict[str, Any]:
"""
Async validate integrity of multiple NFO files
Args:
nfo_paths: List of NFO file paths to validate
max_concurrent: Maximum concurrent validations
Returns:
Dictionary with validation results and statistics
"""
results = {
'total_files': len(nfo_paths),
'valid_files': 0,
'invalid_files': 0,
'missing_files': 0,
'validation_errors': [],
'file_results': {}
}
async def _validate_single_nfo(nfo_path: Path) -> Dict[str, Any]:
file_result = {
'path': str(nfo_path),
'exists': False,
'valid_xml': False,
'has_imdb_id': False,
'has_dates': False,
'error': None
}
try:
# Check if file exists
if not await async_file_exists(nfo_path):
file_result['error'] = 'File does not exist'
return file_result
file_result['exists'] = True
# Try to parse NFO
root = await async_read_nfo_file(nfo_path)
if root is None:
file_result['error'] = 'Failed to parse XML'
return file_result
file_result['valid_xml'] = True
# Check for IMDb ID
imdb_id = extract_imdb_from_nfo_content(root)
file_result['has_imdb_id'] = bool(imdb_id)
# Check for dates
dates = extract_dates_from_nfo(root)
file_result['has_dates'] = any(dates.values())
except Exception as e:
file_result['error'] = str(e)
return file_result
# Validate all files concurrently
semaphore = asyncio.Semaphore(max_concurrent)
async def _validate_with_semaphore(nfo_path: Path) -> Dict[str, Any]:
async with semaphore:
return await _validate_single_nfo(nfo_path)
tasks = [_validate_with_semaphore(nfo_path) for nfo_path in nfo_paths]
file_results = await asyncio.gather(*tasks, return_exceptions=True)
# Process results
for i, result in enumerate(file_results):
if isinstance(result, dict):
path_str = str(nfo_paths[i])
results['file_results'][path_str] = result
if not result['exists']:
results['missing_files'] += 1
elif result['valid_xml']:
results['valid_files'] += 1
else:
results['invalid_files'] += 1
if result.get('error'):
results['validation_errors'].append(f"{path_str}: {result['error']}")
return results
+570
View File
@@ -0,0 +1,570 @@
#!/usr/bin/env python3
"""
PostgreSQL database management for NFOGuard
Handles database operations for tracking media dates and processing history
"""
import json
import threading
from datetime import datetime
from typing import Optional, Dict, List, Any
from contextlib import contextmanager
import psycopg2
import psycopg2.extras
class NFOGuardDatabase:
"""PostgreSQL database manager for NFOGuard media tracking and processing history"""
def __init__(self, config):
"""
Initialize PostgreSQL database connection
Args:
config: Configuration object with database settings
"""
if not config:
raise ValueError("PostgreSQL configuration is required")
self.db_host = config.db_host
self.db_port = config.db_port
self.db_name = config.db_name
self.db_user = config.db_user
self.db_password = config.db_password
self.db_type = "postgresql" # NFOGuard uses PostgreSQL
self._local = threading.local()
self._init_database()
def _get_connection(self) -> 'psycopg2.extensions.connection':
"""Get thread-local PostgreSQL database connection"""
if not hasattr(self._local, 'connection'):
self._local.connection = psycopg2.connect(
host=self.db_host,
port=self.db_port,
database=self.db_name,
user=self.db_user,
password=self.db_password,
cursor_factory=psycopg2.extras.RealDictCursor
)
self._local.connection.autocommit = True
return self._local.connection
def _get_first_value(self, row):
"""Get first value from row from PostgreSQL RealDictCursor"""
# RealDictCursor returns dict-like objects
return list(row.values())[0] if row else None
@contextmanager
def get_connection(self):
"""Context manager for PostgreSQL database connections"""
conn = self._get_connection()
try:
yield conn
# PostgreSQL uses autocommit - no manual commit needed
except Exception:
# PostgreSQL uses autocommit - no manual rollback needed
raise
def _init_database(self):
"""Initialize PostgreSQL database tables"""
with self.get_connection() as conn:
cursor = conn.cursor()
self._init_postgresql_tables(cursor)
# Test the connection works and verify autocommit
cursor.execute("SELECT 1")
print(f"✅ PostgreSQL database initialized and connection verified")
print(f"🔍 Autocommit status: {conn.autocommit}")
def _init_postgresql_tables(self, cursor):
"""Initialize database tables"""
# Series table
cursor.execute("""
CREATE TABLE IF NOT EXISTS series (
imdb_id VARCHAR(20) PRIMARY KEY,
path TEXT NOT NULL,
last_updated TIMESTAMP NOT NULL,
metadata JSONB
)
""")
# Episodes table
cursor.execute("""
CREATE TABLE IF NOT EXISTS episodes (
imdb_id VARCHAR(20) NOT NULL,
season INTEGER NOT NULL,
episode INTEGER NOT NULL,
aired DATE,
dateadded TIMESTAMP,
source VARCHAR(100),
last_updated TIMESTAMP NOT NULL,
has_video_file BOOLEAN DEFAULT FALSE,
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 VARCHAR(20) PRIMARY KEY,
path TEXT NOT NULL,
released DATE,
dateadded TIMESTAMP,
source VARCHAR(100),
last_updated TIMESTAMP NOT NULL,
has_video_file BOOLEAN DEFAULT FALSE
)
""")
# Processing history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS processing_history (
id SERIAL PRIMARY KEY,
imdb_id VARCHAR(20) NOT NULL,
media_type VARCHAR(20) NOT NULL,
event_type VARCHAR(50) NOT NULL,
processed_at TIMESTAMP NOT NULL,
details TEXT
)
""")
# Create indexes for PostgreSQL
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()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO series (imdb_id, path, last_updated, metadata)
VALUES (%s, %s, %s, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
path = EXCLUDED.path,
last_updated = EXCLUDED.last_updated,
metadata = EXCLUDED.metadata
""", (imdb_id, path, timestamp, 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()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO episodes
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (imdb_id, season, episode) DO UPDATE SET
aired = EXCLUDED.aired,
dateadded = EXCLUDED.dateadded,
source = EXCLUDED.source,
has_video_file = EXCLUDED.has_video_file,
last_updated = EXCLUDED.last_updated
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, timestamp))
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DEBUG: PostgreSQL upsert executed for {imdb_id} S{season:02d}E{episode:02d}, rows affected: {cursor.rowcount}")
def upsert_movie(self, imdb_id: str, path: str):
"""Insert or update movie record"""
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO movies (imdb_id, path, last_updated)
VALUES (%s, %s, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
path = EXCLUDED.path,
last_updated = EXCLUDED.last_updated
""", (imdb_id, path, timestamp))
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"""
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
with self.get_connection() as conn:
cursor = conn.cursor()
timestamp = datetime.utcnow()
cursor.execute("""
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s)
ON CONFLICT (imdb_id) DO UPDATE SET
released = EXCLUDED.released,
dateadded = EXCLUDED.dateadded,
source = EXCLUDED.source,
has_video_file = EXCLUDED.has_video_file,
last_updated = EXCLUDED.last_updated
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp))
# Debug: Check what was actually saved
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,))
result = cursor.fetchone()
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] 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 = %s"
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 = %s AND season = %s AND episode = %s
""", (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 = %s", (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 (%s, %s, %s, %s, %s)
""", (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() # Regular cursor for PostgreSQL
# Series stats
cursor.execute("SELECT COUNT(*) FROM series")
series_count = self._get_first_value(cursor.fetchone())
# Episode stats
cursor.execute("SELECT COUNT(*) FROM episodes")
episodes_total = self._get_first_value(cursor.fetchone())
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
episodes_with_video = self._get_first_value(cursor.fetchone())
# Movie stats
cursor.execute("SELECT COUNT(*) FROM movies")
movies_total = self._get_first_value(cursor.fetchone())
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
movies_with_video = self._get_first_value(cursor.fetchone())
# Processing history
cursor.execute("SELECT COUNT(*) FROM processing_history")
history_count = self._get_first_value(cursor.fetchone())
# Database size calculation for PostgreSQL
cursor.execute("SELECT pg_database_size(%s)", (self.db_name,))
db_size_bytes = self._get_first_value(cursor.fetchone())
db_size_mb = round(db_size_bytes / 1024 / 1024, 2) if db_size_bytes else 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": db_size_mb,
"database_type": "postgresql"
}
def delete_episode(self, imdb_id: str, season: int, episode: int) -> bool:
"""
Delete a specific episode from the database
Args:
imdb_id: Series IMDb ID
season: Season number
episode: Episode number
Returns:
True if episode was deleted, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM episodes
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (imdb_id, season, episode))
deleted_count = cursor.rowcount
conn.commit()
return deleted_count > 0
def delete_series_episodes(self, imdb_id: str) -> int:
"""
Delete all episodes for a series from the database
Args:
imdb_id: Series IMDb ID
Returns:
Number of episodes deleted
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM episodes
WHERE imdb_id = %s
""", (imdb_id,))
deleted_count = cursor.rowcount
conn.commit()
return deleted_count
def delete_orphaned_episodes(self) -> List[Dict]:
"""
Find and delete episodes that don't have corresponding video files on disk
This requires checking filesystem for each episode, so use carefully
Returns:
List of deleted episodes with their details
"""
from utils.file_utils import find_episodes_on_disk
from pathlib import Path
deleted_episodes = []
with self.get_connection() as conn:
cursor = conn.cursor()
# Get all series with their paths
cursor.execute("""
SELECT DISTINCT imdb_id, path FROM series
""")
series_list = cursor.fetchall()
for series in series_list:
imdb_id = series['imdb_id']
series_path = Path(series['path'])
if not series_path.exists():
continue
# Get episodes on disk
disk_episodes = find_episodes_on_disk(series_path)
disk_episode_keys = set(disk_episodes.keys())
# Get episodes in database
cursor.execute("""
SELECT season, episode, dateadded, source
FROM episodes
WHERE imdb_id = %s
""", (imdb_id,))
db_episodes = cursor.fetchall()
# Find orphaned episodes (in DB but not on disk)
for db_episode in db_episodes:
season = db_episode['season']
episode = db_episode['episode']
episode_key = (season, episode)
if episode_key not in disk_episode_keys:
# Episode is orphaned - delete it
cursor.execute("""
DELETE FROM episodes
WHERE imdb_id = %s AND season = %s AND episode = %s
""", (imdb_id, season, episode))
deleted_episodes.append({
'imdb_id': imdb_id,
'season': season,
'episode': episode,
'dateadded': db_episode['dateadded'],
'source': db_episode['source'],
'series_path': str(series_path)
})
conn.commit()
return deleted_episodes
def delete_movie(self, imdb_id: str) -> bool:
"""
Delete a specific movie from the database
Args:
imdb_id: Movie IMDb ID
Returns:
True if movie was deleted, False if not found
"""
with self.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
DELETE FROM movies
WHERE imdb_id = %s
""", (imdb_id,))
deleted_count = cursor.rowcount
conn.commit()
return deleted_count > 0
def delete_orphaned_movies(self) -> List[Dict]:
"""
Find and delete movies that don't have corresponding video files on disk
This requires checking filesystem for each movie, so use carefully
Returns:
List of deleted movies with their details
"""
from pathlib import Path
deleted_movies = []
with self.get_connection() as conn:
cursor = conn.cursor()
# Get all movies with their paths
cursor.execute("""
SELECT imdb_id, path, dateadded, source
FROM movies
""")
movies_list = cursor.fetchall()
for movie in movies_list:
imdb_id = movie['imdb_id']
movie_path = Path(movie['path'])
if not movie_path.exists():
# Movie directory doesn't exist - delete it
cursor.execute("""
DELETE FROM movies
WHERE imdb_id = %s
""", (imdb_id,))
deleted_movies.append({
'imdb_id': imdb_id,
'reason': 'directory_not_found',
'path': str(movie_path),
'dateadded': movie['dateadded'],
'source': movie['source']
})
continue
# Check for video files
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
has_video = any(f.is_file() and f.suffix.lower() in video_exts
for f in movie_path.iterdir() if f.is_file())
if not has_video:
# No video files found - delete this movie
cursor.execute("""
DELETE FROM movies
WHERE imdb_id = %s
""", (imdb_id,))
deleted_movies.append({
'imdb_id': imdb_id,
'reason': 'no_video_files',
'path': str(movie_path),
'dateadded': movie['dateadded'],
'source': movie['source']
})
conn.commit()
return deleted_movies
def delete_orphaned_series(self) -> List[Dict]:
"""
Find and delete TV series that don't have corresponding directories on disk
This requires checking filesystem for each series, so use carefully
Returns:
List of deleted series with their details
"""
from pathlib import Path
deleted_series = []
with self.get_connection() as conn:
cursor = conn.cursor()
# Get all series with their paths
cursor.execute("""
SELECT imdb_id, path, last_updated, metadata
FROM series
""")
series_list = cursor.fetchall()
for series in series_list:
imdb_id = series['imdb_id']
series_path = Path(series['path'])
if not series_path.exists():
# Series directory doesn't exist - delete the series and all its episodes
cursor.execute("""
DELETE FROM episodes
WHERE imdb_id = %s
""", (imdb_id,))
episodes_deleted = cursor.rowcount
cursor.execute("""
DELETE FROM series
WHERE imdb_id = %s
""", (imdb_id,))
deleted_series.append({
'imdb_id': imdb_id,
'reason': 'directory_not_found',
'path': str(series_path),
'last_updated': series['last_updated'],
'episodes_deleted': episodes_deleted
})
conn.commit()
return deleted_series
def close(self):
"""Close all database connections"""
if hasattr(self._local, 'connection'):
try:
self._local.connection.close()
delattr(self._local, 'connection')
except Exception:
pass # Connection may already be closed
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""
Episode NFO Manager - Handles TV episode NFO creation with video filename matching
Core principle: NFO filenames should match video filenames
"""
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
import re
from .logging import _log
class EpisodeNFOManager:
"""Manages episode NFO files with video filename matching"""
def __init__(self, manager_brand: str = "NFOGuard"):
self.manager_brand = manager_brand
def find_video_files_for_season(self, season_dir: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all video files in season directory, grouped by (season, episode)"""
if not season_dir.exists():
_log("DEBUG", f"Season directory does not exist: {season_dir}")
return {}
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v"]
episodes = {}
_log("DEBUG", f"Scanning video files in: {season_dir}")
for video_file in season_dir.iterdir():
_log("DEBUG", f"Checking file: {video_file.name} (is_file: {video_file.is_file()}, suffix: {video_file.suffix.lower()})")
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
episode_info = self._parse_episode_from_filename(video_file.name)
_log("DEBUG", f"Episode parsing for '{video_file.name}': {episode_info}")
if episode_info:
season_num, episode_num = episode_info
key = (season_num, episode_num)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
_log("DEBUG", f"Added video file: S{season_num:02d}E{episode_num:02d}{video_file.name}")
_log("DEBUG", f"Total video files found: {len(episodes)} episodes")
return episodes
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Extract season and episode numbers from filename"""
# Try S##E## format first (most common)
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
return None
def find_nfo_for_episode(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
"""Find existing NFO file for episode (prefer video-matching filename)"""
if not season_dir.exists():
return None
# First, look for NFO files that match video filenames
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files:
for video_file in video_files[key]:
potential_nfo = season_dir / f"{video_file.stem}.nfo"
if potential_nfo.exists():
_log("DEBUG", f"Found video-matching NFO: {potential_nfo.name}")
return potential_nfo
# Fallback: look for short name NFO
short_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
if short_nfo.exists():
_log("DEBUG", f"Found short-name NFO: {short_nfo.name}")
return short_nfo
# Last resort: search all NFO files for matching season/episode data
for nfo_file in season_dir.glob("*.nfo"):
if self._nfo_matches_episode(nfo_file, season_num, episode_num):
_log("DEBUG", f"Found matching NFO by content: {nfo_file.name}")
return nfo_file
return None
def _nfo_matches_episode(self, nfo_path: Path, season_num: int, episode_num: int) -> bool:
"""Check if NFO file contains the specified season/episode"""
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag == "episodedetails":
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)
return file_season == season_num and file_episode == episode_num
except ValueError:
pass
except (ET.ParseError, Exception):
pass
return False
def get_target_nfo_path(self, season_dir: Path, season_num: int, episode_num: int) -> Path:
"""Get the target NFO path (prefer video filename, fallback to short name)"""
video_files = self.find_video_files_for_season(season_dir)
key = (season_num, episode_num)
if key in video_files and video_files[key]:
# Use the first video file found (handle multiple files gracefully)
video_file = video_files[key][0]
target_nfo = season_dir / f"{video_file.stem}.nfo"
_log("DEBUG", f"Target NFO will match video: {target_nfo.name}")
return target_nfo
else:
# Fallback to short name if no video file found
target_nfo = season_dir / f"S{season_num:02d}E{episode_num:02d}.nfo"
_log("WARNING", f"No video file found for S{season_num:02d}E{episode_num:02d}, using short name: {target_nfo.name}")
return target_nfo
def migrate_nfo_to_video_filename(self, season_dir: Path, season_num: int, episode_num: int) -> bool:
"""If short-name NFO exists, rename it to match video filename"""
existing_nfo = self.find_nfo_for_episode(season_dir, season_num, episode_num)
target_nfo = self.get_target_nfo_path(season_dir, season_num, episode_num)
# If we already have the right filename, nothing to do
if existing_nfo and existing_nfo == target_nfo:
return True
# If we have an NFO but it doesn't match target, rename it
if existing_nfo and existing_nfo != target_nfo:
try:
_log("INFO", f"Migrating NFO filename: {existing_nfo.name} -> {target_nfo.name}")
existing_nfo.rename(target_nfo)
return True
except Exception as e:
_log("ERROR", f"Failed to rename NFO: {e}")
return False
return False
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
aired: Optional[str], dateadded: Optional[str], source: str,
title: Optional[str] = None, plot: Optional[str] = None) -> bool:
"""Create or update episode NFO with video filename matching"""
# Get the target NFO path (matching video filename)
nfo_path = self.get_target_nfo_path(season_dir, season_num, episode_num)
# Migrate existing NFO if needed
self.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
try:
# Load existing NFO if it exists
episode_elem = None
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
episode_elem = tree.getroot()
if episode_elem.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
# Remove NFOGuard-managed fields (we'll re-add them)
for tag in ["season", "episode", "aired", "premiered", "dateadded", "lockdata"]:
existing = episode_elem.find(tag)
if existing is not None:
episode_elem.remove(existing)
_log("DEBUG", f"Loaded existing NFO content for {nfo_path.name}")
except (ET.ParseError, ValueError) as e:
_log("WARNING", f"Corrupted NFO file {nfo_path.name}: {e}. Creating new one.")
episode_elem = None
# Create new structure if needed
if episode_elem is None:
episode_elem = ET.Element("episodedetails")
# Add title if provided and not already present
if title and not episode_elem.find("title"):
title_elem = ET.SubElement(episode_elem, "title")
title_elem.text = title
# Add plot if provided and not already present
if plot and not episode_elem.find("plot"):
plot_elem = ET.SubElement(episode_elem, "plot")
plot_elem.text = plot
# Add NFOGuard fields at the end
season_elem = ET.SubElement(episode_elem, "season")
season_elem.text = str(season_num)
episode_num_elem = ET.SubElement(episode_elem, "episode")
episode_num_elem.text = str(episode_num)
if aired:
aired_elem = ET.SubElement(episode_elem, "aired")
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
# Also add premiered for compatibility
premiered_elem = ET.SubElement(episode_elem, "premiered")
premiered_elem.text = aired[:10] if len(aired) >= 10 else aired
if dateadded:
dateadded_elem = ET.SubElement(episode_elem, "dateadded")
dateadded_elem.text = dateadded
# Add lockdata
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
lockdata_elem.text = "true"
# Add comment with source
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
episode_elem.append(comment)
# Write the NFO file
tree = ET.ElementTree(episode_elem)
ET.indent(tree, space=" ", level=0)
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
_log("INFO", f"✅ Created/updated episode NFO: {nfo_path.name}")
_log("INFO", f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, DateAdded: {dateadded}, Source: {source}")
return True
except Exception as e:
_log("ERROR", f"Failed to create episode NFO {nfo_path}: {e}")
return False
def extract_nfoguard_data(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed data from existing NFO"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
if root.tag != "episodedetails":
return None
# Look for NFOGuard fields
dateadded_elem = root.find("dateadded")
aired_elem = root.find("aired")
lockdata_elem = root.find("lockdata")
# Only consider it NFOGuard-managed if it has dateadded and lockdata
if (dateadded_elem is not None and dateadded_elem.text and
lockdata_elem is not None and lockdata_elem.text == "true"):
result = {
"dateadded": dateadded_elem.text.strip(),
"source": "existing_nfo"
}
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
_log("DEBUG", f"Found NFOGuard data in {nfo_path.name}: {result}")
return result
except (ET.ParseError, Exception) as e:
_log("WARNING", f"Error parsing NFO {nfo_path.name}: {e}")
return None
+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
+789
View File
@@ -0,0 +1,789 @@
#!/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, Tuple
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:
root = self._parse_nfo_with_tolerance(nfo_path)
# 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
# Last resort: Check for TMDB ID as fallback identifier
# This handles movies that only have TMDB IDs in NFO files
tmdb_uniqueid = root.find('.//uniqueid[@type="tmdb"]')
if tmdb_uniqueid is not None and tmdb_uniqueid.text:
tmdb_id = tmdb_uniqueid.text.strip()
if tmdb_id.isdigit():
print(f"⚠️ Found TMDB ID {tmdb_id} but no IMDb ID - using TMDB ID as fallback")
# Return TMDB ID with prefix to distinguish from IMDb IDs
return f"tmdb-{tmdb_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 (including TMDB fallback)
nfo_path = movie_dir / "movie.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def find_series_imdb_id(self, series_dir: Path) -> Optional[str]:
"""Find IMDb ID from TV series directory name, filenames, or tvshow.nfo file"""
# First try directory name
imdb_id = self.parse_imdb_from_path(series_dir)
if imdb_id:
return imdb_id
# Try all files in the directory for IMDb ID patterns
for file_path in series_dir.iterdir():
if file_path.is_file():
imdb_id = self.parse_imdb_from_path(file_path)
if imdb_id:
return imdb_id
# Finally, try tvshow.nfo file content
nfo_path = series_dir / "tvshow.nfo"
imdb_id = self.parse_imdb_from_nfo(nfo_path)
if imdb_id:
return imdb_id
return None
def extract_nfoguard_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing NFO file"""
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields
dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered')
aired_elem = root.find('.//aired') # For TV episodes
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
if lockdata_elem is not None and lockdata_elem.text == "true":
# Extract original source from NFOGuard comment, default to nfo_file_existing
source = "nfo_file_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if premiered_elem is not None and premiered_elem.text:
result["released"] = premiered_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in NFO: dateadded={result.get('dateadded', 'None')}, source={source}, released={result.get('released', 'None')}, aired={result.get('aired', 'None')}")
return result
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing NFO for NFOGuard data: {e}")
pass
return None
def extract_nfoguard_dates_from_episode_nfo(self, season_path: Path, season_num: int, episode_num: int) -> Optional[Dict[str, str]]:
"""Extract NFOGuard-managed dates from existing episode NFO file"""
nfo_filename = f"s{season_num:02d}e{episode_num:02d}.nfo"
nfo_path = season_path / nfo_filename
if not nfo_path.exists():
return None
try:
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for NFOGuard fields in episode NFO
dateadded_elem = root.find('.//dateadded')
aired_elem = root.find('.//aired')
lockdata_elem = root.find('.//lockdata')
# Consider it NFOGuard-managed if it has lockdata=true (with or without dateadded)
if lockdata_elem is not None and lockdata_elem.text == "true":
# Extract original source from NFOGuard comment, default to episode_nfo_existing
source = "episode_nfo_existing"
# Parse XML content to find NFOGuard comment with source
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', nfo_content)
if source_match:
source = source_match.group(1).strip()
print(f"🔍 Extracted original source from episode NFO comment: {source}")
result = {
"source": source
}
if dateadded_elem is not None and dateadded_elem.text:
result["dateadded"] = dateadded_elem.text.strip()
if aired_elem is not None and aired_elem.text:
result["aired"] = aired_elem.text.strip()
print(f"✅ Found NFOGuard data in episode NFO S{season_num:02d}E{episode_num:02d}: dateadded={result.get('dateadded', 'None')}, source={source}, aired={result.get('aired', 'None')}")
return result
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing episode NFO for NFOGuard data: {e}")
pass
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"
# Debug output only if DEBUG=true in environment
import os
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 create_movie_nfo called: imdb_id={imdb_id}, dateadded={dateadded}, released={released}, source={source}")
print(f"🔍 NFO path: {nfo_path}")
print(f"🔍 NFO exists: {nfo_path.exists()}")
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>")
# Only remove elements that are clearly NFOGuard-managed
# Look for elements that have NFOGuard characteristics
elements_to_remove = []
# Remove lockdata=true (this is definitely NFOGuard)
for lockdata in movie.findall("lockdata"):
if lockdata.text == "true":
elements_to_remove.append(lockdata)
# Remove IMDb uniqueids that we manage
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
elements_to_remove.append(uniqueid)
# For dateadded/premiered/year, only remove if they appear to be NFOGuard-managed
# (i.e., if lockdata=true exists, these are likely ours)
has_nfoguard_lockdata = any(ld.text == "true" for ld in movie.findall("lockdata"))
if has_nfoguard_lockdata:
# This NFO was managed by NFOGuard, safe to remove our fields
for tag in ["dateadded", "premiered", "year"]:
existing = movie.find(tag)
if existing is not None:
# Store the value before removing (for premiered/year)
if tag == "premiered" and not released:
print(f"🔍 Preserving existing premiered date: {existing.text}")
released = existing.text
elements_to_remove.append(existing)
else:
# No NFOGuard lockdata found, be more conservative
# Only remove dateadded if it looks like NFOGuard format (ISO timestamp)
dateadded_elem = movie.find("dateadded")
if dateadded_elem is not None and dateadded_elem.text:
# NFOGuard uses ISO format like "2025-10-12 16:26:02"
if re.match(r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}', dateadded_elem.text.strip()):
elements_to_remove.append(dateadded_elem)
# Remove all identified elements
for elem in elements_to_remove:
movie.remove(elem)
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")
# Create all NFOGuard elements first, then append them in correct order
# This ensures they appear as a group at the bottom of the file
nfoguard_elements = []
# Add NFOGuard comment marker as the first of our additions
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
nfoguard_elements.append(nfoguard_comment)
# Add IMDb uniqueid
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
uniqueid.text = imdb_id
nfoguard_elements.append(uniqueid)
# Add premiered date if we have it
if released:
premiered_elem = ET.Element("premiered")
premiered_elem.text = released[:10] if len(released) >= 10 else released
nfoguard_elements.append(premiered_elem)
# 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.Element("year")
year_elem.text = year_value
nfoguard_elements.append(year_elem)
except:
pass # Skip year if we can't extract it
# Add dateadded - THIS IS CRITICAL FOR EMBY PLUGIN
if os.environ.get("DEBUG", "false").lower() == "true":
print(f"🔍 About to add dateadded: {dateadded} (type: {type(dateadded)})")
if dateadded:
dateadded_elem = ET.Element("dateadded")
dateadded_elem.text = dateadded
nfoguard_elements.append(dateadded_elem)
print(f"✅ Adding dateadded to NFO: {dateadded}")
else:
print(f"❌ dateadded is empty/None, not adding to NFO")
# Add lockdata at the very end
if lock_metadata:
lockdata = ET.Element("lockdata")
lockdata.text = "true"
nfoguard_elements.append(lockdata)
# Now append all NFOGuard elements to the movie in one batch
# This ensures they appear as a contiguous block at the bottom
for elem in nfoguard_elements:
movie.append(elem)
print(f"✅ Added {len(nfoguard_elements)} NFOGuard elements to bottom of NFO")
# Write file with proper formatting
tree = ET.ElementTree(movie)
ET.indent(tree, space=" ", level=0)
# Write directly to file (comment is already embedded in XML)
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write('<?xml version="1.0" encoding="utf-8"?>\n')
tree.write(f, encoding='unicode', xml_declaration=False)
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 any existing episode NFO file that matches season/episode"""
if not season_dir.exists():
return None
# Look for NFO files in the season directory
for nfo_file in season_dir.glob("*.nfo"):
# 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}")
return nfo_file
except ValueError:
continue
except (ET.ParseError, Exception):
# Skip corrupted or non-XML files
continue
return None
def _get_target_episode_nfo_name(self, season_dir: Path, season_num: int, episode_num: int) -> str:
"""Get target NFO filename - prefer existing NFO, then matching video file, fallback to short name"""
if not season_dir.exists():
return f"S{season_num:02d}E{episode_num:02d}.nfo"
# First check if an NFO already exists for this episode
existing_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
if existing_nfo:
print(f"📂 Existing NFO found, will preserve filename: {existing_nfo.name}")
return existing_nfo.name
# Look for video files with matching season/episode
video_extensions = [".mkv", ".mp4", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".webm"]
for video_file in season_dir.iterdir():
if (video_file.is_file() and
video_file.suffix.lower() in video_extensions):
# Parse episode info from video filename
episode_info = self._parse_episode_from_filename(video_file.name)
if episode_info and episode_info == (season_num, episode_num):
# Found matching video file - use its name for NFO
target_nfo_name = f"{video_file.stem}.nfo"
print(f"🎯 Target NFO will match video: {target_nfo_name}")
return target_nfo_name
# Fallback to short name if no matching video found
short_name = f"S{season_num:02d}E{episode_num:02d}.nfo"
print(f"⚠️ No matching video file found, using short name: {short_name}")
return short_name
def _parse_episode_from_filename(self, filename: str) -> Optional[Tuple[int, int]]:
"""Parse season and episode numbers from filename"""
# Try S##E## format first
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
# Try ##x## format
match = re.search(r'(\d{1,2})x(\d{1,2})', filename)
if match:
return int(match.group(1)), int(match.group(2))
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"""
# Get target NFO filename (prefer long name matching video file)
target_nfo_name = self._get_target_episode_nfo_name(season_dir, season_num, episode_num)
nfo_path = season_dir / target_nfo_name
try:
# Check for existing NFO file at target location
source_nfo_path = 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>")
print(f"📝 Updating existing NFO: {nfo_path.name}")
# 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" ️ NFO contains only NFOGuard metadata (no additional content fields)")
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")
# Convert datetime objects to strings
aired_str = str(aired)
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
if dateadded:
dateadded_elem = ET.SubElement(episode, "dateadded")
# Convert datetime objects to strings
dateadded_elem.text = str(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}")
# NFO file created/updated successfully
pass
except Exception as e:
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
def set_file_mtime(self, file_path: Path, iso_timestamp) -> None:
"""Set file modification time to match import date"""
try:
# Convert datetime objects to strings first
if hasattr(iso_timestamp, 'isoformat'):
iso_timestamp = iso_timestamp.isoformat()
elif not isinstance(iso_timestamp, str):
iso_timestamp = str(iso_timestamp)
# 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)
elif ' ' in iso_timestamp:
# Handle space-separated datetime format (e.g., "2025-10-16 20:31:22")
dt = datetime.fromisoformat(iso_timestamp.replace(' ', 'T'))
else:
# Assume it's a simple date (e.g., "2025-10-16")
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
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Debug script to check specific movie data in NFOGuard database
"""
import os
import sys
from pathlib import Path
# Add the project root to the path
sys.path.insert(0, str(Path(__file__).parent))
from core.database import NFOGuardDatabase
def debug_movie(imdb_id: str):
"""Debug a specific movie's data"""
print(f"🔍 DEBUG MOVIE: {imdb_id}")
print("=" * 50)
# Initialize database
db = NFOGuardDatabase()
# Get movie data
movie = db.get_movie_dates(imdb_id)
if not movie:
print(f"❌ Movie {imdb_id} not found in database")
return
print("📊 RAW MOVIE DATA:")
for key, value in movie.items():
print(f" {key}: {repr(value)}")
print("\n🎬 FORMATTED MOVIE DATA:")
print(f" Title/Path: {movie.get('path', 'Unknown')}")
print(f" Released: {movie.get('released', 'None')}")
print(f" Date Added: {movie.get('dateadded', 'None')}")
print(f" Source: {movie.get('source', 'None')}")
print(f" Has Video: {movie.get('has_video_file', False)}")
print(f" Last Updated: {movie.get('last_updated', 'None')}")
# Check if released date is valid
released = movie.get('released')
if released and released.strip():
try:
from datetime import datetime
test_date = f"{released}T00:00:00"
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
print(f"\n✅ Released date is valid: {parsed}")
except Exception as e:
print(f"\n❌ Released date is INVALID: {e}")
else:
print(f"\n⚠️ Released date is empty or None")
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python debug_movie.py <imdb_id>")
sys.exit(1)
imdb_id = sys.argv[1]
if not imdb_id.startswith('tt'):
imdb_id = f'tt{imdb_id}'
debug_movie(imdb_id)
+222
View File
@@ -0,0 +1,222 @@
version: '3.8'
services:
nfoguard:
# Use the official image from Docker Hub
image: sbcrumb/nfoguard:latest
# Alternative: Use specific version
# image: sbcrumb/nfoguard:v2.6.5
# Alternative: Use development version
# image: sbcrumb/nfoguard:dev
container_name: nfoguard
# Database dependency
depends_on:
- nfoguard-postgres
# Restart policy
restart: unless-stopped
# Graceful shutdown configuration
stop_grace_period: 30s
stop_signal: SIGTERM
# 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"
# PostgreSQL Database (Required for v2.6+)
nfoguard-postgres:
image: postgres:15-alpine
container_name: nfoguard-db
# Restart policy
restart: unless-stopped
# Environment variables for PostgreSQL
environment:
- POSTGRES_DB=nfoguard
- POSTGRES_USER=nfoguard
- POSTGRES_PASSWORD=${DB_PASSWORD} # Set in .env.secrets
- POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C
# PostgreSQL data persistence
volumes:
- ./postgres-data:/var/lib/postgresql/data
# PostgreSQL port (optional - only needed for external access)
# ports:
# - "5432:5432"
# Health check for database
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nfoguard -d nfoguard"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# Resource limits for database
deploy:
resources:
limits:
memory: 256M
cpus: '0.3'
reservations:
memory: 128M
cpus: '0.1'
# 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.example to .env and configure all settings
# 4. Copy .env.secrets.example to .env.secrets and add credentials (especially DB_PASSWORD)
# 5. Create directories: mkdir -p ./data ./postgres-data
# 6. Run: docker-compose up -d
# 7. Check logs: docker-compose logs -f
# 8. Access web interface: http://localhost:8080
# 9. 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
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
NFOGuard - Automated NFO file management for Radarr and Sonarr
Modular architecture with webhook processing and intelligent date handling
"""
import os
import sys
import signal
import asyncio
from pathlib import Path
from datetime import datetime, timezone
import uvicorn
from fastapi import FastAPI
# Import configuration first
from config.settings import config
from utils.logging import _log
# Import core components
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
# Import clients
from clients.external_clients import ExternalClientManager
# Import processors
from processors.tv_processor import TVProcessor
from processors.movie_processor import MovieProcessor
# Import webhook handling
from webhooks.webhook_batcher import WebhookBatcher
# Import API routes
from api.routes import register_routes
# Global shutdown event for graceful shutdown coordination
shutdown_event = asyncio.Event()
def get_version() -> str:
"""Get application version"""
try:
version = (Path(__file__).parent / "VERSION").read_text().strip()
except:
version = "0.1.0"
# Check if running from dev branch (detect at runtime)
try:
# Try to read git branch from .git/HEAD
git_head_path = Path(__file__).parent / ".git" / "HEAD"
if git_head_path.exists():
head_content = git_head_path.read_text().strip()
if "ref: refs/heads/dev" in head_content:
version = f"{version}-dev"
elif head_content.startswith("ref: refs/heads/"):
# Extract branch name for other branches
branch = head_content.split("refs/heads/")[-1]
if branch != "main":
version = f"{version}-{branch}"
except Exception:
# If git detection fails, that's fine - use base version
pass
# Check for build source (only add -gitea for local Gitea builds)
build_source = os.environ.get("BUILD_SOURCE", "")
if build_source == "gitea":
if "gitea" not in version: # Don't double-add gitea suffix
version = f"{version}-gitea"
return version
def create_app() -> FastAPI:
"""Create and configure the FastAPI application"""
version = get_version()
app = FastAPI(
title="NFOGuard",
description="Webhook server for preserving media import dates",
version=version
)
return app
def initialize_components():
"""Initialize all application components"""
start_time = datetime.now(timezone.utc)
# Initialize core components
db = NFOGuardDatabase(config=config)
nfo_manager = NFOManager(config.manager_brand, config.debug)
path_mapper = PathMapper(config)
# Initialize processors
tv_processor = TVProcessor(db, nfo_manager, path_mapper)
movie_processor = MovieProcessor(db, nfo_manager, path_mapper)
# Initialize webhook batcher with nfo_manager for comprehensive IMDb detection
batcher = WebhookBatcher(nfo_manager)
batcher.set_processors(tv_processor, movie_processor)
return {
"db": db,
"nfo_manager": nfo_manager,
"path_mapper": path_mapper,
"tv_processor": tv_processor,
"movie_processor": movie_processor,
"batcher": batcher,
"start_time": start_time,
"config": config,
"version": get_version(),
"shutdown_event": shutdown_event
}
def signal_handler(signum, frame):
"""Handle shutdown signals gracefully"""
_log("INFO", f"Received signal {signum}, shutting down gracefully...")
# Set shutdown event to notify background tasks
shutdown_event.set()
# Get the global dependencies if they exist
if hasattr(signal_handler, 'dependencies') and signal_handler.dependencies:
deps = signal_handler.dependencies
# Shutdown webhook batcher cleanly
if 'batcher' in deps:
try:
_log("INFO", "Shutting down webhook batcher...")
deps['batcher'].shutdown()
except Exception as e:
_log("WARNING", f"Error during batcher shutdown: {e}")
# Close database connection
if 'db' in deps:
try:
_log("INFO", "Closing database connection...")
deps['db'].close()
except Exception as e:
_log("WARNING", f"Error closing database: {e}")
_log("INFO", "Graceful shutdown complete")
sys.exit(0)
def main():
"""Main application entry point"""
# Register signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
version = get_version()
_log("INFO", "Starting NFOGuard")
_log("INFO", f"Version: {version}")
_log("INFO", f"TV paths: {[str(p) for p in config.tv_paths]}")
_log("INFO", f"Movie paths: {[str(p) for p in config.movie_paths]}")
if config.db_type == "postgresql":
_log("INFO", f"Database: PostgreSQL at {config.db_host}:{config.db_port}/{config.db_name}")
_log("INFO", f"Database user: {config.db_user}")
else:
_log("INFO", f"Database: {config.db_path}")
_log("INFO", f"Config: manage_nfo={config.manage_nfo}, fix_mtimes={config.fix_dir_mtimes}")
_log("INFO", f"Movie priority: {config.movie_priority}")
# Create FastAPI app
app = create_app()
# Initialize components
dependencies = initialize_components()
# Store dependencies globally for signal handler access
signal_handler.dependencies = dependencies
# Register routes
register_routes(app, dependencies)
try:
uvicorn.run(
app,
host="0.0.0.0",
port=int(os.environ.get("PORT", "8080")),
reload=False,
access_log=False, # Reduce logging overhead
server_header=False, # Reduce response overhead
timeout_graceful_shutdown=15 # Give more time for graceful shutdown
)
except KeyboardInterrupt:
_log("INFO", "NFOGuard stopped by user")
except Exception as e:
_log("ERROR", f"NFOGuard crashed: {e}")
sys.exit(1)
finally:
# Ensure cleanup happens even if uvicorn doesn't trigger signal handler
if hasattr(signal_handler, 'dependencies') and signal_handler.dependencies:
deps = signal_handler.dependencies
if 'batcher' in deps:
try:
deps['batcher'].shutdown()
except Exception:
pass
if 'db' in deps:
try:
deps['db'].close()
except Exception:
pass
if __name__ == "__main__":
main()
View File
+474
View File
@@ -0,0 +1,474 @@
"""
Health Check System for NFOGuard
Provides health and readiness endpoints for monitoring and orchestration
"""
import time
import asyncio
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from config.runtime_validator import RuntimeValidator, HealthCheckResult
from monitoring.metrics import metrics
class HealthStatus(Enum):
"""Health check status levels"""
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
@dataclass
class HealthCheck:
"""Individual health check result"""
name: str
status: HealthStatus
message: str
duration_ms: float
details: Dict[str, Any] = None
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"status": self.status.value,
"message": self.message,
"duration_ms": round(self.duration_ms, 2),
"details": self.details or {}
}
@dataclass
class OverallHealth:
"""Overall system health status"""
status: HealthStatus
checks: List[HealthCheck]
timestamp: float
uptime_seconds: float
version: str = "2.0.0"
def to_dict(self) -> Dict[str, Any]:
return {
"status": self.status.value,
"timestamp": self.timestamp,
"uptime_seconds": round(self.uptime_seconds, 2),
"version": self.version,
"checks": [check.to_dict() for check in self.checks],
"summary": {
"total_checks": len(self.checks),
"healthy_checks": len([c for c in self.checks if c.status == HealthStatus.HEALTHY]),
"degraded_checks": len([c for c in self.checks if c.status == HealthStatus.DEGRADED]),
"unhealthy_checks": len([c for c in self.checks if c.status == HealthStatus.UNHEALTHY])
}
}
class HealthChecker:
"""Comprehensive health checking system"""
def __init__(self):
self.start_time = time.time()
self._last_health_check = None
self._health_check_cache_ttl = 30 # Cache for 30 seconds
self._runtime_validator = None
def _get_runtime_validator(self):
"""Get runtime validator instance"""
if self._runtime_validator is None:
try:
from config.settings import config
self._runtime_validator = RuntimeValidator(config)
except Exception as e:
# Create a dummy validator if config fails
self._runtime_validator = None
return self._runtime_validator
async def check_basic_health(self) -> HealthCheck:
"""Basic health check - always succeeds if service is running"""
start_time = time.time()
try:
# Basic service availability
uptime = time.time() - self.start_time
if uptime < 30:
status = HealthStatus.DEGRADED
message = f"Service starting up (uptime: {uptime:.1f}s)"
else:
status = HealthStatus.HEALTHY
message = f"Service running normally (uptime: {uptime:.1f}s)"
return HealthCheck(
name="basic",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={"uptime_seconds": uptime}
)
except Exception as e:
return HealthCheck(
name="basic",
status=HealthStatus.UNHEALTHY,
message=f"Basic health check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_filesystem_health(self) -> HealthCheck:
"""Check filesystem access for media paths"""
start_time = time.time()
try:
from config.settings import config
accessible_paths = 0
total_paths = len(config.tv_paths) + len(config.movie_paths)
issues = []
# Check TV paths
for path in config.tv_paths:
try:
if path.exists() and path.is_dir():
# Try to read directory
list(path.iterdir())
accessible_paths += 1
else:
issues.append(f"TV path not accessible: {path}")
except PermissionError:
issues.append(f"TV path permission denied: {path}")
except Exception as e:
issues.append(f"TV path error {path}: {e}")
# Check movie paths
for path in config.movie_paths:
try:
if path.exists() and path.is_dir():
list(path.iterdir())
accessible_paths += 1
else:
issues.append(f"Movie path not accessible: {path}")
except PermissionError:
issues.append(f"Movie path permission denied: {path}")
except Exception as e:
issues.append(f"Movie path error {path}: {e}")
# Determine status
if accessible_paths == total_paths:
status = HealthStatus.HEALTHY
message = f"All {total_paths} media paths accessible"
elif accessible_paths > 0:
status = HealthStatus.DEGRADED
message = f"{accessible_paths}/{total_paths} media paths accessible"
else:
status = HealthStatus.UNHEALTHY
message = "No media paths accessible"
return HealthCheck(
name="filesystem",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"accessible_paths": accessible_paths,
"total_paths": total_paths,
"issues": issues[:5] # Limit to first 5 issues
}
)
except Exception as e:
return HealthCheck(
name="filesystem",
status=HealthStatus.UNHEALTHY,
message=f"Filesystem check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_database_health(self) -> HealthCheck:
"""Check database connectivity and performance"""
start_time = time.time()
try:
import sqlite3
from config.settings import config
# Test local database
db_path = config.db_path
def test_db():
with sqlite3.connect(str(db_path), timeout=5) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sqlite_master WHERE type='table'")
table_count = cursor.fetchone()[0]
return table_count
# Run database test
table_count = await asyncio.get_event_loop().run_in_executor(None, test_db)
duration = (time.time() - start_time) * 1000
if duration < 100: # < 100ms is good
status = HealthStatus.HEALTHY
message = f"Database responsive ({duration:.1f}ms, {table_count} tables)"
elif duration < 1000: # < 1s is acceptable
status = HealthStatus.DEGRADED
message = f"Database slow ({duration:.1f}ms, {table_count} tables)"
else:
status = HealthStatus.UNHEALTHY
message = f"Database very slow ({duration:.1f}ms)"
return HealthCheck(
name="database",
status=status,
message=message,
duration_ms=duration,
details={
"db_path": str(db_path),
"table_count": table_count,
"response_time_category": "fast" if duration < 100 else "slow" if duration < 1000 else "very_slow"
}
)
except Exception as e:
return HealthCheck(
name="database",
status=HealthStatus.UNHEALTHY,
message=f"Database check failed: {e}",
duration_ms=(time.time() - start_time) * 1000,
details={"error": str(e)}
)
async def check_external_apis_health(self) -> HealthCheck:
"""Check external API connectivity"""
start_time = time.time()
try:
import aiohttp
from config.settings import config
api_results = []
apis_tested = 0
apis_healthy = 0
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
# Test Radarr API if configured
if hasattr(config, 'radarr_url') and config.radarr_url:
apis_tested += 1
try:
test_url = f"{config.radarr_url.rstrip('/')}/api/v3/health"
async with session.get(test_url) as response:
if response.status == 200:
apis_healthy += 1
api_results.append({"api": "radarr", "status": "healthy"})
else:
api_results.append({"api": "radarr", "status": f"unhealthy (HTTP {response.status})"})
except Exception as e:
api_results.append({"api": "radarr", "status": f"error: {str(e)[:50]}"})
# Test Sonarr API if configured
if hasattr(config, 'sonarr_url') and config.sonarr_url:
apis_tested += 1
try:
test_url = f"{config.sonarr_url.rstrip('/')}/api/v3/health"
async with session.get(test_url) as response:
if response.status == 200:
apis_healthy += 1
api_results.append({"api": "sonarr", "status": "healthy"})
else:
api_results.append({"api": "sonarr", "status": f"unhealthy (HTTP {response.status})"})
except Exception as e:
api_results.append({"api": "sonarr", "status": f"error: {str(e)[:50]}"})
# Determine overall API health
if apis_tested == 0:
status = HealthStatus.HEALTHY
message = "No external APIs configured"
elif apis_healthy == apis_tested:
status = HealthStatus.HEALTHY
message = f"All {apis_tested} external APIs healthy"
elif apis_healthy > 0:
status = HealthStatus.DEGRADED
message = f"{apis_healthy}/{apis_tested} external APIs healthy"
else:
status = HealthStatus.UNHEALTHY
message = "No external APIs responding"
return HealthCheck(
name="external_apis",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"apis_tested": apis_tested,
"apis_healthy": apis_healthy,
"api_results": api_results
}
)
except Exception as e:
return HealthCheck(
name="external_apis",
status=HealthStatus.UNHEALTHY,
message=f"API health check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def check_performance_health(self) -> HealthCheck:
"""Check system performance metrics"""
start_time = time.time()
try:
system_metrics = metrics.get_system_metrics()
processing_metrics = metrics.get_processing_metrics()
issues = []
warnings = []
# Check CPU usage
cpu_percent = system_metrics.get("cpu_percent", 0)
if cpu_percent > 90:
issues.append(f"High CPU usage: {cpu_percent:.1f}%")
elif cpu_percent > 70:
warnings.append(f"Elevated CPU usage: {cpu_percent:.1f}%")
# Check memory usage
memory_percent = system_metrics.get("memory_percent", 0)
if memory_percent > 90:
issues.append(f"High memory usage: {memory_percent:.1f}%")
elif memory_percent > 80:
warnings.append(f"Elevated memory usage: {memory_percent:.1f}%")
# Check disk space
if "db_disk_free" in system_metrics and system_metrics["db_disk_free"]:
free_space_gb = system_metrics["db_disk_free"] / (1024**3)
if free_space_gb < 1:
issues.append(f"Low disk space: {free_space_gb:.1f}GB free")
elif free_space_gb < 5:
warnings.append(f"Low disk space: {free_space_gb:.1f}GB free")
# Check active operations
active_ops = system_metrics.get("active_operations", 0)
if active_ops > 10:
warnings.append(f"High concurrent operations: {active_ops}")
# Determine status
if issues:
status = HealthStatus.UNHEALTHY
message = f"Performance issues detected: {', '.join(issues[:2])}"
elif warnings:
status = HealthStatus.DEGRADED
message = f"Performance warnings: {', '.join(warnings[:2])}"
else:
status = HealthStatus.HEALTHY
message = "System performance normal"
return HealthCheck(
name="performance",
status=status,
message=message,
duration_ms=(time.time() - start_time) * 1000,
details={
"cpu_percent": cpu_percent,
"memory_percent": memory_percent,
"active_operations": active_ops,
"issues": issues,
"warnings": warnings
}
)
except Exception as e:
return HealthCheck(
name="performance",
status=HealthStatus.DEGRADED,
message=f"Performance check failed: {e}",
duration_ms=(time.time() - start_time) * 1000
)
async def get_full_health_status(self) -> OverallHealth:
"""Get comprehensive health status"""
start_time = time.time()
# Run all health checks concurrently
checks = await asyncio.gather(
self.check_basic_health(),
self.check_filesystem_health(),
self.check_database_health(),
self.check_external_apis_health(),
self.check_performance_health(),
return_exceptions=True
)
# Filter out any exceptions and convert to HealthCheck objects
valid_checks = []
for check in checks:
if isinstance(check, HealthCheck):
valid_checks.append(check)
elif isinstance(check, Exception):
valid_checks.append(HealthCheck(
name="unknown",
status=HealthStatus.UNHEALTHY,
message=f"Health check exception: {check}",
duration_ms=0
))
# Determine overall status
unhealthy_count = len([c for c in valid_checks if c.status == HealthStatus.UNHEALTHY])
degraded_count = len([c for c in valid_checks if c.status == HealthStatus.DEGRADED])
if unhealthy_count > 0:
overall_status = HealthStatus.UNHEALTHY
elif degraded_count > 0:
overall_status = HealthStatus.DEGRADED
else:
overall_status = HealthStatus.HEALTHY
return OverallHealth(
status=overall_status,
checks=valid_checks,
timestamp=start_time,
uptime_seconds=time.time() - self.start_time
)
async def get_readiness_status(self) -> Dict[str, Any]:
"""Get readiness status for Kubernetes readiness probes"""
# Readiness is simpler - just check critical components
checks = await asyncio.gather(
self.check_basic_health(),
self.check_filesystem_health(),
self.check_database_health(),
return_exceptions=True
)
critical_failures = 0
for check in checks:
if isinstance(check, HealthCheck) and check.status == HealthStatus.UNHEALTHY:
critical_failures += 1
is_ready = critical_failures == 0
return {
"ready": is_ready,
"timestamp": time.time(),
"critical_failures": critical_failures,
"message": "Service ready" if is_ready else f"{critical_failures} critical failures"
}
async def get_liveness_status(self) -> Dict[str, Any]:
"""Get liveness status for Kubernetes liveness probes"""
# Liveness is even simpler - just check if service is responsive
basic_check = await self.check_basic_health()
is_alive = basic_check.status != HealthStatus.UNHEALTHY
return {
"alive": is_alive,
"timestamp": time.time(),
"uptime_seconds": time.time() - self.start_time,
"message": basic_check.message
}
# Global health checker instance
health_checker = HealthChecker()
+404
View File
@@ -0,0 +1,404 @@
"""
Enhanced Logging System for NFOGuard
Provides structured logging with correlation IDs, request tracing, and monitoring integration
"""
import logging
import json
import time
import uuid
import threading
from typing import Dict, Any, Optional, List, Union
from dataclasses import dataclass, field
from contextlib import contextmanager
from datetime import datetime
import sys
import traceback
from monitoring.metrics import metrics
# Thread-local storage for correlation context
_context = threading.local()
@dataclass
class LogContext:
"""Logging context with correlation and tracing information"""
correlation_id: str
request_id: Optional[str] = None
user_id: Optional[str] = None
operation: Optional[str] = None
media_type: Optional[str] = None
media_title: Optional[str] = None
webhook_type: Optional[str] = None
processing_stage: Optional[str] = None
additional_fields: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert context to dictionary for logging"""
context = {
"correlation_id": self.correlation_id,
"timestamp": datetime.utcnow().isoformat(),
}
# Add non-None fields
for field_name in ["request_id", "user_id", "operation", "media_type",
"media_title", "webhook_type", "processing_stage"]:
value = getattr(self, field_name)
if value is not None:
context[field_name] = value
# Add additional fields
context.update(self.additional_fields)
return context
class StructuredFormatter(logging.Formatter):
"""JSON formatter for structured logging"""
def __init__(self, include_context: bool = True):
super().__init__()
self.include_context = include_context
def format(self, record: logging.LogRecord) -> str:
# Base log entry
log_entry = {
"timestamp": datetime.utcnow().isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# Add thread information
log_entry["thread"] = {
"id": record.thread,
"name": record.threadName
}
# Add correlation context if available
if self.include_context and hasattr(_context, 'log_context'):
log_entry["context"] = _context.log_context.to_dict()
# Add exception information if present
if record.exc_info:
log_entry["exception"] = {
"type": record.exc_info[0].__name__,
"message": str(record.exc_info[1]),
"traceback": traceback.format_exception(*record.exc_info)
}
# Add any extra fields passed to log call
if hasattr(record, 'extra_fields') and record.extra_fields:
log_entry["extra"] = record.extra_fields
# Add performance metrics if available
if hasattr(record, 'performance_data') and record.performance_data:
log_entry["performance"] = record.performance_data
return json.dumps(log_entry, default=str, ensure_ascii=False)
class CorrelationIDFilter(logging.Filter):
"""Filter to add correlation ID to log records"""
def filter(self, record: logging.LogRecord) -> bool:
# Add correlation ID to record if available
if hasattr(_context, 'log_context'):
record.correlation_id = _context.log_context.correlation_id
else:
record.correlation_id = "no-correlation"
return True
class EnhancedLogger:
"""Enhanced logger with correlation IDs and structured logging"""
def __init__(self, name: str):
self.logger = logging.getLogger(name)
self.name = name
# Track log events for metrics
self._log_counts = {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0}
def _log_with_context(self, level: int, message: str, extra_fields: Optional[Dict[str, Any]] = None,
performance_data: Optional[Dict[str, Any]] = None, **kwargs):
"""Log with enhanced context and metrics tracking"""
# Track log counts for metrics
level_name = logging.getLevelName(level).lower()
if level_name in self._log_counts:
self._log_counts[level_name] += 1
metrics.increment_counter(f"log_messages", 1, {"level": level_name, "logger": self.name})
# Create log record with extra data
extra = {}
if extra_fields:
extra['extra_fields'] = extra_fields
if performance_data:
extra['performance_data'] = performance_data
# Log the message
self.logger.log(level, message, extra=extra, **kwargs)
# Track errors in metrics
if level >= logging.ERROR:
metrics.record_error("logging_error", message, self.name)
def debug(self, message: str, **kwargs):
"""Log debug message"""
self._log_with_context(logging.DEBUG, message, **kwargs)
def info(self, message: str, **kwargs):
"""Log info message"""
self._log_with_context(logging.INFO, message, **kwargs)
def warning(self, message: str, **kwargs):
"""Log warning message"""
self._log_with_context(logging.WARNING, message, **kwargs)
def error(self, message: str, **kwargs):
"""Log error message"""
self._log_with_context(logging.ERROR, message, **kwargs)
def critical(self, message: str, **kwargs):
"""Log critical message"""
self._log_with_context(logging.CRITICAL, message, **kwargs)
def exception(self, message: str, **kwargs):
"""Log exception with traceback"""
kwargs['exc_info'] = True
self._log_with_context(logging.ERROR, message, **kwargs)
def log_operation_start(self, operation: str, **context_fields):
"""Log the start of an operation"""
self.info(f"Starting operation: {operation}",
extra_fields={"operation_event": "start", "operation": operation, **context_fields})
def log_operation_end(self, operation: str, success: bool = True, duration: Optional[float] = None, **context_fields):
"""Log the end of an operation"""
outcome = "success" if success else "failure"
extra = {"operation_event": "end", "operation": operation, "outcome": outcome, **context_fields}
if duration is not None:
extra["duration_seconds"] = duration
level = logging.INFO if success else logging.ERROR
self._log_with_context(level, f"Operation {outcome}: {operation}", extra_fields=extra)
def log_webhook_received(self, webhook_type: str, payload_size: int, **context_fields):
"""Log webhook reception"""
self.info(f"Webhook received: {webhook_type}",
extra_fields={
"event_type": "webhook_received",
"webhook_type": webhook_type,
"payload_size_bytes": payload_size,
**context_fields
})
def log_nfo_operation(self, operation: str, file_path: str, success: bool = True, **context_fields):
"""Log NFO file operations"""
outcome = "success" if success else "failure"
level = logging.INFO if success else logging.ERROR
self._log_with_context(level, f"NFO {operation} {outcome}: {file_path}",
extra_fields={
"event_type": "nfo_operation",
"nfo_operation": operation,
"file_path": file_path,
"outcome": outcome,
**context_fields
})
def log_performance_metrics(self, operation: str, duration: float, success: bool = True, **metrics_data):
"""Log performance metrics"""
self.debug(f"Performance: {operation} took {duration:.3f}s",
performance_data={
"operation": operation,
"duration_seconds": duration,
"success": success,
**metrics_data
})
def get_log_stats(self) -> Dict[str, int]:
"""Get logging statistics"""
return self._log_counts.copy()
def setup_enhanced_logging(
log_level: str = "INFO",
structured: bool = True,
log_file: Optional[str] = None,
max_bytes: int = 10 * 1024 * 1024, # 10MB
backup_count: int = 5
) -> None:
"""Setup enhanced logging configuration"""
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level.upper()))
# Clear existing handlers
root_logger.handlers.clear()
# Create console handler
console_handler = logging.StreamHandler(sys.stdout)
if structured:
# Use structured JSON formatter
formatter = StructuredFormatter(include_context=True)
else:
# Use simple text formatter with correlation ID
formatter = logging.Formatter(
'%(asctime)s [%(correlation_id)s] %(levelname)s %(name)s: %(message)s'
)
console_handler.setFormatter(formatter)
console_handler.addFilter(CorrelationIDFilter())
root_logger.addHandler(console_handler)
# Add file handler if specified
if log_file:
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler(
log_file, maxBytes=max_bytes, backupCount=backup_count
)
file_handler.setFormatter(formatter)
file_handler.addFilter(CorrelationIDFilter())
root_logger.addHandler(file_handler)
# Reduce noise from external libraries
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("aiohttp").setLevel(logging.WARNING)
def get_enhanced_logger(name: str) -> EnhancedLogger:
"""Get enhanced logger instance"""
return EnhancedLogger(name)
def set_log_context(
correlation_id: Optional[str] = None,
request_id: Optional[str] = None,
operation: Optional[str] = None,
**kwargs
) -> LogContext:
"""Set logging context for current thread"""
if correlation_id is None:
correlation_id = str(uuid.uuid4())
context = LogContext(
correlation_id=correlation_id,
request_id=request_id,
operation=operation,
**kwargs
)
_context.log_context = context
return context
def get_log_context() -> Optional[LogContext]:
"""Get current logging context"""
return getattr(_context, 'log_context', None)
def clear_log_context():
"""Clear logging context for current thread"""
if hasattr(_context, 'log_context'):
delattr(_context, 'log_context')
@contextmanager
def log_context(correlation_id: Optional[str] = None, **context_fields):
"""Context manager for scoped logging context"""
original_context = get_log_context()
try:
# Set new context
new_context = set_log_context(correlation_id=correlation_id, **context_fields)
yield new_context
finally:
# Restore original context
if original_context:
_context.log_context = original_context
else:
clear_log_context()
@contextmanager
def log_operation(operation: str, logger: Optional[EnhancedLogger] = None, **context_fields):
"""Context manager for logging operation start/end with timing"""
if logger is None:
logger = get_enhanced_logger(__name__)
start_time = time.time()
success = True
# Update context with operation
current_context = get_log_context()
if current_context:
current_context.operation = operation
current_context.processing_stage = "executing"
logger.log_operation_start(operation, **context_fields)
try:
yield
except Exception as e:
success = False
logger.exception(f"Operation failed: {operation}",
extra_fields={"operation": operation, "error": str(e), **context_fields})
raise
finally:
duration = time.time() - start_time
logger.log_operation_end(operation, success, duration, **context_fields)
# Update metrics
metrics.record_operation_duration(operation, duration, success)
def trace_request(request_id: Optional[str] = None, **context_fields):
"""Decorator/context manager for request tracing"""
def decorator(func):
def wrapper(*args, **kwargs):
correlation_id = str(uuid.uuid4())
req_id = request_id or f"req_{int(time.time())}"
with log_context(correlation_id=correlation_id, request_id=req_id, **context_fields):
return func(*args, **kwargs)
return wrapper
# Can be used as context manager or decorator
if request_id is None and len(context_fields) == 1 and callable(list(context_fields.values())[0]):
# Used as decorator without parentheses
func = list(context_fields.values())[0]
return decorator(func)
else:
# Used as decorator with parameters or context manager
return decorator
# Module-level logger for this module
logger = get_enhanced_logger(__name__)
def get_logging_stats() -> Dict[str, Any]:
"""Get comprehensive logging statistics"""
# Collect stats from all enhanced loggers
total_stats = {"debug": 0, "info": 0, "warning": 0, "error": 0, "critical": 0}
# This is a simplified version - in practice you'd track all logger instances
return {
"total_log_messages": sum(total_stats.values()),
"by_level": total_stats,
"structured_logging_enabled": True,
"correlation_tracking_enabled": True
}
+354
View File
@@ -0,0 +1,354 @@
"""
Metrics Collection System for NFOGuard
Provides performance monitoring, counters, and operational metrics
"""
import time
import psutil
import threading
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from collections import defaultdict, deque
from contextlib import contextmanager
import asyncio
@dataclass
class MetricValue:
"""Individual metric value with timestamp"""
value: float
timestamp: float = field(default_factory=time.time)
labels: Dict[str, str] = field(default_factory=dict)
@dataclass
class TimeSeriesMetric:
"""Time series metric with historical data"""
name: str
values: deque = field(default_factory=lambda: deque(maxlen=1000))
total: float = 0.0
count: int = 0
def add_value(self, value: float, labels: Optional[Dict[str, str]] = None):
"""Add a new metric value"""
metric_value = MetricValue(value, labels=labels or {})
self.values.append(metric_value)
self.total += value
self.count += 1
def get_average(self, window_seconds: int = 300) -> float:
"""Get average value over time window"""
cutoff_time = time.time() - window_seconds
recent_values = [v.value for v in self.values if v.timestamp > cutoff_time]
return sum(recent_values) / len(recent_values) if recent_values else 0.0
def get_rate_per_minute(self, window_seconds: int = 300) -> float:
"""Get rate per minute over time window"""
cutoff_time = time.time() - window_seconds
recent_count = len([v for v in self.values if v.timestamp > cutoff_time])
return (recent_count / window_seconds) * 60 if window_seconds > 0 else 0.0
class MetricsCollector:
"""Central metrics collection system"""
def __init__(self):
self._metrics: Dict[str, TimeSeriesMetric] = {}
self._counters: Dict[str, int] = defaultdict(int)
self._gauges: Dict[str, float] = {}
self._histograms: Dict[str, List[float]] = defaultdict(list)
self._start_time = time.time()
self._lock = threading.RLock()
# Processing metrics
self._active_operations = 0
self._operation_durations = deque(maxlen=1000)
# Error tracking
self._error_counts = defaultdict(int)
self._last_errors = deque(maxlen=100)
# System metrics
self._system_stats_cache = {}
self._system_stats_last_update = 0
self._system_stats_cache_ttl = 30 # 30 seconds
def increment_counter(self, name: str, value: int = 1, labels: Optional[Dict[str, str]] = None):
"""Increment a counter metric"""
with self._lock:
full_name = self._build_metric_name(name, labels)
self._counters[full_name] += value
# Also track in time series for rate calculations
if name not in self._metrics:
self._metrics[name] = TimeSeriesMetric(name)
self._metrics[name].add_value(value, labels)
def set_gauge(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
"""Set a gauge metric value"""
with self._lock:
full_name = self._build_metric_name(name, labels)
self._gauges[full_name] = value
def record_histogram(self, name: str, value: float, labels: Optional[Dict[str, str]] = None):
"""Record a histogram value"""
with self._lock:
full_name = self._build_metric_name(name, labels)
self._histograms[full_name].append(value)
# Keep only recent values (last 1000)
if len(self._histograms[full_name]) > 1000:
self._histograms[full_name] = self._histograms[full_name][-1000:]
# Also track in time series
if name not in self._metrics:
self._metrics[name] = TimeSeriesMetric(name)
self._metrics[name].add_value(value, labels)
def record_operation_duration(self, operation: str, duration: float, success: bool = True):
"""Record operation duration and outcome"""
with self._lock:
# Record duration
self.record_histogram(f"operation_duration_{operation}", duration)
# Record outcome
outcome = "success" if success else "error"
self.increment_counter(f"operation_total", 1, {"operation": operation, "outcome": outcome})
# Track active operations
if operation.endswith("_start"):
self._active_operations += 1
elif operation.endswith("_end"):
self._active_operations = max(0, self._active_operations - 1)
def record_error(self, error_type: str, error_message: str, operation: Optional[str] = None):
"""Record an error occurrence"""
with self._lock:
self._error_counts[error_type] += 1
error_info = {
"type": error_type,
"message": error_message,
"operation": operation,
"timestamp": time.time()
}
self._last_errors.append(error_info)
# Increment error counter
labels = {"error_type": error_type}
if operation:
labels["operation"] = operation
self.increment_counter("errors_total", 1, labels)
@contextmanager
def operation_timer(self, operation: str):
"""Context manager for timing operations"""
start_time = time.time()
success = True
try:
self.record_operation_duration(f"{operation}_start", 0)
yield
except Exception as e:
success = False
self.record_error("operation_error", str(e), operation)
raise
finally:
duration = time.time() - start_time
self.record_operation_duration(operation, duration, success)
self.record_operation_duration(f"{operation}_end", 0)
def get_system_metrics(self) -> Dict[str, Any]:
"""Get current system resource metrics"""
now = time.time()
# Use cached values if recent
if (now - self._system_stats_last_update) < self._system_stats_cache_ttl:
return self._system_stats_cache
try:
# CPU metrics
cpu_percent = psutil.cpu_percent(interval=0.1)
cpu_count = psutil.cpu_count()
# Memory metrics
memory = psutil.virtual_memory()
# Disk metrics for database path
try:
from config.settings import config
db_disk = psutil.disk_usage(str(config.db_path.parent))
except:
db_disk = None
# Process metrics
process = psutil.Process()
process_memory = process.memory_info()
self._system_stats_cache = {
"cpu_percent": cpu_percent,
"cpu_count": cpu_count,
"memory_total": memory.total,
"memory_available": memory.available,
"memory_percent": memory.percent,
"process_memory_rss": process_memory.rss,
"process_memory_vms": process_memory.vms,
"db_disk_free": db_disk.free if db_disk else None,
"db_disk_total": db_disk.total if db_disk else None,
"active_operations": self._active_operations,
"uptime_seconds": now - self._start_time
}
self._system_stats_last_update = now
except Exception as e:
# Return basic metrics if detailed collection fails
self._system_stats_cache = {
"uptime_seconds": now - self._start_time,
"active_operations": self._active_operations,
"error": str(e)
}
return self._system_stats_cache
def get_processing_metrics(self) -> Dict[str, Any]:
"""Get processing-related metrics"""
with self._lock:
# Calculate rates and averages
webhook_rate = self._metrics.get("webhooks_received", TimeSeriesMetric("webhooks_received")).get_rate_per_minute()
nfo_rate = self._metrics.get("nfo_created", TimeSeriesMetric("nfo_created")).get_rate_per_minute()
avg_processing_time = 0.0
if "processing_duration" in self._metrics:
avg_processing_time = self._metrics["processing_duration"].get_average()
return {
"webhooks_received_per_minute": webhook_rate,
"nfo_files_created_per_minute": nfo_rate,
"average_processing_time_seconds": avg_processing_time,
"active_operations": self._active_operations,
"total_webhooks": self._counters.get("webhooks_received", 0),
"total_nfo_created": self._counters.get("nfo_created", 0),
"total_errors": sum(self._error_counts.values())
}
def get_error_metrics(self) -> Dict[str, Any]:
"""Get error-related metrics"""
with self._lock:
recent_errors = []
cutoff_time = time.time() - 3600 # Last hour
for error in self._last_errors:
if error["timestamp"] > cutoff_time:
recent_errors.append({
"type": error["type"],
"message": error["message"][:100], # Truncate long messages
"operation": error["operation"],
"timestamp": error["timestamp"]
})
return {
"error_counts_by_type": dict(self._error_counts),
"recent_errors": recent_errors[-10:], # Last 10 errors
"total_errors": sum(self._error_counts.values()),
"error_rate_per_minute": len([e for e in self._last_errors if e["timestamp"] > time.time() - 300]) / 5
}
def get_prometheus_metrics(self) -> str:
"""Generate Prometheus-compatible metrics format"""
lines = []
# Add help and type information
lines.append("# HELP nfoguard_webhooks_total Total number of webhooks received")
lines.append("# TYPE nfoguard_webhooks_total counter")
with self._lock:
# Counters
for name, value in self._counters.items():
metric_name = f"nfoguard_{name.replace('-', '_')}"
lines.append(f"{metric_name} {value}")
# Gauges
lines.append("# HELP nfoguard_active_operations Current number of active operations")
lines.append("# TYPE nfoguard_active_operations gauge")
lines.append(f"nfoguard_active_operations {self._active_operations}")
# System metrics
system_metrics = self.get_system_metrics()
for key, value in system_metrics.items():
if isinstance(value, (int, float)) and value is not None:
metric_name = f"nfoguard_system_{key}"
lines.append(f"{metric_name} {value}")
return "\n".join(lines)
def get_all_metrics(self) -> Dict[str, Any]:
"""Get all metrics in a structured format"""
return {
"system": self.get_system_metrics(),
"processing": self.get_processing_metrics(),
"errors": self.get_error_metrics(),
"timestamp": time.time(),
"uptime_seconds": time.time() - self._start_time
}
def reset_metrics(self, metric_types: Optional[List[str]] = None):
"""Reset specific metric types or all metrics"""
with self._lock:
if not metric_types or "counters" in metric_types:
self._counters.clear()
if not metric_types or "histograms" in metric_types:
self._histograms.clear()
if not metric_types or "errors" in metric_types:
self._error_counts.clear()
self._last_errors.clear()
if not metric_types or "timeseries" in metric_types:
self._metrics.clear()
def _build_metric_name(self, name: str, labels: Optional[Dict[str, str]]) -> str:
"""Build metric name with labels"""
if not labels:
return name
label_str = ",".join(f"{k}={v}" for k, v in sorted(labels.items()))
return f"{name}{{{label_str}}}"
# Global metrics collector instance
metrics = MetricsCollector()
# Convenience functions for common operations
def track_webhook_received(webhook_type: str):
"""Track webhook received"""
metrics.increment_counter("webhooks_received", 1, {"type": webhook_type})
def track_nfo_created(media_type: str, success: bool = True):
"""Track NFO file creation"""
outcome = "success" if success else "error"
metrics.increment_counter("nfo_created", 1, {"media_type": media_type, "outcome": outcome})
def track_api_call(api_name: str, duration: float, success: bool = True):
"""Track external API call"""
metrics.record_histogram(f"api_call_duration", duration, {"api": api_name})
outcome = "success" if success else "error"
metrics.increment_counter("api_calls_total", 1, {"api": api_name, "outcome": outcome})
def track_database_operation(operation: str, duration: float, success: bool = True):
"""Track database operation"""
metrics.record_histogram("database_operation_duration", duration, {"operation": operation})
outcome = "success" if success else "error"
metrics.increment_counter("database_operations_total", 1, {"operation": operation, "outcome": outcome})
def track_file_operation(operation: str, duration: float, success: bool = True):
"""Track file system operation"""
metrics.record_histogram("file_operation_duration", duration, {"operation": operation})
outcome = "success" if success else "error"
metrics.increment_counter("file_operations_total", 1, {"operation": operation, "outcome": outcome})
+413
View File
@@ -0,0 +1,413 @@
"""
Performance Monitoring and Profiling for NFOGuard
Provides detailed performance analysis and optimization insights
"""
import time
import asyncio
import threading
import functools
from typing import Dict, Any, List, Optional, Callable, TypeVar, Union
from dataclasses import dataclass, field
from collections import defaultdict, deque
from contextlib import asynccontextmanager, contextmanager
import traceback
import sys
from monitoring.metrics import metrics
T = TypeVar('T')
@dataclass
class PerformanceProfile:
"""Performance profile for an operation"""
operation_name: str
total_calls: int = 0
total_duration: float = 0.0
min_duration: float = float('inf')
max_duration: float = 0.0
recent_durations: deque = field(default_factory=lambda: deque(maxlen=100))
error_count: int = 0
concurrent_calls: int = 0
def add_measurement(self, duration: float, success: bool = True):
"""Add a performance measurement"""
self.total_calls += 1
self.total_duration += duration
self.min_duration = min(self.min_duration, duration)
self.max_duration = max(self.max_duration, duration)
self.recent_durations.append(duration)
if not success:
self.error_count += 1
def get_average_duration(self) -> float:
"""Get average duration across all calls"""
return self.total_duration / self.total_calls if self.total_calls > 0 else 0.0
def get_recent_average(self, window: int = 50) -> float:
"""Get average of recent calls"""
recent = list(self.recent_durations)[-window:]
return sum(recent) / len(recent) if recent else 0.0
def get_percentiles(self) -> Dict[str, float]:
"""Get duration percentiles for recent calls"""
recent = sorted(list(self.recent_durations))
if not recent:
return {"p50": 0, "p95": 0, "p99": 0}
length = len(recent)
return {
"p50": recent[int(length * 0.5)] if length > 0 else 0,
"p95": recent[int(length * 0.95)] if length > 0 else 0,
"p99": recent[int(length * 0.99)] if length > 0 else 0
}
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API responses"""
percentiles = self.get_percentiles()
return {
"operation_name": self.operation_name,
"total_calls": self.total_calls,
"error_count": self.error_count,
"error_rate": self.error_count / self.total_calls if self.total_calls > 0 else 0,
"concurrent_calls": self.concurrent_calls,
"duration_stats": {
"average": round(self.get_average_duration(), 4),
"recent_average": round(self.get_recent_average(), 4),
"min": round(self.min_duration if self.min_duration != float('inf') else 0, 4),
"max": round(self.max_duration, 4),
"p50": round(percentiles["p50"], 4),
"p95": round(percentiles["p95"], 4),
"p99": round(percentiles["p99"], 4)
},
"performance_rating": self._get_performance_rating()
}
def _get_performance_rating(self) -> str:
"""Get performance rating based on metrics"""
avg_duration = self.get_recent_average()
error_rate = self.error_count / self.total_calls if self.total_calls > 0 else 0
if error_rate > 0.1: # >10% error rate
return "poor"
elif avg_duration > 5.0: # >5 seconds average
return "slow"
elif avg_duration > 1.0: # >1 second average
return "acceptable"
else:
return "excellent"
class PerformanceMonitor:
"""Advanced performance monitoring system"""
def __init__(self):
self._profiles: Dict[str, PerformanceProfile] = {}
self._active_operations: Dict[str, float] = {} # operation_id -> start_time
self._lock = threading.RLock()
# Slow operation tracking
self._slow_operation_threshold = 1.0 # 1 second
self._slow_operations = deque(maxlen=100)
# Memory monitoring
self._memory_samples = deque(maxlen=1000)
self._memory_monitoring_enabled = True
# Async operation tracking
self._async_tasks = {}
self._task_counter = 0
def get_profile(self, operation_name: str) -> PerformanceProfile:
"""Get or create performance profile for operation"""
with self._lock:
if operation_name not in self._profiles:
self._profiles[operation_name] = PerformanceProfile(operation_name)
return self._profiles[operation_name]
@contextmanager
def monitor_operation(self, operation_name: str, **kwargs):
"""Context manager for monitoring synchronous operations"""
start_time = time.time()
operation_id = f"{operation_name}_{id(threading.current_thread())}_{time.time()}"
success = True
profile = self.get_profile(operation_name)
with self._lock:
profile.concurrent_calls += 1
self._active_operations[operation_id] = start_time
try:
yield
except Exception as e:
success = False
metrics.record_error("performance_monitor", str(e), operation_name)
raise
finally:
end_time = time.time()
duration = end_time - start_time
with self._lock:
profile.concurrent_calls = max(0, profile.concurrent_calls - 1)
self._active_operations.pop(operation_id, None)
# Record measurement
profile.add_measurement(duration, success)
# Track slow operations
if duration > self._slow_operation_threshold:
self._slow_operations.append({
"operation": operation_name,
"duration": duration,
"timestamp": end_time,
"success": success,
"metadata": kwargs
})
# Update metrics
metrics.record_histogram(f"operation_duration", duration, {"operation": operation_name})
if not success:
metrics.increment_counter("operation_errors", 1, {"operation": operation_name})
@asynccontextmanager
async def monitor_async_operation(self, operation_name: str, **kwargs):
"""Context manager for monitoring asynchronous operations"""
start_time = time.time()
task_id = f"{operation_name}_{self._task_counter}"
self._task_counter += 1
success = True
profile = self.get_profile(operation_name)
with self._lock:
profile.concurrent_calls += 1
self._async_tasks[task_id] = {
"operation": operation_name,
"start_time": start_time,
"metadata": kwargs
}
try:
yield
except Exception as e:
success = False
metrics.record_error("async_performance_monitor", str(e), operation_name)
raise
finally:
end_time = time.time()
duration = end_time - start_time
with self._lock:
profile.concurrent_calls = max(0, profile.concurrent_calls - 1)
self._async_tasks.pop(task_id, None)
# Record measurement
profile.add_measurement(duration, success)
# Track slow operations
if duration > self._slow_operation_threshold:
self._slow_operations.append({
"operation": operation_name,
"duration": duration,
"timestamp": end_time,
"success": success,
"async": True,
"metadata": kwargs
})
# Update metrics
metrics.record_histogram(f"async_operation_duration", duration, {"operation": operation_name})
if not success:
metrics.increment_counter("async_operation_errors", 1, {"operation": operation_name})
def monitor_function(self, operation_name: Optional[str] = None):
"""Decorator for monitoring function performance"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
name = operation_name or f"{func.__module__}.{func.__name__}"
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
async with self.monitor_async_operation(name):
return await func(*args, **kwargs)
return async_wrapper
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
with self.monitor_operation(name):
return func(*args, **kwargs)
return sync_wrapper
return decorator
def get_performance_summary(self) -> Dict[str, Any]:
"""Get comprehensive performance summary"""
with self._lock:
# Get top operations by various metrics
profiles = list(self._profiles.values())
# Sort by total calls
most_called = sorted(profiles, key=lambda p: p.total_calls, reverse=True)[:10]
# Sort by average duration
slowest_avg = sorted(profiles, key=lambda p: p.get_average_duration(), reverse=True)[:10]
# Sort by recent average
slowest_recent = sorted(profiles, key=lambda p: p.get_recent_average(), reverse=True)[:10]
# Sort by error rate
highest_errors = sorted(
[p for p in profiles if p.total_calls > 0],
key=lambda p: p.error_count / p.total_calls,
reverse=True
)[:10]
# Get active operations count
total_active = sum(p.concurrent_calls for p in profiles)
# Get slow operations
recent_slow = list(self._slow_operations)[-20:] # Last 20 slow operations
return {
"overview": {
"total_operations_tracked": len(profiles),
"total_active_operations": total_active,
"slow_operation_threshold_seconds": self._slow_operation_threshold,
"total_slow_operations": len(self._slow_operations)
},
"top_operations": {
"most_called": [p.to_dict() for p in most_called],
"slowest_average": [p.to_dict() for p in slowest_avg],
"slowest_recent": [p.to_dict() for p in slowest_recent],
"highest_error_rate": [p.to_dict() for p in highest_errors]
},
"recent_slow_operations": recent_slow,
"performance_insights": self._generate_performance_insights(profiles)
}
def get_operation_detail(self, operation_name: str) -> Optional[Dict[str, Any]]:
"""Get detailed performance data for specific operation"""
with self._lock:
if operation_name not in self._profiles:
return None
profile = self._profiles[operation_name]
# Get related slow operations
related_slow = [
op for op in self._slow_operations
if op["operation"] == operation_name
]
detail = profile.to_dict()
detail.update({
"detailed_stats": {
"total_duration": round(profile.total_duration, 4),
"recent_durations": list(profile.recent_durations)[-20:], # Last 20 calls
"slow_operations_count": len(related_slow),
"recent_slow_operations": related_slow[-10:] # Last 10 slow calls
},
"recommendations": self._get_operation_recommendations(profile)
})
return detail
def _generate_performance_insights(self, profiles: List[PerformanceProfile]) -> List[str]:
"""Generate performance optimization insights"""
insights = []
# Check for very slow operations
very_slow = [p for p in profiles if p.get_recent_average() > 5.0]
if very_slow:
insights.append(f"Found {len(very_slow)} operations with >5s average duration - consider optimization")
# Check for high error rates
high_error_rate = [p for p in profiles if p.total_calls > 10 and (p.error_count / p.total_calls) > 0.1]
if high_error_rate:
insights.append(f"Found {len(high_error_rate)} operations with >10% error rate - investigate failures")
# Check for high concurrency
high_concurrency = [p for p in profiles if p.concurrent_calls > 5]
if high_concurrency:
insights.append(f"Found {len(high_concurrency)} operations with high concurrency - may need rate limiting")
# Check total active operations
total_active = sum(p.concurrent_calls for p in profiles)
if total_active > 20:
insights.append(f"High total concurrent operations ({total_active}) - system may be under load")
# Performance trends
recent_slow_count = len([op for op in self._slow_operations if op["timestamp"] > time.time() - 300])
if recent_slow_count > 10:
insights.append(f"Many slow operations recently ({recent_slow_count} in last 5 minutes)")
if not insights:
insights.append("No significant performance issues detected")
return insights
def _get_operation_recommendations(self, profile: PerformanceProfile) -> List[str]:
"""Get recommendations for optimizing specific operation"""
recommendations = []
avg_duration = profile.get_recent_average()
error_rate = profile.error_count / profile.total_calls if profile.total_calls > 0 else 0
if avg_duration > 5.0:
recommendations.append("Consider breaking down this operation into smaller parts")
recommendations.append("Review database queries and file I/O for optimization opportunities")
elif avg_duration > 1.0:
recommendations.append("Monitor for potential optimization opportunities")
if error_rate > 0.1:
recommendations.append("High error rate - investigate common failure causes")
recommendations.append("Consider adding retry logic or better error handling")
if profile.concurrent_calls > 5:
recommendations.append("High concurrency - consider adding rate limiting")
recommendations.append("Review resource usage and potential bottlenecks")
percentiles = profile.get_percentiles()
if percentiles["p99"] > percentiles["p50"] * 3:
recommendations.append("High latency variance - investigate outlier causes")
if not recommendations:
recommendations.append("Performance appears optimal for this operation")
return recommendations
def set_slow_operation_threshold(self, threshold_seconds: float):
"""Set threshold for what constitutes a slow operation"""
with self._lock:
self._slow_operation_threshold = threshold_seconds
def clear_profiles(self, operation_names: Optional[List[str]] = None):
"""Clear performance profiles for specific operations or all"""
with self._lock:
if operation_names:
for name in operation_names:
self._profiles.pop(name, None)
else:
self._profiles.clear()
self._slow_operations.clear()
# Global performance monitor instance
performance_monitor = PerformanceMonitor()
# Decorator shortcuts
def monitor_performance(operation_name: Optional[str] = None):
"""Shortcut decorator for performance monitoring"""
return performance_monitor.monitor_function(operation_name)
def monitor_sync_operation(operation_name: str, **kwargs):
"""Shortcut for synchronous operation monitoring"""
return performance_monitor.monitor_operation(operation_name, **kwargs)
def monitor_async_operation(operation_name: str, **kwargs):
"""Shortcut for asynchronous operation monitoring"""
return performance_monitor.monitor_async_operation(operation_name, **kwargs)
+2554
View File
File diff suppressed because it is too large Load Diff
+742
View File
@@ -0,0 +1,742 @@
"""
Movie Processor for NFOGuard
Handles movie processing and metadata management
"""
import os
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, List, Tuple
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from core.database import NFOGuardDatabase
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from clients.radarr_client import RadarrClient
from clients.external_clients import ExternalClientManager
from config.settings import config
from utils.logging import _log
from utils.file_utils import find_media_path_by_imdb_and_title
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+)
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:
# Final fallback to UTC
return timezone.utc
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
class MovieProcessor:
"""Handles movie processing"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager, path_mapper: PathMapper):
self.db = db
self.nfo_manager = nfo_manager
self.path_mapper = path_mapper
self.radarr = RadarrClient(
os.environ.get("RADARR_URL", ""),
os.environ.get("RADARR_API_KEY", "")
)
self.external_clients = ExternalClientManager()
def find_movie_path(self, movie_title: str, imdb_id: str, radarr_path: str = None) -> Optional[Path]:
"""Find movie directory path using unified file utilities"""
return find_media_path_by_imdb_and_title(
title=movie_title,
imdb_id=imdb_id,
search_paths=config.movie_paths,
webhook_path=radarr_path,
path_mapper=self.path_mapper
)
def should_skip_movie(self, imdb_id: str, movie_name: str = "") -> Tuple[bool, str]:
"""
Determine if we should skip processing this movie based on completion status
Args:
imdb_id: Movie IMDb ID
movie_name: Movie name for logging
Returns:
(should_skip: bool, reason: str)
"""
try:
with self.db.get_connection() as conn:
cursor = conn.cursor()
if self.db.db_type == "postgresql":
cursor.execute("""
SELECT dateadded, source, has_video_file
FROM movies
WHERE imdb_id = %s
""", (imdb_id,))
else:
cursor.execute("""
SELECT dateadded, source, has_video_file
FROM movies
WHERE imdb_id = ?
""", (imdb_id,))
result = cursor.fetchone()
if not result:
return False, "No database record found"
if self.db.db_type == "postgresql":
dateadded = result['dateadded']
source = result['source']
has_video_file = result['has_video_file']
else:
dateadded = result[0] if result[0] else None
source = result[1] if result[1] else None
has_video_file = result[2] if result[2] else False
# Skip if:
# 1. Movie has a valid dateadded timestamp
# 2. Source is valid (not 'unknown' or 'no_valid_date_source')
# 3. Has video file on disk
if (dateadded and
source and
source not in ['unknown', 'no_valid_date_source'] and
has_video_file):
return True, f"Complete: Has valid date '{dateadded}' from source '{source}'"
elif not dateadded:
return False, "Missing dateadded"
elif not source or source in ['unknown', 'no_valid_date_source']:
return False, f"Invalid source: '{source}'"
elif not has_video_file:
return False, "No video file detected"
else:
return False, "Incomplete movie data"
except Exception as e:
_log("ERROR", f"Error checking movie completion for {imdb_id}: {e}")
return False, f"Error checking completion: {e}"
def process_movie(self, movie_path: Path, webhook_mode: bool = False, force_scan: bool = False, shutdown_event=None) -> str:
"""Process a movie directory"""
imdb_id = self.nfo_manager.find_movie_imdb_id(movie_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found in movie directory, filenames, or NFO file: {movie_path}")
return "error"
# Handle TMDB ID fallback case
is_tmdb_fallback = imdb_id.startswith("tmdb-")
if is_tmdb_fallback:
_log("INFO", f"Processing movie: {movie_path.name} (TMDB: {imdb_id})")
else:
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
# Check if we should skip this movie (unless forced or webhook mode)
if not force_scan and not webhook_mode:
should_skip, reason = self.should_skip_movie(imdb_id, movie_path.name)
if should_skip:
_log("INFO", f"⏭️ SKIPPING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
# Still update the movie record to track that we've seen it
self.db.upsert_movie(imdb_id, str(movie_path))
return "skipped"
else:
_log("INFO", f"🎬 PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - {reason}")
elif force_scan:
_log("INFO", f"🔄 FORCE PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Force scan enabled")
else:
_log("INFO", f"📥 WEBHOOK PROCESSING MOVIE: {movie_path.name} [{imdb_id}] - Webhook mode")
# Check for shutdown signal early in processing
if shutdown_event and shutdown_event.is_set():
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing: {movie_path.name}")
return "shutdown"
# Update database
self.db.upsert_movie(imdb_id, str(movie_path))
# Check for video files
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir())
if not has_video:
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
return "no_video_files"
# TIER 1: Check database first (fastest - local lookup)
existing = self.db.get_movie_dates(imdb_id)
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
# Enhanced debug for database state
if existing:
has_dateadded = bool(existing.get("dateadded"))
source_value = existing.get("source")
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: has_dateadded={has_dateadded}, source='{source_value}', dateadded='{existing.get('dateadded')}'")
else:
_log("INFO", f"🔍 TIER 1 DEBUG - {imdb_id}: No database record found")
# If we have complete data in database, use it and skip all other checks
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
_log("INFO", f"✅ TIER 1 - Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})")
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
# Convert datetime objects to strings for NFO manager
if hasattr(dateadded, 'isoformat'):
dateadded = dateadded.isoformat()
if released and hasattr(released, 'isoformat'):
released = released.isoformat()
# Create NFO with existing data and update files
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [database-cached]")
return "processed"
else:
_log("INFO", f"🔍 TIER 1 SKIP - {imdb_id}: Database incomplete, proceeding to Tier 2")
# TIER 2: Check if NFO file has NFOGuard data and cache it in database
nfo_path = movie_path / "movie.nfo"
_log("INFO", f"🔍 TIER 2 - Checking NFO file: {nfo_path}")
_log("INFO", f"🔍 TIER 2 - NFO exists: {nfo_path.exists()}")
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
_log("INFO", f"🔍 TIER 2 - NFOGuard data extracted: {nfo_data}")
if nfo_data:
_log("INFO", f"🚀 TIER 2 - Found NFOGuard data in NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
dateadded = nfo_data["dateadded"]
source = nfo_data["source"]
released = nfo_data.get("released")
# Cache NFO data in database for future lookups
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached NFO data in database for {imdb_id}")
# Update file mtimes if enabled (NFO is already correct)
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [nfo-cached]")
return "processed"
# TIER 2.5: Check for any existing valid date data in NFO (even without lockdata marker)
# Only use NFO dates if prioritize_nfo is enabled, otherwise check external APIs first
existing_nfo_data = self._extract_any_valid_dates_from_nfo(nfo_path)
if existing_nfo_data and config.manual_scan_prioritize_nfo:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"⚡ MANUAL_SCAN_PRIORITIZE_NFO=True - Using NFO date for speed")
dateadded = existing_nfo_data["dateadded"]
source = existing_nfo_data["source"]
released = existing_nfo_data.get("released")
# Cache existing data in database and add proper NFOGuard formatting
# Fixed parameter order: imdb_id, released, dateadded, source
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"✅ Cached existing NFO data in database for {imdb_id}")
# Update NFO file to add NFOGuard formatting (lockdata, comment)
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
_log("INFO", f"✅ Added NFOGuard formatting to existing NFO for {imdb_id}")
# Update file mtimes if enabled
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [existing-nfo-enhanced]")
return "processed"
elif existing_nfo_data:
_log("INFO", f"🔍 TIER 2.5 - Found existing date data in NFO (no lockdata): {existing_nfo_data['dateadded']} (source: {existing_nfo_data['source']})")
_log("INFO", f"🎯 MANUAL_SCAN_PRIORITIZE_NFO=False - Will verify against external APIs first")
# Store NFO data as fallback but continue to TIER 3 to check external APIs
nfo_fallback_data = existing_nfo_data
# TIER 1.5: Special handling for TMDB-only movies - extract dates from existing NFO
if is_tmdb_fallback:
tmdb_nfo_data = self._extract_dates_from_tmdb_nfo(nfo_path)
if tmdb_nfo_data:
_log("INFO", f"🎬 Using TMDB data from existing NFO file: {tmdb_nfo_data['dateadded']} (source: {tmdb_nfo_data['source']})")
dateadded = tmdb_nfo_data["dateadded"]
source = tmdb_nfo_data["source"]
released = tmdb_nfo_data.get("released")
# Create NFO with NFOGuard fields added
if config.manage_nfo:
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
)
# Update file mtimes if enabled
if config.fix_dir_mtimes and dateadded:
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
# Save to database
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source}) [tmdb-nfo]")
return "processed"
# TIER 3: No cached data found - proceed with API lookups and verification
# Check for shutdown signal before expensive API operations
if shutdown_event and shutdown_event.is_set():
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie processing before API calls: {movie_path.name}")
return "shutdown"
# TIER 3: No cached data found - determine if we should query APIs
if webhook_mode:
_log("INFO", f"Webhook processing - no cached data found, using full date decision logic")
should_query = True # Always query for webhooks when no cached data exists
else:
# Manual scan mode - determine if we should query APIs
should_query = config.movie_poll_mode == "always"
_log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}")
# Use existing movie date decision logic
# Pass NFO fallback data if available for cases where external APIs don't have import history
nfo_fallback = locals().get('nfo_fallback_data', None)
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, nfo_fallback)
# Webhook fallback: if ALL date sources fail, use current timestamp
if webhook_mode and dateadded is None:
local_tz = _get_local_timezone()
current_time = datetime.now(local_tz).isoformat(timespec="seconds")
_log("INFO", f"Webhook processing - all date sources failed, using current timestamp as last resort: {current_time}")
dateadded, source = current_time, "webhook:fallback_timestamp"
# If we don't have an import/download date but we have a release date, use it as dateadded
# This ensures we save digital release dates, theatrical dates, etc. to the database
final_dateadded = dateadded
final_source = source
if dateadded is None and released is not None:
final_dateadded = released
final_source = f"{source}_as_dateadded" if source else "release_date_fallback"
_log("INFO", f"Using release date as dateadded: {final_dateadded} (source: {final_source})")
# Create NFO regardless of date availability (preserves existing metadata)
if config.debug:
print(f"🔍 TIER3 - config.manage_nfo: {config.manage_nfo}")
if config.manage_nfo:
if config.debug:
print(f"🔍 TIER3 - Calling create_movie_nfo with final_dateadded: {final_dateadded}")
self.nfo_manager.create_movie_nfo(
movie_path, imdb_id, final_dateadded, released, final_source, config.lock_metadata
)
else:
if config.debug:
print(f"❌ TIER3 - manage_nfo is disabled, skipping NFO creation")
# Skip remaining processing if no valid date found and file dates disabled
if final_dateadded is None:
_log("WARNING", f"Movie {movie_path.name} - no valid date source available, but NFO was still processed")
self.db.upsert_movie_dates(imdb_id, released, None, source, True)
return "processed"
# Update dateadded and source for the rest of processing
dateadded = final_dateadded
source = final_source
_log("DEBUG", f"Movie {movie_path.name} proceeding to save: dateadded={dateadded}, source={source}")
# Update file mtimes (only if we have a valid date)
if config.fix_dir_mtimes and dateadded and dateadded != "MANUAL_REVIEW_NEEDED":
self.nfo_manager.update_movie_files_mtime(movie_path, dateadded)
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
# Yield control briefly during movie processing to allow web interface requests
import time
time.sleep(0.005) # 5ms yield per movie to improve responsiveness
# Save to database
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
try:
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
_log("DEBUG", f"Database save completed for {imdb_id}")
except Exception as e:
_log("ERROR", f"Database save failed for {imdb_id}: {e}")
raise
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
return "processed"
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract date information from TMDB-based NFO file"""
if not nfo_path.exists():
return None
try:
root = self.nfo_manager._parse_nfo_with_tolerance(nfo_path)
# Look for premiered date (from TMDB)
premiered_elem = root.find('.//premiered')
if premiered_elem is not None and premiered_elem.text:
premiered_date = premiered_elem.text.strip()
print(f"✅ Found TMDB premiered date: {premiered_date}")
return {
"dateadded": premiered_date,
"source": "tmdb:premiered_from_nfo",
"released": premiered_date
}
except (ET.ParseError, Exception) as e:
print(f"⚠️ Error parsing TMDB NFO for dates: {e}")
return None
def _extract_any_valid_dates_from_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
"""Extract any valid date information from NFO file, even without NFOGuard markers"""
if not nfo_path.exists():
return None
try:
import xml.etree.ElementTree as ET
tree = ET.parse(nfo_path)
root = tree.getroot()
# Look for dateadded element (indicates previously processed by NFOGuard or similar)
dateadded_elem = root.find('.//dateadded')
premiered_elem = root.find('.//premiered')
if dateadded_elem is not None and dateadded_elem.text:
dateadded = dateadded_elem.text.strip()
# Try to determine source from NFOGuard comment if present
source = "existing_nfo_data"
try:
nfo_content = nfo_path.read_text(encoding='utf-8')
import re
# Look for NFOGuard comment pattern
source_match = re.search(r'<!--.*?NFOGuard.*?Source:\s*([^-\s]+).*?-->', nfo_content, re.DOTALL | re.IGNORECASE)
if source_match:
source = source_match.group(1).strip()
_log("DEBUG", f"Found source in NFOGuard comment: {source}")
else:
# Try to infer source from dateadded format/content
if "tmdb" in nfo_content.lower() or (premiered_elem and premiered_elem.text):
source = "tmdb:digital"
elif "radarr" in nfo_content.lower():
source = "radarr:db.history.import"
else:
source = "existing_nfo_data"
_log("DEBUG", f"Inferred source from NFO content: {source}")
except Exception as e:
_log("DEBUG", f"Could not determine source from NFO content: {e}")
result = {
"dateadded": dateadded,
"source": source
}
if premiered_elem is not None and premiered_elem.text:
result["released"] = premiered_elem.text.strip()
_log("INFO", f"✅ Found existing date data in NFO: dateadded={dateadded}, source={source}")
return result
except (ET.ParseError, Exception) as e:
_log("DEBUG", f"Error parsing NFO for existing date data: {e}")
return None
def _decide_movie_dates(self, imdb_id: str, movie_path: Path, should_query: bool, existing: Optional[Dict]) -> Tuple[str, str, Optional[str]]:
"""Decide movie dates based on configuration and available data"""
_log("DEBUG", f"_decide_movie_dates for {imdb_id}: should_query={should_query}, existing={existing}")
if not should_query and existing:
_log("DEBUG", f"Using existing data without querying: dateadded={existing.get('dateadded')}, source={existing.get('source')}")
return existing["dateadded"], existing["source"], existing.get("released")
# Query Radarr for movie info
radarr_movie = None
if should_query and self.radarr.api_key:
radarr_movie = self.radarr.movie_by_imdb(imdb_id)
released = None
if radarr_movie:
released = self._parse_date_to_iso(radarr_movie.get("inCinemas"))
# Try import history first if configured
if config.movie_priority == "import_then_digital":
import_date, import_source = None, None
if radarr_movie:
movie_id = radarr_movie.get("id")
if movie_id:
import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback)
_log("INFO", f"Movie {imdb_id}: Radarr import result: date={import_date}, source={import_source}")
# Check for special case: rename-first scenario (should prefer release dates)
if import_source == "radarr:db.prefer_release_dates":
_log("INFO", f"🎯 Movie {imdb_id} has rename-first history - skipping import, preferring release dates")
# Fall through to release date logic below
# Check if we got a real import date or just file date fallback
elif import_date and import_source != "radarr:db.file.dateAdded":
# Convert import date to local timezone for NFO files
local_import_date = convert_utc_to_local(import_date)
_log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}")
return local_import_date, import_source, released
# Get digital release date for comparison/fallback
_log("INFO", f"🔍 Movie {imdb_id}: Trying digital release date fallback...")
digital_date, digital_source = self._get_digital_release_date(imdb_id)
_log("INFO", f"Movie {imdb_id}: Digital release result: date={digital_date}, source={digital_source}")
# If we only have file date and release date exists, prefer it if reasonable and enabled
if import_date and import_source == "radarr:db.file.dateAdded" and digital_date and config.prefer_release_dates_over_file_dates:
# Compare dates - prefer release date if it's reasonable
if self._should_prefer_release_over_file_date(digital_date, digital_source, released, imdb_id):
_log("INFO", f"✅ Movie {imdb_id}: Preferring digital release date {digital_date} over file date")
return digital_date, digital_source, released
else:
# Convert file date to local timezone for NFO files
local_file_date = convert_utc_to_local(import_date)
_log("INFO", f"✅ Movie {imdb_id}: Keeping file date {local_file_date} - digital date not reasonable")
return local_file_date, import_source, released
# Use whichever we have
if import_date:
# Convert import date to local timezone for NFO files
local_import_date = convert_utc_to_local(import_date)
_log("INFO", f"✅ Movie {imdb_id}: Using import date {local_import_date} from {import_source}")
return local_import_date, import_source, released
elif digital_date:
_log("INFO", f"✅ Movie {imdb_id}: Using digital release date {digital_date} from {digital_source}")
return digital_date, digital_source, released
else:
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
# Try Radarr's own NFO premiered date as fallback
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
if radarr_premiered:
_log("INFO", f"✅ Movie {imdb_id}: Using Radarr NFO premiered date {radarr_premiered}")
return radarr_premiered, "radarr:nfo.premiered", released
else: # digital_then_import
# Try digital release first
digital_date, digital_source = self._get_digital_release_date(imdb_id)
if digital_date:
return digital_date, digital_source, released
# Fall back to import history
if radarr_movie:
movie_id = radarr_movie.get("id")
if movie_id:
import_date, import_source = self.radarr.get_movie_import_date(movie_id, fallback_to_file_date=config.allow_file_date_fallback)
if import_date:
# Convert import date to local timezone for NFO files
local_import_date = convert_utc_to_local(import_date)
return local_import_date, import_source, released
# Last resort: check if we have NFO fallback data (when external APIs don't have import history)
if existing and existing.get('dateadded'):
_log("INFO", f"✅ Movie {imdb_id}: External APIs don't have import history, using NFO fallback date: {existing['dateadded']} (source: {existing['source']})")
return existing["dateadded"], f"nfo_fallback:{existing['source']}", existing.get("released")
# Last resort: file mtime (if allowed)
if config.allow_file_date_fallback:
return self._get_file_mtime_date(movie_path)
else:
_log("INFO", f"No valid dates found for {imdb_id} and file date fallback disabled - skipping NFO creation")
# Log to failed movies debug file for troubleshooting
self._log_failed_movie(movie_path, imdb_id, "No import date, no release date, file date fallback disabled")
return None, "no_valid_date_source", None
def _get_digital_release_date(self, imdb_id: str) -> Tuple[Optional[str], str]:
"""Get release date from external sources using configured priority"""
_log("INFO", f"🔍 Calling external clients for {imdb_id}")
_log("INFO", f"Release date priority: {config.release_date_priority}")
_log("INFO", f"Smart validation enabled: {config.enable_smart_date_validation}")
try:
release_result = self.external_clients.get_release_date_by_priority(
imdb_id,
config.release_date_priority,
enable_smart_validation=config.enable_smart_date_validation
)
_log("INFO", f"External clients result for {imdb_id}: {release_result}")
if release_result:
_log("INFO", f"✅ Got release date: {release_result[0]} from {release_result[1]}")
return release_result[0], release_result[1]
else:
_log("WARNING", f"❌ No release date found from external clients for {imdb_id}")
return None, "release:none"
except Exception as e:
_log("ERROR", f"External clients error for {imdb_id}: {e}")
return None, f"release:error:{str(e)}"
def _get_radarr_nfo_premiered_date(self, movie_path: Path) -> Optional[str]:
"""Extract premiered date from Radarr's existing movie.nfo file"""
try:
nfo_path = movie_path / "movie.nfo"
if not nfo_path.exists():
_log("DEBUG", f"No existing NFO file found at {nfo_path}")
return None
nfo_content = nfo_path.read_text(encoding='utf-8')
# Look for <premiered>YYYY-MM-DD</premiered>
match = re.search(r'<premiered>(\d{4}-\d{2}-\d{2})</premiered>', nfo_content)
if match:
premiered_date = match.group(1)
# Convert to ISO format with timezone
iso_date = f"{premiered_date}T00:00:00+00:00"
_log("INFO", f"✅ Found Radarr NFO premiered date: {premiered_date}")
return iso_date
else:
_log("DEBUG", f"No <premiered> tag found in existing NFO")
return None
except Exception as e:
_log("ERROR", f"Error reading Radarr NFO file: {e}")
return None
def _log_failed_movie(self, movie_path: Path, imdb_id: str, reason: str, available_countries: List[str] = None):
"""Log movies that failed to get valid dates to a debug file"""
try:
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
failed_log_path = log_dir / "failed_movies.log"
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] {movie_path.name} | IMDb: {imdb_id} | Reason: {reason}"
if available_countries:
log_entry += f" | Available Countries: {', '.join(available_countries)}"
log_entry += "\n"
with open(failed_log_path, "a", encoding="utf-8") as f:
f.write(log_entry)
_log("INFO", f"📝 Logged failed movie to {failed_log_path}: {movie_path.name}")
except Exception as e:
_log("ERROR", f"Failed to write to failed movies log: {e}")
def _get_file_mtime_date(self, movie_path: Path) -> Tuple[str, str, Optional[str]]:
"""Get date from file modification time as last resort"""
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
newest_mtime = None
for file_path in movie_path.iterdir():
if file_path.is_file() and file_path.suffix.lower() in video_exts:
try:
mtime = file_path.stat().st_mtime
if newest_mtime is None or mtime > newest_mtime:
newest_mtime = mtime
except Exception:
continue
if newest_mtime:
try:
# Use local timezone for file modification times
local_tz = _get_local_timezone()
iso_date = datetime.fromtimestamp(newest_mtime, tz=local_tz).isoformat(timespec="seconds")
return iso_date, "file:mtime", None
except Exception:
pass
return "MANUAL_REVIEW_NEEDED", "manual_review_required", None
def _should_prefer_release_over_file_date(self, release_date: str, release_source: str, theatrical_release: Optional[str], imdb_id: str) -> bool:
"""
Decide if release date should be preferred over file date
Logic:
- For theatrical dates: Always prefer over file dates (they're authoritative)
- For physical dates: Usually prefer over file dates
- For digital dates: Prefer if reasonable (not decades before theatrical)
"""
try:
release_dt = datetime.fromisoformat(release_date.replace("Z", "+00:00"))
# Always prefer theatrical and physical releases over file dates
if any(release_type in release_source for release_type in ["theatrical", "physical"]):
_log("INFO", f"Release date {release_date} ({release_source}) for {imdb_id}, preferring over file date")
return True
# If we have theatrical release date, compare digital against it
if theatrical_release:
theatrical_dt = datetime.fromisoformat(theatrical_release.replace("Z", "+00:00"))
year_diff = release_dt.year - theatrical_dt.year
# If digital is more than 10 years before theatrical, it's probably wrong
if year_diff < -10:
_log("INFO", f"Release date {release_date} is {abs(year_diff)} years before theatrical {theatrical_release} for {imdb_id}, using file date instead")
return False
# If digital is within reasonable range (theatrical to +20 years), use it
if -2 <= year_diff <= 20:
_log("INFO", f"Release date {release_date} is reasonable for {imdb_id} (theatrical: {theatrical_release}), preferring over file date")
return True
# If no theatrical date, use digital if it's not absurdly old
if release_dt.year >= 1990: # Reasonable minimum for digital releases
_log("INFO", f"Release date {release_date} seems reasonable for {imdb_id}, preferring over file date")
return True
_log("INFO", f"Release date {release_date} seems too old for {imdb_id}, using file date instead")
return False
except Exception as e:
_log("WARNING", f"Error comparing dates for {imdb_id}: {e}")
return False
def _parse_date_to_iso(self, date_str: str) -> Optional[str]:
"""Parse date string to ISO format"""
if not date_str:
return None
try:
if len(date_str) == 10 and date_str[4] == "-":
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
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
#!/usr/bin/env python3
"""
TV Series Processor - Clean implementation for TV episode processing
Handles manual scans and webhook processing with proper NFO filename matching
"""
from pathlib import Path
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timezone
import re
from core.database import NFOGuardDatabase
from core.episode_nfo_manager import EpisodeNFOManager
from core.nfo_manager import NFOManager
from core.path_mapper import PathMapper
from clients.sonarr_client import SonarrClient
from clients.external_clients import ExternalClientManager
from core.logging import _log, convert_utc_to_local
class TVSeriesProcessor:
"""Clean TV series processor with video filename matching"""
def __init__(self, db: NFOGuardDatabase, nfo_manager: NFOManager,
path_mapper: PathMapper, sonarr_client: SonarrClient):
self.db = db
self.nfo_manager = nfo_manager # Keep for series/season NFOs
self.episode_nfo_manager = EpisodeNFOManager()
self.path_mapper = path_mapper
self.sonarr = sonarr_client
self.external_manager = ExternalClientManager()
def process_series_manual_scan(self, series_path: Path) -> bool:
"""Process a TV series during manual scan"""
_log("INFO", f"Processing TV series: {series_path.name}")
# Extract IMDb ID
imdb_id = self._extract_imdb_id(series_path)
if not imdb_id:
_log("ERROR", f"No IMDb ID found for series: {series_path.name}")
return False
# Find all episodes on disk
episodes_on_disk = self._find_episodes_on_disk(series_path)
if not episodes_on_disk:
_log("WARNING", f"No episodes found on disk for: {series_path.name}")
return False
_log("INFO", f"Found {len(episodes_on_disk)} episodes on disk")
# Process each episode
episodes_processed = 0
for (season_num, episode_num), video_files in episodes_on_disk.items():
if self._process_episode_manual_scan(series_path, imdb_id, season_num, episode_num):
episodes_processed += 1
# Create series-level NFOs if any episodes were processed
if episodes_processed > 0:
self._create_series_nfos(series_path, imdb_id)
_log("INFO", f"Completed processing series: {series_path.name} ({episodes_processed} episodes)")
return episodes_processed > 0
def process_episode_webhook(self, webhook_data: Dict[str, Any]) -> bool:
"""Process a single episode from Sonarr webhook"""
# TODO: Parse webhook data and extract episode info
# This will be implemented when we add webhook support
pass
def _extract_imdb_id(self, series_path: Path) -> Optional[str]:
"""Extract IMDb ID from series directory or files"""
# Try directory name first
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
if imdb_id:
return imdb_id
# Try tvshow.nfo if it exists
tvshow_nfo = series_path / "tvshow.nfo"
if tvshow_nfo.exists():
imdb_id = self.nfo_manager.parse_imdb_from_nfo(tvshow_nfo)
if imdb_id:
return imdb_id
# Try any existing episode NFO files
for season_dir in series_path.iterdir():
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
for nfo_file in season_dir.glob("*.nfo"):
imdb_id = self.nfo_manager.parse_imdb_from_nfo(nfo_file)
if imdb_id:
return imdb_id
return None
def _is_season_directory(self, dirname: str) -> bool:
"""Check if directory name matches season pattern"""
return bool(re.match(r'^[Ss]eason\s+\d+$', dirname, re.IGNORECASE))
def _find_episodes_on_disk(self, series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""Find all episodes on disk, grouped by (season, episode)"""
episodes = {}
try:
_log("DEBUG", f"Scanning for season directories in: {series_path}")
_log("DEBUG", f"Series path exists: {series_path.exists()}, is_dir: {series_path.is_dir()}")
if not series_path.exists() or not series_path.is_dir():
_log("ERROR", f"Series path does not exist or is not a directory: {series_path}")
return episodes
# List all items in directory for debugging
try:
items = list(series_path.iterdir())
_log("DEBUG", f"Found {len(items)} items in series directory")
for item in items:
_log("DEBUG", f" Item: {item.name} (is_dir: {item.is_dir()})")
except Exception as e:
_log("ERROR", f"Failed to list directory contents: {e}")
return episodes
for season_dir in series_path.iterdir():
_log("DEBUG", f"Checking directory: {season_dir.name} (is_dir: {season_dir.is_dir()})")
_log("DEBUG", f"Season directory regex test for '{season_dir.name}': {self._is_season_directory(season_dir.name)}")
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
season_num = self._extract_season_number(season_dir.name)
_log("DEBUG", f"Found season directory: {season_dir.name} → season {season_num}")
if season_num is None:
_log("WARNING", f"Could not extract season number from: {season_dir.name}")
continue
# Find video files in this season
season_episodes = self.episode_nfo_manager.find_video_files_for_season(season_dir)
_log("DEBUG", f"Found {len(season_episodes)} episodes in {season_dir.name}: {list(season_episodes.keys())}")
# Add season directory info to episodes
for (s_num, e_num), video_files in season_episodes.items():
_log("DEBUG", f"Episode S{s_num:02d}E{e_num:02d}: season_dir={season_num}, filename_season={s_num}")
if s_num == season_num: # Verify season matches directory
episodes[(s_num, e_num)] = video_files
_log("DEBUG", f"Added episode S{s_num:02d}E{e_num:02d} to processing list")
else:
_log("WARNING", f"Season mismatch: directory={season_num}, filename={s_num} for S{s_num:02d}E{e_num:02d}")
_log("DEBUG", f"Total episodes found on disk: {len(episodes)}")
return episodes
except Exception as e:
_log("ERROR", f"Exception in _find_episodes_on_disk: {e}")
return episodes
def _extract_season_number(self, dirname: str) -> Optional[int]:
"""Extract season number from directory name"""
match = re.search(r'[Ss]eason\s+(\d+)', dirname, re.IGNORECASE)
if match:
return int(match.group(1))
return None
def _process_episode_manual_scan(self, series_path: Path, imdb_id: str,
season_num: int, episode_num: int) -> bool:
"""Process a single episode during manual scan"""
season_dir = series_path / f"Season {season_num:02d}"
if not season_dir.exists():
# Try alternate format
season_dir = series_path / f"Season {season_num}"
if not season_dir.exists():
_log("ERROR", f"Season directory not found for S{season_num:02d}E{episode_num:02d}")
return False
_log("DEBUG", f"Processing episode S{season_num:02d}E{episode_num:02d}")
# Step 1: Check for existing NFOGuard data
existing_nfo = self.episode_nfo_manager.find_nfo_for_episode(season_dir, season_num, episode_num)
if existing_nfo:
nfo_data = self.episode_nfo_manager.extract_nfoguard_data(existing_nfo)
if nfo_data:
# Verify against database
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
if db_data and db_data.get("dateadded") == nfo_data.get("dateadded"):
_log("DEBUG", f"Episode S{season_num:02d}E{episode_num:02d} already up to date")
# Still migrate filename if needed
self.episode_nfo_manager.migrate_nfo_to_video_filename(season_dir, season_num, episode_num)
return True
# Step 2: Check database
db_data = self.db.get_episode_date(imdb_id, season_num, episode_num)
if db_data and db_data.get("dateadded"):
_log("DEBUG", f"Using database data for S{season_num:02d}E{episode_num:02d}")
aired = db_data.get("aired")
dateadded = db_data.get("dateadded")
source = db_data.get("source", "database")
else:
# Step 3: Query Sonarr for episode data
aired, dateadded, source = self._get_episode_dates_from_sonarr(imdb_id, season_num, episode_num)
# Step 4: Create/update NFO and database
if dateadded or aired:
# Get episode metadata for title/plot
title, plot = self._get_episode_metadata_from_sonarr(imdb_id, season_num, episode_num)
# Use aired date as dateadded if no import date found (user requirement)
if not dateadded and aired:
dateadded = aired
if source == "no_data_found":
source = "tmdb:air_date_fallback"
else:
source = f"{source}_used_as_dateadded"
_log("INFO", f"Using aired date as dateadded for S{season_num:02d}E{episode_num:02d}: {dateadded}")
# Create/update NFO with video filename
success = self.episode_nfo_manager.create_episode_nfo(
season_dir, season_num, episode_num, aired, dateadded, source, title, plot
)
if success:
# Update database
self.db.upsert_episode_date(imdb_id, season_num, episode_num, aired, dateadded, source, True)
return True
_log("WARNING", f"Could not get dates for episode S{season_num:02d}E{episode_num:02d}")
return False
def _get_episode_dates_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str], str]:
"""Get episode dates from Sonarr API with proper fallback"""
aired = None
dateadded = None
source = "no_data_found"
if not self.sonarr.enabled:
_log("WARNING", "Sonarr not enabled, cannot get episode dates")
return aired, dateadded, source
try:
# Find series in Sonarr using lookup endpoint first
series = self.sonarr.series_by_imdb(imdb_id)
if not series:
_log("WARNING", f"Series not found via Sonarr lookup for IMDb: {imdb_id}")
# Try direct method as fallback (slower but more reliable)
_log("DEBUG", f"Trying direct series search for IMDb: {imdb_id}")
series = self.sonarr.series_by_imdb_direct(imdb_id)
if not series:
_log("WARNING", f"Series not found via direct search either for IMDb: {imdb_id}")
# Fall through to external API fallback
if series:
# Get episodes for series
episodes = self.sonarr.episodes_for_series(series["id"])
target_episode = None
for episode in episodes:
if (episode.get("seasonNumber") == season_num and
episode.get("episodeNumber") == episode_num):
target_episode = episode
break
if not target_episode:
_log("WARNING", f"Episode S{season_num:02d}E{episode_num:02d} not found in Sonarr")
# Don't return here - fall through to external API fallback
else:
# Get airdate
aired = target_episode.get("airDateUtc")
# Try to get import history
episode_id = target_episode.get("id")
if episode_id:
import_date = self.sonarr.get_episode_import_history(episode_id)
if import_date:
dateadded = convert_utc_to_local(import_date)
source = "sonarr:history.import"
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
return aired, dateadded, source
# Fallback to airdate if no import history
if aired:
dateadded = convert_utc_to_local(aired)
source = "sonarr:episode.airDateUtc"
_log("WARNING", f"No import history for S{season_num:02d}E{episode_num:02d}, using airdate: {dateadded}")
return aired, dateadded, source
except Exception as e:
_log("ERROR", f"Sonarr API error for S{season_num:02d}E{episode_num:02d}: {e}")
# Try external APIs for episode airdate
_log("INFO", f"Trying external APIs for episode S{season_num:02d}E{episode_num:02d} airdate")
aired, source = self._get_episode_airdate_from_external_apis(imdb_id, season_num, episode_num)
if aired:
dateadded = convert_utc_to_local(aired)
return aired, dateadded, source
_log("ERROR", f"Could not get any date information for S{season_num:02d}E{episode_num:02d}")
return aired, dateadded, source
def _get_episode_metadata_from_sonarr(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], Optional[str]]:
"""Get episode title and plot from Sonarr"""
if not self.sonarr.enabled:
return None, None
try:
series = self.sonarr.series_by_imdb(imdb_id)
if not series:
# Try direct method as fallback
series = self.sonarr.series_by_imdb_direct(imdb_id)
if series:
episodes = self.sonarr.episodes_for_series(series["id"])
for episode in episodes:
if (episode.get("seasonNumber") == season_num and
episode.get("episodeNumber") == episode_num):
title = episode.get("title")
plot = episode.get("overview")
return title, plot
except Exception as e:
_log("DEBUG", f"Could not get metadata for S{season_num:02d}E{episode_num:02d}: {e}")
return None, None
def _get_episode_airdate_from_external_apis(self, imdb_id: str, season_num: int, episode_num: int) -> Tuple[Optional[str], str]:
"""Get episode airdate from external APIs (TMDB, OMDb) as fallback"""
# Try TMDB first
if self.external_manager.tmdb.enabled:
try:
_log("DEBUG", f"Trying TMDB for episode S{season_num:02d}E{episode_num:02d} airdate")
# First convert IMDb to TMDB TV ID
tv_search = self.external_manager.tmdb._get(f"/find/{imdb_id}", {"external_source": "imdb_id"})
if tv_search and tv_search.get("tv_results"):
tv_id = tv_search["tv_results"][0].get("id")
if tv_id:
_log("DEBUG", f"Found TMDB TV ID {tv_id} for {imdb_id}")
# Get episode details
episode_data = self.external_manager.tmdb._get(f"/tv/{tv_id}/season/{season_num}/episode/{episode_num}")
if episode_data and episode_data.get("air_date"):
airdate = episode_data["air_date"]
# Convert to ISO format with UTC timezone
iso_airdate = f"{airdate}T00:00:00Z"
_log("INFO", f"Found TMDB airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
return iso_airdate, "tmdb:episode.air_date"
except Exception as e:
_log("WARNING", f"TMDB episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
# Try OMDb as fallback
if self.external_manager.omdb.enabled:
try:
_log("DEBUG", f"Trying OMDb for episode S{season_num:02d}E{episode_num:02d} airdate")
episode_dates = self.external_manager.omdb.get_tv_season_episodes(imdb_id, season_num)
if episode_num in episode_dates:
airdate = episode_dates[episode_num]
# Convert to ISO format
from datetime import datetime, timezone
try:
# Try to parse OMDb date format (usually DD MMM YYYY)
dt = datetime.strptime(airdate, "%d %b %Y").replace(tzinfo=timezone.utc)
iso_airdate = dt.isoformat(timespec="seconds")
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
return iso_airdate, "omdb:episode.released"
except ValueError:
# Try other common formats
for fmt in ["%Y-%m-%d", "%d %B %Y"]:
try:
dt = datetime.strptime(airdate, fmt).replace(tzinfo=timezone.utc)
iso_airdate = dt.isoformat(timespec="seconds")
_log("INFO", f"Found OMDb airdate for S{season_num:02d}E{episode_num:02d}: {iso_airdate}")
return iso_airdate, "omdb:episode.released"
except ValueError:
continue
except Exception as e:
_log("WARNING", f"OMDb episode lookup failed for S{season_num:02d}E{episode_num:02d}: {e}")
_log("WARNING", f"No external API airdate found for S{season_num:02d}E{episode_num:02d}")
return None, "no_external_data"
def _create_series_nfos(self, series_path: Path, imdb_id: str):
"""Create tvshow.nfo and season.nfo files only if they don't exist"""
# Create tvshow.nfo only if it doesn't exist
tvshow_nfo = series_path / "tvshow.nfo"
if not tvshow_nfo.exists():
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id)
else:
_log("DEBUG", f"Skipping tvshow.nfo creation - already exists: {tvshow_nfo}")
# Create season.nfo for each season directory only if they don't exist
for season_dir in series_path.iterdir():
if season_dir.is_dir() and self._is_season_directory(season_dir.name):
season_num = self._extract_season_number(season_dir.name)
if season_num is not None:
season_nfo = season_dir / "season.nfo"
if not season_nfo.exists():
self.nfo_manager.create_season_nfo(season_dir, season_num)
else:
_log("DEBUG", f"Skipping season.nfo creation - already exists: {season_nfo}")
+10
View File
@@ -0,0 +1,10 @@
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
aiohttp==3.8.6
psutil==5.9.6
python-dotenv==1.0.0
+805
View File
@@ -0,0 +1,805 @@
/* NFOGuard Web Interface Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--dark-color: #343a40;
--light-color: #f8f9fa;
--border-color: #dee2e6;
--text-muted: #6c757d;
--shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--shadow-lg: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--dark-color);
background-color: #f5f5f5;
}
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 0;
box-shadow: var(--shadow-lg);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
.header-content h1 {
font-size: 2rem;
font-weight: 300;
margin-bottom: 0.5rem;
}
.header-content h1 i {
margin-right: 0.5rem;
}
.header-content p {
opacity: 0.9;
font-size: 1rem;
}
.nav-tabs {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: center;
}
.nav-tab {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-tab:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
}
.nav-tab.active {
background: rgba(255, 255, 255, 0.9);
color: var(--dark-color);
}
/* Main Content */
.main-content {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
width: 100%;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 1rem;
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
.stat-icon.movies { background: linear-gradient(135deg, #667eea, #764ba2); }
.stat-icon.tv { background: linear-gradient(135deg, #f093fb, #f5576c); }
.stat-icon.missing { background: linear-gradient(135deg, #ffecd2, #fcb69f); }
.stat-icon.activity { background: linear-gradient(135deg, #a8edea, #fed6e3); }
.stat-info h3 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-info p {
font-weight: 500;
margin-bottom: 0.25rem;
}
.stat-info small {
color: var(--text-muted);
font-size: 0.85rem;
}
.dashboard-charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.chart-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.chart-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.chart-container {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: var(--light-color);
border-radius: 0.25rem;
color: var(--text-muted);
}
/* Content Header */
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.content-header h2 {
color: var(--dark-color);
font-weight: 600;
}
.content-controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.search-controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.search-box {
position: relative;
display: flex;
align-items: center;
}
.search-box i {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
}
.search-box input {
padding: 0.5rem 0.75rem 0.5rem 2.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
width: 250px;
}
.search-box input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
transform: translateY(-1px);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #545b62;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #1e7e34;
}
.btn-warning {
background-color: var(--warning-color);
color: var(--dark-color);
}
.btn-warning:hover {
background-color: #e0a800;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Tables */
.table-container {
background: white;
border-radius: 0.5rem;
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 1rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background-color: var(--light-color);
font-weight: 600;
color: var(--dark-color);
position: sticky;
top: 0;
}
.data-table tr:hover {
background-color: rgba(0, 123, 255, 0.05);
}
.data-table .loading {
text-align: center;
color: var(--text-muted);
font-style: italic;
padding: 2rem;
}
/* Status badges */
.badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-success {
background-color: #d4edda;
color: #155724;
}
.badge-warning {
background-color: #fff3cd;
color: #856404;
}
.badge-danger {
background-color: #f8d7da;
color: #721c24;
}
.badge-secondary {
background-color: #e9ecef;
color: #495057;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.pagination .btn {
padding: 0.5rem 0.75rem;
}
.pagination .page-info {
margin: 0 1rem;
color: var(--text-muted);
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 0.25rem;
font-size: 0.9rem;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.form-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Modal */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 0.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-muted);
}
.modal-close:hover {
color: var(--dark-color);
}
.modal-body {
padding: 1.5rem;
}
/* Higher z-index for edit modals that appear on top of other modals */
#edit-modal, #smart-fix-modal {
z-index: 1100 !important;
}
/* Reports */
.report-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: var(--shadow);
text-align: center;
}
.summary-card h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
.summary-card p {
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.summary-card span {
font-weight: 700;
color: var(--primary-color);
}
.report-section {
margin-bottom: 2rem;
}
.report-section h3 {
margin-bottom: 1rem;
color: var(--dark-color);
}
/* Tools */
.tools-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
}
.tool-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: var(--shadow);
}
.tool-card h3 {
margin-bottom: 0.5rem;
color: var(--dark-color);
}
.tool-card p {
margin-bottom: 1.5rem;
color: var(--text-muted);
}
.stats-display {
background: var(--light-color);
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
min-height: 100px;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.toast {
background: white;
border-radius: 0.25rem;
box-shadow: var(--shadow-lg);
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
min-width: 300px;
border-left: 4px solid var(--primary-color);
animation: slideIn 0.3s ease;
}
.toast.success {
border-left-color: var(--success-color);
}
.toast.warning {
border-left-color: var(--warning-color);
}
.toast.error {
border-left-color: var(--danger-color);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Smart Fix Modal */
.smart-fix-options {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.option-card {
border: 2px solid var(--border-color);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.option-card:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow);
}
.option-label {
display: block;
padding: 1rem;
cursor: pointer;
margin: 0;
}
.option-label input[type="radio"] {
margin-right: 0.75rem;
margin-top: 0.1rem;
width: auto;
}
.option-content h4 {
margin: 0 0 0.5rem 0;
color: var(--dark-color);
font-size: 1rem;
}
.option-content p {
margin: 0 0 0.5rem 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.option-content small {
color: var(--text-muted);
font-size: 0.8rem;
}
.manual-date-input {
width: 100% !important;
margin-top: 0.5rem !important;
}
.option-card input[type="radio"]:checked + .option-content {
color: var(--primary-color);
}
.option-card:has(input[type="radio"]:checked) {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.05);
}
/* Additional badge styles */
.badge-info {
background-color: #d1ecf1;
color: #0c5460;
}
/* Enhanced Edit Modal Date Options */
.date-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.date-option-card {
border: 1px solid var(--border-color);
border-radius: 0.375rem;
transition: all 0.2s ease;
}
.date-option-card:hover {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
.date-option-label {
display: block;
padding: 0.75rem;
cursor: pointer;
margin: 0;
}
.date-option-label input[type="radio"] {
margin-right: 0.5rem;
margin-top: 0.1rem;
width: auto;
}
.date-option-content h4 {
margin: 0 0 0.25rem 0;
color: var(--dark-color);
font-size: 0.9rem;
font-weight: 600;
}
.date-option-content p {
margin: 0 0 0.25rem 0;
color: var(--text-muted);
font-size: 0.8rem;
}
.date-option-content small {
color: var(--primary-color);
font-size: 0.75rem;
font-weight: 500;
}
.date-option-card input[type="radio"]:checked + .date-option-content h4 {
color: var(--primary-color);
}
.date-option-card:has(input[type="radio"]:checked) {
border-color: var(--primary-color);
background-color: rgba(0, 123, 255, 0.03);
}
/* Responsive */
@media (max-width: 768px) {
.content-header {
flex-direction: column;
align-items: stretch;
}
.content-controls {
justify-content: center;
}
.search-box input {
width: 200px;
}
.nav-tabs {
flex-direction: column;
gap: 0.25rem;
}
.data-table {
font-size: 0.8rem;
}
.data-table th,
.data-table td {
padding: 0.5rem 0.25rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.tools-grid {
grid-template-columns: 1fr;
}
}
/* Utility classes */
.text-center { text-align: center; }
.text-muted { color: var(--text-muted); }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.d-none { display: none; }
.d-block { display: block; }
.d-flex { display: flex; }
.justify-content-between { justify-content: space-between; }
.align-items-center { align-items: center; }
+401
View File
@@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFOGuard - Database Management</title>
<link rel="stylesheet" href="/static/css/styles.css">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="app-container">
<!-- Header -->
<header class="app-header">
<div class="header-content">
<h1><i class="fas fa-shield-alt"></i> NFOGuard</h1>
<p>Database Management & Reporting</p>
</div>
<nav class="nav-tabs">
<button class="nav-tab active" data-tab="dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
</button>
<button class="nav-tab" data-tab="movies">
<i class="fas fa-film"></i> Movies
</button>
<button class="nav-tab" data-tab="tv">
<i class="fas fa-tv"></i> TV Series
</button>
<button class="nav-tab" data-tab="reports">
<i class="fas fa-chart-bar"></i> Reports
</button>
<button class="nav-tab" data-tab="tools">
<i class="fas fa-tools"></i> Tools
</button>
</nav>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Dashboard Tab -->
<div class="tab-content active" id="dashboard">
<div class="dashboard-grid">
<div class="stat-card">
<div class="stat-icon movies">
<i class="fas fa-film"></i>
</div>
<div class="stat-info">
<h3 id="movies-total">-</h3>
<p>Total Movies</p>
<small id="movies-with-dates">- with dates</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon tv">
<i class="fas fa-tv"></i>
</div>
<div class="stat-info">
<h3 id="series-total">-</h3>
<p>TV Series</p>
<small id="episodes-total">- episodes</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon missing">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="stat-info">
<h3 id="missing-dates-total">-</h3>
<p>Missing Dates</p>
<small id="no-valid-source-total">- no valid source</small>
</div>
</div>
<div class="stat-card">
<div class="stat-icon activity">
<i class="fas fa-history"></i>
</div>
<div class="stat-info">
<h3 id="recent-activity">-</h3>
<p>Recent Activity</p>
<small>Last 7 days</small>
</div>
</div>
</div>
<div class="dashboard-charts">
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Movie Sources</h3>
<div id="movie-sources-chart" class="chart-container"></div>
</div>
<div class="chart-card">
<h3><i class="fas fa-chart-pie"></i> Episode Sources</h3>
<div id="episode-sources-chart" class="chart-container"></div>
</div>
</div>
</div>
<!-- Movies Tab -->
<div class="tab-content" id="movies">
<div class="content-header">
<h2><i class="fas fa-film"></i> Movies Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="movies-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="movies-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="movies-filter-date">
<option value="">All Movies</option>
<option value="true">With Dates</option>
<option value="false">Missing Dates</option>
</select>
<select id="movies-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshMovies()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table" id="movies-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Movie Released</th>
<th>Date Added to Library</th>
<th>Source</th>
<th>Date Type</th>
<th>Video File</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="movies-tbody">
<tr>
<td colspan="8" class="loading">Loading movies...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="movies-pagination"></div>
</div>
<!-- TV Series Tab -->
<div class="tab-content" id="tv">
<div class="content-header">
<h2><i class="fas fa-tv"></i> TV Series Database</h2>
<div class="content-controls">
<div class="search-controls">
<div class="search-box">
<i class="fas fa-search"></i>
<input type="text" id="series-search" placeholder="Search title/path...">
</div>
<div class="search-box">
<i class="fas fa-hashtag"></i>
<input type="text" id="series-imdb-search" placeholder="Search IMDb ID...">
</div>
</div>
<div class="filter-controls">
<select id="series-filter-date">
<option value="">All Series</option>
<option value="complete">Fully Dated</option>
<option value="incomplete">Missing Dates</option>
<option value="none">No Dates</option>
</select>
<select id="series-filter-source">
<option value="">All Sources</option>
</select>
<button class="btn btn-primary" onclick="refreshSeries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
</div>
</div>
<div class="table-container">
<table class="data-table" id="series-table">
<thead>
<tr>
<th>Series Title</th>
<th>IMDb ID</th>
<th>Episodes</th>
<th>With Dates</th>
<th>With Video</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="series-tbody">
<tr>
<td colspan="6" class="loading">Loading series...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="series-pagination"></div>
</div>
<!-- Reports Tab -->
<div class="tab-content" id="reports">
<div class="content-header">
<h2><i class="fas fa-chart-bar"></i> Missing Dates Report</h2>
<div class="content-controls">
<button class="btn btn-primary" onclick="refreshReport()">
<i class="fas fa-sync"></i> Refresh Report
</button>
</div>
</div>
<div class="report-summary" id="report-summary">
<div class="summary-card">
<h3>Movies</h3>
<p><span id="report-movies-with">-</span> with dates</p>
<p><span id="report-movies-missing">-</span> missing dates</p>
</div>
<div class="summary-card">
<h3>Episodes</h3>
<p><span id="report-episodes-with">-</span> with dates</p>
<p><span id="report-episodes-missing">-</span> missing dates</p>
</div>
</div>
<div class="report-content">
<div class="report-section">
<h3><i class="fas fa-film"></i> Movies Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>IMDb ID</th>
<th>Released</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-movies-tbody">
<tr>
<td colspan="5" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="report-section">
<h3><i class="fas fa-tv"></i> Episodes Missing Dates</h3>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Series</th>
<th>Episode</th>
<th>IMDb ID</th>
<th>Aired</th>
<th>Source</th>
<th>Smart Fix</th>
</tr>
</thead>
<tbody id="report-episodes-tbody">
<tr>
<td colspan="6" class="loading">Loading report...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Tools Tab -->
<div class="tab-content" id="tools">
<div class="content-header">
<h2><i class="fas fa-tools"></i> Database Tools</h2>
</div>
<div class="tools-grid">
<div class="tool-card">
<h3><i class="fas fa-exchange-alt"></i> Bulk Source Update</h3>
<p>Change source for multiple items at once</p>
<form id="bulk-update-form">
<div class="form-group">
<label>Media Type:</label>
<select id="bulk-media-type" required>
<option value="">Select type...</option>
<option value="movies">Movies</option>
<option value="episodes">Episodes</option>
</select>
</div>
<div class="form-group">
<label>From Source:</label>
<input type="text" id="bulk-old-source" placeholder="e.g., no_valid_date_source" required>
</div>
<div class="form-group">
<label>To Source:</label>
<select id="bulk-new-source" required>
<option value="">Select new source...</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="manual">Manual</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
</select>
</div>
<button type="submit" class="btn btn-warning">
<i class="fas fa-exchange-alt"></i> Update Sources
</button>
</form>
</div>
<div class="tool-card">
<h3><i class="fas fa-database"></i> Database Statistics</h3>
<p>View detailed database information</p>
<div class="stats-display" id="detailed-stats">
<p>Click refresh to load detailed statistics</p>
</div>
<button class="btn btn-secondary" onclick="loadDetailedStats()">
<i class="fas fa-sync"></i> Refresh Stats
</button>
</div>
</div>
</div>
</main>
</div>
<!-- Smart Fix Modal -->
<div class="modal" id="smart-fix-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="smart-fix-title">Choose Date Source</h3>
<button class="modal-close" onclick="closeSmartFixModal()">&times;</button>
</div>
<div class="modal-body">
<div id="smart-fix-content">
<p>Loading available options...</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeSmartFixModal()">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal" id="edit-modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Edit Entry</h3>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<form id="edit-form">
<input type="hidden" id="edit-imdb-id">
<input type="hidden" id="edit-season">
<input type="hidden" id="edit-episode">
<input type="hidden" id="edit-media-type">
<div class="form-group">
<label for="edit-dateadded">Date Added:</label>
<input type="datetime-local" id="edit-dateadded">
<small>Leave empty to clear date</small>
</div>
<div class="form-group">
<label for="edit-source">Source:</label>
<select id="edit-source" required>
<option value="manual">Manual</option>
<option value="airdate">Air Date</option>
<option value="digital_release">Digital Release</option>
<option value="radarr:db.history.import">Radarr Import</option>
<option value="sonarr:history.import">Sonarr Import</option>
<option value="no_valid_date_source">No Valid Source</option>
</select>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Toast Notifications -->
<div class="toast-container" id="toast-container"></div>
<script src="/static/js/app.js"></script>
</body>
</html>
+1378
View File
File diff suppressed because it is too large Load Diff
View File
+468
View File
@@ -0,0 +1,468 @@
"""
Async file utilities for NFOGuard
High-performance async file operations with concurrent processing
"""
import asyncio
import aiofiles
import aiofiles.os
from pathlib import Path
from typing import List, Dict, Optional, Tuple, Any, Set
import xml.etree.ElementTree as ET
from datetime import datetime
from utils.logging import _log
from utils.exceptions import FileOperationError, NFOCreationError
from utils.file_utils import VIDEO_EXTENSIONS, extract_episode_info, extract_imdb_id_from_path
from utils.nfo_patterns import parse_nfo_with_tolerance, write_nfo_file
async def async_read_text_file(file_path: Path, encoding: str = 'utf-8') -> Optional[str]:
"""
Async read text file with error handling
Args:
file_path: Path to file to read
encoding: Text encoding (default: utf-8)
Returns:
File content as string or None if error
"""
try:
async with aiofiles.open(file_path, 'r', encoding=encoding, errors='ignore') as f:
return await f.read()
except Exception as e:
_log("WARNING", f"Failed to read file {file_path}: {e}")
return None
async def async_write_text_file(file_path: Path, content: str, encoding: str = 'utf-8') -> bool:
"""
Async write text file with error handling
Args:
file_path: Path to file to write
content: Content to write
encoding: Text encoding (default: utf-8)
Returns:
True if successful, False otherwise
"""
try:
# Ensure parent directory exists
await aiofiles.os.makedirs(file_path.parent, exist_ok=True)
async with aiofiles.open(file_path, 'w', encoding=encoding) as f:
await f.write(content)
return True
except Exception as e:
_log("ERROR", f"Failed to write file {file_path}: {e}")
return False
async def async_file_exists(file_path: Path) -> bool:
"""
Async check if file exists
Args:
file_path: Path to check
Returns:
True if file exists, False otherwise
"""
try:
return await aiofiles.os.path.exists(file_path)
except Exception:
return False
async def async_get_file_mtime(file_path: Path) -> Optional[float]:
"""
Async get file modification time
Args:
file_path: Path to file
Returns:
Modification time as timestamp or None if error
"""
try:
stat_result = await aiofiles.os.stat(file_path)
return stat_result.st_mtime
except Exception:
return None
async def async_set_file_mtime(file_path: Path, mtime: float) -> bool:
"""
Async set file modification time
Args:
file_path: Path to file
mtime: New modification time as timestamp
Returns:
True if successful, False otherwise
"""
try:
await aiofiles.os.utime(file_path, (mtime, mtime))
return True
except Exception as e:
_log("WARNING", f"Failed to set mtime for {file_path}: {e}")
return False
async def async_find_video_files(directory: Path, recursive: bool = True) -> List[Path]:
"""
Async find all video files in a directory
Args:
directory: Directory to search
recursive: Whether to search recursively
Returns:
List of video file paths
"""
if not await async_file_exists(directory):
return []
video_files = []
try:
if recursive:
# Use os.walk equivalent for async
async def _walk_directory(path: Path):
try:
entries = await aiofiles.os.listdir(path)
for entry in entries:
entry_path = path / entry
if await aiofiles.os.path.isfile(entry_path):
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(entry_path)
elif await aiofiles.os.path.isdir(entry_path):
await _walk_directory(entry_path)
except Exception as e:
_log("WARNING", f"Failed to scan directory {path}: {e}")
await _walk_directory(directory)
else:
# Non-recursive scan
try:
entries = await aiofiles.os.listdir(directory)
for entry in entries:
entry_path = directory / entry
if await aiofiles.os.path.isfile(entry_path):
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(entry_path)
except Exception as e:
_log("WARNING", f"Failed to scan directory {directory}: {e}")
except Exception as e:
_log("ERROR", f"Failed to find video files in {directory}: {e}")
return video_files
async def async_find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""
Async find all episodes on disk with concurrent processing
Args:
series_path: Path to series directory
Returns:
Dictionary mapping (season, episode) tuples to lists of video files
"""
episodes = {}
if not await async_file_exists(series_path):
return episodes
# Get all video files concurrently
video_files = await async_find_video_files(series_path, recursive=True)
# Process files to extract episode information
for video_file in video_files:
episode_info = extract_episode_info(video_file.name)
if episode_info:
season, episode = episode_info["season"], episode_info["episode"]
key = (season, episode)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
return episodes
async def async_read_nfo_file(nfo_path: Path) -> Optional[ET.Element]:
"""
Async read and parse NFO file
Args:
nfo_path: Path to NFO file
Returns:
XML root element if successful, None otherwise
"""
if not await async_file_exists(nfo_path):
return None
try:
content = await async_read_text_file(nfo_path)
if not content:
return None
# Parse XML content
try:
root = ET.fromstring(content)
return root
except ET.ParseError:
# Try with tolerance (sync operation for now)
return parse_nfo_with_tolerance(nfo_path)
except Exception as e:
_log("ERROR", f"Failed to read NFO file {nfo_path}: {e}")
return None
async def async_write_nfo_file(
nfo_path: Path,
root: ET.Element,
lock_metadata: bool = True
) -> bool:
"""
Async write NFO XML content to file
Args:
nfo_path: Path where to write the NFO file
root: XML root element to write
lock_metadata: Whether to add file locking attributes
Returns:
True if successful, False otherwise
"""
try:
# Ensure parent directory exists
await aiofiles.os.makedirs(nfo_path.parent, exist_ok=True)
# Add file locking if requested
if lock_metadata:
root.set('nfoguard_managed', 'true')
root.set('last_updated', datetime.now().isoformat())
# Create tree and format
tree = ET.ElementTree(root)
ET.indent(tree, space=" ", level=0) # Pretty formatting
# Convert to string
xml_str = ET.tostring(root, encoding='unicode', xml_declaration=False)
xml_content = f'<?xml version="1.0" encoding="utf-8"?>\n{xml_str}'
# Write asynchronously
success = await async_write_text_file(nfo_path, xml_content)
if success:
_log("DEBUG", f"Successfully wrote NFO file: {nfo_path}")
return success
except Exception as e:
_log("ERROR", f"Failed to write NFO file {nfo_path}: {e}")
return False
async def async_batch_process_files(
file_paths: List[Path],
process_func,
max_concurrent: int = 10,
progress_callback: Optional[callable] = None
) -> List[Any]:
"""
Process multiple files concurrently with controlled concurrency
Args:
file_paths: List of file paths to process
process_func: Async function to process each file
max_concurrent: Maximum number of concurrent operations
progress_callback: Optional callback for progress updates
Returns:
List of results from processing each file
"""
semaphore = asyncio.Semaphore(max_concurrent)
results = []
async def _process_with_semaphore(file_path: Path, index: int) -> Any:
async with semaphore:
try:
result = await process_func(file_path)
if progress_callback:
progress_callback(index + 1, len(file_paths), file_path)
return result
except Exception as e:
_log("ERROR", f"Failed to process {file_path}: {e}")
return None
# Create tasks for all files
tasks = [
_process_with_semaphore(file_path, i)
for i, file_path in enumerate(file_paths)
]
# Execute all tasks concurrently with controlled concurrency
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
async def async_batch_nfo_operations(
nfo_operations: List[Dict[str, Any]],
max_concurrent: int = 5
) -> List[bool]:
"""
Batch NFO operations (read/write) with controlled concurrency
Args:
nfo_operations: List of operation dictionaries with 'type', 'path', and other params
max_concurrent: Maximum number of concurrent operations
Returns:
List of success/failure results
"""
async def _execute_nfo_operation(operation: Dict[str, Any]) -> bool:
try:
op_type = operation.get('type')
path = operation.get('path')
if op_type == 'read':
result = await async_read_nfo_file(path)
return result is not None
elif op_type == 'write':
root = operation.get('root')
lock_metadata = operation.get('lock_metadata', True)
return await async_write_nfo_file(path, root, lock_metadata)
else:
_log("ERROR", f"Unknown NFO operation type: {op_type}")
return False
except Exception as e:
_log("ERROR", f"Failed to execute NFO operation: {e}")
return False
return await async_batch_process_files(
[op.get('path') for op in nfo_operations],
lambda path: _execute_nfo_operation(next(op for op in nfo_operations if op.get('path') == path)),
max_concurrent
)
async def async_concurrent_episode_processing(
episodes_data: List[Dict[str, Any]],
process_episode_func,
max_concurrent: int = 3
) -> List[Any]:
"""
Process multiple episodes concurrently
Args:
episodes_data: List of episode data dictionaries
process_episode_func: Async function to process each episode
max_concurrent: Maximum number of concurrent episode processes
Returns:
List of processing results
"""
semaphore = asyncio.Semaphore(max_concurrent)
async def _process_episode_with_semaphore(episode_data: Dict[str, Any]) -> Any:
async with semaphore:
try:
return await process_episode_func(episode_data)
except Exception as e:
_log("ERROR", f"Failed to process episode {episode_data}: {e}")
return None
# Create tasks for all episodes
tasks = [
_process_episode_with_semaphore(episode_data)
for episode_data in episodes_data
]
# Execute all tasks concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
async def async_directory_scan_with_stats(
directories: List[Path],
file_extensions: Optional[Set[str]] = None
) -> Dict[str, Any]:
"""
Async scan multiple directories and gather statistics
Args:
directories: List of directories to scan
file_extensions: Optional set of file extensions to filter by
Returns:
Dictionary with scan statistics and file lists
"""
if file_extensions is None:
file_extensions = VIDEO_EXTENSIONS
stats = {
'total_files': 0,
'total_directories': len(directories),
'files_by_directory': {},
'scan_errors': [],
'total_size_bytes': 0
}
async def _scan_single_directory(directory: Path) -> Dict[str, Any]:
dir_stats = {
'path': str(directory),
'files': [],
'file_count': 0,
'size_bytes': 0,
'error': None
}
try:
if not await async_file_exists(directory):
dir_stats['error'] = 'Directory does not exist'
return dir_stats
files = await async_find_video_files(directory, recursive=True)
dir_stats['files'] = [str(f) for f in files]
dir_stats['file_count'] = len(files)
# Calculate total size
for file_path in files:
try:
stat_result = await aiofiles.os.stat(file_path)
dir_stats['size_bytes'] += stat_result.st_size
except Exception:
pass # Skip files we can't stat
except Exception as e:
dir_stats['error'] = str(e)
stats['scan_errors'].append(f"{directory}: {e}")
return dir_stats
# Scan all directories concurrently
directory_results = await asyncio.gather(
*[_scan_single_directory(directory) for directory in directories],
return_exceptions=True
)
# Aggregate results
for result in directory_results:
if isinstance(result, dict) and not result.get('error'):
stats['files_by_directory'][result['path']] = result
stats['total_files'] += result['file_count']
stats['total_size_bytes'] += result['size_bytes']
return stats
+284
View File
@@ -0,0 +1,284 @@
"""
Error handling utilities for NFOGuard
Provides structured error handling, retry mechanisms, and error reporting
"""
import time
import functools
from typing import Callable, Optional, Type, Union, List, Any
from pathlib import Path
from utils.logging import _log
from utils.exceptions import (
NFOGuardException,
RetryableError,
NetworkRetryableError,
TemporaryFileError,
ExternalAPIError,
FileOperationError
)
def with_error_handling(
operation_name: str,
log_errors: bool = True,
reraise: bool = True,
fallback_value: Any = None
):
"""
Decorator for standardized error handling
Args:
operation_name: Name of the operation for logging
log_errors: Whether to log errors automatically
reraise: Whether to reraise exceptions after logging
fallback_value: Value to return if error occurs and reraise=False
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except NFOGuardException as e:
if log_errors:
_log("ERROR", f"{operation_name} failed: {e.message}")
if e.details:
_log("DEBUG", f"{operation_name} error details: {e.details}")
if reraise:
raise
return fallback_value
except Exception as e:
if log_errors:
_log("ERROR", f"{operation_name} failed with unexpected error: {e}")
if reraise:
# Wrap unexpected errors in our custom exception
raise NFOGuardException(
f"{operation_name} failed: {str(e)}",
{"original_error": str(e), "error_type": type(e).__name__}
)
return fallback_value
return wrapper
return decorator
def with_retry(
max_attempts: int = 3,
delay: float = 1.0,
backoff_factor: float = 2.0,
retry_on: Union[Type[Exception], List[Type[Exception]]] = None
):
"""
Decorator for retry logic on retryable errors
Args:
max_attempts: Maximum number of retry attempts
delay: Initial delay between retries in seconds
backoff_factor: Factor to multiply delay by after each attempt
retry_on: Exception types to retry on (defaults to RetryableError)
"""
if retry_on is None:
retry_on = [RetryableError]
elif not isinstance(retry_on, list):
retry_on = [retry_on]
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
current_delay = delay
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
last_exception = e
# Check if this is a retryable error
should_retry = any(isinstance(e, exc_type) for exc_type in retry_on)
if not should_retry or attempt == max_attempts - 1:
# Don't retry or max attempts reached
raise
# Use custom retry delay if available
retry_delay = current_delay
if isinstance(e, RetryableError) and e.retry_after:
retry_delay = e.retry_after
_log("WARNING", f"Attempt {attempt + 1}/{max_attempts} failed: {e}. Retrying in {retry_delay}s...")
time.sleep(retry_delay)
current_delay *= backoff_factor
# This should never be reached, but just in case
raise last_exception
return wrapper
return decorator
def safe_file_operation(
operation: str,
file_path: Union[str, Path],
operation_func: Callable,
*args,
**kwargs
) -> Any:
"""
Safely perform file operations with error handling
Args:
operation: Description of the operation
file_path: Path to the file being operated on
operation_func: Function to execute
*args, **kwargs: Arguments to pass to operation_func
Returns:
Result of operation_func or None if error
Raises:
FileOperationError: If file operation fails
"""
try:
return operation_func(*args, **kwargs)
except PermissionError as e:
raise FileOperationError(operation, str(file_path), f"Permission denied: {e}")
except FileNotFoundError as e:
raise FileOperationError(operation, str(file_path), f"File not found: {e}")
except OSError as e:
# Check if this might be a temporary error
if e.errno in [28, 122]: # No space left, quota exceeded
raise TemporaryFileError(str(file_path), operation, f"Disk space issue: {e}")
raise FileOperationError(operation, str(file_path), f"OS error: {e}")
except Exception as e:
raise FileOperationError(operation, str(file_path), f"Unexpected error: {e}")
def safe_api_call(
api_name: str,
operation: str,
api_func: Callable,
*args,
**kwargs
) -> Any:
"""
Safely perform API calls with error handling
Args:
api_name: Name of the API (e.g., "Sonarr", "TMDB")
operation: Description of the operation
api_func: Function to execute
*args, **kwargs: Arguments to pass to api_func
Returns:
Result of api_func
Raises:
ExternalAPIError: If API call fails
NetworkRetryableError: If network error that can be retried
"""
try:
return api_func(*args, **kwargs)
except ConnectionError as e:
raise NetworkRetryableError(f"{api_name} API", f"Connection error: {e}")
except TimeoutError as e:
raise NetworkRetryableError(f"{api_name} API", f"Timeout error: {e}")
except Exception as e:
# Check if it's an HTTP error with status code
status_code = getattr(e, 'status_code', None) or getattr(e, 'response', {}).get('status_code')
response_text = getattr(e, 'text', None) or str(e)
# Retry on certain HTTP status codes
if status_code in [429, 502, 503, 504]: # Rate limit, bad gateway, service unavailable, gateway timeout
raise NetworkRetryableError(f"{api_name} API", f"HTTP {status_code}: {response_text}")
raise ExternalAPIError(api_name, operation, status_code, response_text)
def log_structured_error(error: NFOGuardException, context: Optional[str] = None) -> None:
"""
Log structured error information
Args:
error: NFOGuardException to log
context: Additional context for the error
"""
error_dict = error.to_dict()
if context:
error_dict['context'] = context
_log("ERROR", f"Structured error: {error.message}")
_log("DEBUG", f"Error details: {error_dict}")
def create_error_response(error: NFOGuardException, include_details: bool = False) -> dict:
"""
Create standardized error response for API endpoints
Args:
error: NFOGuardException to convert
include_details: Whether to include detailed error information
Returns:
Dictionary suitable for JSON response
"""
response = {
"status": "error",
"error_type": error.__class__.__name__,
"message": error.message
}
if include_details and error.details:
response["details"] = error.details
return response
def validate_required_config(config_dict: dict, required_keys: List[str]) -> None:
"""
Validate that required configuration keys are present and not empty
Args:
config_dict: Configuration dictionary to validate
required_keys: List of required configuration keys
Raises:
ConfigurationError: If required configuration is missing or invalid
"""
from utils.exceptions import ConfigurationError
missing_keys = []
empty_keys = []
for key in required_keys:
if key not in config_dict:
missing_keys.append(key)
elif not config_dict[key]:
empty_keys.append(key)
if missing_keys:
raise ConfigurationError(
"missing_required_config",
f"Missing required configuration keys: {missing_keys}",
{"missing_keys": missing_keys}
)
if empty_keys:
raise ConfigurationError(
"empty_required_config",
f"Required configuration keys are empty: {empty_keys}",
{"empty_keys": empty_keys}
)
class ErrorContext:
"""Context manager for adding context to errors"""
def __init__(self, context: str):
self.context = context
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type and issubclass(exc_type, NFOGuardException):
exc_val.details = exc_val.details or {}
exc_val.details['error_context'] = self.context
return False # Don't suppress the exception
+172
View File
@@ -0,0 +1,172 @@
"""
Custom exceptions for NFOGuard
Provides structured error handling and better error reporting
"""
from typing import Optional, Dict, Any
class NFOGuardException(Exception):
"""Base exception for all NFOGuard errors"""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
super().__init__(message)
self.message = message
self.details = details or {}
def to_dict(self) -> Dict[str, Any]:
"""Convert exception to dictionary for structured logging"""
return {
"error_type": self.__class__.__name__,
"message": self.message,
"details": self.details
}
class MediaPathNotFoundError(NFOGuardException):
"""Raised when media directory cannot be found"""
def __init__(self, media_type: str, title: str, imdb_id: Optional[str] = None, search_paths: Optional[list] = None):
details = {
"media_type": media_type,
"title": title,
"imdb_id": imdb_id,
"search_paths": [str(p) for p in (search_paths or [])]
}
message = f"{media_type.title()} directory not found: {title}"
if imdb_id:
message += f" (IMDb: {imdb_id})"
super().__init__(message, details)
class IMDbIDNotFoundError(NFOGuardException):
"""Raised when IMDb ID cannot be extracted from path or files"""
def __init__(self, path: str, media_type: str = "media"):
details = {
"path": path,
"media_type": media_type
}
message = f"No IMDb ID found for {media_type}: {path}"
super().__init__(message, details)
class WebhookProcessingError(NFOGuardException):
"""Raised when webhook processing fails"""
def __init__(self, webhook_type: str, reason: str, payload: Optional[Dict] = None):
details = {
"webhook_type": webhook_type,
"reason": reason,
"payload": payload
}
message = f"{webhook_type} webhook processing failed: {reason}"
super().__init__(message, details)
class ExternalAPIError(NFOGuardException):
"""Raised when external API calls fail"""
def __init__(self, api_name: str, operation: str, status_code: Optional[int] = None, response: Optional[str] = None):
details = {
"api_name": api_name,
"operation": operation,
"status_code": status_code,
"response": response
}
message = f"{api_name} API error during {operation}"
if status_code:
message += f" (HTTP {status_code})"
super().__init__(message, details)
class DatabaseError(NFOGuardException):
"""Raised when database operations fail"""
def __init__(self, operation: str, table: Optional[str] = None, original_error: Optional[Exception] = None):
details = {
"operation": operation,
"table": table,
"original_error": str(original_error) if original_error else None
}
message = f"Database error during {operation}"
if table:
message += f" on table {table}"
super().__init__(message, details)
class NFOCreationError(NFOGuardException):
"""Raised when NFO file creation fails"""
def __init__(self, nfo_path: str, reason: str, media_type: str = "media"):
details = {
"nfo_path": nfo_path,
"reason": reason,
"media_type": media_type
}
message = f"Failed to create {media_type} NFO file: {reason}"
super().__init__(message, details)
class ConfigurationError(NFOGuardException):
"""Raised when configuration is invalid or missing"""
def __init__(self, setting: str, reason: str, current_value: Optional[Any] = None):
details = {
"setting": setting,
"reason": reason,
"current_value": current_value
}
message = f"Configuration error for {setting}: {reason}"
super().__init__(message, details)
class FileOperationError(NFOGuardException):
"""Raised when file operations fail"""
def __init__(self, operation: str, file_path: str, reason: str):
details = {
"operation": operation,
"file_path": file_path,
"reason": reason
}
message = f"File {operation} failed for {file_path}: {reason}"
super().__init__(message, details)
class DateProcessingError(NFOGuardException):
"""Raised when date processing or parsing fails"""
def __init__(self, date_value: str, operation: str, media_type: str = "media"):
details = {
"date_value": date_value,
"operation": operation,
"media_type": media_type
}
message = f"Date processing error during {operation} for {media_type}: {date_value}"
super().__init__(message, details)
class RetryableError(NFOGuardException):
"""Base class for errors that can be retried"""
def __init__(self, message: str, details: Optional[Dict[str, Any]] = None, retry_after: Optional[int] = None):
super().__init__(message, details)
self.retry_after = retry_after # Seconds to wait before retry
class NetworkRetryableError(RetryableError):
"""Network errors that can be retried"""
def __init__(self, url: str, reason: str, retry_after: Optional[int] = 30):
details = {"url": url, "reason": reason}
message = f"Network error for {url}: {reason}"
super().__init__(message, details, retry_after)
class TemporaryFileError(RetryableError):
"""Temporary file system errors that can be retried"""
def __init__(self, file_path: str, operation: str, reason: str, retry_after: Optional[int] = 5):
details = {"file_path": file_path, "operation": operation, "reason": reason}
message = f"Temporary file error during {operation} for {file_path}: {reason}"
super().__init__(message, details, retry_after)
+277
View File
@@ -0,0 +1,277 @@
"""
File utility functions for NFOGuard
Common file operations to eliminate code duplication
"""
import glob
import re
from pathlib import Path
from typing import Optional, List, Dict, Tuple, Union
from utils.logging import _log
# Video file extensions used throughout the application
VIDEO_EXTENSIONS = {'.mkv', '.mp4', '.avi', '.m4v', '.mov', '.ts'}
# Episode pattern for TV series files
EPISODE_PATTERN = re.compile(
r'.*[sS](\d{1,2})[eE](\d{1,3}).*|.*(\d{1,2})x(\d{1,3}).*'
)
def find_media_path_by_imdb_and_title(
title: str,
imdb_id: str,
search_paths: List[Path],
webhook_path: Optional[str] = None,
path_mapper = None
) -> Optional[Path]:
"""
Unified media path finder for both TV series and movies
Args:
title: Media title to search for
imdb_id: IMDb ID to search for
search_paths: List of paths to search in (tv_paths or movie_paths)
webhook_path: Optional webhook path to try first
path_mapper: Optional path mapper for webhook path conversion
Returns:
Path to media directory if found, None otherwise
"""
# Try webhook path first if provided
if webhook_path and path_mapper:
try:
if hasattr(path_mapper, 'sonarr_path_to_container_path'):
container_path = path_mapper.sonarr_path_to_container_path(webhook_path)
elif hasattr(path_mapper, 'radarr_path_to_container_path'):
container_path = path_mapper.radarr_path_to_container_path(webhook_path)
else:
container_path = webhook_path
path_obj = Path(container_path)
if path_obj.exists():
return path_obj
except Exception as e:
_log("WARNING", f"Failed to process webhook path {webhook_path}: {e}")
# Search by IMDb ID or title in configured paths
for media_path in search_paths:
if not media_path.exists():
continue
# Search by IMDb ID first (more reliable)
if imdb_id:
# Use proper glob pattern - escape brackets to match literal [imdb-ID]
pattern = str(media_path / f"*\\[imdb-{imdb_id}\\]*")
matches = glob.glob(pattern)
if matches:
return Path(matches[0])
# Search by title as fallback
if title:
title_clean = clean_title_for_search(title)
for item in media_path.iterdir():
if item.is_dir() and "[imdb-" in item.name.lower():
item_clean = clean_title_for_search(item.name)
if title_clean in item_clean:
return item
return None
def clean_title_for_search(title: str) -> str:
"""
Clean title for fuzzy matching
Args:
title: Raw title string
Returns:
Cleaned title for comparison
"""
return title.lower().replace(" ", "").replace("-", "").replace(".", "")
def find_video_files(directory: Path, recursive: bool = True) -> List[Path]:
"""
Find all video files in a directory
Args:
directory: Directory to search
recursive: Whether to search recursively
Returns:
List of video file paths
"""
if not directory.exists():
return []
video_files = []
if recursive:
for item in directory.rglob('*'):
if item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(item)
else:
for item in directory.iterdir():
if item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS:
video_files.append(item)
return video_files
def extract_episode_info(filename: str) -> Optional[Tuple[int, int]]:
"""
Extract season and episode numbers from filename
Args:
filename: Video filename to parse
Returns:
Tuple of (season, episode) if found, None otherwise
"""
match = EPISODE_PATTERN.match(filename)
if not match:
return None
if match.group(1) and match.group(2): # SxxExx format
season = int(match.group(1))
episode = int(match.group(2))
elif match.group(3) and match.group(4): # NxNN format
season = int(match.group(3))
episode = int(match.group(4))
else:
return None
return (season, episode)
def find_episodes_on_disk(series_path: Path) -> Dict[Tuple[int, int], List[Path]]:
"""
Find all episodes on disk and return mapping of (season, episode) -> [video_files]
Args:
series_path: Path to series directory
Returns:
Dictionary mapping (season, episode) tuples to lists of video files
"""
episodes = {}
if not series_path.exists():
return episodes
for video_file in find_video_files(series_path, recursive=True):
episode_info = extract_episode_info(video_file.name)
if episode_info:
season, episode = episode_info
key = (season, episode)
if key not in episodes:
episodes[key] = []
episodes[key].append(video_file)
return episodes
def extract_title_from_directory_name(directory_name: str) -> Optional[str]:
"""
Extract clean title from directory name, removing year and IMDb ID
Args:
directory_name: Directory name to parse
Returns:
Cleaned title or None if no title found
"""
name = directory_name
# Remove IMDb ID part: [imdb-ttXXXXXX] or [ttXXXXXX]
name = re.sub(r'\s*\[imdb-?tt\d+\]', '', name, flags=re.IGNORECASE)
name = re.sub(r'\s*\[tt\d+\]', '', name, flags=re.IGNORECASE)
# Remove year in parentheses: (YYYY)
name = re.sub(r'\s*\(\d{4}\)', '', name)
# Clean up extra spaces
name = ' '.join(name.split())
return name.strip() if name.strip() else None
def extract_imdb_id_from_path(path: Union[str, Path]) -> Optional[str]:
"""
Extract IMDb ID from directory or file path
Args:
path: Path to examine (string or Path object)
Returns:
IMDb ID if found, None otherwise
"""
path_str = str(path)
# Look for [imdb-ttXXXXXX] or [ttXXXXXX] patterns
patterns = [
r'\[imdb-(tt\d+)\]', # [imdb-tt1234567]
r'\[(tt\d+)\]', # [tt1234567]
r'imdb[_-]?(tt\d+)', # imdb_tt1234567 or imdb-tt1234567
r'(tt\d{7,})', # standalone tt1234567 (7+ digits)
]
for pattern in patterns:
match = re.search(pattern, path_str, re.IGNORECASE)
if match:
imdb_id = match.group(1)
# Ensure it starts with 'tt'
if not imdb_id.startswith('tt'):
imdb_id = f'tt{imdb_id}'
return imdb_id
return None
def is_video_file(file_path: Path) -> bool:
"""
Check if a file is a video file based on extension
Args:
file_path: Path to check
Returns:
True if it's a video file, False otherwise
"""
return file_path.suffix.lower() in VIDEO_EXTENSIONS
def safe_directory_scan(directory: Path, pattern: str = "*") -> List[Path]:
"""
Safely scan directory with error handling
Args:
directory: Directory to scan
pattern: Glob pattern to match
Returns:
List of matching paths, empty list if scan fails
"""
try:
if not directory.exists():
return []
return list(directory.glob(pattern))
except (PermissionError, OSError) as e:
_log("WARNING", f"Failed to scan directory {directory}: {e}")
return []
def normalize_path_separators(path: str) -> str:
"""
Normalize path separators for cross-platform compatibility
Args:
path: Path string to normalize
Returns:
Normalized path string
"""
return str(Path(path).as_posix())
+170
View File
@@ -0,0 +1,170 @@
"""
Logging utilities for NFOGuard
"""
import os
import re
import logging
import logging.handlers
from pathlib import Path
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
class TimezoneAwareFormatter(logging.Formatter):
"""Formatter that respects the container timezone"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.timezone = self._get_local_timezone()
def _get_local_timezone(self):
"""Get the local timezone, respecting TZ environment variable"""
tz_name = os.environ.get('TZ', 'UTC')
try:
# Try zoneinfo first (Python 3.9+)
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 formatTime(self, record, datefmt=None):
dt = datetime.fromtimestamp(record.created, tz=self.timezone)
if datefmt:
return dt.strftime(datefmt)
return dt.isoformat(timespec='seconds')
def _setup_file_logging():
"""Setup file logging for NFOGuard"""
log_dir = Path(os.environ.get("LOG_DIR", "/app/data/logs"))
log_dir.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger("NFOGuard")
logger.setLevel(logging.DEBUG)
file_handler = logging.handlers.RotatingFileHandler(
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
)
formatter = TimezoneAwareFormatter(
'[%(asctime)s] %(levelname)s: %(message)s'
)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
return logger
def _mask_sensitive_data(msg: str) -> str:
"""Mask API keys and other sensitive data in log messages"""
# List of patterns to mask
sensitive_patterns = [
(r'api_key=([a-zA-Z0-9_\-]+)', r'api_key=***masked***'),
(r'password=([^\s&]+)', r'password=***masked***'),
(r'token=([a-zA-Z0-9_\-]+)', r'token=***masked***'),
(r'key=([a-zA-Z0-9_\-]{8,})', r'key=***masked***'), # Keys longer than 8 chars
(r'([a-zA-Z0-9]{32,})', lambda m: m.group(1)[:8] + '***masked***' if len(m.group(1)) > 16 else m.group(1)) # Long strings likely to be keys
]
masked_msg = msg
for pattern, replacement in sensitive_patterns:
if isinstance(replacement, str):
masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE)
else:
masked_msg = re.sub(pattern, replacement, masked_msg, flags=re.IGNORECASE)
return masked_msg
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+)
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):
"""Enhanced logging that writes to both console and file with sensitive data masking"""
masked_msg = _mask_sensitive_data(msg)
tz = _get_local_timezone()
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {masked_msg}")
try:
file_logger = _setup_file_logging()
getattr(file_logger, level.lower(), file_logger.info)(masked_msg)
except Exception as e:
print(f"File logging error: {e}")
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
def _load_environment_files():
"""Load environment variables from .env and optionally .env.secrets"""
from pathlib import Path
# Try to load from python-dotenv if available
try:
from dotenv import load_dotenv
# Load main .env file
env_file = Path(".env")
if env_file.exists():
load_dotenv(env_file)
_log("INFO", f"Loaded environment from {env_file}")
# Load secrets file if it exists
secrets_file = Path(".env.secrets")
if secrets_file.exists():
load_dotenv(secrets_file)
_log("INFO", f"Loaded secrets from {secrets_file}")
except ImportError:
_log("WARNING", "python-dotenv not available - environment files not loaded")
# Initialize logging and load environment files
_setup_file_logging()
_load_environment_files()
+375
View File
@@ -0,0 +1,375 @@
"""
NFO parsing patterns and utilities for NFOGuard
Consolidates common NFO parsing logic and patterns
"""
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Dict, Any, List
from datetime import datetime
from utils.logging import _log
from utils.exceptions import NFOCreationError, FileOperationError
from utils.validation import validate_imdb_id, validate_date_string
# Common IMDb ID patterns used across the application
IMDB_PATTERNS = [
r'\[imdb-?(tt\d+)\]', # [imdb-tt1234567] or [imdb-1234567]
r'\[(tt\d+)\]', # [tt1234567]
r'\{imdb-?(tt\d+)\}', # {imdb-tt1234567} or {imdb-1234567}
r'\(imdb-?(tt\d+)\)', # (imdb-tt1234567) or (imdb-1234567)
r'[-_\s](tt\d+)$', # tt1234567 at end of string
r'imdb[_-]?(tt\d+)', # imdb_tt1234567 or imdb-tt1234567
]
# Episode filename patterns
EPISODE_PATTERNS = [
r'.*[sS](\d{1,2})[eE](\d{1,3}).*', # S01E01
r'.*(\d{1,2})x(\d{1,3}).*', # 1x01
r'.*[sS](\d{1,2})\.?[eE](\d{1,3}).*', # S01.E01
r'.*Season[_\s]?(\d{1,2})[_\s]?Episode[_\s]?(\d{1,3}).*', # Season 1 Episode 1
]
# Common NFO XML namespaces
NFO_NAMESPACES = {
'kodi': 'http://kodi.tv/moviedb',
'tmdb': 'https://www.themoviedb.org',
'imdb': 'https://www.imdb.com'
}
def extract_imdb_id_from_text(text: str) -> Optional[str]:
"""
Extract IMDb ID from text using consolidated patterns
Args:
text: Text to search for IMDb ID
Returns:
IMDb ID if found, None otherwise
"""
if not text:
return None
text_lower = text.lower()
for pattern in IMDB_PATTERNS:
match = re.search(pattern, text_lower)
if match:
imdb_id = match.group(1)
# Ensure it starts with 'tt'
if not imdb_id.startswith('tt'):
imdb_id = f'tt{imdb_id}'
if validate_imdb_id(imdb_id):
return imdb_id
return None
def extract_episode_info_from_filename(filename: str) -> Optional[Dict[str, int]]:
"""
Extract season and episode information from filename
Args:
filename: Filename to parse
Returns:
Dictionary with 'season' and 'episode' keys if found, None otherwise
"""
for pattern in EPISODE_PATTERNS:
match = re.search(pattern, filename, re.IGNORECASE)
if match:
try:
season = int(match.group(1))
episode = int(match.group(2))
# Validate reasonable ranges
if 0 <= season <= 99 and 1 <= episode <= 999:
return {"season": season, "episode": episode}
except (ValueError, IndexError):
continue
return None
def parse_nfo_with_tolerance(nfo_path: Path) -> Optional[ET.Element]:
"""
Parse NFO file with error tolerance
Args:
nfo_path: Path to NFO file
Returns:
XML root element if successful, None otherwise
"""
if not nfo_path.exists():
return None
try:
# Try normal parsing first
tree = ET.parse(nfo_path)
return tree.getroot()
except ET.ParseError as e:
_log("WARNING", f"NFO parse error for {nfo_path}: {e}. Trying with tolerance...")
try:
# Try reading and cleaning the content
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
# Basic cleanup for common issues
content = content.replace('&', '&amp;') # Fix unescaped ampersands
content = re.sub(r'<(\w+)([^>]*?)(?<!/)>', r'<\1\2/>', content) # Fix unclosed tags
root = ET.fromstring(content)
return root
except Exception as e:
_log("ERROR", f"Failed to parse NFO file {nfo_path} even with tolerance: {e}")
return None
def extract_text_from_nfo_element(root: ET.Element, xpath: str, namespaces: Optional[Dict] = None) -> Optional[str]:
"""
Extract text content from NFO element using XPath
Args:
root: XML root element
xpath: XPath expression
namespaces: Optional namespace dictionary
Returns:
Text content if found, None otherwise
"""
try:
if namespaces:
elements = root.findall(xpath, namespaces)
else:
elements = root.findall(xpath)
if elements and elements[0].text:
return elements[0].text.strip()
except Exception as e:
_log("DEBUG", f"Failed to extract text from XPath {xpath}: {e}")
return None
def extract_imdb_from_nfo_content(root: ET.Element) -> Optional[str]:
"""
Extract IMDb ID from NFO XML content
Args:
root: XML root element
Returns:
IMDb ID if found, None otherwise
"""
# Common XPath patterns for IMDb ID
imdb_xpaths = [
'.//imdb',
'.//imdbid',
'.//id[@type="imdb"]',
'.//uniqueid[@type="imdb"]',
'.//uniqueid[@default="true"]',
'.//id',
'.//uniqueid'
]
for xpath in imdb_xpaths:
imdb_text = extract_text_from_nfo_element(root, xpath)
if imdb_text:
imdb_id = extract_imdb_id_from_text(imdb_text)
if imdb_id:
return imdb_id
# Check in plot/overview text as fallback
plot_xpaths = ['.//plot', './/overview', './/summary']
for xpath in plot_xpaths:
plot_text = extract_text_from_nfo_element(root, xpath)
if plot_text:
imdb_id = extract_imdb_id_from_text(plot_text)
if imdb_id:
return imdb_id
return None
def extract_dates_from_nfo(root: ET.Element) -> Dict[str, Optional[str]]:
"""
Extract various date fields from NFO content
Args:
root: XML root element
Returns:
Dictionary with date fields (premiered, aired, dateadded, etc.)
"""
date_fields = {
'premiered': ['.//premiered', './/releasedate', './/year'],
'aired': ['.//aired', './/firstaired'],
'dateadded': ['.//dateadded', './/added'],
'lastplayed': ['.//lastplayed'],
'filelastmodified': ['.//filelastmodified']
}
result = {}
for field_name, xpaths in date_fields.items():
for xpath in xpaths:
date_text = extract_text_from_nfo_element(root, xpath)
if date_text and validate_date_string(date_text):
result[field_name] = date_text
break
else:
result[field_name] = None
return result
def create_basic_nfo_structure(
media_type: str,
title: str,
imdb_id: Optional[str] = None,
dates: Optional[Dict[str, str]] = None,
additional_fields: Optional[Dict[str, str]] = None
) -> ET.Element:
"""
Create basic NFO XML structure
Args:
media_type: Type of media ('movie', 'tvshow', 'episode')
title: Media title
imdb_id: Optional IMDb ID
dates: Optional dictionary of date fields
additional_fields: Optional additional fields to include
Returns:
XML root element
"""
root = ET.Element(media_type)
# Add title
title_elem = ET.SubElement(root, 'title')
title_elem.text = title
# Add IMDb ID if provided
if imdb_id and validate_imdb_id(imdb_id):
imdb_elem = ET.SubElement(root, 'imdb')
imdb_elem.text = imdb_id
# Also add as uniqueid
uniqueid_elem = ET.SubElement(root, 'uniqueid', type='imdb', default='true')
uniqueid_elem.text = imdb_id
# Add dates if provided
if dates:
for field_name, date_value in dates.items():
if date_value and validate_date_string(date_value):
date_elem = ET.SubElement(root, field_name)
date_elem.text = date_value
# Add additional fields
if additional_fields:
for field_name, field_value in additional_fields.items():
if field_value:
field_elem = ET.SubElement(root, field_name)
field_elem.text = str(field_value)
return root
def write_nfo_file(
nfo_path: Path,
root: ET.Element,
lock_metadata: bool = True
) -> None:
"""
Write NFO XML content to file
Args:
nfo_path: Path where to write the NFO file
root: XML root element to write
lock_metadata: Whether to add file locking attributes
Raises:
NFOCreationError: If writing fails
"""
try:
# Ensure parent directory exists
nfo_path.parent.mkdir(parents=True, exist_ok=True)
# Add file locking if requested
if lock_metadata:
root.set('nfoguard_managed', 'true')
root.set('last_updated', datetime.now().isoformat())
# Create tree and write
tree = ET.ElementTree(root)
ET.indent(tree, space=" ", level=0) # Pretty formatting
# Write with proper XML declaration
with open(nfo_path, 'wb') as f:
tree.write(f, encoding='utf-8', xml_declaration=True)
_log("DEBUG", f"Successfully wrote NFO file: {nfo_path}")
except (OSError, ET.ParseError) as e:
raise NFOCreationError(
str(nfo_path),
f"Failed to write NFO file: {e}",
"unknown"
)
def is_nfo_managed_by_nfoguard(nfo_path: Path) -> bool:
"""
Check if NFO file is managed by NFOGuard
Args:
nfo_path: Path to NFO file
Returns:
True if managed by NFOGuard, False otherwise
"""
root = parse_nfo_with_tolerance(nfo_path)
if root is None:
return False
return root.get('nfoguard_managed') == 'true'
def extract_title_from_directory_name(directory_name: str) -> Optional[str]:
"""
Extract clean title from directory name
Args:
directory_name: Directory name to parse
Returns:
Cleaned title or None if no title found
"""
name = directory_name
# Remove IMDb ID patterns
for pattern in IMDB_PATTERNS:
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Remove year in parentheses: (YYYY)
name = re.sub(r'\s*\(\d{4}\)', '', name)
# Remove common release info patterns
release_patterns = [
r'\s*\[.*?\]', # [1080p], [BluRay], etc.
r'\s*\{.*?\}', # {edition info}
r'\s*\(.*?\)', # (additional info)
]
for pattern in release_patterns:
name = re.sub(pattern, '', name, flags=re.IGNORECASE)
# Clean up extra spaces and special characters
name = re.sub(r'[._-]+', ' ', name) # Replace dots, underscores, dashes with spaces
name = ' '.join(name.split()) # Normalize whitespace
return name.strip() if name.strip() else None
+372
View File
@@ -0,0 +1,372 @@
"""
Validation utilities for NFOGuard
Provides runtime validation and type checking for critical paths
"""
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Union, Callable, TypeVar, Type
from datetime import datetime
from utils.exceptions import ConfigurationError, NFOGuardException
T = TypeVar('T')
def validate_imdb_id(imdb_id: str) -> bool:
"""
Validate IMDb ID format
Args:
imdb_id: IMDb ID to validate
Returns:
True if valid, False otherwise
"""
if not imdb_id or not isinstance(imdb_id, str):
return False
# Must start with 'tt' followed by 7+ digits
return bool(re.match(r'^tt\d{7,}$', imdb_id))
def validate_tmdb_id(tmdb_id: str) -> bool:
"""
Validate TMDB ID format
Args:
tmdb_id: TMDB ID to validate
Returns:
True if valid, False otherwise
"""
if not tmdb_id or not isinstance(tmdb_id, str):
return False
# Can be numeric or have tmdb- prefix
if tmdb_id.startswith('tmdb-'):
return tmdb_id[5:].isdigit()
return tmdb_id.isdigit()
def validate_season_episode(season: int, episode: int) -> bool:
"""
Validate season and episode numbers
Args:
season: Season number
episode: Episode number
Returns:
True if valid, False otherwise
"""
return (
isinstance(season, int) and season >= 0 and
isinstance(episode, int) and episode >= 1
)
def validate_date_string(date_str: str) -> bool:
"""
Validate date string format (ISO format)
Args:
date_str: Date string to validate
Returns:
True if valid ISO date, False otherwise
"""
if not date_str or not isinstance(date_str, str):
return False
try:
datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return True
except ValueError:
return False
def validate_path_exists(path: Union[str, Path]) -> bool:
"""
Validate that a path exists
Args:
path: Path to validate
Returns:
True if path exists, False otherwise
"""
try:
return Path(path).exists()
except (OSError, ValueError):
return False
def validate_video_file(file_path: Union[str, Path]) -> bool:
"""
Validate that a file is a video file
Args:
file_path: Path to validate
Returns:
True if valid video file, False otherwise
"""
try:
path = Path(file_path)
video_extensions = {'.mkv', '.mp4', '.avi', '.m4v', '.mov', '.ts'}
return path.is_file() and path.suffix.lower() in video_extensions
except (OSError, ValueError):
return False
def validate_webhook_payload(payload: Dict[str, Any], required_fields: List[str]) -> List[str]:
"""
Validate webhook payload has required fields
Args:
payload: Webhook payload to validate
required_fields: List of required field names
Returns:
List of missing field names (empty if all present)
"""
missing_fields = []
for field in required_fields:
if field not in payload or payload[field] is None:
missing_fields.append(field)
return missing_fields
def validate_config_paths(paths: List[Union[str, Path]], path_type: str) -> None:
"""
Validate configuration paths exist and are directories
Args:
paths: List of paths to validate
path_type: Type of paths for error messages (e.g., "TV", "Movie")
Raises:
ConfigurationError: If any paths are invalid
"""
invalid_paths = []
for path in paths:
try:
path_obj = Path(path)
if not path_obj.exists():
invalid_paths.append(f"{path} (does not exist)")
elif not path_obj.is_dir():
invalid_paths.append(f"{path} (not a directory)")
except (OSError, ValueError) as e:
invalid_paths.append(f"{path} (invalid: {e})")
if invalid_paths:
raise ConfigurationError(
f"{path_type.lower()}_paths",
f"Invalid {path_type} paths found",
{"invalid_paths": invalid_paths}
)
def require_type(value: Any, expected_type: Type[T], name: str) -> T:
"""
Require value to be of specific type
Args:
value: Value to check
expected_type: Expected type
name: Name of the value for error messages
Returns:
The value if it matches the type
Raises:
TypeError: If value is not of expected type
"""
if not isinstance(value, expected_type):
raise TypeError(
f"{name} must be {expected_type.__name__}, got {type(value).__name__}"
)
return value
def require_non_empty(value: Optional[str], name: str) -> str:
"""
Require string value to be non-empty
Args:
value: String value to check
name: Name of the value for error messages
Returns:
The value if it's non-empty
Raises:
ValueError: If value is None or empty
"""
if not value:
raise ValueError(f"{name} cannot be None or empty")
return value
def validate_and_clean_imdb_id(imdb_id: Optional[str]) -> Optional[str]:
"""
Validate and clean IMDb ID
Args:
imdb_id: IMDb ID to validate and clean
Returns:
Cleaned IMDb ID or None if invalid
"""
if not imdb_id:
return None
# Clean the ID
cleaned = imdb_id.strip().lower()
# Remove common prefixes
if cleaned.startswith('imdb-'):
cleaned = cleaned[5:]
elif cleaned.startswith('imdb_'):
cleaned = cleaned[5:]
# Ensure it starts with 'tt'
if not cleaned.startswith('tt'):
cleaned = f'tt{cleaned}'
# Validate format
if validate_imdb_id(cleaned):
return cleaned
return None
def create_validator(validation_func: Callable[[Any], bool], error_message: str) -> Callable:
"""
Create a validator decorator
Args:
validation_func: Function that returns True if value is valid
error_message: Error message to raise if validation fails
Returns:
Decorator function
"""
def decorator(func: Callable) -> Callable:
def wrapper(*args, **kwargs):
# Apply validation to first argument
if args and not validation_func(args[0]):
raise ValueError(error_message.format(args[0]))
return func(*args, **kwargs)
return wrapper
return decorator
# Common validators
validate_imdb_required = create_validator(
lambda x: validate_imdb_id(x),
"Invalid IMDb ID format: {}"
)
validate_path_required = create_validator(
lambda x: validate_path_exists(x),
"Path does not exist: {}"
)
validate_date_required = create_validator(
lambda x: validate_date_string(x),
"Invalid date format: {}"
)
class ValidationError(NFOGuardException):
"""Raised when validation fails"""
def __init__(self, field_name: str, value: Any, reason: str):
details = {
"field_name": field_name,
"value": str(value),
"reason": reason
}
message = f"Validation failed for {field_name}: {reason}"
super().__init__(message, details)
def validate_episode_file_pattern(filename: str) -> Optional[Dict[str, int]]:
"""
Validate and extract episode information from filename
Args:
filename: Filename to validate
Returns:
Dictionary with season and episode if valid, None otherwise
"""
# Episode patterns
patterns = [
r'[sS](\d{1,2})[eE](\d{1,3})', # S01E01
r'(\d{1,2})x(\d{1,3})', # 1x01
r'[sS](\d{1,2})\.?[eE](\d{1,3})', # S01.E01
]
for pattern in patterns:
match = re.search(pattern, filename)
if match:
season = int(match.group(1))
episode = int(match.group(2))
if validate_season_episode(season, episode):
return {"season": season, "episode": episode}
return None
def sanitize_filename(filename: str) -> str:
"""
Sanitize filename by removing invalid characters
Args:
filename: Filename to sanitize
Returns:
Sanitized filename
"""
# Remove invalid characters for most filesystems
invalid_chars = r'<>:"/\|?*'
for char in invalid_chars:
filename = filename.replace(char, '_')
# Remove leading/trailing dots and spaces
filename = filename.strip('. ')
# Ensure it's not empty
if not filename:
filename = 'unnamed'
return filename
def validate_url_format(url: str) -> bool:
"""
Validate URL format
Args:
url: URL to validate
Returns:
True if valid URL format, False otherwise
"""
if not url or not isinstance(url, str):
return False
# Basic URL validation
url_pattern = re.compile(
r'^https?://' # http:// or https://
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain...
r'localhost|' # localhost...
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip
r'(?::\d+)?' # optional port
r'(?:/?|[/?]\S+)$', re.IGNORECASE)
return bool(url_pattern.match(url))
View File
+204
View File
@@ -0,0 +1,204 @@
"""
Webhook Batching System for NFOGuard
Handles batching and processing of webhook events to avoid processing storms
"""
import threading
from pathlib import Path
from typing import Dict, Set
from concurrent.futures import ThreadPoolExecutor
from config.settings import config
from utils.logging import _log
class WebhookBatcher:
"""Batches webhook events to avoid processing storms"""
def __init__(self, nfo_manager=None):
self.pending: Dict[str, Dict] = {}
self.timers: Dict[str, threading.Timer] = {}
self.processing: Set[str] = set()
self.lock = threading.Lock()
self.executor = ThreadPoolExecutor(max_workers=config.max_concurrent)
# Will be set by the application when processors are available
self.tv_processor = None
self.movie_processor = None
# NFO manager for comprehensive IMDb detection
self.nfo_manager = nfo_manager
def set_processors(self, tv_processor, movie_processor):
"""Set the processor instances"""
self.tv_processor = tv_processor
self.movie_processor = movie_processor
def add_webhook(self, key: str, webhook_data: Dict, media_type: str):
"""Add webhook to batch queue"""
with self.lock:
if key in self.timers:
self.timers[key].cancel()
webhook_data['media_type'] = media_type
self.pending[key] = webhook_data
_log("INFO", f"Batched {media_type} webhook for {key}")
_log("DEBUG", f"Batch added - key: {key}, media_type: {media_type}, timer scheduled for {config.batch_delay}s")
timer = threading.Timer(config.batch_delay, self._process_item, args=[key])
self.timers[key] = timer
timer.start()
def _process_item(self, key: str):
"""Process a batched item"""
with self.lock:
if key in self.processing or key not in self.pending:
return
self.processing.add(key)
webhook_data = self.pending.pop(key)
self.timers.pop(key, None)
try:
self.executor.submit(self._process_sync, key, webhook_data)
except Exception as e:
_log("ERROR", f"Error submitting processing for {key}: {e}")
with self.lock:
self.processing.discard(key)
def _process_sync(self, key: str, webhook_data: Dict):
"""Synchronous processing of webhook data with validation"""
try:
media_type = webhook_data.get('media_type')
path_str = webhook_data.get('path')
_log("DEBUG", f"Processing batch item: key={key}, media_type={media_type}, path={path_str}")
if not path_str:
_log("ERROR", f"No path found for {media_type} {key}")
return
path_obj = Path(path_str)
if not path_obj.exists():
_log("ERROR", f"BATCH PROCESSING FAILED: Path does not exist: {path_obj}")
_log("ERROR", f"This indicates a path mapping issue - webhook rejected to prevent wrong processing")
return
# CRITICAL: Validate that the path contains the expected IMDb ID for movies
if media_type == 'movie':
expected_imdb = key.replace('movie:', '') if key.startswith('movie:') else key
# Use comprehensive IMDb detection (directory, filenames, NFO files)
if self.nfo_manager:
detected_imdb = self.nfo_manager.find_movie_imdb_id(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in path {path_str}")
_log("ERROR", f"Detected IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong movies due to batch corruption")
return
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
# CRITICAL: Validate that the path contains the expected IMDb ID for TV shows
if media_type == 'tv':
expected_imdb = key.replace('tv:', '') if key.startswith('tv:') else key
# Use comprehensive IMDb detection (directory, filenames, tvshow.nfo files)
if self.nfo_manager:
detected_imdb = self.nfo_manager.find_series_imdb_id(path_obj)
imdb_match = False
if detected_imdb:
# Compare with and without 'tt' prefix for flexibility
if detected_imdb == expected_imdb or detected_imdb.replace('tt', '') == expected_imdb.replace('tt', ''):
imdb_match = True
if not imdb_match:
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found via comprehensive detection in TV path {path_str}")
_log("ERROR", f"Detected TV IMDb: {detected_imdb}, Expected: {expected_imdb}")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} matches detected {detected_imdb}")
else:
# Fallback to simple string search if nfo_manager not available
if expected_imdb not in path_str.lower():
_log("ERROR", f"BATCH VALIDATION FAILED: Expected IMDb {expected_imdb} not found in TV path {path_str} (fallback mode)")
_log("ERROR", f"This prevents processing wrong TV series due to batch corruption")
return
_log("DEBUG", f"TV batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
if not self.tv_processor:
_log("ERROR", "TV processor not available")
return
# Check processing mode for TV webhooks
processing_mode = webhook_data.get('processing_mode', config.tv_webhook_processing_mode)
episodes_data = webhook_data.get('episodes', [])
if processing_mode == 'targeted' and episodes_data:
_log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
else:
_log("INFO", f"Using series processing mode (fallback or configured)")
self.tv_processor.process_series(path_obj)
elif media_type == 'movie':
if not self.movie_processor:
_log("ERROR", "Movie processor not available")
return
self.movie_processor.process_movie(path_obj, webhook_mode=True)
else:
_log("ERROR", f"Unknown media type: {media_type}")
except Exception as e:
_log("ERROR", f"Error processing {media_type} {key}: {e}")
finally:
with self.lock:
self.processing.discard(key)
def get_status(self) -> Dict:
"""Get batch queue status"""
with self.lock:
return {
"pending_items": list(self.pending.keys()),
"processing_items": list(self.processing),
"pending_count": len(self.pending),
"processing_count": len(self.processing)
}
def shutdown(self):
"""Shutdown the webhook batcher gracefully"""
_log("INFO", "Shutting down webhook batcher...")
with self.lock:
# Cancel all pending timers
for timer in self.timers.values():
try:
timer.cancel()
except Exception as e:
_log("WARNING", f"Error canceling timer: {e}")
self.timers.clear()
# Log any remaining items
if self.pending:
_log("WARNING", f"Shutting down with {len(self.pending)} pending items")
if self.processing:
_log("INFO", f"Waiting for {len(self.processing)} items to finish processing...")
# Shutdown the thread pool executor
try:
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
_log("INFO", "Thread pool executor shut down successfully")
except Exception as e:
_log("WARNING", f"Error shutting down thread pool: {e}")
_log("INFO", "Webhook batcher shutdown complete")