Compare commits
242 Commits
e609c32df0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e883d896b9 | |||
| 041caf0d0d | |||
| 7fffaac247 | |||
| 6e491fb091 | |||
| 4b71b0e7ee | |||
| 7bab45a9aa | |||
| 7f0ad27009 | |||
| 614dab632a | |||
| 43449ca7a3 | |||
| db0a43dd18 | |||
| 6711e926a6 | |||
| f11839e005 | |||
| 6d2435a037 | |||
| b1598114d9 | |||
| b709a377c8 | |||
| babe5ac0df | |||
| a4c07eaa19 | |||
| a1a04b2711 | |||
| 1dbad2f50b | |||
| afdbe1f755 | |||
| 1916dc2646 | |||
| 36ab22ee3d | |||
| c6a8cce509 | |||
| 2eda166328 | |||
| f31430256e | |||
| ed3d7e903c | |||
| 0b9f450b1d | |||
| 10a9677804 | |||
| d6488a99bd | |||
| d1480e1b96 | |||
| c6b6b0ef64 | |||
| 9fbb9e37ab | |||
| d933ac7585 | |||
| d50ac88237 | |||
| 9c8da4d63b | |||
| 14ae2a205c | |||
| a68f7ee4bb | |||
| 63ab35b24c | |||
| 6b9e0b43f8 | |||
| d4f4bcf722 | |||
| 4cb9250c3a | |||
| 24f05079f3 | |||
| 483bcc9d25 | |||
| 0b27fc1c65 | |||
| 07cdbf1a29 | |||
| eba70ec045 | |||
| f38ca539d6 | |||
| 254c9c9c5f | |||
| 319838d305 | |||
| 4d7d9752fc | |||
| 5d3754b401 | |||
| ee403f9ac7 | |||
| 4516a80208 | |||
| eb66af837f | |||
| 1d837102e9 | |||
| cb46b56243 | |||
| 2a972f0e14 | |||
| 18d1b45609 | |||
| f46f1df7cb | |||
| cf6ee5d6d8 | |||
| ef899a05c7 | |||
| 8c850ab38c | |||
| 059d3bae3f | |||
| 98da1a07f0 | |||
| cb62ddb187 | |||
| 4713bf5ed1 | |||
| 228053a573 | |||
| 6d64812c94 | |||
| b3adf45f7b | |||
| 0666a4d9a9 | |||
| 2f4d4638ef | |||
| ff24e0c431 | |||
| def8d293b9 | |||
| 952319b061 | |||
| 4c4a805c5d | |||
| 2811d30352 | |||
| 47311864cf | |||
| ab48d485b8 | |||
| 1314ee3e70 | |||
| 7b62d73753 | |||
| e054c945ff | |||
| f6c5ccd2b6 | |||
| 7fb07fcddf | |||
| 6946ce2082 | |||
| 017681cd55 | |||
| a37d552303 | |||
| 864a82cbc5 | |||
| 68baa1766c | |||
| 413c8ad6f6 | |||
| 1ceccfe31d | |||
| ec36ce7b0e | |||
| 2583a668bc | |||
| 6d9e057a91 | |||
| 18ae174276 | |||
| 4752b424f5 | |||
| cebc31e24c | |||
| 5c0ba04832 | |||
| 4ba8db48f7 | |||
| f67663a338 | |||
| 543c5f44e7 | |||
| fc950007cc | |||
| 59dbf93f70 | |||
| 337bb4e049 | |||
| 4a2d410921 | |||
| 43f4851f40 | |||
| df15ce141f | |||
| ac30a91879 | |||
| 835d313b6c | |||
| 24dce6c970 | |||
| e4ef60847a | |||
| 3ac5aa3121 | |||
| 66a80992b5 | |||
| 78813b9189 | |||
| c61c35ebad | |||
| 80a09cf66e | |||
| df17305160 | |||
| b55b786787 | |||
| 470d77c28f | |||
| 916f237e68 | |||
| c1ab3cd874 | |||
| 379f7a6e44 | |||
| 50fe5b272f | |||
| 46465d1749 | |||
| ea69ad9202 | |||
| decad07a0d | |||
| c84d08b266 | |||
| ca796bdb58 | |||
| b65ba5d15d | |||
| 53fe4dd9a3 | |||
| a55da8d189 | |||
| 8b683a9fad | |||
| 000fb85c03 | |||
| aafb85ba69 | |||
| 57122769df | |||
| c66c19dc59 | |||
| e6bb5293c4 | |||
| af779f9c59 | |||
| 1ad2a3d945 | |||
| c1240964ee | |||
| 5ade14c63e | |||
| 445206de4b | |||
| 1e2cf49c10 | |||
| 114628ad5c | |||
| 748392c425 | |||
| 55de6284c8 | |||
| 808322c7c7 | |||
| 148937ae08 | |||
| f6a161a014 | |||
| 741a283df0 | |||
| 3c4e47b08e | |||
| eb9bfbf82e | |||
| 8ef4d1076c | |||
| 3c4cac6177 | |||
| d1d1e1a3d1 | |||
| 7089c883a9 | |||
| 783f02f7d7 | |||
| 0734b4d14d | |||
| 55d5f891b5 | |||
| beb471d081 | |||
| dc96df70f8 | |||
| fdc8703044 | |||
| c8b98f6765 | |||
| 852c45bbb5 | |||
| 46fa6b3b75 | |||
| 3b32f7e023 | |||
| e06df9c8b0 | |||
| 6c353dcaf8 | |||
| cb18c358c6 | |||
| 6ef1d2fd89 | |||
| 762031d3f3 | |||
| 95945f7189 | |||
| d552d97440 | |||
| 5630022f6e | |||
| 9535e91db6 | |||
| 6e859ad112 | |||
| 6b2d610b20 | |||
| e89cbca125 | |||
| 5ecf46df92 | |||
| 87930efb79 | |||
| 6d5635f56a | |||
| fd2f8f2c51 | |||
| 47eb74da06 | |||
| 113b44b9ab | |||
| 8a3e01d966 | |||
| c5177ba920 | |||
| d17f924192 | |||
| 857d423ae4 | |||
| 4ea202cca2 | |||
| b8d85e356a | |||
| 366eaa2877 | |||
| 987ca1977b | |||
| 4b77872fd5 | |||
| d02ee396e9 | |||
| 9850dc596a | |||
| 068507adce | |||
| ecc70812af | |||
| 9f05e4cb92 | |||
| 79a311f8ec | |||
| 909df0cc83 | |||
| 913aed1f60 | |||
| 0dfc296bf4 | |||
| 66ba17991e | |||
| f903606d3a | |||
| 051f9d7173 | |||
| 536fc70350 | |||
| bb9c12a0be | |||
| 0bf72c1e5c | |||
| 00f2b77bfb | |||
| 212e51e54f | |||
| 6fae41aef5 | |||
| 249b55964e | |||
| 7b7ac6f179 | |||
| 8de6a58209 | |||
| dcace84406 | |||
| 8da23013ff | |||
| b34691b22a | |||
| cebed7a6bb | |||
| 867bd008c5 | |||
| c8fde41dfb | |||
| a559873999 | |||
| baa6474d12 | |||
| 1a98c406a2 | |||
| 97aa2042a3 | |||
| 53d7753231 | |||
| 7216491d76 | |||
| a5b2381023 | |||
| 80df0a0278 | |||
| e780b9e125 | |||
| 59f562c2fa | |||
| 716dcc05e8 | |||
| 000d57e9fe | |||
| a0f676a368 | |||
| 60bbf9440a | |||
| a3f7fbe68e | |||
| 348f83d547 | |||
| 41bccb82b1 | |||
| 0827d30a76 | |||
| 5b9f5c44e1 | |||
| 21bfb97f4b | |||
| 14d551fc36 | |||
| be075f884e | |||
| fb3040c554 |
+159
-19
@@ -1,28 +1,168 @@
|
||||
# NFOGuard Environment Variables
|
||||
# ===========================================
|
||||
# NFOGuard Configuration - CORRECTED VERSION
|
||||
# ===========================================
|
||||
# Main configuration file - safe to share for debugging
|
||||
# Sensitive data (API keys, passwords) are in .env.secrets
|
||||
|
||||
# User and Group IDs
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
# ===========================================
|
||||
# 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
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
# Radarr paths (what Radarr sees on the host system)
|
||||
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
|
||||
|
||||
# Media path (for read-only access to scan NFO files)
|
||||
MEDIA_PATH=/path/to/your/media
|
||||
# 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
|
||||
|
||||
# Database settings (if using PostgreSQL)
|
||||
# DB_USER=nfoguard
|
||||
# DB_PASSWORD=your_secure_password
|
||||
# Download detection paths (for identifying downloads vs existing files)
|
||||
DOWNLOAD_PATH_INDICATORS=/downloads/,/nzbget/,/completed/,/sabnzbd/,/torrents/
|
||||
|
||||
# Emby Plugin Deployment - Bind Mount Method (Recommended)
|
||||
# Set this to the path where Emby plugins are installed on your host
|
||||
# Common paths:
|
||||
# - /var/lib/emby/plugins (native Emby install)
|
||||
# - /path/to/emby/config/plugins (Docker Emby)
|
||||
# - /config/plugins (linuxserver.io Emby)
|
||||
EMBY_PLUGINS_PATH=/path/to/emby/plugins
|
||||
# ===========================================
|
||||
# SERVER CONFIGURATION
|
||||
# ===========================================
|
||||
# Timezone for container logging and timestamps
|
||||
TZ=America/New_York
|
||||
|
||||
# NFOGuard specific settings
|
||||
# NFOGuard Core API (webhooks, processing, database management)
|
||||
CORE_API_HOST=0.0.0.0
|
||||
CORE_API_PORT=8080
|
||||
|
||||
# NFOGuard Web Interface (dashboard, series/movie management)
|
||||
WEB_API_HOST=0.0.0.0
|
||||
WEB_API_PORT=8081
|
||||
|
||||
# External port where web interface is accessible (for dynamic port reference)
|
||||
WEB_EXTERNAL_PORT=8081
|
||||
|
||||
# ===========================================
|
||||
# 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
|
||||
DB_USER=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
|
||||
|
||||
# Sequential processing delay for bulk downloads (seconds)
|
||||
# When multiple episodes are downloaded at once, process them one by one with this delay
|
||||
# Set to 0 to disable sequential processing (parallel mode)
|
||||
SEQUENTIAL_DELAY=20.0
|
||||
|
||||
# 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
|
||||
|
||||
# ===========================================
|
||||
# WEB INTERFACE AUTHENTICATION
|
||||
# ===========================================
|
||||
# Enable web interface authentication (default: false)
|
||||
WEB_AUTH_ENABLED=false
|
||||
|
||||
# Session timeout in seconds (default: 3600 = 1 hour)
|
||||
WEB_AUTH_SESSION_TIMEOUT=3600
|
||||
@@ -4,9 +4,17 @@
|
||||
# Add .env.secrets to your .gitignore
|
||||
|
||||
# ===========================================
|
||||
# RADARR DATABASE CREDENTIALS
|
||||
# NFOGUARD DATABASE CREDENTIALS (REQUIRED)
|
||||
# ===========================================
|
||||
# Database password for PostgreSQL connection
|
||||
# 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
|
||||
|
||||
# ===========================================
|
||||
@@ -31,3 +39,11 @@ JELLYSEERR_API_KEY=your_jellyseerr_api_key
|
||||
# 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
|
||||
|
||||
# ===========================================
|
||||
# WEB INTERFACE AUTHENTICATION (OPTIONAL)
|
||||
# ===========================================
|
||||
# Web interface login credentials (only used if WEB_AUTH_ENABLED=true in .env)
|
||||
# Set WEB_AUTH_ENABLED=true in .env file to enable authentication
|
||||
WEB_AUTH_USERNAME=admin
|
||||
WEB_AUTH_PASSWORD=your_secure_web_password
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
# ===========================================
|
||||
# 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
|
||||
@@ -11,6 +11,10 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup
|
||||
run: |
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
- name: Checkout code (DEV)
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
|
||||
@@ -11,6 +11,15 @@ jobs:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Pre-cleanup Docker space
|
||||
run: |
|
||||
echo "🧹 Cleaning up Docker space before build..."
|
||||
docker system prune -f
|
||||
docker builder prune -f --filter until=24h
|
||||
docker image prune -af --filter until=48h
|
||||
echo "📊 Docker space after cleanup:"
|
||||
docker system df
|
||||
|
||||
- name: Checkout code
|
||||
run: |
|
||||
echo "Current workspace: $(pwd)"
|
||||
@@ -203,6 +212,15 @@ jobs:
|
||||
echo ""
|
||||
echo "🐳 Pull with: docker pull 192.168.253.221:3000/sbcrumb/nfoguard:${VERSION}-gitea"
|
||||
|
||||
- name: Post-cleanup Docker space
|
||||
if: always() # Run even if build fails
|
||||
run: |
|
||||
echo "🧹 Final cleanup to prevent disk space issues..."
|
||||
docker system prune -f
|
||||
docker builder prune -af --filter until=24h
|
||||
echo "📊 Final Docker space usage:"
|
||||
docker system df
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: host
|
||||
|
||||
+8
-1
@@ -12,11 +12,12 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
GIT_BRANCH=${GIT_BRANCH} \
|
||||
BUILD_SOURCE=${BUILD_SOURCE}
|
||||
|
||||
# Install system dependencies including PostgreSQL client libraries
|
||||
# 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
|
||||
@@ -31,6 +32,9 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy web starter file to root directory
|
||||
COPY start_web.py .
|
||||
|
||||
# Create git metadata for version detection based on build arg
|
||||
RUN mkdir -p .git && \
|
||||
echo "ref: refs/heads/${GIT_BRANCH}" > .git/HEAD
|
||||
@@ -67,5 +71,8 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
# 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.
@@ -0,0 +1,207 @@
|
||||
# Maintainarr Webhook Integration
|
||||
|
||||
This document describes how to configure NFOGuard to receive webhooks from Maintainarr for automatic database cleanup when media is removed.
|
||||
|
||||
## Overview
|
||||
|
||||
When Maintainarr removes media from your Plex/Radarr/Sonarr collections, NFOGuard can automatically remove the corresponding entries from its database to keep it clean and up-to-date.
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
### 1. NFOGuard Endpoint
|
||||
|
||||
The webhook endpoint is available at:
|
||||
```
|
||||
http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr
|
||||
```
|
||||
|
||||
**Important**: Use the core NFOGuard container port (typically 8080), not the web interface port (8081).
|
||||
|
||||
### 2. Maintainarr Configuration
|
||||
|
||||
In Maintainarr, create a new webhook notification agent with these settings:
|
||||
|
||||
#### Basic Settings
|
||||
- **Name**: `NFOGuard Cleanup`
|
||||
- **Enabled**: ✅ Checked
|
||||
- **Agent**: `Webhook`
|
||||
|
||||
#### Webhook Configuration
|
||||
- **Webhook URL**: `http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr`
|
||||
- **JSON Payload**: Use the template below
|
||||
- **Auth Header**: *(Leave empty - no authentication required)*
|
||||
|
||||
#### JSON Payload Template
|
||||
Copy and paste this JSON template into the "Json Payload" field:
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "{{notification_type}}",
|
||||
"subject": "{{subject}}",
|
||||
"message": "{{message}}",
|
||||
"extra": "{{extra}}"
|
||||
}
|
||||
```
|
||||
|
||||
**Important Note**: Maintainarr's template variables may not include IMDb IDs directly. The webhook will attempt to extract IMDb IDs from the notification content, but this may require manual configuration or rule setup in Maintainarr to include IMDb IDs in the notification text.
|
||||
|
||||
**Alternative Approach**: If Maintainarr doesn't provide IMDb IDs in notifications, you may need to use NFOGuard's manual cleanup tools or configure Maintainarr rules to include IMDb information in the message content.
|
||||
|
||||
#### Event Types
|
||||
Select these notification types:
|
||||
- ✅ **Media Removed From Collection** - Removes media from NFOGuard database
|
||||
- ✅ **Media About To Be Handled** - Optional: Log when media is about to be processed
|
||||
|
||||
Optional event types (will be logged but not processed):
|
||||
- ☐ Media Added To Collection
|
||||
- ☐ Media Handled
|
||||
- ☐ Rule Handling Failed
|
||||
- ☐ Collection Handling Failed
|
||||
|
||||
## Supported Operations
|
||||
|
||||
### Movies
|
||||
When a movie is removed from Maintainarr:
|
||||
- NFOGuard checks if the movie exists in its database (by IMDb ID)
|
||||
- If found, removes the movie record from the `movies` table
|
||||
- Logs the deletion operation
|
||||
|
||||
### TV Series
|
||||
When a TV series is removed from Maintainarr:
|
||||
- NFOGuard checks if the series exists in its database (by IMDb ID)
|
||||
- If found, removes all episode records from the `episodes` table
|
||||
- Removes the series record from the `series` table
|
||||
- Logs the deletion operation with episode count
|
||||
|
||||
## Webhook Payload
|
||||
|
||||
Maintainarr sends webhook payloads using template variables that you configure:
|
||||
|
||||
```json
|
||||
{
|
||||
"notification_type": "Media Removed",
|
||||
"subject": "Example Movie (2023)",
|
||||
"message": "Removed movie Example Movie from collection Action Movies - IMDb: tt1234567",
|
||||
"extra": "tt1234567"
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
1. **Maintainarr** populates the template variables ({{notification_type}}, {{subject}}, {{message}}, {{extra}})
|
||||
2. **NFOGuard** receives the webhook and parses the content to extract:
|
||||
- **IMDb ID**: Extracted from `message`, `subject`, or `extra` fields using pattern matching
|
||||
- **Media Type**: Determined from message content keywords or database lookup
|
||||
- **Title**: Extracted from `subject` or `message` fields
|
||||
|
||||
### Media Identification
|
||||
NFOGuard looks for IMDb IDs in this format:
|
||||
- `tt1234567` (preferred)
|
||||
- `1234567` (will be converted to tt1234567)
|
||||
|
||||
The webhook handler uses intelligent parsing to:
|
||||
- Extract IMDb IDs from any field using regex patterns
|
||||
- Determine if media is a Movie or Series based on keywords or database lookup
|
||||
- Extract the media title from subject or message content
|
||||
|
||||
## Response Format
|
||||
|
||||
NFOGuard responds with JSON indicating the result:
|
||||
|
||||
### Success Response
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Processed Media Removed for Example Movie",
|
||||
"media_type": "Movie",
|
||||
"imdb_id": "tt1234567",
|
||||
"removed_count": 1,
|
||||
"removed_items": ["Movie: Example Movie (tt1234567)"]
|
||||
}
|
||||
```
|
||||
|
||||
### Ignored Response
|
||||
```json
|
||||
{
|
||||
"status": "ignored",
|
||||
"reason": "Media tt1234567 not found in database"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "No IMDb ID found in webhook payload"
|
||||
}
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
All webhook activities are logged with details:
|
||||
|
||||
```
|
||||
INFO: Received Maintainarr webhook: Media Removed
|
||||
INFO: Processing movie deletion for Example Movie (tt1234567)
|
||||
SUCCESS: Removed movie Example Movie (tt1234567) from database
|
||||
INFO: Maintainarr cleanup: Media Removed - Movie 'Example Movie' (tt1234567). Removed from database: Movie: Example Movie (tt1234567)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **No IMDb ID Found**:
|
||||
- Maintainarr template variables may not include IMDb IDs
|
||||
- Check if the notification message contains IMDb information
|
||||
- You may need to manually include IMDb IDs in Maintainarr rule configurations
|
||||
|
||||
2. **Media Not Found**:
|
||||
- Check if the media exists in NFOGuard's database
|
||||
- Verify the IMDb ID matches between Maintainarr and NFOGuard
|
||||
|
||||
3. **Connection Issues**:
|
||||
- Ensure NFOGuard core container is accessible on port 8080
|
||||
- Check firewall settings and network connectivity
|
||||
|
||||
4. **Authentication Errors**:
|
||||
- No authentication is required for the webhook endpoint
|
||||
- Ensure you're using the core container port, not web interface port
|
||||
|
||||
5. **Test Notifications**:
|
||||
- Test notifications (like the one you just sent) will be acknowledged but not processed
|
||||
- Real media removal events will trigger the cleanup process
|
||||
|
||||
### Testing the Webhook
|
||||
|
||||
You can test the webhook manually using curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://YOUR_NFOGUARD_HOST:8080/webhook/maintainarr \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notification_type": "Media Removed",
|
||||
"subject": "Test Movie (2023)",
|
||||
"message": "Removed movie Test Movie from collection - IMDb: tt1234567",
|
||||
"extra": "tt1234567"
|
||||
}'
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- The webhook endpoint does not require authentication
|
||||
- Consider using firewalls or network restrictions to limit access
|
||||
- The endpoint only processes deletion requests, not additions
|
||||
- All operations are logged for audit purposes
|
||||
|
||||
## Integration Benefits
|
||||
|
||||
- **Automatic Cleanup**: Keeps NFOGuard database synchronized with your media collection
|
||||
- **Accurate Statistics**: Dashboard stats reflect only currently available media
|
||||
- **Reduced Manual Maintenance**: No need to manually clean up orphaned entries
|
||||
- **Audit Trail**: All deletions are logged with full details
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
- NFOGuard: v2.8.0+
|
||||
- Maintainarr: All versions with webhook support
|
||||
- Requires NFOGuard core container (processing container), not web-only container
|
||||
@@ -8,64 +8,59 @@
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
||||
>
|
||||
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
||||
>
|
||||
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||
>
|
||||
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
||||
> **💬 Community Feedback**: Join our Discord if you'd like to share feedback, test new features, 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.*
|
||||
> **[Join Discord: https://discord.gg/ZykJRGt72b](https://discord.gg/ZykJRGt72b)**
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
## Features
|
||||
|
||||
### **🎬 Core Media Management**
|
||||
### **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**
|
||||
### **Performance & Scalability**
|
||||
- **Async I/O Operations** - High-performance concurrent file processing
|
||||
- **Batch Processing** - Efficient handling of multiple files simultaneously
|
||||
- **Database Integration** - Direct PostgreSQL/SQLite access for better performance
|
||||
- **Smart Concurrency** - Controlled parallel processing with rate limiting
|
||||
- **PostgreSQL Database** - Production-ready database with optimized queries
|
||||
- **Smart Skip Logic** - Database-first checking eliminates expensive filesystem scans
|
||||
- **88% Scan Optimization** - Subsequent scans reduced from hours to minutes via smart skip logic
|
||||
|
||||
### **🔧 Configuration & Validation**
|
||||
### **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
|
||||
|
||||
### **📊 Monitoring & Observability**
|
||||
- **Health Check APIs** - `/api/v1/health`, `/api/v1/health/ready`, `/api/v1/health/live`
|
||||
- **Prometheus Metrics** - `/api/v1/metrics` endpoint for monitoring integration
|
||||
- **Performance Monitoring** - Operation timing, error rates, and bottleneck detection
|
||||
- **Structured Logging** - JSON logs with correlation IDs for request tracing
|
||||
- **System Status APIs** - Real-time performance and error metrics
|
||||
|
||||
### **🐳 Production Ready**
|
||||
### **Production Ready**
|
||||
- **Docker & Kubernetes** - Health checks for orchestration platforms
|
||||
- **Grafana Compatible** - Metrics endpoints for dashboard integration
|
||||
- **Graceful Shutdown** - Proper signal handling for container management
|
||||
- **Configuration CLI** - Validation tools for troubleshooting
|
||||
- **Modular Architecture** - Clean separation of concerns for maintainability
|
||||
|
||||
## 🚀 Quick Start
|
||||
## 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
|
||||
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
|
||||
@@ -101,7 +96,7 @@ docker-compose logs -f nfoguard
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
## Configuration
|
||||
|
||||
### Environment Files
|
||||
|
||||
@@ -134,7 +129,7 @@ DEBUG=false # Clean production logs
|
||||
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
|
||||
```
|
||||
|
||||
## 🐳 Docker Images
|
||||
## Docker Images
|
||||
|
||||
### Production (Stable)
|
||||
```yaml
|
||||
@@ -148,10 +143,10 @@ image: sbcrumb/nfoguard:dev
|
||||
|
||||
### Specific Version
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:v2.0.0 # Latest with monitoring & validation
|
||||
image: sbcrumb/nfoguard:v2.6.7 # Latest with PostgreSQL & optimization
|
||||
```
|
||||
|
||||
> **🚀 Version 2.0.0** includes major architecture improvements, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
|
||||
> **🚀 Version 2.6.7** includes major architecture improvements, PostgreSQL database migration, 88% scan optimization, async I/O performance enhancements, comprehensive monitoring, and configuration validation.
|
||||
|
||||
## 🔗 Webhook Setup
|
||||
|
||||
@@ -183,8 +178,44 @@ 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"
|
||||
|
||||
# Verify NFO files match database data
|
||||
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
|
||||
|
||||
# Fix NFO files that don't match database
|
||||
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
|
||||
```
|
||||
|
||||
### NFO Verification & Synchronization
|
||||
|
||||
NFOGuard includes comprehensive verification tools to ensure NFO files remain synchronized with database dates:
|
||||
|
||||
```bash
|
||||
# Verify all NFO files (movies and episodes)
|
||||
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=both"
|
||||
|
||||
# Verify only movies
|
||||
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=movies"
|
||||
|
||||
# Verify only TV episodes
|
||||
curl -X POST "http://localhost:8080/database/verify/nfo-sync?media_type=episodes"
|
||||
|
||||
# Fix detected issues by regenerating NFO files
|
||||
curl -X POST "http://localhost:8080/database/fix/nfo-sync?media_type=both"
|
||||
```
|
||||
|
||||
**Verification Checks:**
|
||||
- **Missing NFO files** - Database entries without corresponding NFO files
|
||||
- **Empty NFO files** - NFO files that exist but contain no content
|
||||
- **Date mismatches** - Database dates don't match NFO file dates
|
||||
- **Source mismatches** - Database source doesn't match NFO source information
|
||||
|
||||
**Fix Operations:**
|
||||
- Regenerates NFO files from database data for any detected issues
|
||||
- Preserves existing metadata while updating NFOGuard date sections
|
||||
- Only operates when `MANAGE_NFO=true` is configured
|
||||
- Creates proper movie.nfo and episode S##E##.nfo files
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### **Core Operations**
|
||||
@@ -213,6 +244,16 @@ curl -X POST "http://localhost:8080/bulk/update"
|
||||
| `/api/v1/metrics/errors` | GET | Error metrics and recent failures |
|
||||
| `/api/v1/metrics/system` | GET | System resource metrics |
|
||||
|
||||
#### **Database Management**
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/database/backfill/movie-release-dates` | POST | Backfill missing release dates for existing movies |
|
||||
| `/database/cleanup/orphaned-episodes` | POST | Delete episodes without video files |
|
||||
| `/database/cleanup/orphaned-movies` | POST | Delete movies without video files |
|
||||
| `/database/cleanup/orphaned-series` | POST | Delete TV series without directories |
|
||||
| `/database/verify/nfo-sync` | POST | Verify that database dates match NFO file contents |
|
||||
| `/database/fix/nfo-sync` | POST | Fix NFO sync issues by regenerating NFO files from database data |
|
||||
|
||||
#### **Configuration & Debugging**
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
@@ -257,7 +298,7 @@ volumes:
|
||||
- /home/user/media/tv:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
## Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
```bash
|
||||
@@ -276,7 +317,7 @@ PATH_DEBUG=true
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## 📊 Monitoring & Observability
|
||||
## Monitoring & Observability
|
||||
|
||||
NFOGuard provides comprehensive monitoring capabilities for production deployments:
|
||||
|
||||
@@ -356,7 +397,7 @@ LOG_LEVEL=INFO
|
||||
3. **Webhooks**: Check URLs and ensure port 8080 is accessible
|
||||
4. **Database**: Verify PostgreSQL credentials in `.env.secrets`
|
||||
|
||||
## 📊 What NFOGuard Does
|
||||
## What NFOGuard Does
|
||||
|
||||
### Before
|
||||
```xml
|
||||
@@ -392,7 +433,7 @@ LOG_LEVEL=INFO
|
||||
|
||||
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**
|
||||
### **Movies**
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
@@ -498,7 +539,7 @@ NFOGuard will ignore:
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
||||
- **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)
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
- ./.env:/app/.env:ro # Main configuration
|
||||
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||
environment:
|
||||
- PORT=8080
|
||||
- CORE_API_PORT=8080
|
||||
depends_on:
|
||||
- radarr-postgres
|
||||
```
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Simple authentication middleware for NFOGuard web interface
|
||||
Provides basic HTTP auth and session management for web interface protection
|
||||
"""
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status, Request, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class AuthSession:
|
||||
"""Simple session management for web interface"""
|
||||
|
||||
def __init__(self, timeout_seconds: int = 3600):
|
||||
self.sessions: Dict[str, Dict[str, Any]] = {}
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def create_session(self, username: str) -> str:
|
||||
"""Create a new session and return session token"""
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
self.sessions[session_token] = {
|
||||
"username": username,
|
||||
"created_at": datetime.utcnow(),
|
||||
"last_activity": datetime.utcnow()
|
||||
}
|
||||
return session_token
|
||||
|
||||
def validate_session(self, session_token: str) -> bool:
|
||||
"""Validate session token and update last activity"""
|
||||
if not session_token or session_token not in self.sessions:
|
||||
return False
|
||||
|
||||
session = self.sessions[session_token]
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Check if session expired
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
del self.sessions[session_token]
|
||||
return False
|
||||
|
||||
# Update last activity
|
||||
session["last_activity"] = now
|
||||
return True
|
||||
|
||||
def get_session_user(self, session_token: str) -> Optional[str]:
|
||||
"""Get username from valid session"""
|
||||
if self.validate_session(session_token):
|
||||
return self.sessions[session_token]["username"]
|
||||
return None
|
||||
|
||||
def delete_session(self, session_token: str) -> None:
|
||||
"""Delete a session (logout)"""
|
||||
if session_token in self.sessions:
|
||||
del self.sessions[session_token]
|
||||
|
||||
def cleanup_expired_sessions(self) -> None:
|
||||
"""Remove expired sessions"""
|
||||
now = datetime.utcnow()
|
||||
expired_tokens = []
|
||||
|
||||
for token, session in self.sessions.items():
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
expired_tokens.append(token)
|
||||
|
||||
for token in expired_tokens:
|
||||
del self.sessions[token]
|
||||
|
||||
|
||||
class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple authentication middleware for web interface routes"""
|
||||
|
||||
def __init__(self, app, config, session_manager=None):
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.session_manager = session_manager or AuthSession(config.web_auth_session_timeout)
|
||||
self.security = HTTPBasic()
|
||||
|
||||
# Routes that require authentication (web interface)
|
||||
self.protected_routes = [
|
||||
"/", # Main web interface
|
||||
"/static/", # Static files (CSS, JS)
|
||||
"/api/movies", # Web API endpoints
|
||||
"/api/series",
|
||||
"/api/episodes",
|
||||
"/api/dashboard"
|
||||
]
|
||||
|
||||
# Routes that are always public (webhooks, health checks, API endpoints)
|
||||
self.public_routes = [
|
||||
"/webhook/",
|
||||
"/health",
|
||||
"/logo/", # Logo files should always be accessible
|
||||
"/favicon.ico", # Favicon should always be accessible
|
||||
"/ping",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
"/database/", # Database management endpoints (API access)
|
||||
"/manual/", # Manual scan endpoints (API access)
|
||||
"/debug/", # Debug endpoints (API access)
|
||||
"/test/", # Test endpoints (API access)
|
||||
"/bulk/" # Bulk operation endpoints (API access)
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware"""
|
||||
|
||||
# Skip authentication if disabled
|
||||
if not self.config.web_auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Check if route requires authentication
|
||||
path = request.url.path
|
||||
needs_auth = any(path.startswith(route) for route in self.protected_routes)
|
||||
is_public = any(path.startswith(route) for route in self.public_routes)
|
||||
|
||||
if is_public or not needs_auth:
|
||||
return await call_next(request)
|
||||
|
||||
# Check for existing session
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token and self.session_manager.validate_session(session_token):
|
||||
# Valid session, proceed
|
||||
return await call_next(request)
|
||||
|
||||
# Check for HTTP Basic Auth
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Basic "):
|
||||
credentials = self._parse_basic_auth(auth_header)
|
||||
if credentials and self._validate_credentials(credentials.username, credentials.password):
|
||||
# Create session for successful login
|
||||
session_token = self.session_manager.create_session(credentials.username)
|
||||
response = await call_next(request)
|
||||
response.set_cookie(
|
||||
key="nfoguard_session",
|
||||
value=session_token,
|
||||
max_age=self.config.web_auth_session_timeout,
|
||||
httponly=True,
|
||||
secure=False # Set to True if using HTTPS
|
||||
)
|
||||
return response
|
||||
|
||||
# Authentication required
|
||||
return self._auth_required_response()
|
||||
|
||||
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
|
||||
"""Parse HTTP Basic Auth header"""
|
||||
try:
|
||||
import base64
|
||||
encoded_credentials = auth_header.split(" ")[1]
|
||||
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
||||
username, password = decoded_credentials.split(":", 1)
|
||||
return HTTPBasicCredentials(username=username, password=password)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _validate_credentials(self, username: str, password: str) -> bool:
|
||||
"""Validate username and password"""
|
||||
return (username == self.config.web_auth_username and
|
||||
password == self.config.web_auth_password)
|
||||
|
||||
def _auth_required_response(self) -> Response:
|
||||
"""Return 401 response with WWW-Authenticate header"""
|
||||
return Response(
|
||||
content="Authentication required",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
|
||||
)
|
||||
|
||||
|
||||
def create_auth_dependencies(config) -> Dict[str, Any]:
|
||||
"""Create authentication-related dependencies for dependency injection"""
|
||||
session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
|
||||
return {
|
||||
"session_manager": session_manager,
|
||||
"auth_enabled": config.web_auth_enabled,
|
||||
"auth_config": {
|
||||
"username": config.web_auth_username,
|
||||
"timeout": config.web_auth_session_timeout
|
||||
}
|
||||
}
|
||||
+131
@@ -31,6 +31,18 @@ class RadarrWebhook(BaseModel):
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class MaintainarrWebhook(BaseModel):
|
||||
"""Maintainarr webhook payload model - uses template variables"""
|
||||
notification_type: Optional[str] = None # e.g., "Media Removed"
|
||||
subject: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
extra: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
extra = "allow"
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model"""
|
||||
status: str
|
||||
@@ -49,5 +61,124 @@ class TVSeasonRequest(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
# Scheduled Scans Models
|
||||
|
||||
class CreateScheduledScanRequest(BaseModel):
|
||||
"""Request model for creating a scheduled scan"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
cron_expression: str
|
||||
media_type: str # 'tv', 'movies', 'both'
|
||||
scan_mode: str # 'smart', 'full', 'incomplete'
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class UpdateScheduledScanRequest(BaseModel):
|
||||
"""Request model for updating a scheduled scan"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
media_type: Optional[str] = None
|
||||
scan_mode: Optional[str] = None
|
||||
specific_paths: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ScheduledScanResponse(BaseModel):
|
||||
"""Response model for scheduled scan data"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
cron_expression: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
specific_paths: Optional[str]
|
||||
enabled: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_run_at: Optional[str]
|
||||
next_run_at: Optional[str]
|
||||
run_count: int
|
||||
created_by: Optional[str]
|
||||
updated_by: Optional[str]
|
||||
|
||||
|
||||
class ScheduleExecutionResponse(BaseModel):
|
||||
"""Response model for schedule execution data"""
|
||||
id: int
|
||||
schedule_id: int
|
||||
schedule_name: str
|
||||
started_at: str
|
||||
completed_at: Optional[str]
|
||||
status: str
|
||||
media_type: str
|
||||
scan_mode: str
|
||||
items_processed: int
|
||||
items_skipped: int
|
||||
items_failed: int
|
||||
execution_time_seconds: Optional[int]
|
||||
error_message: Optional[str]
|
||||
logs: Optional[str]
|
||||
triggered_by: Optional[str]
|
||||
+2574
-37
File diff suppressed because it is too large
Load Diff
+2222
File diff suppressed because it is too large
Load Diff
@@ -150,6 +150,10 @@ class SonarrClient:
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def get_series_by_id(self, series_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get series information by Sonarr series ID"""
|
||||
return self._get(f"/series/{series_id}")
|
||||
|
||||
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 []
|
||||
@@ -222,7 +226,7 @@ class SonarrClient:
|
||||
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
|
||||
# Check chronological order of events
|
||||
if rename_events:
|
||||
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||
rename_date = earliest_rename["date"]
|
||||
@@ -230,12 +234,16 @@ class SonarrClient:
|
||||
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
|
||||
# If import happened BEFORE rename, it's valid original import
|
||||
if import_dt <= rename_dt:
|
||||
_log("INFO", f"Import {import_date} happened before/during rename {rename_date} - using import date")
|
||||
return import_date
|
||||
|
||||
# If rename happened BEFORE import - always use aired date fallback
|
||||
else:
|
||||
_log("WARNING", f"Rename {rename_date} happened before import {import_date} - using aired date fallback")
|
||||
return None # Trigger aired date fallback
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error comparing dates: {e}")
|
||||
@@ -248,7 +256,7 @@ class SonarrClient:
|
||||
_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}")
|
||||
_log("WARNING", f"No reliable import events found for episode {episode_id} - should use air date instead")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+60
-11
@@ -45,6 +45,9 @@ class NFOGuardConfig:
|
||||
|
||||
def _load_configuration(self) -> None:
|
||||
"""Load all configuration from environment variables"""
|
||||
# Server configuration
|
||||
self._load_server_settings()
|
||||
|
||||
# Core paths - Required
|
||||
self._load_paths()
|
||||
|
||||
@@ -58,10 +61,20 @@ class NFOGuardConfig:
|
||||
# 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)
|
||||
self.sequential_delay = self._get_float_env("SEQUENTIAL_DELAY", 20.0, 0.0, 60.0) # Delay between sequential episodes (default 20s)
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -71,6 +84,9 @@ class NFOGuardConfig:
|
||||
# TV processing
|
||||
self._load_tv_settings()
|
||||
|
||||
# Web interface authentication
|
||||
self._load_auth_settings()
|
||||
|
||||
def _load_paths(self) -> None:
|
||||
"""Load and validate path configuration"""
|
||||
tv_paths_env = os.environ.get("TV_PATHS", "")
|
||||
@@ -106,6 +122,16 @@ class NFOGuardConfig:
|
||||
current_value=movie_paths_env
|
||||
)
|
||||
|
||||
def _load_server_settings(self) -> None:
|
||||
"""Load server configuration"""
|
||||
# Core API settings (webhooks, processing, database management)
|
||||
self.core_api_host = os.environ.get("CORE_API_HOST", "0.0.0.0")
|
||||
self.core_api_port = self._get_int_env("CORE_API_PORT", 8080, 1024, 65535)
|
||||
|
||||
# Web API settings (dashboard, web interface) - for reference/connection
|
||||
self.web_api_host = os.environ.get("WEB_API_HOST", "0.0.0.0")
|
||||
self.web_api_port = self._get_int_env("WEB_API_PORT", 8081, 1024, 65535)
|
||||
|
||||
def _load_external_connections(self) -> None:
|
||||
"""Load external API and database connection settings"""
|
||||
# API URLs
|
||||
@@ -113,12 +139,12 @@ class NFOGuardConfig:
|
||||
self.sonarr_url = os.environ.get("SONARR_URL", "")
|
||||
self.jellyseerr_url = os.environ.get("JELLYSEERR_URL", "")
|
||||
|
||||
# Database settings
|
||||
self.db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||
self.db_host = os.environ.get("RADARR_DB_HOST", "")
|
||||
self.db_port = self._get_int_env("RADARR_DB_PORT", 5432, 1, 65535)
|
||||
self.db_name = os.environ.get("RADARR_DB_NAME", "")
|
||||
self.db_user = os.environ.get("RADARR_DB_USER", "")
|
||||
# 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)
|
||||
@@ -129,6 +155,9 @@ class NFOGuardConfig:
|
||||
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()]
|
||||
@@ -144,6 +173,19 @@ class NFOGuardConfig:
|
||||
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_season_dir_name(self, season: int) -> str:
|
||||
"""Get the directory name for a specific season, handling Season 0 as 'Specials'"""
|
||||
if season == 0:
|
||||
return "Specials"
|
||||
return self.tv_season_dir_format.format(season=season)
|
||||
|
||||
def _load_auth_settings(self) -> None:
|
||||
"""Load web interface authentication settings"""
|
||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||
self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
|
||||
self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
|
||||
self.web_auth_session_timeout = self._get_int_env("WEB_AUTH_SESSION_TIMEOUT", 3600, 300, 86400) # 1 hour default, 5min-24h range
|
||||
|
||||
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)
|
||||
@@ -219,15 +261,21 @@ class NFOGuardConfig:
|
||||
return {
|
||||
"tv_paths": [str(p) for p in self.tv_paths],
|
||||
"movie_paths": [str(p) for p in self.movie_paths],
|
||||
"database_path": str(self.db_path),
|
||||
"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)
|
||||
},
|
||||
"database_connection": {
|
||||
"type": self.db_type,
|
||||
"configured": bool(self.db_type and self.db_host)
|
||||
"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,
|
||||
@@ -238,7 +286,8 @@ class NFOGuardConfig:
|
||||
"manage_nfo": self.manage_nfo,
|
||||
"fix_dir_mtimes": self.fix_dir_mtimes,
|
||||
"lock_metadata": self.lock_metadata,
|
||||
"debug": self.debug
|
||||
"debug": self.debug,
|
||||
"manual_scan_prioritize_nfo": self.manual_scan_prioritize_nfo
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from utils.async_file_utils import (
|
||||
async_file_exists,
|
||||
async_set_file_mtime,
|
||||
async_batch_nfo_operations,
|
||||
async_concurrent_episode_processing
|
||||
async_concurrent_episode_processing,
|
||||
aiofiles
|
||||
)
|
||||
from utils.nfo_patterns import (
|
||||
create_basic_nfo_structure,
|
||||
@@ -24,6 +25,7 @@ from utils.nfo_patterns import (
|
||||
extract_imdb_id_from_text
|
||||
)
|
||||
from utils.validation import validate_date_string
|
||||
from utils.file_utils import VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class AsyncNFOManager:
|
||||
@@ -33,6 +35,54 @@ class AsyncNFOManager:
|
||||
self.manager_brand = manager_brand
|
||||
self.debug = debug
|
||||
|
||||
async def _async_get_target_nfo_path(self, season_dir: Path, season: int, episode: int) -> Path:
|
||||
"""
|
||||
Get the target NFO path using video filename matching to prevent concatenation
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
season: Season number
|
||||
episode: Episode number
|
||||
|
||||
Returns:
|
||||
Path to target NFO file (video-matching preferred, generic fallback)
|
||||
"""
|
||||
try:
|
||||
# Find video files in the season directory
|
||||
video_files = []
|
||||
if await async_file_exists(season_dir):
|
||||
import re
|
||||
try:
|
||||
entries = await aiofiles.os.listdir(season_dir)
|
||||
for entry in entries:
|
||||
entry_path = season_dir / entry
|
||||
if await aiofiles.os.path.isfile(entry_path):
|
||||
if entry_path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
# Parse episode info from filename
|
||||
match = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', entry)
|
||||
if match:
|
||||
s, e = int(match.group(1)), int(match.group(2))
|
||||
if s == season and e == episode:
|
||||
# Found matching video file - use its name for NFO
|
||||
target_nfo = season_dir / f"{entry_path.stem}.nfo"
|
||||
if self.debug:
|
||||
_log("DEBUG", f"Video-matching NFO path: {target_nfo.name}")
|
||||
return target_nfo
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
_log("WARNING", f"Failed to scan season directory {season_dir}: {e}")
|
||||
|
||||
# Fallback to generic filename if no matching video found
|
||||
target_nfo = season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||
if self.debug:
|
||||
_log("WARNING", f"No video file found for S{season:02d}E{episode:02d}, using generic: {target_nfo.name}")
|
||||
return target_nfo
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to determine NFO path for S{season:02d}E{episode:02d}: {e}")
|
||||
# Emergency fallback
|
||||
return season_dir / f"S{season:02d}E{episode:02d}.nfo"
|
||||
|
||||
async def async_parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""
|
||||
Async extract IMDb ID from directory path or filename
|
||||
@@ -184,7 +234,7 @@ class AsyncNFOManager:
|
||||
lock_metadata: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Async create episode NFO file
|
||||
Async create episode NFO file with proper video filename matching
|
||||
|
||||
Args:
|
||||
season_dir: Path to season directory
|
||||
@@ -199,8 +249,8 @@ class AsyncNFOManager:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
nfo_filename = f"S{season:02d}E{episode:02d}.nfo"
|
||||
nfo_path = season_dir / nfo_filename
|
||||
# Use proper video filename matching to prevent NFO concatenation
|
||||
nfo_path = await self._async_get_target_nfo_path(season_dir, season, episode)
|
||||
|
||||
# Prepare dates
|
||||
dates = {}
|
||||
|
||||
+793
-107
File diff suppressed because it is too large
Load Diff
@@ -220,7 +220,7 @@ class EpisodeNFOManager:
|
||||
lockdata_elem = ET.SubElement(episode_elem, "lockdata")
|
||||
lockdata_elem.text = "true"
|
||||
|
||||
# Add comment with source
|
||||
# Add comment with source at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode_elem.append(comment)
|
||||
|
||||
|
||||
+278
-93
@@ -50,6 +50,70 @@ class NFOManager:
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_path_with_nfo_fallback(self, path: Path, sonarr_client=None, shutdown_event=None) -> Optional[str]:
|
||||
"""
|
||||
Enhanced IMDb detection that fallback to Sonarr ID lookup from NFO files
|
||||
|
||||
1. First try to parse IMDb ID from directory path/name
|
||||
2. If not found, scan NFO files in directory for Sonarr series ID
|
||||
3. Use Sonarr API to lookup series and extract IMDb ID
|
||||
"""
|
||||
# Primary: Try directory name first
|
||||
imdb_id = self.parse_imdb_from_path(path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Check for shutdown signal before expensive operations
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Fallback: Check NFO files for Sonarr series ID
|
||||
if not sonarr_client or not sonarr_client.enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Look for episode NFO files in the directory (and subdirectories)
|
||||
nfo_files = []
|
||||
if path.is_dir():
|
||||
# Check current directory and season subdirectories
|
||||
nfo_files.extend(list(path.glob("*.nfo")))
|
||||
for subdir in path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.lower().startswith('season'):
|
||||
nfo_files.extend(list(subdir.glob("*.nfo")))
|
||||
|
||||
# Extract Sonarr series ID from any NFO file
|
||||
for nfo_file in nfo_files:
|
||||
# Check for shutdown signal during NFO processing
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# Look for Sonarr series ID
|
||||
for uniqueid in root.findall('.//uniqueid[@type="sonarr"]'):
|
||||
sonarr_id = uniqueid.text
|
||||
if sonarr_id and sonarr_id.isdigit():
|
||||
# Check for shutdown signal before API call
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
return None
|
||||
|
||||
# Look up series in Sonarr to get IMDb ID
|
||||
series_data = sonarr_client.get_series_by_id(int(sonarr_id))
|
||||
if series_data:
|
||||
imdb_id = series_data.get('imdbId')
|
||||
if imdb_id:
|
||||
return imdb_id.replace('tt', '') if imdb_id.startswith('tt') else imdb_id
|
||||
|
||||
except Exception as e:
|
||||
continue # Skip this NFO file and try the next one
|
||||
|
||||
except Exception as e:
|
||||
pass # Fallback failed, return None
|
||||
|
||||
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():
|
||||
@@ -97,12 +161,9 @@ class NFOManager:
|
||||
|
||||
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||
print(f"🔍 Searching for IMDb ID in: {movie_dir.name}")
|
||||
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
print(f"✅ Found IMDb ID in directory name: {imdb_id}")
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
@@ -110,20 +171,36 @@ class NFOManager:
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
print(f"✅ Found IMDb ID in filename: {imdb_id} from {file_path.name}")
|
||||
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:
|
||||
if imdb_id.startswith("tmdb-"):
|
||||
print(f"✅ Found TMDB ID in NFO file: {imdb_id} from {nfo_path} (fallback mode)")
|
||||
else:
|
||||
print(f"✅ Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
|
||||
return imdb_id
|
||||
|
||||
print(f"❌ No IMDb or TMDB ID found in directory, filenames, or NFO for: {movie_dir.name}")
|
||||
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]]:
|
||||
@@ -138,29 +215,98 @@ class NFOManager:
|
||||
# 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')
|
||||
|
||||
# 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"):
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# 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 = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
"source": "nfo_file_existing"
|
||||
"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()
|
||||
|
||||
print(f"✅ Found NFOGuard data in NFO: dateadded={result['dateadded']}, released={result.get('released', 'None')}")
|
||||
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}")
|
||||
except ET.ParseError as e:
|
||||
# Handle malformed XML files gracefully (common with external NFO files)
|
||||
print(f"🔍 NFO XML parse error (will try text fallback): {nfo_path.name}")
|
||||
# Try text-based fallback for extracting NFOGuard data
|
||||
return self._extract_nfoguard_data_text_fallback(nfo_path)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Unexpected error reading NFO file {nfo_path.name}: {e}")
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _extract_nfoguard_data_text_fallback(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Text-based fallback for extracting NFOGuard data from malformed XML files"""
|
||||
try:
|
||||
content = nfo_path.read_text(encoding='utf-8', errors='ignore')
|
||||
|
||||
# Look for NFOGuard elements using regex
|
||||
import re
|
||||
|
||||
# Check for lockdata=true first (NFOGuard marker)
|
||||
if not re.search(r'<lockdata>true</lockdata>', content, re.IGNORECASE):
|
||||
return None
|
||||
|
||||
# Extract dateadded
|
||||
dateadded_match = re.search(r'<dateadded>([^<]+)</dateadded>', content, re.IGNORECASE)
|
||||
if not dateadded_match:
|
||||
return None
|
||||
|
||||
dateadded = dateadded_match.group(1).strip()
|
||||
if not dateadded:
|
||||
return None
|
||||
|
||||
result = {
|
||||
"dateadded": dateadded,
|
||||
"source": "nfo_file_existing" # Default source
|
||||
}
|
||||
|
||||
# Extract optional fields
|
||||
premiered_match = re.search(r'<premiered>([^<]+)</premiered>', content, re.IGNORECASE)
|
||||
if premiered_match:
|
||||
result["released"] = premiered_match.group(1).strip()
|
||||
|
||||
aired_match = re.search(r'<aired>([^<]+)</aired>', content, re.IGNORECASE)
|
||||
if aired_match:
|
||||
result["aired"] = aired_match.group(1).strip()
|
||||
|
||||
# Extract source from NFOGuard comment
|
||||
source_match = re.search(r'<!--\s*NFOGuard\s*-\s*Source:\s*([^-]+?)\s*-->', content)
|
||||
if source_match:
|
||||
result["source"] = source_match.group(1).strip()
|
||||
|
||||
print(f"✅ Extracted NFOGuard data via text fallback: dateadded={result.get('dateadded', 'None')}, source={result['source']}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Text fallback extraction failed for {nfo_path.name}: {e}")
|
||||
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"
|
||||
@@ -178,19 +324,32 @@ class NFOManager:
|
||||
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"):
|
||||
# Consider it NFOGuard-managed ONLY if it has BOTH lockdata=true AND dateadded
|
||||
# This prevents incomplete episode NFO files from being marked as "complete"
|
||||
if (lockdata_elem is not None and lockdata_elem.text == "true" and
|
||||
dateadded_elem is not None and dateadded_elem.text):
|
||||
# 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 = {
|
||||
"dateadded": dateadded_elem.text.strip(),
|
||||
"source": "episode_nfo_existing"
|
||||
"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['dateadded']}, aired={result.get('aired', 'None')}")
|
||||
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:
|
||||
@@ -233,6 +392,9 @@ class NFOManager:
|
||||
"""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()}")
|
||||
@@ -248,24 +410,45 @@ class NFOManager:
|
||||
if movie.tag != "movie":
|
||||
raise ValueError("Root element is not <movie>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the very bottom
|
||||
nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"]
|
||||
for tag in nfoguard_fields:
|
||||
# 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 was None)")
|
||||
released = existing.text # Preserve existing premiered date
|
||||
elif tag == "premiered":
|
||||
print(f"🔍 NOT preserving premiered date: existing={existing.text}, released={released}")
|
||||
movie.remove(existing)
|
||||
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 existing uniqueid with type="imdb" regardless of attributes
|
||||
# We'll add a clean one at the bottom
|
||||
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||
movie.remove(uniqueid)
|
||||
# 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]}...")
|
||||
@@ -275,23 +458,20 @@ class NFOManager:
|
||||
# Create new NFO structure
|
||||
movie = ET.Element("movie")
|
||||
|
||||
# Now append ALL NFOGuard fields at the VERY END of the file
|
||||
# Create elements and explicitly append them to ensure they're at the bottom
|
||||
# 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} ")
|
||||
movie.append(nfoguard_comment)
|
||||
|
||||
# Add IMDb uniqueid at the end (after all existing content)
|
||||
# Add IMDb uniqueid
|
||||
uniqueid = ET.Element("uniqueid", type="imdb", default="true")
|
||||
uniqueid.text = imdb_id
|
||||
movie.append(uniqueid)
|
||||
nfoguard_elements.append(uniqueid)
|
||||
|
||||
# Add premiered date at the bottom if we have it
|
||||
# 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
|
||||
movie.append(premiered_elem)
|
||||
nfoguard_elements.append(premiered_elem)
|
||||
|
||||
# Extract year from premiered date for consistency
|
||||
try:
|
||||
@@ -299,17 +479,18 @@ class NFOManager:
|
||||
if year_value and year_value.isdigit():
|
||||
year_elem = ET.Element("year")
|
||||
year_elem.text = year_value
|
||||
movie.append(year_elem)
|
||||
nfoguard_elements.append(year_elem)
|
||||
except:
|
||||
pass # Skip year if we can't extract it
|
||||
|
||||
# Add dateadded at the end - THIS IS CRITICAL FOR EMBY PLUGIN
|
||||
# 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
|
||||
movie.append(dateadded_elem)
|
||||
print(f"✅ Added dateadded to NFO: {dateadded}")
|
||||
nfoguard_elements.append(dateadded_elem)
|
||||
print(f"✅ Adding dateadded to NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to NFO")
|
||||
|
||||
@@ -317,13 +498,24 @@ class NFOManager:
|
||||
if lock_metadata:
|
||||
lockdata = ET.Element("lockdata")
|
||||
lockdata.text = "true"
|
||||
movie.append(lockdata)
|
||||
nfoguard_elements.append(lockdata)
|
||||
|
||||
# Add NFOGuard comment as the very last element (appears at bottom)
|
||||
nfoguard_comment = ET.Comment(f" NFOGuard - Source: {source} ")
|
||||
nfoguard_elements.append(nfoguard_comment)
|
||||
|
||||
# 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)
|
||||
# Write directly to file (comment is already embedded in XML at bottom)
|
||||
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)
|
||||
@@ -383,22 +575,14 @@ class NFOManager:
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
tvshow.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
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 ""))
|
||||
@@ -445,22 +629,14 @@ class NFOManager:
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} ")
|
||||
season.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||
print(f" Season: {season_number}")
|
||||
@@ -595,7 +771,7 @@ class NFOManager:
|
||||
if preserved_fields:
|
||||
print(f" 🔍 Content preserved after cleanup: {', '.join(preserved_fields)}")
|
||||
else:
|
||||
print(f" ⚠️ No content fields found after cleanup!")
|
||||
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]}...")
|
||||
@@ -631,33 +807,33 @@ class NFOManager:
|
||||
# Dates at the end
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode, "aired")
|
||||
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
# Convert datetime objects to strings
|
||||
aired_str = str(aired)
|
||||
aired_elem.text = aired_str[:10] if len(aired_str) >= 10 else aired_str
|
||||
|
||||
# Debug logging for dateadded
|
||||
print(f"🔍 DEBUG: dateadded value: {repr(dateadded)} (type: {type(dateadded)})")
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
# Convert datetime objects to strings
|
||||
dateadded_elem.text = str(dateadded)
|
||||
print(f"✅ Added dateadded to episode NFO: {dateadded}")
|
||||
else:
|
||||
print(f"❌ dateadded is empty/None, not adding to episode NFO")
|
||||
|
||||
# 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} "
|
||||
# Add NFOGuard comment at the bottom
|
||||
comment = ET.Comment(f" Created by {self.manager_brand} - Source: {source} ")
|
||||
episode.append(comment)
|
||||
|
||||
# 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)
|
||||
tree.write(nfo_path, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
@@ -667,16 +843,25 @@ class NFOManager:
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||
|
||||
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
||||
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 already a simple date
|
||||
# Assume it's a simple date (e.g., "2025-10-16")
|
||||
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
||||
|
||||
# Convert to timestamp
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/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
|
||||
from config.settings import config
|
||||
|
||||
def debug_movie(imdb_id: str):
|
||||
"""Debug a specific movie's data"""
|
||||
print(f"🔍 DEBUG MOVIE: {imdb_id}")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize database
|
||||
db = NFOGuardDatabase(config=config)
|
||||
|
||||
# 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)
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script to check specific TV series/episode 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
|
||||
from config.settings import config
|
||||
|
||||
def debug_series(imdb_id: str):
|
||||
"""Debug a specific TV series' data"""
|
||||
print(f"📺 DEBUG TV SERIES: {imdb_id}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize database
|
||||
db = NFOGuardDatabase(config=config)
|
||||
|
||||
# Get series episodes
|
||||
episodes = db.get_series_episodes(imdb_id)
|
||||
if not episodes:
|
||||
print(f"❌ TV series {imdb_id} not found in database")
|
||||
return
|
||||
|
||||
print(f"📊 SERIES OVERVIEW:")
|
||||
print(f" IMDb ID: {imdb_id}")
|
||||
print(f" Total Episodes: {len(episodes)}")
|
||||
|
||||
# Count episodes by status
|
||||
with_dates = sum(1 for ep in episodes if ep.get('dateadded'))
|
||||
without_dates = len(episodes) - with_dates
|
||||
with_video = sum(1 for ep in episodes if ep.get('has_video_file'))
|
||||
|
||||
print(f" Episodes with dates: {with_dates}")
|
||||
print(f" Episodes without dates: {without_dates}")
|
||||
print(f" Episodes with video files: {with_video}")
|
||||
|
||||
# Group by season
|
||||
seasons = {}
|
||||
for ep in episodes:
|
||||
season = ep.get('season', 'Unknown')
|
||||
if season not in seasons:
|
||||
seasons[season] = []
|
||||
seasons[season].append(ep)
|
||||
|
||||
print(f" Seasons: {len(seasons)} ({', '.join(f'S{s}' if isinstance(s, int) else str(s) for s in sorted(seasons.keys()))})")
|
||||
|
||||
# Show sources breakdown
|
||||
sources = {}
|
||||
for ep in episodes:
|
||||
source = ep.get('source', 'None')
|
||||
sources[source] = sources.get(source, 0) + 1
|
||||
|
||||
print(f"\n📈 SOURCES BREAKDOWN:")
|
||||
for source, count in sorted(sources.items(), key=lambda x: x[1], reverse=True):
|
||||
print(f" {source}: {count} episodes")
|
||||
|
||||
# Show recent episodes (last 10 by date added)
|
||||
episodes_with_dates = [ep for ep in episodes if ep.get('dateadded')]
|
||||
recent_episodes = sorted(episodes_with_dates, key=lambda x: x.get('last_updated', ''), reverse=True)[:10]
|
||||
|
||||
if recent_episodes:
|
||||
print(f"\n🕒 RECENT EPISODES (by last_updated):")
|
||||
for ep in recent_episodes:
|
||||
season = ep.get('season', '?')
|
||||
episode = ep.get('episode', '?')
|
||||
dateadded = ep.get('dateadded', 'None')
|
||||
source = ep.get('source', 'None')
|
||||
video = "✅" if ep.get('has_video_file') else "❌"
|
||||
print(f" S{season:02d}E{episode:02d}: {dateadded} | {source} | Video: {video}")
|
||||
|
||||
def debug_episode(imdb_id: str, season: int, episode: int):
|
||||
"""Debug a specific episode's data"""
|
||||
print(f"📺 DEBUG TV EPISODE: {imdb_id} S{season:02d}E{episode:02d}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize database
|
||||
db = NFOGuardDatabase(config=config)
|
||||
|
||||
# Get specific episode
|
||||
episode_data = db.get_episode_date(imdb_id, season, episode)
|
||||
if not episode_data:
|
||||
print(f"❌ Episode S{season:02d}E{episode:02d} for series {imdb_id} not found in database")
|
||||
return
|
||||
|
||||
print("📊 RAW EPISODE DATA:")
|
||||
for key, value in episode_data.items():
|
||||
print(f" {key}: {repr(value)}")
|
||||
|
||||
print("\n📺 FORMATTED EPISODE DATA:")
|
||||
print(f" Series IMDb: {episode_data.get('imdb_id', 'Unknown')}")
|
||||
print(f" Season/Episode: S{episode_data.get('season', '?'):02d}E{episode_data.get('episode', '?'):02d}")
|
||||
print(f" Title: {episode_data.get('title', 'Unknown')}")
|
||||
print(f" Air Date: {episode_data.get('air_date', 'None')}")
|
||||
print(f" Date Added: {episode_data.get('dateadded', 'None')}")
|
||||
print(f" Source: {episode_data.get('source', 'None')}")
|
||||
print(f" Has Video: {episode_data.get('has_video_file', False)}")
|
||||
print(f" Video Path: {episode_data.get('video_path', 'None')}")
|
||||
print(f" Last Updated: {episode_data.get('last_updated', 'None')}")
|
||||
|
||||
# Check if air date is valid
|
||||
air_date = episode_data.get('air_date')
|
||||
if air_date and air_date.strip():
|
||||
try:
|
||||
from datetime import datetime
|
||||
test_date = f"{air_date}T00:00:00"
|
||||
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
|
||||
print(f"\n✅ Air date is valid: {parsed}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Air date is INVALID: {e}")
|
||||
else:
|
||||
print(f"\n⚠️ Air date is empty or None")
|
||||
|
||||
# Check if dateadded is valid
|
||||
dateadded = episode_data.get('dateadded')
|
||||
if dateadded and dateadded.strip():
|
||||
try:
|
||||
from datetime import datetime
|
||||
if isinstance(dateadded, str):
|
||||
test_date = f"{dateadded}T00:00:00" if 'T' not in dateadded else dateadded
|
||||
parsed = datetime.fromisoformat(test_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
parsed = dateadded
|
||||
print(f"✅ Date added is valid: {parsed}")
|
||||
except Exception as e:
|
||||
print(f"❌ Date added is INVALID: {e}")
|
||||
else:
|
||||
print(f"⚠️ Date added is empty or None")
|
||||
|
||||
def debug_season(imdb_id: str, season: int):
|
||||
"""Debug all episodes in a specific season"""
|
||||
print(f"📺 DEBUG TV SEASON: {imdb_id} Season {season}")
|
||||
print("=" * 60)
|
||||
|
||||
# Initialize database
|
||||
db = NFOGuardDatabase(config=config)
|
||||
|
||||
# Get series episodes
|
||||
all_episodes = db.get_series_episodes(imdb_id)
|
||||
season_episodes = [ep for ep in all_episodes if ep.get('season') == season]
|
||||
|
||||
if not season_episodes:
|
||||
print(f"❌ No episodes found for season {season} of series {imdb_id}")
|
||||
return
|
||||
|
||||
print(f"📊 SEASON {season} OVERVIEW:")
|
||||
print(f" Total Episodes: {len(season_episodes)}")
|
||||
|
||||
# Sort by episode number
|
||||
season_episodes.sort(key=lambda x: x.get('episode', 0))
|
||||
|
||||
with_dates = sum(1 for ep in season_episodes if ep.get('dateadded'))
|
||||
without_dates = len(season_episodes) - with_dates
|
||||
with_video = sum(1 for ep in season_episodes if ep.get('has_video_file'))
|
||||
|
||||
print(f" Episodes with dates: {with_dates}")
|
||||
print(f" Episodes without dates: {without_dates}")
|
||||
print(f" Episodes with video files: {with_video}")
|
||||
|
||||
print(f"\n📋 EPISODE LIST:")
|
||||
for ep in season_episodes:
|
||||
episode_num = ep.get('episode', '?')
|
||||
title = ep.get('title', 'Unknown')[:30] + ('...' if len(ep.get('title', '')) > 30 else '')
|
||||
dateadded = ep.get('dateadded', 'None')
|
||||
source = ep.get('source', 'None')
|
||||
video = "✅" if ep.get('has_video_file') else "❌"
|
||||
air_date = ep.get('air_date', 'None')
|
||||
|
||||
print(f" E{episode_num:02d}: {title:<33} | Added: {dateadded} | Air: {air_date} | Video: {video}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage:")
|
||||
print(" python debug_tv.py <imdb_id> # Debug entire series")
|
||||
print(" python debug_tv.py <imdb_id> <season> # Debug specific season")
|
||||
print(" python debug_tv.py <imdb_id> <season> <episode> # Debug specific episode")
|
||||
print("\nExamples:")
|
||||
print(" python debug_tv.py tt0121955 # Debug South Park")
|
||||
print(" python debug_tv.py tt0121955 27 # Debug South Park Season 27")
|
||||
print(" python debug_tv.py tt0121955 27 6 # Debug South Park S27E06")
|
||||
sys.exit(1)
|
||||
|
||||
imdb_id = sys.argv[1]
|
||||
if not imdb_id.startswith('tt'):
|
||||
imdb_id = f'tt{imdb_id}'
|
||||
|
||||
if len(sys.argv) == 4:
|
||||
# Debug specific episode
|
||||
season = int(sys.argv[2])
|
||||
episode = int(sys.argv[3])
|
||||
debug_episode(imdb_id, season, episode)
|
||||
elif len(sys.argv) == 3:
|
||||
# Debug specific season
|
||||
season = int(sys.argv[2])
|
||||
debug_season(imdb_id, season)
|
||||
else:
|
||||
# Debug entire series
|
||||
debug_series(imdb_id)
|
||||
@@ -0,0 +1,144 @@
|
||||
# NFOGuard Production Docker Compose - 3-Container Architecture
|
||||
#
|
||||
# RECOMMENDED SETUP: Separated core processing and web interface for optimal performance
|
||||
#
|
||||
# This is the default configuration providing:
|
||||
# - Performance isolation between web and processing
|
||||
# - Webhook responsiveness during scans
|
||||
# - Independent scaling and updates
|
||||
# - Professional web interface with branding
|
||||
#
|
||||
# For legacy single-container setup, see: docker-compose.legacy-single.yml
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
nfoguard-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: nfoguard-db
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME}
|
||||
- POSTGRES_USER=${DB_USER}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- TZ=${TZ}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_EXTERNAL_PORT:-5432}:5432" # Optional external access
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-nfoguard} -d ${DB_NAME:-nfoguard}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
container_name: nfoguard-core
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ}
|
||||
- WEB_EXTERNAL_PORT=${WEB_EXTERNAL_PORT}
|
||||
volumes:
|
||||
# Media paths (adjust to your setup)
|
||||
- /mnt/unionfs/Media/TV:/media/TV:ro
|
||||
- /mnt/unionfs/Media/Movies:/media/Movies:ro
|
||||
# Data persistence
|
||||
- nfoguard_data:/app/data
|
||||
# Logs
|
||||
- nfoguard_logs:/app/data/logs
|
||||
ports:
|
||||
- "${CORE_API_PORT:-8080}:8080" # Core API (webhooks, processing)
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
image: sbcrumb/nfoguard:latest # Same image as core!
|
||||
container_name: nfoguard-web
|
||||
restart: unless-stopped
|
||||
command: ["python", "start_web.py"] # Different entry point
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
environment:
|
||||
- TZ=${TZ:-America/New_York}
|
||||
ports:
|
||||
- "${WEB_API_PORT:-8081}:8081" # Web Interface
|
||||
depends_on:
|
||||
nfoguard-db:
|
||||
condition: service_healthy
|
||||
nfoguard:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- nfoguard-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
nfoguard_data:
|
||||
driver: local
|
||||
nfoguard_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
nfoguard-network:
|
||||
driver: bridge
|
||||
|
||||
# Configuration Notes:
|
||||
# 1. Core Processing (nfoguard): Handles webhooks, scanning, NFO management
|
||||
# 2. Web Interface (nfoguard-web): Lightweight dashboard and management
|
||||
# 3. Database (nfoguard-db): Shared PostgreSQL database
|
||||
#
|
||||
# Port Configuration:
|
||||
# - Core API: ${CORE_API_PORT:-8080} (webhooks, processing)
|
||||
# - Web Interface: ${WEB_API_PORT:-8081} (dashboard)
|
||||
# - Database: ${DB_EXTERNAL_PORT:-5432} (optional external access)
|
||||
#
|
||||
# Performance Benefits:
|
||||
# - Web interface operations don't impact core processing
|
||||
# - Webhooks remain responsive during long scans
|
||||
# - Independent scaling and resource allocation
|
||||
# - Separated concerns for maintenance and updates
|
||||
# NFOGuard Core (Processing Engine)
|
||||
nfoguard:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/simple"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 60s # Increased from 40s
|
||||
|
||||
# NFOGuard Web Interface
|
||||
nfoguard-web:
|
||||
# ... other settings ...
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 15s # Increased from 10s
|
||||
retries: 3
|
||||
start_period: 30s # Increased from 10s
|
||||
@@ -1,3 +1,14 @@
|
||||
# NFOGuard Legacy Single-Container Configuration
|
||||
#
|
||||
# DEPRECATED: This is the legacy single-container setup where web interface
|
||||
# and core processing run in the same container, which can cause performance
|
||||
# issues during intensive scans.
|
||||
#
|
||||
# RECOMMENDED: Use docker-compose.example.yml for the new 3-container
|
||||
# architecture with better performance isolation.
|
||||
#
|
||||
# This file is maintained for backward compatibility and migration purposes.
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
@@ -6,16 +17,24 @@ services:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
|
||||
# Alternative: Use specific version
|
||||
# image: sbcrumb/nfoguard:v1.5.5
|
||||
# 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
|
||||
@@ -83,6 +102,54 @@ services:
|
||||
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:
|
||||
@@ -93,12 +160,13 @@ services:
|
||||
# ===========================================
|
||||
# 1. Copy this file to docker-compose.yml
|
||||
# 2. Update volume mounts to match your actual media paths
|
||||
# 3. Copy .env.template to .env and configure
|
||||
# 4. Copy .env.secrets.template to .env.secrets and add credentials
|
||||
# 5. Create data directory: mkdir -p ./data
|
||||
# 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 nfoguard
|
||||
# 8. Access health check: curl http://localhost:8080/health
|
||||
# 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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
@@ -1,11 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard - Automated NFO file management for Radarr and Sonarr
|
||||
Modular architecture with webhook processing and intelligent date handling
|
||||
NFOGuard Core - Automated NFO file management and processing engine
|
||||
Core processing container with webhooks, scanning, and database management
|
||||
Web interface separated to nfoguard-web container
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -14,6 +16,8 @@ from fastapi import FastAPI
|
||||
|
||||
# Import configuration first
|
||||
from config.settings import config
|
||||
|
||||
# Authentication removed - handled by separate web container
|
||||
from utils.logging import _log
|
||||
|
||||
# Import core components
|
||||
@@ -34,6 +38,8 @@ 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"""
|
||||
@@ -86,7 +92,7 @@ def initialize_components():
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Initialize core components
|
||||
db = NFOGuardDatabase(config.db_path)
|
||||
db = NFOGuardDatabase(config=config)
|
||||
nfo_manager = NFOManager(config.manager_brand, config.debug)
|
||||
path_mapper = PathMapper(config)
|
||||
|
||||
@@ -107,13 +113,51 @@ def initialize_components():
|
||||
"batcher": batcher,
|
||||
"start_time": start_time,
|
||||
"config": config,
|
||||
"version": get_version()
|
||||
"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")
|
||||
|
||||
# Force exit after 2 seconds if graceful shutdown doesn't work
|
||||
import threading
|
||||
def force_exit():
|
||||
import time
|
||||
time.sleep(2)
|
||||
_log("WARNING", "Force exiting after timeout")
|
||||
os._exit(0)
|
||||
|
||||
force_thread = threading.Thread(target=force_exit, daemon=True)
|
||||
force_thread.start()
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
@@ -129,6 +173,10 @@ def main():
|
||||
_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}")
|
||||
@@ -139,21 +187,52 @@ def main():
|
||||
# Initialize components
|
||||
dependencies = initialize_components()
|
||||
|
||||
# Note: Authentication and web interface handled by separate nfoguard-web container
|
||||
_log("INFO", "Core API: Authentication handled by separate web container")
|
||||
|
||||
# Store dependencies globally for signal handler access
|
||||
signal_handler.dependencies = dependencies
|
||||
|
||||
# Register routes
|
||||
register_routes(app, dependencies)
|
||||
|
||||
try:
|
||||
# Core API configuration (webhooks, processing, database management)
|
||||
core_host = config.core_api_host if hasattr(config, 'core_api_host') else "0.0.0.0"
|
||||
core_port = config.core_api_port if hasattr(config, 'core_api_port') else 8080
|
||||
|
||||
_log("INFO", f"🚀 Starting NFOGuard Core API on {core_host}:{core_port}")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=int(os.environ.get("PORT", "8080")),
|
||||
reload=False
|
||||
host=core_host,
|
||||
port=core_port,
|
||||
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__":
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web Interface Package
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web API Package
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Simple authentication middleware for NFOGuard web interface
|
||||
Provides basic HTTP auth and session management for web interface protection
|
||||
"""
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import HTTPException, status, Request, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
class AuthSession:
|
||||
"""Simple session management for web interface"""
|
||||
|
||||
def __init__(self, timeout_seconds: int = 3600):
|
||||
self.sessions: Dict[str, Dict[str, Any]] = {}
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def create_session(self, username: str) -> str:
|
||||
"""Create a new session and return session token"""
|
||||
session_token = secrets.token_urlsafe(32)
|
||||
self.sessions[session_token] = {
|
||||
"username": username,
|
||||
"created_at": datetime.utcnow(),
|
||||
"last_activity": datetime.utcnow()
|
||||
}
|
||||
return session_token
|
||||
|
||||
def validate_session(self, session_token: str) -> bool:
|
||||
"""Validate session token and update last activity"""
|
||||
if not session_token or session_token not in self.sessions:
|
||||
return False
|
||||
|
||||
session = self.sessions[session_token]
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Check if session expired
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
del self.sessions[session_token]
|
||||
return False
|
||||
|
||||
# Update last activity
|
||||
session["last_activity"] = now
|
||||
return True
|
||||
|
||||
def get_session_user(self, session_token: str) -> Optional[str]:
|
||||
"""Get username from valid session"""
|
||||
if self.validate_session(session_token):
|
||||
return self.sessions[session_token]["username"]
|
||||
return None
|
||||
|
||||
def delete_session(self, session_token: str) -> None:
|
||||
"""Delete a session (logout)"""
|
||||
if session_token in self.sessions:
|
||||
del self.sessions[session_token]
|
||||
|
||||
def cleanup_expired_sessions(self) -> None:
|
||||
"""Remove expired sessions"""
|
||||
now = datetime.utcnow()
|
||||
expired_tokens = []
|
||||
|
||||
for token, session in self.sessions.items():
|
||||
if (now - session["last_activity"]).seconds > self.timeout_seconds:
|
||||
expired_tokens.append(token)
|
||||
|
||||
for token in expired_tokens:
|
||||
del self.sessions[token]
|
||||
|
||||
|
||||
class SimpleAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Simple authentication middleware for web interface routes"""
|
||||
|
||||
def __init__(self, app, config):
|
||||
super().__init__(app)
|
||||
self.config = config
|
||||
self.session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
self.security = HTTPBasic()
|
||||
|
||||
# Routes that require authentication (web interface)
|
||||
self.protected_routes = [
|
||||
"/", # Main web interface
|
||||
"/static/", # Static files (CSS, JS)
|
||||
"/api/movies", # Web API endpoints
|
||||
"/api/series",
|
||||
"/api/episodes",
|
||||
"/api/dashboard"
|
||||
]
|
||||
|
||||
# Routes that are always public (webhooks, health checks, API endpoints)
|
||||
self.public_routes = [
|
||||
"/webhook/",
|
||||
"/health",
|
||||
"/ping",
|
||||
"/api/v1/health",
|
||||
"/api/v1/metrics",
|
||||
"/database/", # Database management endpoints (API access)
|
||||
"/manual/", # Manual scan endpoints (API access)
|
||||
"/debug/", # Debug endpoints (API access)
|
||||
"/test/", # Test endpoints (API access)
|
||||
"/bulk/" # Bulk operation endpoints (API access)
|
||||
]
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""Process request through authentication middleware"""
|
||||
|
||||
# Skip authentication if disabled
|
||||
if not self.config.web_auth_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Check if route requires authentication
|
||||
path = request.url.path
|
||||
needs_auth = any(path.startswith(route) for route in self.protected_routes)
|
||||
is_public = any(path.startswith(route) for route in self.public_routes)
|
||||
|
||||
if is_public or not needs_auth:
|
||||
return await call_next(request)
|
||||
|
||||
# Check for existing session
|
||||
session_token = request.cookies.get("nfoguard_session")
|
||||
if session_token and self.session_manager.validate_session(session_token):
|
||||
# Valid session, proceed
|
||||
return await call_next(request)
|
||||
|
||||
# Check for HTTP Basic Auth
|
||||
auth_header = request.headers.get("authorization")
|
||||
if auth_header and auth_header.startswith("Basic "):
|
||||
credentials = self._parse_basic_auth(auth_header)
|
||||
if credentials and self._validate_credentials(credentials.username, credentials.password):
|
||||
# Create session for successful login
|
||||
session_token = self.session_manager.create_session(credentials.username)
|
||||
response = await call_next(request)
|
||||
response.set_cookie(
|
||||
key="nfoguard_session",
|
||||
value=session_token,
|
||||
max_age=self.config.web_auth_session_timeout,
|
||||
httponly=True,
|
||||
secure=False # Set to True if using HTTPS
|
||||
)
|
||||
return response
|
||||
|
||||
# Authentication required
|
||||
return self._auth_required_response()
|
||||
|
||||
def _parse_basic_auth(self, auth_header: str) -> Optional[HTTPBasicCredentials]:
|
||||
"""Parse HTTP Basic Auth header"""
|
||||
try:
|
||||
import base64
|
||||
encoded_credentials = auth_header.split(" ")[1]
|
||||
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
||||
username, password = decoded_credentials.split(":", 1)
|
||||
return HTTPBasicCredentials(username=username, password=password)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _validate_credentials(self, username: str, password: str) -> bool:
|
||||
"""Validate username and password"""
|
||||
return (username == self.config.web_auth_username and
|
||||
password == self.config.web_auth_password)
|
||||
|
||||
def _auth_required_response(self) -> Response:
|
||||
"""Return 401 response with WWW-Authenticate header"""
|
||||
return Response(
|
||||
content="Authentication required",
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Basic realm=\"NFOGuard Web Interface\""}
|
||||
)
|
||||
|
||||
|
||||
def create_auth_dependencies(config) -> Dict[str, Any]:
|
||||
"""Create authentication-related dependencies for dependency injection"""
|
||||
session_manager = AuthSession(config.web_auth_session_timeout)
|
||||
|
||||
return {
|
||||
"session_manager": session_manager,
|
||||
"auth_enabled": config.web_auth_enabled,
|
||||
"auth_config": {
|
||||
"username": config.web_auth_username,
|
||||
"timeout": config.web_auth_session_timeout
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
NFOGuard Web Interface Configuration
|
||||
Lightweight configuration for web-only container
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
def _bool_env(name: str, default: bool = False) -> bool:
|
||||
"""Convert environment variable to boolean"""
|
||||
value = os.environ.get(name, "").lower()
|
||||
return value in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
class WebConfig:
|
||||
"""Configuration for NFOGuard Web Interface"""
|
||||
|
||||
def __init__(self):
|
||||
self._load_server_settings()
|
||||
self._load_database_settings()
|
||||
self._load_auth_settings()
|
||||
self._load_ui_settings()
|
||||
|
||||
def _load_server_settings(self) -> None:
|
||||
"""Load web server configuration"""
|
||||
self.web_host = os.environ.get("WEB_HOST", "0.0.0.0")
|
||||
self.web_port = int(os.environ.get("WEB_PORT", "8081"))
|
||||
self.web_workers = int(os.environ.get("WEB_WORKERS", "1"))
|
||||
self.web_debug = _bool_env("WEB_DEBUG", False)
|
||||
|
||||
# Core NFOGuard API connection (for some operations)
|
||||
self.core_api_host = os.environ.get("CORE_API_HOST", "nfoguard")
|
||||
self.core_api_port = int(os.environ.get("CORE_API_PORT", "8080"))
|
||||
self.core_api_url = f"http://{self.core_api_host}:{self.core_api_port}"
|
||||
|
||||
def _load_database_settings(self) -> None:
|
||||
"""Load database configuration (read-only access)"""
|
||||
self.db_type = os.environ.get("DB_TYPE", "postgresql").lower()
|
||||
self.db_host = os.environ.get("DB_HOST", "nfoguard-db")
|
||||
self.db_port = int(os.environ.get("DB_PORT", "5432"))
|
||||
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", "")
|
||||
|
||||
if not self.db_password:
|
||||
raise ValueError("DB_PASSWORD must be set for web interface database access")
|
||||
|
||||
def _load_auth_settings(self) -> None:
|
||||
"""Load web interface authentication settings"""
|
||||
self.web_auth_enabled = _bool_env("WEB_AUTH_ENABLED", False)
|
||||
self.web_auth_username = os.environ.get("WEB_AUTH_USERNAME", "admin")
|
||||
self.web_auth_password = os.environ.get("WEB_AUTH_PASSWORD", "")
|
||||
self.web_auth_session_timeout = int(os.environ.get("WEB_AUTH_SESSION_TIMEOUT", "3600"))
|
||||
|
||||
if self.web_auth_enabled and not self.web_auth_password:
|
||||
raise ValueError("WEB_AUTH_PASSWORD must be set when authentication is enabled")
|
||||
|
||||
def _load_ui_settings(self) -> None:
|
||||
"""Load UI-specific settings"""
|
||||
self.app_title = os.environ.get("APP_TITLE", "NFOGuard")
|
||||
self.app_subtitle = os.environ.get("APP_SUBTITLE", "Database Management & Reporting")
|
||||
self.pagination_limit = int(os.environ.get("PAGINATION_LIMIT", "50"))
|
||||
self.refresh_interval = int(os.environ.get("REFRESH_INTERVAL", "30")) # seconds
|
||||
|
||||
# Logo configuration
|
||||
self.logo_enabled = _bool_env("LOGO_ENABLED", True)
|
||||
self.logo_path = "/static/logo/NFOguardLogoPlain.png"
|
||||
|
||||
|
||||
# Global config instance
|
||||
web_config = WebConfig()
|
||||
@@ -0,0 +1 @@
|
||||
# NFOGuard Web Core Components
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
NFOGuard Web Database - Lightweight Read-Only Database Access
|
||||
Optimized for web interface queries with minimal dependencies
|
||||
"""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDatabase:
|
||||
"""Lightweight database access for web interface"""
|
||||
|
||||
def __init__(self, db_type: str, host: str, port: int, database: str, user: str, password: str):
|
||||
self.db_type = db_type.lower()
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.connection = None
|
||||
|
||||
# Connect to database
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
"""Connect to PostgreSQL database"""
|
||||
if self.db_type != "postgresql":
|
||||
raise ValueError("Web interface only supports PostgreSQL")
|
||||
|
||||
try:
|
||||
self.connection = psycopg2.connect(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
cursor_factory=psycopg2.extras.RealDictCursor
|
||||
)
|
||||
# Set to autocommit for read operations
|
||||
self.connection.autocommit = True
|
||||
logger.info(f"Connected to PostgreSQL: {self.host}:{self.port}/{self.database}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to database: {e}")
|
||||
raise
|
||||
|
||||
def execute_query(self, query: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
|
||||
"""Execute a SELECT query and return results"""
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Query failed: {query[:100]}... Error: {e}")
|
||||
raise
|
||||
|
||||
def execute_single(self, query: str, params: Optional[Tuple] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Execute a query and return single result"""
|
||||
results = self.execute_query(query, params)
|
||||
return results[0] if results else None
|
||||
|
||||
def execute_scalar(self, query: str, params: Optional[Tuple] = None) -> Any:
|
||||
"""Execute a query and return single value"""
|
||||
result = self.execute_single(query, params)
|
||||
return list(result.values())[0] if result else None
|
||||
|
||||
# Dashboard Statistics
|
||||
def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
stats = {}
|
||||
|
||||
# Movie statistics
|
||||
movie_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_movies,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as movies_with_dates,
|
||||
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as movies_without_dates
|
||||
FROM movies
|
||||
"""
|
||||
movie_stats = self.execute_single(movie_query)
|
||||
stats.update(movie_stats)
|
||||
|
||||
# TV statistics
|
||||
tv_query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT imdb_id) as total_series,
|
||||
COUNT(*) as total_episodes,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source != 'unknown' THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN dateadded IS NULL OR source = 'unknown' THEN 1 END) as episodes_without_dates
|
||||
FROM episodes
|
||||
"""
|
||||
tv_stats = self.execute_single(tv_query)
|
||||
stats.update(tv_stats)
|
||||
|
||||
return stats
|
||||
|
||||
# Movie queries
|
||||
def get_movies(self, skip: int = 0, limit: int = 50, has_date: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||
"""Get movies with pagination"""
|
||||
where_clause = ""
|
||||
params = []
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
|
||||
else:
|
||||
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
|
||||
|
||||
query = f"""
|
||||
SELECT imdb_id, title, year, dateadded, released, source, last_updated
|
||||
FROM movies
|
||||
{where_clause}
|
||||
ORDER BY title, year
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params.extend([limit, skip])
|
||||
|
||||
return self.execute_query(query, params)
|
||||
|
||||
def get_movie_count(self, has_date: Optional[bool] = None) -> int:
|
||||
"""Get total movie count"""
|
||||
where_clause = ""
|
||||
params = []
|
||||
|
||||
if has_date is not None:
|
||||
if has_date:
|
||||
where_clause = "WHERE dateadded IS NOT NULL AND source != 'unknown'"
|
||||
else:
|
||||
where_clause = "WHERE dateadded IS NULL OR source = 'unknown'"
|
||||
|
||||
query = f"SELECT COUNT(*) FROM movies {where_clause}"
|
||||
return self.execute_scalar(query, params)
|
||||
|
||||
# TV Series queries
|
||||
def get_series(self, skip: int = 0, limit: int = 50, date_filter: str = "none") -> List[Dict[str, Any]]:
|
||||
"""Get TV series with episode statistics"""
|
||||
where_clause = ""
|
||||
if date_filter == "complete":
|
||||
where_clause = """
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
elif date_filter == "incomplete":
|
||||
where_clause = """
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
e.imdb_id,
|
||||
e.series_title,
|
||||
COUNT(*) as total_episodes,
|
||||
COUNT(CASE WHEN e.dateadded IS NOT NULL AND e.source != 'unknown' THEN 1 END) as episodes_with_dates,
|
||||
COUNT(CASE WHEN e.dateadded IS NULL OR e.source = 'unknown' THEN 1 END) as episodes_without_dates,
|
||||
MAX(e.last_updated) as last_updated
|
||||
FROM episodes e
|
||||
{where_clause}
|
||||
GROUP BY e.imdb_id, e.series_title
|
||||
ORDER BY e.series_title
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
|
||||
return self.execute_query(query, [limit, skip])
|
||||
|
||||
def get_series_count(self, date_filter: str = "none") -> int:
|
||||
"""Get total series count"""
|
||||
where_clause = ""
|
||||
if date_filter == "complete":
|
||||
where_clause = """
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
elif date_filter == "incomplete":
|
||||
where_clause = """
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM episodes e2
|
||||
WHERE e2.imdb_id = e.imdb_id
|
||||
AND (e2.dateadded IS NULL OR e2.source = 'unknown')
|
||||
)
|
||||
"""
|
||||
|
||||
query = f"""
|
||||
SELECT COUNT(DISTINCT imdb_id)
|
||||
FROM episodes e
|
||||
{where_clause}
|
||||
"""
|
||||
|
||||
return self.execute_scalar(query)
|
||||
|
||||
def get_episodes_for_series(self, imdb_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
query = """
|
||||
SELECT imdb_id, series_title, season, episode, episode_title,
|
||||
dateadded, source, last_updated
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s
|
||||
ORDER BY season, episode
|
||||
"""
|
||||
return self.execute_query(query, [imdb_id])
|
||||
|
||||
# Source statistics
|
||||
def get_series_sources(self) -> List[Dict[str, Any]]:
|
||||
"""Get source statistics for series"""
|
||||
query = """
|
||||
SELECT
|
||||
source,
|
||||
COUNT(DISTINCT imdb_id) as series_count,
|
||||
COUNT(*) as episode_count
|
||||
FROM episodes
|
||||
WHERE source != 'unknown'
|
||||
GROUP BY source
|
||||
ORDER BY series_count DESC, episode_count DESC
|
||||
"""
|
||||
return self.execute_query(query)
|
||||
|
||||
# Episode-specific methods for web interface
|
||||
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||
"""Get episode data including dates"""
|
||||
query = """
|
||||
SELECT imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
"""
|
||||
return self.execute_single(query, (imdb_id, season, episode))
|
||||
|
||||
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) -> None:
|
||||
"""Update or insert episode date information"""
|
||||
# First check if episode exists
|
||||
existing = self.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if existing:
|
||||
# Update existing episode
|
||||
query = """
|
||||
UPDATE episodes
|
||||
SET aired = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
|
||||
WHERE imdb_id = %s AND season = %s AND episode = %s
|
||||
"""
|
||||
cursor.execute(query, (aired, dateadded, source, has_video_file, imdb_id, season, episode))
|
||||
else:
|
||||
# Insert new episode
|
||||
query = """
|
||||
INSERT INTO episodes (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(query, (imdb_id, season, episode, aired, dateadded, source, has_video_file))
|
||||
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to upsert episode date: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""Get movie data including dates"""
|
||||
query = """
|
||||
SELECT imdb_id, path, released, dateadded, source, has_video_file, last_updated
|
||||
FROM movies
|
||||
WHERE imdb_id = %s
|
||||
"""
|
||||
return self.execute_single(query, (imdb_id,))
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str,
|
||||
has_video_file: bool = False, path: str = "") -> None:
|
||||
"""Update or insert movie date information"""
|
||||
# First check if movie exists
|
||||
existing = self.get_movie_dates(imdb_id)
|
||||
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
if existing:
|
||||
# Update existing movie
|
||||
query = """
|
||||
UPDATE movies
|
||||
SET released = %s, dateadded = %s, source = %s, has_video_file = %s, last_updated = NOW()
|
||||
WHERE imdb_id = %s
|
||||
"""
|
||||
cursor.execute(query, (released, dateadded, source, has_video_file, imdb_id))
|
||||
else:
|
||||
# Insert new movie
|
||||
query = """
|
||||
INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, NOW())
|
||||
"""
|
||||
cursor.execute(query, (imdb_id, path, released, dateadded, source, has_video_file))
|
||||
|
||||
self.connection.commit()
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to upsert movie dates: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def get_connection(self):
|
||||
"""Get database connection for advanced operations"""
|
||||
return self.connection
|
||||
|
||||
def _get_first_value(self, row):
|
||||
"""Extract first value from a database row (compatibility method)"""
|
||||
if row is None:
|
||||
return None
|
||||
if isinstance(row, dict):
|
||||
return list(row.values())[0] if row else None
|
||||
return row[0] if row else None
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic database statistics (compatibility method)"""
|
||||
return self.get_dashboard_stats()
|
||||
|
||||
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Dict) -> None:
|
||||
"""Add processing history entry (simplified for web interface)"""
|
||||
# Temporarily disable autocommit for the transaction
|
||||
original_autocommit = self.connection.autocommit
|
||||
self.connection.autocommit = False
|
||||
|
||||
try:
|
||||
with self.connection.cursor() as cursor:
|
||||
# Check if processing_history table exists
|
||||
cursor.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'processing_history'
|
||||
)
|
||||
""")
|
||||
table_exists = cursor.fetchone()[0]
|
||||
|
||||
if table_exists:
|
||||
query = """
|
||||
INSERT INTO processing_history (imdb_id, media_type, event_type, details, processed_at)
|
||||
VALUES (%s, %s, %s, %s, NOW())
|
||||
"""
|
||||
import json
|
||||
cursor.execute(query, (imdb_id, media_type, event_type, json.dumps(details)))
|
||||
self.connection.commit()
|
||||
else:
|
||||
# Table doesn't exist, skip logging
|
||||
logger.debug("Processing history table not found, skipping log entry")
|
||||
except Exception as e:
|
||||
self.connection.rollback()
|
||||
logger.error(f"Failed to add processing history: {e}")
|
||||
# Don't raise, this is non-critical
|
||||
finally:
|
||||
# Restore original autocommit setting
|
||||
self.connection.autocommit = original_autocommit
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
logger.info("Database connection closed")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 926 KiB |
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
NFOGuard Web Interface - Separated Web Application
|
||||
Lightweight FastAPI application for web interface only
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Add current directory and parent directory to path for imports
|
||||
sys.path.append(str(Path(__file__).parent))
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
# Import web-specific configuration
|
||||
from config.web_settings import web_config
|
||||
|
||||
# Import database (lightweight, read-only access)
|
||||
from core.web_database import WebDatabase
|
||||
|
||||
# Import web routes and authentication
|
||||
from api.web_routes import register_web_routes
|
||||
from api.auth import SimpleAuthMiddleware, create_auth_dependencies
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.6.12-web",
|
||||
docs_url="/docs" if web_config.web_debug else None,
|
||||
redoc_url="/redoc" if web_config.web_debug else None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def initialize_web_database() -> WebDatabase:
|
||||
"""Initialize web database connection (read-only optimized)"""
|
||||
return WebDatabase(
|
||||
db_type=web_config.db_type,
|
||||
host=web_config.db_host,
|
||||
port=web_config.db_port,
|
||||
database=web_config.db_name,
|
||||
user=web_config.db_user,
|
||||
password=web_config.db_password
|
||||
)
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
# Mount main static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Mount logo separately for easy access
|
||||
app.mount("/logo", StaticFiles(directory="logo"), name="logo")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
def setup_signal_handlers():
|
||||
"""Setup graceful shutdown signal handlers"""
|
||||
def signal_handler(signum, frame):
|
||||
print(f"\n🛑 Received signal {signum}, shutting down web interface...")
|
||||
# Web interface can shutdown immediately (no background processing)
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
print(f"📊 Configuration: Port {web_config.web_port}, Auth: {'Enabled' if web_config.web_auth_enabled else 'Disabled'}")
|
||||
|
||||
# Setup signal handlers
|
||||
setup_signal_handlers()
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
db = initialize_web_database()
|
||||
print(f"✅ Connected to database: {web_config.db_host}:{web_config.db_port}/{web_config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": web_config
|
||||
}
|
||||
|
||||
# Add authentication dependencies if enabled
|
||||
if web_config.web_auth_enabled:
|
||||
auth_deps = create_auth_dependencies(web_config)
|
||||
dependencies.update(auth_deps)
|
||||
|
||||
# Add authentication middleware
|
||||
app.add_middleware(SimpleAuthMiddleware, config=web_config)
|
||||
print(f"🔐 Web authentication enabled for user: {web_config.web_auth_username}")
|
||||
else:
|
||||
print("🔓 Web authentication disabled - interface is public")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes
|
||||
register_web_routes(app, dependencies)
|
||||
|
||||
print(f"🚀 Starting web server on {web_config.web_host}:{web_config.web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_config.web_host,
|
||||
port=web_config.web_port,
|
||||
workers=web_config.web_workers,
|
||||
log_level="debug" if web_config.web_debug else "info",
|
||||
access_log=web_config.web_debug
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,902 @@
|
||||
/* 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);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
.auth-status {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-logout {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
<!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?v=manual-scan-ui">
|
||||
<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>
|
||||
<div class="auth-status" id="auth-status" style="display: none;">
|
||||
<span class="auth-user">
|
||||
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
|
||||
</span>
|
||||
<button class="auth-logout" id="logout-btn" onclick="logout()">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</button>
|
||||
</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="scheduled-scans">
|
||||
<i class="fas fa-clock"></i> Scheduled Scans
|
||||
</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>
|
||||
|
||||
<!-- Scheduled Scans Tab -->
|
||||
<div class="tab-content" id="scheduled-scans">
|
||||
<div class="content-header">
|
||||
<h2><i class="fas fa-clock"></i> Scheduled Scans</h2>
|
||||
<button class="btn btn-primary" id="add-schedule-btn">
|
||||
<i class="fas fa-plus"></i> Add Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Active Schedules Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-list"></i> Active Schedules</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="schedules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Mode</th>
|
||||
<th>Schedule</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="schedules-table-body">
|
||||
<!-- Schedules will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Execution History Section -->
|
||||
<div class="section-card">
|
||||
<h3><i class="fas fa-history"></i> Recent Executions</h3>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="executions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schedule</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
<th>Items Processed</th>
|
||||
<th>Items Skipped</th>
|
||||
<th>Items Failed</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="executions-table-body">
|
||||
<!-- Executions will be loaded here -->
|
||||
</tbody>
|
||||
</table>
|
||||
</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-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</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()">×</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()">×</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>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<div class="modal" id="schedule-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="schedule-modal-title">Add New Schedule</h3>
|
||||
<button class="modal-close" onclick="closeScheduleModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="schedule-form">
|
||||
<input type="hidden" id="schedule-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-name">Schedule Name:</label>
|
||||
<input type="text" id="schedule-name" required placeholder="e.g., Daily TV Incomplete Scan">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-description">Description:</label>
|
||||
<textarea id="schedule-description" placeholder="Optional description of what this schedule does"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="schedule-media-type">Media Type:</label>
|
||||
<select id="schedule-media-type" required>
|
||||
<option value="both">Both TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-scan-mode">Scan Mode:</label>
|
||||
<select id="schedule-scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
<option value="full">Full Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-cron">Schedule (Cron Expression):</label>
|
||||
<div class="cron-input-container">
|
||||
<input type="text" id="schedule-cron" required placeholder="0 2 * * *" pattern="^(\*|[0-5]?\d|\*\/[0-9]+)(\s+(\*|[0-5]?\d|\*\/[0-9]+)){4}$">
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="cron-builder-btn">
|
||||
<i class="fas fa-magic"></i> Builder
|
||||
</button>
|
||||
</div>
|
||||
<small class="help-text">
|
||||
Examples: "0 2 * * *" (daily at 2 AM), "0 2 * * 0" (weekly on Sunday at 2 AM)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="schedule-paths">Specific Paths (Optional):</label>
|
||||
<textarea id="schedule-paths" placeholder="Leave empty to scan entire library, or specify paths separated by commas"></textarea>
|
||||
<small class="help-text">
|
||||
Example: /media/TV/Series Name, /media/Movies/Movie Name
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="schedule-enabled" checked>
|
||||
<span class="checkmark"></span>
|
||||
Enable this schedule
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeScheduleModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span id="schedule-submit-text">Create Schedule</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Builder Modal -->
|
||||
<div class="modal" id="cron-builder-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Cron Expression Builder</h3>
|
||||
<button class="modal-close" onclick="closeCronBuilder()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cron-builder">
|
||||
<div class="cron-presets">
|
||||
<h4>Quick Presets:</h4>
|
||||
<div class="preset-buttons">
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * *')">Daily at 2 AM</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 * * 0')">Weekly (Sunday 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 2 1 * *')">Monthly (1st at 2 AM)</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */6 * * *')">Every 6 Hours</button>
|
||||
<button type="button" class="btn btn-outline" onclick="setCronPreset('0 */12 * * *')">Every 12 Hours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-fields">
|
||||
<h4>Custom Schedule:</h4>
|
||||
<div class="field-group">
|
||||
<label>Minute (0-59):</label>
|
||||
<input type="text" id="cron-minute" value="0" placeholder="0">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Hour (0-23):</label>
|
||||
<input type="text" id="cron-hour" value="2" placeholder="2">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Month (1-31):</label>
|
||||
<input type="text" id="cron-day" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Month (1-12):</label>
|
||||
<input type="text" id="cron-month" value="*" placeholder="*">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label>Day of Week (0-6):</label>
|
||||
<input type="text" id="cron-dow" value="*" placeholder="*">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cron-preview">
|
||||
<h4>Preview:</h4>
|
||||
<div class="cron-expression">
|
||||
<code id="cron-preview-text">0 2 * * *</code>
|
||||
</div>
|
||||
<div class="cron-description" id="cron-description">
|
||||
Runs daily at 2:00 AM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeCronBuilder()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="applyCronExpression()">Use This Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script src="/static/js/app.js?v=scheduled-scans-system"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
+406
-68
@@ -88,12 +88,75 @@ class MovieProcessor:
|
||||
path_mapper=self.path_mapper
|
||||
)
|
||||
|
||||
def process_movie(self, movie_path: Path, webhook_mode: bool = False) -> None:
|
||||
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, scan_mode: str = "smart", 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
|
||||
return "error"
|
||||
|
||||
# Handle TMDB ID fallback case
|
||||
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||
@@ -102,6 +165,27 @@ class MovieProcessor:
|
||||
else:
|
||||
_log("INFO", f"Processing movie: {movie_path.name} (IMDb: {imdb_id})")
|
||||
|
||||
# Check if we should skip this movie (unless forced, webhook mode, or incomplete mode)
|
||||
# Skip database optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and not webhook_mode and scan_mode != "incomplete":
|
||||
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))
|
||||
|
||||
@@ -110,25 +194,110 @@ class MovieProcessor:
|
||||
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}")
|
||||
self.db.upsert_movie_dates(imdb_id, None, None, None, False)
|
||||
return
|
||||
_log("WARNING", f"No video files found in: {movie_path} - skipping database entry")
|
||||
return "no_video_files"
|
||||
|
||||
# TIER 1: Check if NFO file already has NFOGuard data (fastest - no DB or API calls)
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._process_movie_nfo_first(movie_path, imdb_id, shutdown_event)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# 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"🚀 Using existing NFOGuard data from NFO file: {nfo_data['dateadded']} (source: {nfo_data['source']})")
|
||||
_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-only]")
|
||||
return
|
||||
_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:
|
||||
@@ -153,70 +322,35 @@ class MovieProcessor:
|
||||
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
|
||||
return "processed"
|
||||
|
||||
# TIER 2: Check database for existing data
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
_log("DEBUG", f"Database lookup for {imdb_id}: {existing}")
|
||||
# TIER 3: No cached data found - proceed with API lookups and verification
|
||||
|
||||
# If we have complete data in database, use it and skip expensive API calls
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
_log("INFO", f"✅ Using complete database data for {imdb_id}: {existing['dateadded']} (source: {existing['source']})")
|
||||
# Still create NFO and update files but skip API queries
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
# 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"
|
||||
|
||||
# Create NFO with existing data
|
||||
print(f"🔍 TIER2 - config.manage_nfo: {config.manage_nfo}")
|
||||
if config.manage_nfo:
|
||||
print(f"🔍 TIER2 - Calling create_movie_nfo with dateadded: {dateadded}")
|
||||
self.nfo_manager.create_movie_nfo(
|
||||
movie_path, imdb_id, dateadded, released, source, config.lock_metadata
|
||||
)
|
||||
else:
|
||||
print(f"❌ TIER2 - manage_nfo is disabled, skipping NFO creation")
|
||||
|
||||
# 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}) [database-only]")
|
||||
return
|
||||
|
||||
# Handle webhook mode - prioritize database, then use proper date logic
|
||||
# TIER 3: No cached data found - determine if we should query APIs
|
||||
if webhook_mode:
|
||||
_log("DEBUG", f"Webhook mode: existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else 'N/A'}")
|
||||
if existing and existing.get("dateadded"):
|
||||
_log("INFO", f"Webhook processing - using existing database entry: {existing['dateadded']} (source: {existing.get('source', 'unknown')})")
|
||||
dateadded, source, released = existing["dateadded"], existing["source"], existing.get("released")
|
||||
_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:
|
||||
if existing:
|
||||
_log("INFO", f"Webhook processing - database entry exists but no dateadded field: {existing}")
|
||||
else:
|
||||
_log("INFO", f"Webhook processing - no database entry found for {imdb_id}")
|
||||
_log("INFO", f"Using full date decision logic")
|
||||
# Use same logic as manual scan to check Radarr import dates, release dates, etc.
|
||||
should_query = True # Always query for webhooks when no database entry exists
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
|
||||
# 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}")
|
||||
|
||||
# Only if ALL date sources fail, fall back to current timestamp
|
||||
if dateadded is None:
|
||||
# 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"
|
||||
else:
|
||||
# Manual scan mode - determine if we should query APIs
|
||||
should_query = (
|
||||
config.movie_poll_mode == "always" or
|
||||
(config.movie_poll_mode == "if_missing" and not existing) or
|
||||
(config.movie_poll_mode == "if_missing" and existing and existing.get("source") == "file:mtime") or
|
||||
(config.movie_poll_mode == "if_missing" and existing and not existing.get("dateadded"))
|
||||
)
|
||||
|
||||
_log("DEBUG", f"Movie {imdb_id}: should_query={should_query}, poll_mode={config.movie_poll_mode}, existing={bool(existing)}, has_dateadded={bool(existing and existing.get('dateadded')) if existing else False}")
|
||||
|
||||
# Use existing movie date decision logic
|
||||
dateadded, source, released = self._decide_movie_dates(imdb_id, movie_path, should_query, existing)
|
||||
|
||||
# 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
|
||||
@@ -229,20 +363,23 @@ class MovieProcessor:
|
||||
_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
|
||||
return "processed"
|
||||
|
||||
# Update dateadded and source for the rest of processing
|
||||
dateadded = final_dateadded
|
||||
@@ -256,6 +393,7 @@ class MovieProcessor:
|
||||
|
||||
_log("DEBUG", f"Movie processing reached file mtime section: fix_dir_mtimes={config.fix_dir_mtimes}, dateadded={dateadded}")
|
||||
|
||||
|
||||
# Save to database
|
||||
_log("DEBUG", f"About to save to database: imdb_id={imdb_id}, dateadded={dateadded}")
|
||||
try:
|
||||
@@ -266,6 +404,142 @@ class MovieProcessor:
|
||||
raise
|
||||
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
|
||||
def _process_movie_nfo_first(self, movie_path: Path, imdb_id: str, shutdown_event=None) -> str:
|
||||
"""Process movie for incomplete mode: Check NFO files first for missing dateadded elements"""
|
||||
_log("INFO", f"🔍 NFO-FIRST MODE: Checking movie for missing dateadded in NFO file: {movie_path.name}")
|
||||
|
||||
# Check for shutdown signal
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping movie NFO-first processing: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
nfo_path = movie_path / "movie.nfo"
|
||||
|
||||
# STEP 1: Check if NFO file exists and has dateadded
|
||||
_log("DEBUG", f"STEP 1 - Checking NFO file for missing dateadded: {nfo_path}")
|
||||
_log("INFO", f"🔍 NFO exists: {nfo_path.exists()}")
|
||||
|
||||
if nfo_path.exists():
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
_log("INFO", f"🔍 NFOGuard data extracted: {nfo_data}")
|
||||
|
||||
if nfo_data and nfo_data.get('dateadded'):
|
||||
# NFO has dateadded - this movie is complete
|
||||
_log("INFO", f"✅ NFO has dateadded={nfo_data['dateadded']}, movie marked as complete")
|
||||
dateadded = nfo_data["dateadded"]
|
||||
source = nfo_data["source"]
|
||||
released = nfo_data.get("released")
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
# Update file mtimes if needed
|
||||
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-complete]")
|
||||
return "processed"
|
||||
else:
|
||||
# NFO exists but missing dateadded
|
||||
_log("INFO", f"🔍 NFO exists but missing dateadded - needs DB/API lookup")
|
||||
else:
|
||||
# No NFO file found
|
||||
_log("INFO", f"🔍 No NFO file found - needs DB/API lookup")
|
||||
|
||||
# STEP 2: For movies missing dateadded in NFO, check database
|
||||
_log("DEBUG", f"STEP 2 - Checking database for missing dateadded")
|
||||
existing = self.db.get_movie_dates(imdb_id)
|
||||
|
||||
if existing and existing.get("dateadded") and existing.get("source") != "no_valid_date_source":
|
||||
# Found in database - use cached data and will add to NFO
|
||||
_log("INFO", f"✅ Database has dateadded={existing['dateadded']} - will add to NFO")
|
||||
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 database data
|
||||
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-to-nfo]")
|
||||
return "processed"
|
||||
|
||||
# STEP 3: For movies still missing dateadded, query APIs
|
||||
_log("DEBUG", f"STEP 3 - Querying APIs for missing dateadded")
|
||||
|
||||
# Check for shutdown signal before API calls
|
||||
if shutdown_event and shutdown_event.is_set():
|
||||
_log("INFO", f"⚠️ SHUTDOWN SIGNAL RECEIVED - Stopping before API calls: {movie_path.name}")
|
||||
return "shutdown"
|
||||
|
||||
# Handle TMDB ID fallback case
|
||||
is_tmdb_fallback = imdb_id.startswith("tmdb-")
|
||||
|
||||
if is_tmdb_fallback:
|
||||
# TMDB fallback processing
|
||||
_log("INFO", f"🔍 TMDB fallback processing for {imdb_id}")
|
||||
|
||||
# Check for existing TMDB NFO date data
|
||||
tmdb_data = self._extract_dates_from_tmdb_nfo(nfo_path)
|
||||
if tmdb_data and tmdb_data.get('dateadded'):
|
||||
dateadded = tmdb_data['dateadded']
|
||||
released = tmdb_data.get('released')
|
||||
source = "tmdb_nfo"
|
||||
else:
|
||||
# Use file modification time as fallback for TMDB
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as fallback for TMDB movie: {dateadded}")
|
||||
else:
|
||||
# Standard IMDb processing
|
||||
# Try to get digital release date from external APIs
|
||||
digital_date, digital_source = self._get_digital_release_date(imdb_id)
|
||||
|
||||
if digital_date:
|
||||
dateadded = digital_date
|
||||
source = digital_source
|
||||
released = digital_date # For movies, digital release is often the key date
|
||||
_log("INFO", f"Got digital release date from APIs: {dateadded} (source: {source})")
|
||||
else:
|
||||
# Check Radarr NFO for premiered date
|
||||
radarr_premiered = self._get_radarr_nfo_premiered_date(movie_path)
|
||||
if radarr_premiered:
|
||||
dateadded = radarr_premiered
|
||||
source = "radarr_nfo"
|
||||
released = radarr_premiered
|
||||
_log("INFO", f"Got premiered date from Radarr NFO: {dateadded}")
|
||||
else:
|
||||
# Last resort: file modification time
|
||||
dateadded, source, released = self._get_file_mtime_date(movie_path)
|
||||
_log("INFO", f"Using file mtime as last resort: {dateadded}")
|
||||
|
||||
# Save to database and create NFO
|
||||
if dateadded:
|
||||
self.db.upsert_movie_dates(imdb_id, released, dateadded, source, True)
|
||||
|
||||
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"🔍 NFO-FIRST COMPLETE: {movie_path.name} (source: {source})")
|
||||
return "processed"
|
||||
else:
|
||||
_log("WARNING", f"Could not determine dateadded for movie: {movie_path.name}")
|
||||
return "error"
|
||||
|
||||
def _extract_dates_from_tmdb_nfo(self, nfo_path: Path) -> Optional[Dict[str, str]]:
|
||||
"""Extract date information from TMDB-based NFO file"""
|
||||
@@ -292,6 +566,61 @@ class MovieProcessor:
|
||||
|
||||
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}")
|
||||
@@ -339,7 +668,8 @@ class MovieProcessor:
|
||||
# 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
|
||||
# When using digital release date, store it as both dateadded and released
|
||||
return digital_date, digital_source, digital_date
|
||||
else:
|
||||
# Convert file date to local timezone for NFO files
|
||||
local_file_date = convert_utc_to_local(import_date)
|
||||
@@ -354,7 +684,8 @@ class MovieProcessor:
|
||||
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
|
||||
# When using digital release date, store it as both dateadded and released
|
||||
return digital_date, digital_source, digital_date
|
||||
else:
|
||||
_log("WARNING", f"⚠️ Movie {imdb_id}: No import date OR digital release date found - trying additional fallbacks")
|
||||
|
||||
@@ -362,13 +693,15 @@ class MovieProcessor:
|
||||
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
|
||||
# When using Radarr NFO premiered date, store it as both dateadded and released
|
||||
return radarr_premiered, "radarr:nfo.premiered", radarr_premiered
|
||||
|
||||
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
|
||||
# When using digital release date, store it as both dateadded and released
|
||||
return digital_date, digital_source, digital_date
|
||||
|
||||
# Fall back to import history
|
||||
if radarr_movie:
|
||||
@@ -380,6 +713,11 @@ class MovieProcessor:
|
||||
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)
|
||||
|
||||
+516
-51
@@ -4,6 +4,7 @@ Handles TV series processing and episode management with async I/O support
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Set, Tuple, Any
|
||||
@@ -52,31 +53,149 @@ class TVProcessor:
|
||||
path_mapper=self.path_mapper
|
||||
)
|
||||
|
||||
def process_series(self, series_path: Path) -> None:
|
||||
def should_skip_series_fast(self, imdb_id: str, series_name: str = "") -> Tuple[bool, str, int]:
|
||||
"""
|
||||
Fast preliminary check to skip series without filesystem scan
|
||||
|
||||
Args:
|
||||
imdb_id: Series IMDb ID
|
||||
series_name: Series name for logging
|
||||
|
||||
Returns:
|
||||
(should_skip: bool, reason: str, episodes_in_db: int)
|
||||
"""
|
||||
try:
|
||||
with self.db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if we have complete episodes in database
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_in_db,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source IS NOT NULL AND source != 'unknown' AND source != 'no_valid_date_source' THEN 1 END) as complete_episodes
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False, "No database records found", 0
|
||||
|
||||
total_in_db = result['total_in_db']
|
||||
complete_episodes = result['complete_episodes']
|
||||
|
||||
# Skip if we have episodes and all are complete
|
||||
# We'll verify disk count later if needed
|
||||
if total_in_db > 0 and complete_episodes == total_in_db:
|
||||
return True, f"Likely complete: {complete_episodes} episodes in DB all have valid dates", total_in_db
|
||||
else:
|
||||
return False, f"Needs checking: {complete_episodes}/{total_in_db} episodes complete in DB", total_in_db
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error in fast series check for {imdb_id}: {e}")
|
||||
return False, f"Error in fast check: {e}", 0
|
||||
|
||||
def should_skip_series(self, imdb_id: str, episodes_on_disk: int, series_name: str = "") -> Tuple[bool, str]:
|
||||
"""
|
||||
Determine if we should skip processing this series based on completion status
|
||||
|
||||
Args:
|
||||
imdb_id: Series IMDb ID
|
||||
episodes_on_disk: Number of episodes found on disk
|
||||
series_name: Series name for logging
|
||||
|
||||
Returns:
|
||||
(should_skip: bool, reason: str)
|
||||
"""
|
||||
try:
|
||||
with self.db.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# PostgreSQL-only query
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_in_db,
|
||||
COUNT(CASE WHEN dateadded IS NOT NULL AND source IS NOT NULL AND source != 'unknown' AND source != 'no_valid_date_source' THEN 1 END) as complete_episodes
|
||||
FROM episodes
|
||||
WHERE imdb_id = %s
|
||||
""", (imdb_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
return False, "No database records found"
|
||||
|
||||
# PostgreSQL RealDictCursor returns dict-like objects
|
||||
total_in_db = result['total_in_db']
|
||||
complete_episodes = result['complete_episodes']
|
||||
|
||||
# Skip if:
|
||||
# 1. We have episodes in database
|
||||
# 2. Database count matches disk count (no missing episodes)
|
||||
# 3. All episodes have valid dates and sources
|
||||
if total_in_db > 0 and total_in_db == episodes_on_disk and complete_episodes == episodes_on_disk:
|
||||
return True, f"Complete: {complete_episodes}/{episodes_on_disk} episodes have valid dates"
|
||||
elif total_in_db == 0:
|
||||
return False, f"New series: No episodes in database"
|
||||
elif total_in_db != episodes_on_disk:
|
||||
return False, f"Disk mismatch: {total_in_db} in DB vs {episodes_on_disk} on disk"
|
||||
else:
|
||||
return False, f"Incomplete: {complete_episodes}/{episodes_on_disk} episodes have valid dates"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking series completion for {imdb_id}: {e}")
|
||||
return False, f"Error checking completion: {e}"
|
||||
|
||||
def process_series(self, series_path: Path, force_scan: bool = False, scan_mode: str = "smart") -> str:
|
||||
"""Process a TV series directory"""
|
||||
imdb_id = self.nfo_manager.parse_imdb_from_path(series_path)
|
||||
if not imdb_id:
|
||||
_log("ERROR", f"No IMDb ID found in series path: {series_path}")
|
||||
return
|
||||
return "error"
|
||||
|
||||
_log("INFO", f"Processing TV series: {series_path.name}")
|
||||
|
||||
# Fast check first - avoid expensive filesystem scan if possible
|
||||
# Skip fast optimization for incomplete mode since we need to check NFO files first
|
||||
if not force_scan and scan_mode != "incomplete":
|
||||
should_skip_fast, reason_fast, episodes_in_db = self.should_skip_series_fast(imdb_id, series_path.name)
|
||||
if should_skip_fast:
|
||||
_log("INFO", f"⚡ FAST SKIP: {series_path.name} [{imdb_id}] - {reason_fast}")
|
||||
# Still update the series record to track that we've seen it
|
||||
self.db.upsert_series(imdb_id, str(series_path))
|
||||
return "skipped"
|
||||
|
||||
# Need filesystem scan - either force_scan=True or series not complete in DB
|
||||
disk_episodes = find_episodes_on_disk(series_path)
|
||||
_log("INFO", f"Found {len(disk_episodes)} episodes on disk")
|
||||
|
||||
# Final skip check with actual episode count (unless forced)
|
||||
if not force_scan:
|
||||
should_skip, reason = self.should_skip_series(imdb_id, len(disk_episodes), series_path.name)
|
||||
if should_skip:
|
||||
_log("INFO", f"⏭️ SKIPPING SERIES: {series_path.name} [{imdb_id}] - {reason}")
|
||||
# Still update the series record to track that we've seen it
|
||||
self.db.upsert_series(imdb_id, str(series_path))
|
||||
return "skipped"
|
||||
else:
|
||||
_log("INFO", f"📺 PROCESSING SERIES: {series_path.name} [{imdb_id}] - {reason}")
|
||||
else:
|
||||
_log("INFO", f"🔄 FORCE PROCESSING SERIES: {series_path.name} [{imdb_id}] - Force scan enabled")
|
||||
|
||||
# Update database
|
||||
self.db.upsert_series(imdb_id, str(series_path))
|
||||
|
||||
# Find video files
|
||||
disk_episodes = find_episodes_on_disk(series_path)
|
||||
_log("INFO", f"Found {len(disk_episodes)} episodes on disk")
|
||||
|
||||
# Get episode dates
|
||||
episode_dates = self._gather_episode_dates(series_path, imdb_id, disk_episodes)
|
||||
|
||||
# Process episodes
|
||||
# Process episodes with periodic yielding for non-blocking operation
|
||||
episode_count = 0
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
episode_count += 1
|
||||
|
||||
# Create NFO
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
self.nfo_manager.create_episode_nfo(
|
||||
season_dir,
|
||||
season, episode, aired, dateadded, source, config.lock_metadata
|
||||
@@ -89,26 +208,40 @@ class TVProcessor:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
|
||||
# Skip season.nfo and tvshow.nfo creation - focus only on episode NFOs
|
||||
pass
|
||||
|
||||
_log("INFO", f"Completed processing TV series: {series_path.name}")
|
||||
return "processed"
|
||||
|
||||
def _extract_series_title_from_path(self, series_path: Path) -> Optional[str]:
|
||||
"""Extract series title from directory path using unified file utilities"""
|
||||
return extract_title_from_directory_name(series_path.name)
|
||||
|
||||
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with database-first optimization"""
|
||||
def _gather_episode_dates(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]], scan_mode: str = "smart") -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode air dates and date added information with optimization based on scan mode"""
|
||||
_log("INFO", f"🎯 GATHERING EPISODE DATES for {imdb_id}: {len(disk_episodes)} episodes on disk (mode: {scan_mode})")
|
||||
episode_dates = {}
|
||||
episodes_needing_lookup = []
|
||||
|
||||
# OPTIMIZATION: Check database first for existing dates
|
||||
_log("DEBUG", f"Checking database for existing episode dates for {len(disk_episodes)} episodes")
|
||||
# For incomplete mode: Start with NFO check to find missing dateadded elements
|
||||
if scan_mode == "incomplete":
|
||||
return self._gather_episode_dates_nfo_first(series_path, imdb_id, disk_episodes)
|
||||
|
||||
# For smart/full modes: Use database-first optimization
|
||||
# TIER 1: Check database first for existing dates (fastest)
|
||||
_log("DEBUG", f"TIER 1 - Checking database for existing episode dates for {len(disk_episodes)} episodes")
|
||||
db_cache_hits = 0
|
||||
episodes_needing_nfo_check = []
|
||||
|
||||
for (season, episode) in disk_episodes:
|
||||
# Try database first - this is much faster than API calls
|
||||
@@ -123,14 +256,67 @@ class TVProcessor:
|
||||
db_cache_hits += 1
|
||||
_log("DEBUG", f"Database cache hit for S{season:02d}E{episode:02d}: {dateadded}")
|
||||
else:
|
||||
# Not in database or incomplete - needs lookup
|
||||
# Not in database or incomplete - needs NFO check
|
||||
episodes_needing_nfo_check.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need NFO check: {len(episodes_needing_nfo_check)}")
|
||||
|
||||
# TIER 2: Check NFO files for NFOGuard dates and cache them in database
|
||||
nfo_cache_hits = 0
|
||||
episodes_needing_lookup = []
|
||||
|
||||
if episodes_needing_nfo_check:
|
||||
_log("DEBUG", f"TIER 2 - Checking NFO files for NFOGuard dates for {len(episodes_needing_nfo_check)} episodes")
|
||||
|
||||
for (season, episode) in episodes_needing_nfo_check:
|
||||
# Look for existing NFO files for this episode
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_found = False
|
||||
for episode_file in episode_files:
|
||||
# Try to find matching NFO file
|
||||
nfo_path = episode_file.with_suffix('.nfo')
|
||||
if nfo_path.exists():
|
||||
# Extract NFOGuard data from episode NFO
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
if nfo_data:
|
||||
aired = nfo_data.get('aired')
|
||||
dateadded = nfo_data.get('dateadded')
|
||||
source = nfo_data.get('source', 'nfo_cache')
|
||||
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO data found - aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
# Skip incomplete NFO files with "unknown" source or no useful dates
|
||||
if source == "unknown" and not dateadded and not aired:
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Ignoring incomplete NFO file with source 'unknown' and no dates")
|
||||
break # Break out of NFO search to mark episode as needing API lookup
|
||||
|
||||
# Apply fallback logic if NFO has aired but no dateadded
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
source = f"{source}_aired_fallback" if source != 'nfo_cache' else 'nfo_aired_fallback'
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has aired but no dateadded, using aired as fallback: {dateadded}")
|
||||
|
||||
if dateadded:
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
nfo_cache_hits += 1
|
||||
nfo_found = True
|
||||
|
||||
# Cache NFO data in database for future lookups
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"NFO cache hit for S{season:02d}E{episode:02d}: {dateadded} - cached in DB")
|
||||
break
|
||||
|
||||
if not nfo_found:
|
||||
# No NFO data found - needs API lookup
|
||||
episodes_needing_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database cache hits: {db_cache_hits}/{len(disk_episodes)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
|
||||
_log("INFO", f"NFO cache hits: {nfo_cache_hits}/{len(episodes_needing_nfo_check)} episodes. Need API lookup: {len(episodes_needing_lookup)}")
|
||||
|
||||
# Only call Sonarr API for episodes not in database
|
||||
# TIER 3: Only call Sonarr API for episodes not in database or NFO files
|
||||
if episodes_needing_lookup:
|
||||
_log("DEBUG", f"Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database")
|
||||
_log("DEBUG", f"TIER 3 - Querying Sonarr for {len(episodes_needing_lookup)} episodes missing from database and NFO files")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_lookup)
|
||||
|
||||
# Process episodes that needed lookup
|
||||
@@ -144,30 +330,219 @@ class TVProcessor:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: Full Sonarr data: {sonarr_data}")
|
||||
_log("DEBUG", f"SPECIALS DEBUG S{season:02d}E{episode:02d}: airDate='{aired}', dateAdded='{dateadded}'")
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
else:
|
||||
# Sonarr has episode data but no import date - update source for better fallback handling
|
||||
source = "sonarr:no_import_date"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Sonarr has data but no dateAdded (aired: {aired})")
|
||||
|
||||
# Fallback to external sources if needed
|
||||
if not aired:
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No aired date from Sonarr, trying external APIs for {imdb_id}")
|
||||
external_aired = self.external_clients.get_episode_air_date(imdb_id, season, episode)
|
||||
if external_aired:
|
||||
aired = external_aired
|
||||
if not dateadded:
|
||||
source = "external"
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Found aired date from external APIs: {aired}")
|
||||
else:
|
||||
_log("WARNING", f"S{season:02d}E{episode:02d}: No aired date found from external APIs for {imdb_id}")
|
||||
|
||||
# Use air date as fallback for dateadded if no import date found
|
||||
if not dateadded and aired and source != "sonarr:history.import":
|
||||
if not dateadded and aired:
|
||||
# Always use air date as fallback when no import date is available
|
||||
dateadded = aired
|
||||
if source == "sonarr:no_import_date":
|
||||
source = "sonarr:aired_fallback"
|
||||
elif source == "sonarr:history.import":
|
||||
# This shouldn't happen but handle it gracefully
|
||||
source = "sonarr:aired_fallback"
|
||||
else:
|
||||
source = f"{source}_fallback" if source != "unknown" else "aired_fallback"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as fallback: {dateadded} (source: {source})")
|
||||
|
||||
# Ensure air date is saved to database even if used as dateadded fallback
|
||||
if aired and not dateadded:
|
||||
# This is a fallback for cases where we have air date but absolutely no dateadded
|
||||
dateadded = aired
|
||||
source = "aired_only_fallback"
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: No import date found, using air date as both aired and dateadded: {dateadded}")
|
||||
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
|
||||
_log("INFO", f"🎯 EPISODE DATES GATHERED: {len(episode_dates)} episodes with dates")
|
||||
for (s, e), (aired, dateadded, source) in episode_dates.items():
|
||||
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _gather_episode_dates_nfo_first(self, series_path: Path, imdb_id: str, disk_episodes: Dict[Tuple[int, int], List[Path]]) -> Dict[Tuple[int, int], Tuple[Optional[str], Optional[str], str]]:
|
||||
"""Gather episode dates for incomplete mode: Check NFO files first for missing dateadded elements"""
|
||||
_log("INFO", f"🔍 NFO-FIRST MODE: Checking {len(disk_episodes)} episodes for missing dateadded in NFO files")
|
||||
episode_dates = {}
|
||||
episodes_needing_db_check = []
|
||||
episodes_needing_api_lookup = []
|
||||
|
||||
# STEP 1: Check each NFO file to determine if dateadded is missing
|
||||
_log("DEBUG", f"STEP 1 - Checking NFO files for missing dateadded elements")
|
||||
nfo_complete_count = 0
|
||||
|
||||
for (season, episode) in disk_episodes:
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
episode_files = disk_episodes[(season, episode)]
|
||||
|
||||
nfo_has_dateadded = False
|
||||
nfo_data = None
|
||||
|
||||
# Look for NFO file for this episode
|
||||
for episode_file in episode_files:
|
||||
nfo_path = episode_file.with_suffix('.nfo')
|
||||
if nfo_path.exists():
|
||||
# Extract NFOGuard data from episode NFO
|
||||
nfo_data = self.nfo_manager.extract_nfoguard_dates_from_nfo(nfo_path)
|
||||
if nfo_data and nfo_data.get('dateadded'):
|
||||
# NFO has dateadded - this episode is complete
|
||||
aired = nfo_data.get('aired')
|
||||
dateadded = nfo_data.get('dateadded')
|
||||
source = nfo_data.get('source', 'nfo_cache')
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
nfo_complete_count += 1
|
||||
nfo_has_dateadded = True
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO has dateadded={dateadded}, marked as complete")
|
||||
break
|
||||
|
||||
if not nfo_has_dateadded:
|
||||
# NFO missing or missing dateadded - needs further processing
|
||||
if nfo_data:
|
||||
# NFO exists but missing dateadded
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: NFO exists but missing dateadded - needs DB/API lookup")
|
||||
else:
|
||||
# No NFO file found
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No NFO file found - needs DB/API lookup")
|
||||
episodes_needing_db_check.append((season, episode))
|
||||
|
||||
_log("INFO", f"NFO files with dateadded: {nfo_complete_count}/{len(disk_episodes)} episodes. Need DB check: {len(episodes_needing_db_check)}")
|
||||
|
||||
# STEP 2: For episodes missing dateadded in NFO, check database
|
||||
if episodes_needing_db_check:
|
||||
_log("DEBUG", f"STEP 2 - Checking database for {len(episodes_needing_db_check)} episodes missing dateadded")
|
||||
db_hits = 0
|
||||
|
||||
for (season, episode) in episodes_needing_db_check:
|
||||
db_result = self.db.get_episode_date(imdb_id, season, episode)
|
||||
|
||||
if db_result and db_result.get('dateadded'):
|
||||
# Found in database - use cached data and will add to NFO later
|
||||
aired = db_result.get('aired')
|
||||
dateadded = db_result.get('dateadded')
|
||||
source = db_result.get('source', 'database_cache')
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
db_hits += 1
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database has dateadded={dateadded} - will add to NFO")
|
||||
else:
|
||||
# Not in database or incomplete - needs API lookup
|
||||
episodes_needing_api_lookup.append((season, episode))
|
||||
|
||||
_log("INFO", f"Database hits: {db_hits}/{len(episodes_needing_db_check)} episodes. Need API lookup: {len(episodes_needing_api_lookup)}")
|
||||
|
||||
# STEP 3: For episodes still missing dateadded, query Sonarr
|
||||
if episodes_needing_api_lookup:
|
||||
_log("DEBUG", f"STEP 3 - Querying Sonarr for {len(episodes_needing_api_lookup)} episodes missing from NFO and database")
|
||||
sonarr_episodes = self._get_sonarr_episodes(imdb_id, episodes_needing_api_lookup)
|
||||
|
||||
for (season, episode) in episodes_needing_api_lookup:
|
||||
aired = None
|
||||
dateadded = None
|
||||
source = "unknown"
|
||||
|
||||
# Try Sonarr first
|
||||
if (season, episode) in sonarr_episodes:
|
||||
sonarr_data = sonarr_episodes[(season, episode)]
|
||||
aired = sonarr_data.get('airDate')
|
||||
dateadded = sonarr_data.get('dateAdded')
|
||||
|
||||
if dateadded:
|
||||
source = "sonarr:history.import"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Got Sonarr import date: {dateadded}")
|
||||
else:
|
||||
source = "sonarr:no_import_date"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Sonarr has data but no dateAdded (aired: {aired})")
|
||||
|
||||
# Fallback to external sources if needed
|
||||
if not aired:
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: No aired date from Sonarr, trying external APIs")
|
||||
external_aired = self.external_clients.get_episode_air_date(imdb_id, season, episode)
|
||||
if external_aired:
|
||||
aired = external_aired
|
||||
if not dateadded:
|
||||
source = "external"
|
||||
_log("INFO", f"S{season:02d}E{episode:02d}: Found aired date from external APIs: {aired}")
|
||||
|
||||
# Use air date as fallback for dateadded if no import date found
|
||||
if not dateadded and aired:
|
||||
dateadded = aired
|
||||
if source == "sonarr:no_import_date":
|
||||
source = "sonarr:aired_fallback"
|
||||
elif source == "external":
|
||||
source = "external:aired_fallback"
|
||||
else:
|
||||
source = f"{source}_fallback" if source != "unknown" else "aired_fallback"
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Using aired date as dateadded fallback: {dateadded}")
|
||||
|
||||
# Save to database for future lookups
|
||||
if dateadded or aired:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
|
||||
episode_dates[(season, episode)] = (aired, dateadded, source)
|
||||
|
||||
_log("INFO", f"🔍 NFO-FIRST COMPLETE: {len(episode_dates)} episodes processed")
|
||||
for (s, e), (aired, dateadded, source) in episode_dates.items():
|
||||
_log("INFO", f" S{s:02d}E{e:02d}: aired={aired}, dateadded={dateadded}, source={source}")
|
||||
|
||||
return episode_dates
|
||||
|
||||
def _get_sonarr_episodes(self, imdb_id: str, episodes_filter: List[Tuple[int, int]] = None) -> Dict[Tuple[int, int], Dict[str, Any]]:
|
||||
"""Get episode information from Sonarr including import history - optimized to only fetch needed episodes"""
|
||||
try:
|
||||
series_data = self.sonarr.series_by_imdb(imdb_id)
|
||||
if not series_data:
|
||||
# Try fuzzy matching if exact IMDb lookup fails
|
||||
_log("DEBUG", f"Exact IMDb lookup failed for {imdb_id}, trying fuzzy matching")
|
||||
|
||||
# Get all series and try fuzzy matching
|
||||
all_series = self.sonarr.get_all_series()
|
||||
if all_series:
|
||||
_log("DEBUG", f"Found {len(all_series)} total series in Sonarr")
|
||||
|
||||
for series in all_series:
|
||||
series_imdb = series.get('imdbId', '')
|
||||
if series_imdb and series_imdb.startswith('tt'):
|
||||
# Try fuzzy matching for IMDb numbers
|
||||
try:
|
||||
target_imdb_num = imdb_id.replace('tt', '').lower()
|
||||
series_imdb_num = series_imdb.replace('tt', '').lower()
|
||||
|
||||
target_num = int(target_imdb_num)
|
||||
series_num = int(series_imdb_num)
|
||||
diff = abs(target_num - series_num)
|
||||
|
||||
if diff <= 10: # Allow small IMDb ID differences
|
||||
_log("INFO", f"✅ Found fuzzy IMDb match: {series_imdb} vs {imdb_id} (diff: {diff})")
|
||||
_log("DEBUG", f"Series data found: True")
|
||||
_log("DEBUG", f"Found series '{series.get('title', 'Unknown')}' with ID {series.get('id')}")
|
||||
series_data = series
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if not series_data:
|
||||
return {}
|
||||
|
||||
@@ -182,6 +557,7 @@ class TVProcessor:
|
||||
|
||||
episode_map = {}
|
||||
api_calls_made = 0
|
||||
episodes_processed = 0
|
||||
|
||||
for episode in episodes:
|
||||
season = episode.get('seasonNumber', 0)
|
||||
@@ -191,13 +567,20 @@ class TVProcessor:
|
||||
if filter_set and (season, episode_num) not in filter_set:
|
||||
continue
|
||||
|
||||
if season > 0 and episode_num > 0:
|
||||
if season >= 0 and episode_num > 0:
|
||||
episodes_processed += 1
|
||||
|
||||
# Get basic episode info
|
||||
episode_data = {
|
||||
'airDate': episode.get('airDate'),
|
||||
'dateAdded': None
|
||||
}
|
||||
|
||||
# Enhanced debugging for Season 0 (Specials)
|
||||
if season == 0:
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: airDate='{episode.get('airDate')}', hasFile={episode.get('hasFile')}")
|
||||
_log("DEBUG", f"SPECIALS SONARR RAW S{season:02d}E{episode_num:02d}: Full episode data: {episode}")
|
||||
|
||||
# First try to get import date from history (more accurate)
|
||||
episode_id = episode.get('id')
|
||||
if episode_id and episode.get('hasFile'):
|
||||
@@ -207,6 +590,7 @@ class TVProcessor:
|
||||
episode_data['dateAdded'] = import_date
|
||||
_log("DEBUG", f"Got import date from history for S{season:02d}E{episode_num:02d}: {import_date}")
|
||||
|
||||
|
||||
# Fallback to episodeFile.dateAdded if history didn't work
|
||||
if not episode_data['dateAdded'] and episode.get('hasFile'):
|
||||
file_date = episode.get('episodeFile', {}).get('dateAdded')
|
||||
@@ -216,6 +600,7 @@ class TVProcessor:
|
||||
|
||||
episode_map[(season, episode_num)] = episode_data
|
||||
|
||||
|
||||
if filter_set:
|
||||
_log("DEBUG", f"Made {api_calls_made} Sonarr history API calls for filtered episodes (instead of all episodes)")
|
||||
|
||||
@@ -277,7 +662,12 @@ class TVProcessor:
|
||||
self.nfo_manager.set_file_mtime(video_file, dateadded)
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
processed_count += 1
|
||||
|
||||
_log("INFO", f"Processed {processed_count} episodes in season {season_num}")
|
||||
@@ -397,7 +787,7 @@ class TVProcessor:
|
||||
|
||||
for (season, episode), (aired, dateadded, source) in episode_dates.items():
|
||||
if (season, episode) in disk_episodes:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
|
||||
# Prepare NFO creation data
|
||||
if config.manage_nfo:
|
||||
@@ -418,7 +808,12 @@ class TVProcessor:
|
||||
mtime_operations.append((video_file, dateadded))
|
||||
|
||||
# Save to database
|
||||
try:
|
||||
self.db.upsert_episode_date(imdb_id, season, episode, aired, dateadded, source, True)
|
||||
_log("DEBUG", f"S{season:02d}E{episode:02d}: Database record saved successfully")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"S{season:02d}E{episode:02d}: Database write failed: {e}")
|
||||
# Continue processing other episodes
|
||||
|
||||
# Process NFOs and mtimes concurrently
|
||||
results = {}
|
||||
@@ -525,7 +920,7 @@ class TVProcessor:
|
||||
continue
|
||||
|
||||
# Check if episode file exists on disk
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
if not season_dir.exists():
|
||||
_log("WARNING", f"Season directory not found: {season_dir}")
|
||||
continue
|
||||
@@ -544,7 +939,7 @@ class TVProcessor:
|
||||
|
||||
# Get episode date information - webhook processing prioritizes existing DB entries
|
||||
_log("DEBUG", f"Processing webhook episode: IMDb={imdb_id}, S{season_num:02d}E{episode_num:02d}")
|
||||
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata)
|
||||
aired, dateadded, source = self._get_webhook_episode_date(imdb_id, season_num, episode_num, series_metadata, webhook_episode)
|
||||
enhanced_metadata = self._get_episode_metadata(series_metadata, season_num, episode_num, season_dir) if series_metadata else self._get_episode_metadata(None, season_num, episode_num, season_dir)
|
||||
|
||||
# Create NFO
|
||||
@@ -572,20 +967,20 @@ class TVProcessor:
|
||||
|
||||
episodes_processed += 1
|
||||
|
||||
# Create season/tvshow NFOs if any episodes were processed
|
||||
if episodes_processed > 0 and config.manage_nfo:
|
||||
seasons_processed = set()
|
||||
for webhook_episode in webhook_episodes:
|
||||
season_num = webhook_episode.get("seasonNumber")
|
||||
if season_num and season_num not in seasons_processed:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season_num)
|
||||
if season_dir.exists():
|
||||
self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
seasons_processed.add(season_num)
|
||||
|
||||
# Get TVDB ID for better Emby compatibility
|
||||
tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
|
||||
# DISABLED: Skip creating season.nfo and tvshow.nfo files (user only wants episode NFOs)
|
||||
# if episodes_processed > 0 and config.manage_nfo:
|
||||
# seasons_processed = set()
|
||||
# for webhook_episode in webhook_episodes:
|
||||
# season_num = webhook_episode.get("seasonNumber")
|
||||
# if season_num and season_num not in seasons_processed:
|
||||
# season_dir = series_path / config.get_season_dir_name(season_num)
|
||||
# if season_dir.exists():
|
||||
# self.nfo_manager.create_season_nfo(season_dir, season_num)
|
||||
# seasons_processed.add(season_num)
|
||||
#
|
||||
# # Get TVDB ID for better Emby compatibility
|
||||
# tvdb_id = self.external_clients.get_tvdb_series_id(imdb_id)
|
||||
# self.nfo_manager.create_tvshow_nfo(series_path, imdb_id, tvdb_id)
|
||||
|
||||
_log("INFO", f"Completed targeted processing: {episodes_processed}/{len(webhook_episodes)} episodes processed")
|
||||
|
||||
@@ -626,7 +1021,7 @@ class TVProcessor:
|
||||
season = episode.get('seasonNumber', 0)
|
||||
episode_num = episode.get('episodeNumber', 0)
|
||||
|
||||
if season > 0 and episode_num > 0:
|
||||
if season >= 0 and episode_num > 0:
|
||||
episode_map[(season, episode_num)] = episode
|
||||
|
||||
return {
|
||||
@@ -689,32 +1084,102 @@ class TVProcessor:
|
||||
|
||||
return None
|
||||
|
||||
def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
def _get_webhook_episode_date(self, imdb_id: str, season_num: int, episode_num: int, series_metadata: Optional[Dict[str, Any]] = None, webhook_episode: Optional[Dict[str, Any]] = None) -> Tuple[Optional[str], Optional[str], str]:
|
||||
"""
|
||||
Get episode date for webhook processing with correct priority:
|
||||
1. Check existing NFOGuard database entry (preserve previous import dates)
|
||||
2. If not found, use current time (webhook = new download)
|
||||
3. Get aired date from Sonarr/TMDB for reference
|
||||
"""
|
||||
# Check if we already have this episode in our database (preserve existing dates)
|
||||
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if existing and existing.get('dateadded'):
|
||||
_log("DEBUG", f"Found existing database entry for S{season_num:02d}E{episode_num:02d}: {existing['dateadded']}")
|
||||
return existing.get('aired'), existing.get('dateadded'), existing.get('source', 'nfoguard:database')
|
||||
Get episode date for webhook processing - database-first approach.
|
||||
|
||||
# Get aired date from Sonarr if available
|
||||
Logic:
|
||||
1. Check NFOGuard database FIRST - if we have a date, use it (no re-processing)
|
||||
2. No date in DB? Check Sonarr import history with our rules:
|
||||
- Import before rename = use import date
|
||||
- Rename before import = use airdate
|
||||
- First import matches webhook = valid new episode
|
||||
3. Final fallback = airdate
|
||||
|
||||
This prevents webhooks from overriding existing good data.
|
||||
"""
|
||||
|
||||
# STEP 1: Check NFOGuard database first
|
||||
existing_entry = None
|
||||
existing_date = None
|
||||
try:
|
||||
existing_entry = self.db.get_episode(imdb_id, season_num, episode_num)
|
||||
if existing_entry and existing_entry.get('date_added'):
|
||||
existing_date = existing_entry['date_added']
|
||||
_log("INFO", f"Found existing date in database for S{season_num:02d}E{episode_num:02d}: {existing_date}")
|
||||
|
||||
# For webhook processing, use existing data (fast path)
|
||||
# Manual scans will validate below
|
||||
# Still get aired date for NFO
|
||||
aired = None
|
||||
if series_metadata and 'episodes' in series_metadata:
|
||||
episode_data = series_metadata['episodes'].get((season_num, episode_num))
|
||||
if episode_data:
|
||||
aired = episode_data.get('airDate')
|
||||
|
||||
return aired, str(existing_date), "database:existing"
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Database check failed for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
|
||||
_log("INFO", f"No existing date in database for S{season_num:02d}E{episode_num:02d}, checking Sonarr import history")
|
||||
|
||||
# Get aired date and episode ID from Sonarr
|
||||
aired = None
|
||||
episode_id = None
|
||||
if series_metadata and 'episodes' in series_metadata:
|
||||
episode_data = series_metadata['episodes'].get((season_num, episode_num))
|
||||
if episode_data:
|
||||
aired = episode_data.get('airDate')
|
||||
episode_id = episode_data.get('id')
|
||||
_log("DEBUG", f"Got aired date from Sonarr for S{season_num:02d}E{episode_num:02d}: {aired}")
|
||||
|
||||
# For webhook processing, use current time as dateadded (new download)
|
||||
# STEP 2: Check Sonarr import history (authoritative source)
|
||||
_log("INFO", f"Checking Sonarr import history for S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
if episode_id and hasattr(self, 'sonarr'):
|
||||
try:
|
||||
_log("DEBUG", f"Calling get_episode_import_history for episode_id: {episode_id}")
|
||||
import_history = self.sonarr.get_episode_import_history(episode_id)
|
||||
_log("DEBUG", f"Import history result: {import_history}")
|
||||
|
||||
if import_history:
|
||||
# Found actual import event from Sonarr
|
||||
_log("INFO", f"Found Sonarr import event for S{season_num:02d}E{episode_num:02d}: {import_history}")
|
||||
return aired, import_history, "sonarr:import_history"
|
||||
else:
|
||||
# No import events found - this means only renames/moves exist in history
|
||||
# The episode was already in Sonarr, just being managed/renamed
|
||||
_log("INFO", f"No import events found for S{season_num:02d}E{episode_num:02d} - only renames/moves in history")
|
||||
|
||||
if aired:
|
||||
_log("INFO", f"Using air date for existing episode (rename-only history): {aired}")
|
||||
return aired, aired + "T20:00:00", "airdate"
|
||||
else:
|
||||
_log("DEBUG", f"No air date available for rename-only episode S{season_num:02d}E{episode_num:02d}")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking Sonarr import history for S{season_num:02d}E{episode_num:02d}: {e}")
|
||||
import traceback
|
||||
_log("ERROR", traceback.format_exc())
|
||||
else:
|
||||
if not episode_id:
|
||||
_log("DEBUG", f"No episode_id found for S{season_num:02d}E{episode_num:02d}")
|
||||
if not hasattr(self, 'sonarr'):
|
||||
_log("DEBUG", f"No sonarr client available")
|
||||
|
||||
# STEP 2: No import history found - this is likely a genuinely new show
|
||||
# Check our database to avoid duplicates
|
||||
existing = self.db.get_episode_date(imdb_id, season_num, episode_num)
|
||||
if existing and existing.get('dateadded'):
|
||||
_log("INFO", f"Episode S{season_num:02d}E{episode_num:02d} already exists in our database: {existing['dateadded']}")
|
||||
return existing.get('aired'), existing.get('dateadded'), existing.get('source', 'nfoguard:database')
|
||||
|
||||
# STEP 3: Truly new episode - use webhook date
|
||||
dateadded = datetime.now().isoformat()
|
||||
source = "sonarr:webhook"
|
||||
|
||||
_log("DEBUG", f"Using webhook date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
_log("INFO", f"No import history and not in database - using webhook date for genuinely new episode S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
|
||||
return aired, dateadded, source
|
||||
|
||||
@@ -750,7 +1215,7 @@ class TVProcessor:
|
||||
# Create NFO if needed
|
||||
nfo_success = True
|
||||
if config.manage_nfo:
|
||||
season_dir = series_path / config.tv_season_dir_format.format(season=season)
|
||||
season_dir = series_path / config.get_season_dir_name(season)
|
||||
nfo_success = await self.async_nfo_manager.async_create_episode_nfo(
|
||||
season_dir, season, episode, aired, dateadded, "webhook", config.lock_metadata
|
||||
)
|
||||
|
||||
@@ -192,10 +192,19 @@ class TVSeriesProcessor:
|
||||
aired, dateadded, source = self._get_episode_dates_from_sonarr(imdb_id, season_num, episode_num)
|
||||
|
||||
# Step 4: Create/update NFO and database
|
||||
if dateadded:
|
||||
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
|
||||
@@ -254,9 +263,11 @@ class TVSeriesProcessor:
|
||||
if episode_id:
|
||||
import_date = self.sonarr.get_episode_import_history(episode_id)
|
||||
if import_date:
|
||||
dateadded = convert_utc_to_local(import_date)
|
||||
# Sonarr import dates are already in local timezone despite 'Z' suffix
|
||||
# Remove 'Z' and use as-is to avoid double timezone conversion
|
||||
dateadded = import_date.replace('Z', '') if 'Z' in import_date else import_date
|
||||
source = "sonarr:history.import"
|
||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded}")
|
||||
_log("INFO", f"Found import date for S{season_num:02d}E{episode_num:02d}: {dateadded} (no timezone conversion)")
|
||||
return aired, dateadded, source
|
||||
|
||||
# Fallback to airdate if no import history
|
||||
@@ -360,21 +371,22 @@ class TVSeriesProcessor:
|
||||
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}")
|
||||
"""DISABLED: Skip creating tvshow.nfo and season.nfo files (user only wants episode NFOs)"""
|
||||
# # 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}")
|
||||
pass # Function disabled - only process episode NFOs
|
||||
@@ -8,3 +8,5 @@ aiofiles==23.2.1
|
||||
aiohttp==3.8.6
|
||||
psutil==5.9.6
|
||||
python-dotenv==1.0.0
|
||||
APScheduler==3.10.4
|
||||
croniter==1.4.1
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Scheduler module for NFOGuard
|
||||
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
NFOGuard Background Scheduler
|
||||
Manages scheduled scans using APScheduler with cron-like functionality
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.jobstores.memory import MemoryJobStore
|
||||
from apscheduler.executors.asyncio import AsyncIOExecutor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NFOGuardScheduler:
|
||||
"""
|
||||
Background scheduler for NFOGuard that manages scheduled scans
|
||||
"""
|
||||
|
||||
def __init__(self, dependencies: Dict[str, Any]):
|
||||
"""Initialize the scheduler with dependencies"""
|
||||
self.dependencies = dependencies
|
||||
self.scheduler = None
|
||||
self.running = False
|
||||
|
||||
# Configure APScheduler
|
||||
jobstores = {
|
||||
'default': MemoryJobStore()
|
||||
}
|
||||
executors = {
|
||||
'default': AsyncIOExecutor()
|
||||
}
|
||||
job_defaults = {
|
||||
'coalesce': False,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': 300 # 5 minutes
|
||||
}
|
||||
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
jobstores=jobstores,
|
||||
executors=executors,
|
||||
job_defaults=job_defaults,
|
||||
timezone='UTC'
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler and load existing schedules"""
|
||||
if self.running:
|
||||
logger.warning("Scheduler is already running")
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.start()
|
||||
self.running = True
|
||||
logger.info("✅ NFOGuard Scheduler started successfully")
|
||||
|
||||
# Load existing scheduled scans from database
|
||||
await self.load_schedules()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start scheduler: {e}")
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the scheduler gracefully"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
try:
|
||||
self.scheduler.shutdown()
|
||||
self.running = False
|
||||
logger.info("✅ NFOGuard Scheduler stopped successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping scheduler: {e}")
|
||||
|
||||
async def load_schedules(self):
|
||||
"""Load all enabled scheduled scans from database and add them to scheduler"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error("Database not available for loading schedules")
|
||||
return
|
||||
|
||||
# Get all enabled scheduled scans
|
||||
scheduled_scans = db.get_scheduled_scans(enabled_only=True)
|
||||
|
||||
for scan in scheduled_scans:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
logger.info(f"Loaded {len(scheduled_scans)} scheduled scans")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load schedules: {e}")
|
||||
|
||||
async def add_schedule(self, scan: Dict[str, Any]):
|
||||
"""Add a scheduled scan to the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan['id']}"
|
||||
|
||||
# Remove existing job if it exists
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
# Create cron trigger
|
||||
trigger = CronTrigger.from_crontab(scan['cron_expression'])
|
||||
|
||||
# Add job to scheduler
|
||||
self.scheduler.add_job(
|
||||
func=self._execute_scheduled_scan,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[scan['id']],
|
||||
name=f"Scheduled Scan: {scan['name']}",
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Update next run time in database
|
||||
next_run = self.scheduler.get_job(job_id).next_run_time
|
||||
if next_run:
|
||||
db = self.dependencies.get("db")
|
||||
if db:
|
||||
db.update_scan_next_run(scan['id'], next_run)
|
||||
|
||||
logger.info(f"✅ Added scheduled scan: {scan['name']} ({scan['cron_expression']})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def remove_schedule(self, scan_id: int):
|
||||
"""Remove a scheduled scan from the scheduler"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
logger.info(f"✅ Removed scheduled scan: {scan_id}")
|
||||
else:
|
||||
logger.warning(f"No job found for scan ID: {scan_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to remove schedule for scan {scan_id}: {e}")
|
||||
|
||||
async def update_schedule(self, scan: Dict[str, Any]):
|
||||
"""Update an existing scheduled scan"""
|
||||
try:
|
||||
# Remove old schedule and add new one
|
||||
await self.remove_schedule(scan['id'])
|
||||
if scan['enabled']:
|
||||
await self.add_schedule(scan)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update schedule for scan {scan['id']}: {e}")
|
||||
|
||||
async def _execute_scheduled_scan(self, scan_id: int):
|
||||
"""Execute a scheduled scan"""
|
||||
db = self.dependencies.get("db")
|
||||
if not db:
|
||||
logger.error(f"Database not available for executing scan {scan_id}")
|
||||
return
|
||||
|
||||
# Get scan details
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
if not scan:
|
||||
logger.error(f"Scheduled scan {scan_id} not found")
|
||||
return
|
||||
|
||||
if not scan['enabled']:
|
||||
logger.info(f"Skipping disabled scan: {scan['name']}")
|
||||
return
|
||||
|
||||
execution_id = None
|
||||
try:
|
||||
logger.info(f"🚀 Starting scheduled scan: {scan['name']} (ID: {scan_id})")
|
||||
|
||||
# Create execution record
|
||||
execution_id = db.create_schedule_execution(
|
||||
schedule_id=scan_id,
|
||||
media_type=scan['media_type'],
|
||||
scan_mode=scan['scan_mode'],
|
||||
triggered_by="scheduler"
|
||||
)
|
||||
|
||||
# Update last run time
|
||||
db.update_scan_last_run(scan_id)
|
||||
|
||||
# Execute the actual scan
|
||||
result = await self._run_media_scan(scan, execution_id)
|
||||
|
||||
# Update execution with results
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="completed",
|
||||
items_processed=result.get('items_processed', 0),
|
||||
items_skipped=result.get('items_skipped', 0),
|
||||
items_failed=result.get('items_failed', 0),
|
||||
logs=result.get('logs', '')
|
||||
)
|
||||
|
||||
logger.info(f"✅ Completed scheduled scan: {scan['name']} - Processed: {result.get('items_processed', 0)}, Skipped: {result.get('items_skipped', 0)}, Failed: {result.get('items_failed', 0)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed scheduled scan: {scan['name']} - {e}")
|
||||
|
||||
if execution_id:
|
||||
db.update_schedule_execution(
|
||||
execution_id=execution_id,
|
||||
status="failed",
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
async def _run_media_scan(self, scan: Dict[str, Any], execution_id: int) -> Dict[str, Any]:
|
||||
"""Run the actual media scan based on scan configuration"""
|
||||
try:
|
||||
# Import scan functionality from existing modules
|
||||
from api.routes import run_tv_scan, run_movie_scan
|
||||
|
||||
media_type = scan['media_type']
|
||||
scan_mode = scan['scan_mode']
|
||||
specific_paths = scan.get('specific_paths', '').strip()
|
||||
|
||||
results = {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 0,
|
||||
'logs': []
|
||||
}
|
||||
|
||||
# Parse specific paths if provided
|
||||
paths = []
|
||||
if specific_paths:
|
||||
paths = [p.strip() for p in specific_paths.split(',') if p.strip()]
|
||||
|
||||
# Run TV scan if needed
|
||||
if media_type in ['tv', 'both']:
|
||||
logger.info(f"Running TV scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
tv_result = await self._execute_tv_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += tv_result.get('tv_series_processed', 0)
|
||||
results['items_skipped'] += tv_result.get('tv_series_skipped', 0)
|
||||
results['items_failed'] += tv_result.get('tv_series_failed', 0)
|
||||
results['logs'].append(f"TV Scan: {tv_result.get('message', 'Completed')}")
|
||||
|
||||
# Run movie scan if needed
|
||||
if media_type in ['movies', 'both']:
|
||||
logger.info(f"Running movie scan with mode: {scan_mode}")
|
||||
|
||||
# Use existing scan infrastructure
|
||||
movie_result = await self._execute_movie_scan(scan_mode, paths)
|
||||
|
||||
results['items_processed'] += movie_result.get('movies_processed', 0)
|
||||
results['items_skipped'] += movie_result.get('movies_skipped', 0)
|
||||
results['items_failed'] += movie_result.get('movies_failed', 0)
|
||||
results['logs'].append(f"Movie Scan: {movie_result.get('message', 'Completed')}")
|
||||
|
||||
results['logs'] = '\n'.join(results['logs'])
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in media scan execution: {e}")
|
||||
return {
|
||||
'items_processed': 0,
|
||||
'items_skipped': 0,
|
||||
'items_failed': 1,
|
||||
'logs': f"Scan failed: {str(e)}"
|
||||
}
|
||||
|
||||
async def _execute_tv_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute TV scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 0,
|
||||
'message': f'TV scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"TV scan execution failed: {e}")
|
||||
return {
|
||||
'tv_series_processed': 0,
|
||||
'tv_series_skipped': 0,
|
||||
'tv_series_failed': 1,
|
||||
'message': f'TV scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def _execute_movie_scan(self, scan_mode: str, specific_paths: list = None) -> Dict[str, Any]:
|
||||
"""Execute movie scan using existing infrastructure"""
|
||||
try:
|
||||
# This would integrate with the existing manual scan functionality
|
||||
# For now, return a placeholder result
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 0,
|
||||
'message': f'Movie scan ({scan_mode}) - Integration pending'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Movie scan execution failed: {e}")
|
||||
return {
|
||||
'movies_processed': 0,
|
||||
'movies_skipped': 0,
|
||||
'movies_failed': 1,
|
||||
'message': f'Movie scan failed: {str(e)}'
|
||||
}
|
||||
|
||||
async def run_manual_scan(self, scan_id: int) -> Dict[str, Any]:
|
||||
"""Manually trigger a scheduled scan"""
|
||||
try:
|
||||
db = self.dependencies.get("db")
|
||||
scan = db.get_scheduled_scan(scan_id)
|
||||
|
||||
if not scan:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Scheduled scan not found'
|
||||
}
|
||||
|
||||
# Execute the scan in the background
|
||||
asyncio.create_task(self._execute_scheduled_scan(scan_id))
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': f"Manual execution of '{scan['name']}' started"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run manual scan {scan_id}: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_job_status(self, scan_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get the status of a scheduled job"""
|
||||
try:
|
||||
job_id = f"scan_{scan_id}"
|
||||
job = self.scheduler.get_job(job_id)
|
||||
|
||||
if not job:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get job status for scan {scan_id}: {e}")
|
||||
return None
|
||||
|
||||
def list_jobs(self) -> list:
|
||||
"""List all scheduled jobs"""
|
||||
try:
|
||||
jobs = []
|
||||
for job in self.scheduler.get_jobs():
|
||||
jobs.append({
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
})
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list jobs: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# Global scheduler instance
|
||||
scheduler_instance: Optional[NFOGuardScheduler] = None
|
||||
|
||||
|
||||
async def get_scheduler(dependencies: Dict[str, Any]) -> NFOGuardScheduler:
|
||||
"""Get or create the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance is None:
|
||||
scheduler_instance = NFOGuardScheduler(dependencies)
|
||||
await scheduler_instance.start()
|
||||
|
||||
return scheduler_instance
|
||||
|
||||
|
||||
async def shutdown_scheduler():
|
||||
"""Shutdown the global scheduler instance"""
|
||||
global scheduler_instance
|
||||
|
||||
if scheduler_instance:
|
||||
await scheduler_instance.stop()
|
||||
scheduler_instance = None
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFOGuard Web Interface Starter
|
||||
Simple script to start web interface using existing config system
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Import existing configuration (keep using core config for simplicity)
|
||||
from config.settings import config
|
||||
|
||||
# Import existing database and components
|
||||
from core.database import NFOGuardDatabase
|
||||
|
||||
# Import web routes from existing system (now includes DELETE route)
|
||||
from api.web_routes import register_web_routes
|
||||
|
||||
# Import authentication system
|
||||
from api.auth import SimpleAuthMiddleware, AuthSession
|
||||
|
||||
|
||||
def create_web_app() -> FastAPI:
|
||||
"""Create FastAPI web application"""
|
||||
app = FastAPI(
|
||||
title="NFOGuard Web Interface",
|
||||
description="Web interface for NFOGuard media database management",
|
||||
version="2.7.0-web",
|
||||
docs_url=None, # Disable docs in production
|
||||
redoc_url=None
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def setup_static_files(app: FastAPI) -> None:
|
||||
"""Mount static file directories"""
|
||||
static_path = os.path.join(os.path.dirname(__file__), "nfoguard-web", "static")
|
||||
logo_path = os.path.join(os.path.dirname(__file__), "logo")
|
||||
|
||||
print(f"🔍 Checking static path: {static_path} (exists: {os.path.exists(static_path)})")
|
||||
print(f"🔍 Checking logo path: {logo_path} (exists: {os.path.exists(logo_path)})")
|
||||
|
||||
if os.path.exists(static_path):
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
print(f"✅ Mounted static files from: {static_path}")
|
||||
else:
|
||||
print(f"❌ Static path not found: {static_path}")
|
||||
|
||||
if os.path.exists(logo_path):
|
||||
app.mount("/logo", StaticFiles(directory=logo_path), name="logo")
|
||||
print(f"✅ Mounted logo files from: {logo_path}")
|
||||
else:
|
||||
print(f"❌ Logo path not found: {logo_path}")
|
||||
|
||||
# Serve index.html at root
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
index_file = os.path.join(static_path, "index.html")
|
||||
if os.path.exists(index_file):
|
||||
return FileResponse(index_file)
|
||||
else:
|
||||
return {"message": "NFOGuard Web Interface", "status": "running"}
|
||||
|
||||
# Serve favicon
|
||||
@app.get("/favicon.ico")
|
||||
async def serve_favicon():
|
||||
# Try to serve favicon from logo directory or static files
|
||||
favicon_paths = [
|
||||
os.path.join(logo_path, "favicon.ico"),
|
||||
os.path.join(static_path, "favicon.ico"),
|
||||
os.path.join(logo_path, "NFOGuardLogo.png") # Fallback to new logo
|
||||
]
|
||||
|
||||
for favicon_path in favicon_paths:
|
||||
if os.path.exists(favicon_path):
|
||||
return FileResponse(favicon_path)
|
||||
|
||||
# Return 204 No Content if no favicon found
|
||||
from fastapi import Response
|
||||
return Response(status_code=204)
|
||||
|
||||
# Health check endpoint for Docker
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Docker container monitoring"""
|
||||
try:
|
||||
# Basic health check - verify the web service is responsive
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "nfoguard-web",
|
||||
"timestamp": time.time(),
|
||||
"version": "2.8.0-web"
|
||||
}
|
||||
except Exception as e:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=503, detail=f"Health check failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for NFOGuard Web Interface"""
|
||||
print("🌐 Starting NFOGuard Web Interface...")
|
||||
|
||||
# Use existing config system
|
||||
web_host = os.environ.get("WEB_HOST", "0.0.0.0")
|
||||
web_port = int(os.environ.get("WEB_PORT", "8081"))
|
||||
|
||||
print(f"📊 Configuration: Port {web_port}")
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_web_app()
|
||||
|
||||
# Initialize database using existing system
|
||||
try:
|
||||
db = NFOGuardDatabase(config)
|
||||
print(f"✅ Connected to database: {config.db_host}:{config.db_port}/{config.db_name}")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect to database: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup authentication if enabled
|
||||
auth_enabled = getattr(config, 'web_auth_enabled', False)
|
||||
session_manager = None
|
||||
|
||||
if auth_enabled:
|
||||
session_timeout = getattr(config, 'web_auth_session_timeout', 3600)
|
||||
session_manager = AuthSession(timeout_seconds=session_timeout)
|
||||
print(f"🔐 Web authentication enabled (session timeout: {session_timeout}s)")
|
||||
else:
|
||||
print("🌐 Web authentication disabled")
|
||||
|
||||
# Create dependencies for dependency injection
|
||||
dependencies = {
|
||||
"db": db,
|
||||
"config": config,
|
||||
"nfo_manager": None, # Not needed for read-only web interface
|
||||
"movie_processor": None, # Not needed for read-only web interface
|
||||
"tv_processor": None, # Not needed for read-only web interface
|
||||
"auth_enabled": auth_enabled,
|
||||
"session_manager": session_manager
|
||||
}
|
||||
|
||||
# Add authentication middleware if enabled (BEFORE routes)
|
||||
if auth_enabled:
|
||||
app.add_middleware(SimpleAuthMiddleware, config=config, session_manager=session_manager)
|
||||
print("🔐 Authentication middleware added to web interface")
|
||||
|
||||
# Setup static files and routes
|
||||
setup_static_files(app)
|
||||
|
||||
# Register web routes (now includes DELETE /api/episodes/ route)
|
||||
register_web_routes(app, dependencies)
|
||||
print("✅ Registered web routes with DELETE /api/episodes/ support")
|
||||
|
||||
print(f"🚀 Starting web server on {web_host}:{web_port}")
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
app,
|
||||
host=web_host,
|
||||
port=web_port,
|
||||
workers=1,
|
||||
log_level="info",
|
||||
access_log=False
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Web interface shutdown by user")
|
||||
except Exception as e:
|
||||
print(f"❌ Web interface failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,902 @@
|
||||
/* 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);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Authentication Status */
|
||||
.auth-status {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auth-logout {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-logout:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* Manual Scan Styles */
|
||||
.scan-status {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--light-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.scan-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-color), var(--success-color));
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.scan-info span:first-child {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scan-info span:last-child {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
<!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?v=manual-scan-ui">
|
||||
<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>
|
||||
<div class="auth-status" id="auth-status" style="display: none;">
|
||||
<span class="auth-user">
|
||||
<i class="fas fa-user"></i> <span id="auth-username">Loading...</span>
|
||||
</span>
|
||||
<button class="auth-logout" id="logout-btn" onclick="logout()">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</button>
|
||||
</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-search"></i> Manual Scan</h3>
|
||||
<p>Scan specific folders or perform full library scans</p>
|
||||
<form id="manual-scan-form">
|
||||
<div class="form-group">
|
||||
<label>Scan Type:</label>
|
||||
<select id="scan-type" required>
|
||||
<option value="both">TV Shows & Movies</option>
|
||||
<option value="tv">TV Shows Only</option>
|
||||
<option value="movies">Movies Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Scan Mode:</label>
|
||||
<select id="scan-mode" required>
|
||||
<option value="smart">Smart (Recommended)</option>
|
||||
<option value="full">Full Scan</option>
|
||||
<option value="incomplete">Incomplete Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Specific Path (Optional):</label>
|
||||
<input type="text" id="scan-path" placeholder="e.g., /mnt/unionfs/Media/TV/Series Name" title="Leave empty for full library scan">
|
||||
<small>Leave empty to scan entire library</small>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-play"></i> Start Scan
|
||||
</button>
|
||||
</form>
|
||||
<div id="scan-status" class="scan-status" style="display: none;">
|
||||
<div class="scan-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="scan-progress-bar"></div>
|
||||
</div>
|
||||
<div class="scan-info">
|
||||
<span id="scan-current-operation">Initializing...</span>
|
||||
<span id="scan-progress-text">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" onclick="stopScanPolling()">
|
||||
<i class="fas fa-times"></i> Hide Status
|
||||
</button>
|
||||
</div>
|
||||
</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()">×</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()">×</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?v=manual-scan-ui"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1744
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -62,7 +62,8 @@ def find_media_path_by_imdb_and_title(
|
||||
|
||||
# Search by IMDb ID first (more reliable)
|
||||
if imdb_id:
|
||||
pattern = str(media_path / f"*[imdb-{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])
|
||||
|
||||
+61
-1
@@ -10,6 +10,49 @@ from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
class SafeRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""A RotatingFileHandler that handles missing backup files gracefully"""
|
||||
|
||||
def doRollover(self):
|
||||
"""
|
||||
Override doRollover to handle missing backup files gracefully
|
||||
"""
|
||||
if self.stream:
|
||||
self.stream.close()
|
||||
self.stream = None
|
||||
|
||||
if self.backupCount > 0:
|
||||
# Remove the oldest backup if it exists
|
||||
oldest_backup = f"{self.baseFilename}.{self.backupCount}"
|
||||
if os.path.exists(oldest_backup):
|
||||
try:
|
||||
os.remove(oldest_backup)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Ignore if file doesn't exist or can't be removed
|
||||
|
||||
# Rename existing backups, skipping missing ones
|
||||
for i in range(self.backupCount - 1, 0, -1):
|
||||
sfn = f"{self.baseFilename}.{i}"
|
||||
dfn = f"{self.baseFilename}.{i + 1}"
|
||||
if os.path.exists(sfn):
|
||||
try:
|
||||
os.rename(sfn, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if source doesn't exist or rename fails
|
||||
|
||||
# Rename the main log file
|
||||
dfn = f"{self.baseFilename}.1"
|
||||
if os.path.exists(self.baseFilename):
|
||||
try:
|
||||
os.rename(self.baseFilename, dfn)
|
||||
except (OSError, FileNotFoundError):
|
||||
pass # Skip if main file doesn't exist
|
||||
|
||||
# Open the new log file
|
||||
if not self.delay:
|
||||
self.stream = self._open()
|
||||
|
||||
|
||||
class TimezoneAwareFormatter(logging.Formatter):
|
||||
"""Formatter that respects the container timezone"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -50,7 +93,11 @@ def _setup_file_logging():
|
||||
logger = logging.getLogger("NFOGuard")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
# Clear any existing handlers to avoid duplicates
|
||||
logger.handlers.clear()
|
||||
|
||||
try:
|
||||
file_handler = SafeRotatingFileHandler(
|
||||
log_dir / "nfoguard.log", maxBytes=50*1024*1024, backupCount=3
|
||||
)
|
||||
|
||||
@@ -59,6 +106,19 @@ def _setup_file_logging():
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
# If RotatingFileHandler fails, fall back to regular FileHandler
|
||||
print(f"Warning: Could not setup rotating file handler ({e}), using regular file handler")
|
||||
try:
|
||||
file_handler = logging.FileHandler(log_dir / "nfoguard.log")
|
||||
formatter = TimezoneAwareFormatter(
|
||||
'[%(asctime)s] %(levelname)s: %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e2:
|
||||
print(f"Error: Could not setup any file logging: {e2}")
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ Webhook Batching System for NFOGuard
|
||||
Handles batching and processing of webhook events to avoid processing storms
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, Set
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -107,8 +108,33 @@ class WebhookBatcher:
|
||||
return
|
||||
_log("DEBUG", f"Batch validation passed: IMDb {expected_imdb} found in path (fallback mode)")
|
||||
|
||||
# Process based on media type
|
||||
# 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
|
||||
@@ -119,6 +145,12 @@ class WebhookBatcher:
|
||||
|
||||
if processing_mode == 'targeted' and episodes_data:
|
||||
_log("INFO", f"Using targeted episode processing for {len(episodes_data)} episodes")
|
||||
|
||||
# Handle sequential processing for bulk downloads
|
||||
if len(episodes_data) > 1 and config.sequential_delay > 0:
|
||||
_log("INFO", f"Processing {len(episodes_data)} episodes sequentially with {config.sequential_delay}s delay")
|
||||
self._process_episodes_sequentially(path_obj, episodes_data)
|
||||
else:
|
||||
self.tv_processor.process_webhook_episodes(path_obj, episodes_data)
|
||||
else:
|
||||
_log("INFO", f"Using series processing mode (fallback or configured)")
|
||||
@@ -139,6 +171,29 @@ class WebhookBatcher:
|
||||
with self.lock:
|
||||
self.processing.discard(key)
|
||||
|
||||
def _process_episodes_sequentially(self, path_obj: Path, episodes_data: list):
|
||||
"""Process episodes one by one with delays to avoid API spam"""
|
||||
total_episodes = len(episodes_data)
|
||||
for i, episode in enumerate(episodes_data, 1):
|
||||
try:
|
||||
season = episode.get('seasonNumber', '?')
|
||||
episode_num = episode.get('episodeNumber', '?')
|
||||
_log("INFO", f"Processing episode {i}/{total_episodes}: S{season:02d}E{episode_num:02d}")
|
||||
|
||||
# Process single episode
|
||||
self.tv_processor.process_webhook_episodes(path_obj, [episode])
|
||||
|
||||
# Add delay between episodes (except for the last one)
|
||||
if i < total_episodes and config.sequential_delay > 0:
|
||||
_log("INFO", f"Waiting {config.sequential_delay}s before next episode...")
|
||||
time.sleep(config.sequential_delay)
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error processing episode {i}/{total_episodes}: {e}")
|
||||
# Continue with next episode even if one fails
|
||||
|
||||
_log("INFO", f"Completed sequential processing of {total_episodes} episodes")
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Get batch queue status"""
|
||||
with self.lock:
|
||||
@@ -148,3 +203,37 @@ class WebhookBatcher:
|
||||
"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:
|
||||
# Use timeout parameter only if supported (Python 3.9+)
|
||||
import sys
|
||||
if sys.version_info >= (3, 9):
|
||||
self.executor.shutdown(wait=True, timeout=10) # Wait up to 10 seconds
|
||||
else:
|
||||
self.executor.shutdown(wait=True) # No timeout for older Python versions
|
||||
_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")
|
||||
Reference in New Issue
Block a user