updates
This commit is contained in:
-199
@@ -1,199 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Released] - v0.6.0 - 2025-09-11
|
||||
|
||||
### Added
|
||||
- **📺 Enhanced TV Show Processing**: Comprehensive single season/episode capabilities
|
||||
- **Single Season Processing**: `/tv/scan-season` endpoint for targeted season updates
|
||||
- **Single Episode Processing**: `/tv/scan-episode` endpoint for individual episode handling
|
||||
- **URL-Safe Endpoints**: JSON-based API calls avoid URL encoding issues with spaces/special characters
|
||||
- **Enhanced Path Detection**: Automatically detects season directories and episode files
|
||||
- **Database Integration**: Full episode date tracking and retrieval with `get_episode_date()`
|
||||
- **🎬 Rich NFO Generation**: Full metadata integration from Sonarr API
|
||||
- **Episode Titles**: Real episode names from Sonarr instead of generic S01E01 format
|
||||
- **Plot Summaries**: Episode descriptions for better library browsing experience
|
||||
- **Runtime Data**: Episode duration information for media players
|
||||
- **Rating Integration**: IMDb ratings and vote counts for episodes
|
||||
- **Backwards Compatible**: Existing basic NFOs still work, enhanced when Sonarr API available
|
||||
- **⏰ NFOGuard Timestamps**: All NFO files now include processing timestamps
|
||||
- **Creation Tracking**: Shows when NFOGuard created/updated each NFO file
|
||||
- **Format**: `<!-- managed by NFOGuard at 2025-09-11T13:45:22+00:00 -->`
|
||||
- **Audit Trail**: Helps track metadata management history
|
||||
- **🔧 Enhanced TV Manual Scan**: Smart path detection for seasons and episodes
|
||||
- **Season Detection**: `curl -X POST "manual/scan?path=/media/TV/Series/Season 13"`
|
||||
- **Episode Detection**: `curl -X POST "manual/scan?path=/media/TV/Series/Season 13/episode.mkv"`
|
||||
- **Automatic Processing**: Detects context and processes accordingly
|
||||
- **⚙️ Configurable TV Webhook Processing**: Choose between targeted or comprehensive processing
|
||||
- **Targeted Mode**: `TV_WEBHOOK_PROCESSING_MODE=targeted` (default) - Only process webhook episodes
|
||||
- **Series Mode**: `TV_WEBHOOK_PROCESSING_MODE=series` - Process entire series directory
|
||||
- **Smart Fallback**: Automatically falls back to series mode when episode data unavailable
|
||||
- **Efficiency**: Targeted mode reduces unnecessary file operations and notifications
|
||||
- **🔐 Secure Configuration System**: Two-file configuration with automatic API key masking
|
||||
- **Main `.env`**: Paths and preferences (safe to share for debugging)
|
||||
- **Separate `.env.secrets`**: API keys and passwords (never committed to git)
|
||||
- **Automatic Loading**: Both files loaded at startup with python-dotenv
|
||||
- **Log Security**: API keys automatically masked in all log output
|
||||
- **Git Protection**: Updated .gitignore prevents accidental commits of sensitive data
|
||||
- **🧠 Smart Date Validation**: Intelligent release date selection with configurable gap detection
|
||||
- **Automatic Detection**: Prefers theatrical dates when digital/physical are unreasonably late
|
||||
- **Configurable**: `MAX_RELEASE_DATE_GAP_YEARS=10` (default: 10 years maximum gap)
|
||||
- **Example**: "The Craft (1996)" prefers 1996 theatrical over 2022 physical release
|
||||
- **Toggle**: `ENABLE_SMART_DATE_VALIDATION=true` to enable/disable feature
|
||||
- **🧹 Repository Cleanup**: Removed legacy and duplicate files
|
||||
- **Removed**: `media_date_cache.py`, `webhook_server.py` (replaced by nfoguard.py)
|
||||
- **Removed**: Outdated documentation files (`API_ENDPOINTS.md`, `MIGRATION.md`, `STRUCTURE.md`)
|
||||
- **Streamlined**: Cleaner codebase with only essential files
|
||||
- **🎯 Configurable Release Date Priority System**: Revolutionary smart fallback for manual imports
|
||||
- **Configurable Priority Order**: `RELEASE_DATE_PRIORITY=digital,physical,theatrical` (customizable)
|
||||
- **Digital Releases**: VOD/streaming dates (Netflix, iTunes, etc.) - TMDB type 4
|
||||
- **Physical Releases**: DVD/Blu-ray dates - TMDB type 5
|
||||
- **Theatrical Releases**: Cinema release dates - TMDB type 3
|
||||
- **Smart Comparison Logic**: Prevents unrealistic dates (won't use 2000 digital for 1983 movie)
|
||||
- **Per-Movie Intelligence**: "The Craft (1996)" gets 1996 theatrical, "Top Gun Maverick (2022)" gets digital
|
||||
- **Configuration**: `PREFER_RELEASE_DATES_OVER_FILE_DATES=true` replaces old digital-only setting
|
||||
- **🔇 File Date Fallback Control**: New option to completely disable file date usage
|
||||
- **Configuration**: `ALLOW_FILE_DATE_FALLBACK=false` (default: false)
|
||||
- **Behavior**: Movies with no release dates get skipped instead of using file dates
|
||||
- **Clean Logs**: Eliminates "Using file dateAdded as fallback" warnings
|
||||
- **Enhanced Debug Endpoints**:
|
||||
- `/debug/movie/{imdb_id}/priority` - Shows date selection logic and available sources
|
||||
- Detailed analysis of file date vs digital date decisions
|
||||
- **Complete API Documentation**: Updated README with all curl commands and response examples
|
||||
- **Testing Infrastructure**:
|
||||
- `test_bulk_update.py` - Database connection validation
|
||||
- `test_movie_scan.py` - Directory scanning logic testing
|
||||
- `test_end_to_end.py` - Complete workflow validation
|
||||
- **External API Integration**:
|
||||
- TMDB API for digital release dates
|
||||
- OMDb API for DVD/digital release dates
|
||||
- Jellyseerr integration for additional digital release data
|
||||
- **Deployment Documentation**:
|
||||
- `SETUP.md` - Secure configuration setup guide
|
||||
- `DEPLOYMENT.md` - Complete Docker deployment guide
|
||||
- `TESTING.md` - Testing strategy and troubleshooting
|
||||
- `.env.template` - Clean main configuration template
|
||||
- `.env.secrets.template` - Secure secrets configuration template
|
||||
- `docker-compose.yml` - Production-ready deployment with volume mounts
|
||||
|
||||
### Changed
|
||||
- **Configuration Management**: Secure two-file system replaces single .env approach
|
||||
- **Separated Concerns**: Main config (.env) vs sensitive data (.env.secrets)
|
||||
- **Enhanced Security**: API key masking and git protection built-in
|
||||
- **Docker Integration**: Updated docker-compose.yml with proper volume mounts
|
||||
- **Movie Priority Logic**: Enhanced `import_then_digital` to intelligently handle file date fallbacks
|
||||
- **README.md**: Complete rewrite with secure configuration examples and curl commands
|
||||
|
||||
### Fixed
|
||||
- **Manual Import Handling**: Movies manually imported to Radarr now use digital release dates instead of file modification dates
|
||||
- **Path Configuration**: Fixed hardcoded media paths, now fully configurable via environment variables
|
||||
- **Debug History Endpoint**: Fixed endpoint showing wrong movie events (still needs database-only implementation)
|
||||
|
||||
### Technical Details
|
||||
- Maintains full backward compatibility with existing configurations
|
||||
- Smart date comparison algorithm considers theatrical release dates when available
|
||||
- Environment variable validation and fallback to sensible defaults
|
||||
|
||||
# Changelog
|
||||
|
||||
## [0.5.1] - 2025-09-08
|
||||
|
||||
### Changed
|
||||
- **BREAKING**: RadarrClient now operates in DATABASE ONLY mode
|
||||
- Completely eliminated Radarr API usage for movie lookups and import date detection
|
||||
- All movie operations now use direct database queries exclusively
|
||||
- Debug endpoints updated to use database-only methods
|
||||
- Enhanced error messaging when database access is not configured
|
||||
|
||||
### Fixed
|
||||
- Fixed debug endpoint still calling API methods despite database availability
|
||||
- Removed all API fallback mechanisms for true database-only operation
|
||||
|
||||
## [0.5.0] - 2025-09-08
|
||||
|
||||
### Added
|
||||
- **Major Performance Enhancement**: Direct Radarr database access
|
||||
- New `RadarrDbClient` class for high-performance database queries
|
||||
- Support for both SQLite and PostgreSQL Radarr databases
|
||||
- Eliminates API pagination overhead for import date lookups
|
||||
- Up to 10x faster movie import date detection
|
||||
- **Bulk Operations**: Added bulk import date queries for multiple movies
|
||||
- **Database Configuration**: New environment variables for database connection
|
||||
- `RADARR_DB_TYPE` (sqlite/postgresql)
|
||||
- `RADARR_DB_HOST`, `RADARR_DB_PORT`, `RADARR_DB_NAME` for PostgreSQL
|
||||
- `RADARR_DB_USER`, `RADARR_DB_PASSWORD` for PostgreSQL authentication
|
||||
- `RADARR_DB_PATH` for SQLite databases
|
||||
- `RADARR_DB_URL` connection string support
|
||||
- **Testing Suite**: Added performance comparison test script (`test_db_performance.py`)
|
||||
|
||||
### Changed
|
||||
- **RadarrClient Enhanced**: Automatic database client initialization with API fallback
|
||||
- **Movie Lookup Optimization**: Database-first approach for movie IMDb ID resolution
|
||||
- **Import Date Detection**: Optimized SQL queries replace complex API event parsing
|
||||
- **Dependencies**: Added `psycopg2-binary` for PostgreSQL support
|
||||
|
||||
### Technical Details
|
||||
- Database queries use proper SQL joins for efficient data retrieval
|
||||
- Maintains full backward compatibility with API-only installations
|
||||
- Graceful fallback to API methods when database access is unavailable
|
||||
- Support for both connection strings and individual parameter configuration
|
||||
|
||||
## [0.4.1] - 2025-01-09
|
||||
|
||||
### Fixed
|
||||
- **Radarr Client**: Fixed event type parsing for string-based event types (grabbed, downloadFolderImported, etc.)
|
||||
- **Radarr Client**: Improved grab event detection to filter out library addition events and only count actual download grabs
|
||||
- **Radarr Client**: Fixed JSON parsing errors when handling event data that's already a dictionary
|
||||
- **Radarr Client**: Enhanced validation of grab events to require download metadata (sourceTitle or indexer)
|
||||
|
||||
### Improved
|
||||
- **Radarr Client**: Better chronological event processing to find earliest actual download dates
|
||||
- **Radarr Client**: More accurate import date detection by validating grab events have real download information
|
||||
|
||||
## [0.4.0] - 2025-01-08
|
||||
### Changed
|
||||
- Switched to using Radarr's numeric event types for more reliable import detection
|
||||
- Added constants for EVENT_TYPE_GRABBED (1), IMPORTED (3), etc.
|
||||
- Removed legacy string-based event type checks
|
||||
- Improved event type parsing and validation
|
||||
### Changed
|
||||
- Added documentation for Radarr API event types (1=grabbed, 3=imported)
|
||||
- Improved event type detection using Radarr's internal event type IDs
|
||||
- Removed old string-based event type checks in favor of numeric IDs
|
||||
|
||||
## [0.3.8] - 2025-09-08
|
||||
### Changed
|
||||
- Use Radarr's numeric EventType (3="Imported") for more accurate import date detection
|
||||
- Simplified history processing logic
|
||||
|
||||
## [0.3.7] - 2025-09-08
|
||||
### Changed
|
||||
- Improved import date detection by tracking grab dates as fallback
|
||||
- Added warning when falling back to grab date
|
||||
|
||||
## [0.3.6] - 2025-09-08
|
||||
### Changed
|
||||
- Improved import detection filtering
|
||||
- Reduced debug noise for non-import events
|
||||
- Enhanced logging clarity for import matches
|
||||
- Added more context to import event messages
|
||||
|
||||
## [0.3.5] - 2025-09-08
|
||||
### Changed
|
||||
- Improved Radarr import detection to handle more cases
|
||||
- Added support for downloadFolderImported events
|
||||
- Enhanced path matching with better character cleaning
|
||||
- Added fuzzy title matching by removing articles
|
||||
- Reduced debug noise by only logging relevant matches
|
||||
|
||||
## [0.3.4] - 2025-09-08
|
||||
### Changed
|
||||
- Added downloadFolderImported event support
|
||||
- Improved path matching flexibility
|
||||
|
||||
## [0.3.3] - 2025-09-08
|
||||
### Changed
|
||||
- Added flexible path matching
|
||||
- Added sourcePath support
|
||||
- Initial support for title/year matching
|
||||
@@ -1,45 +0,0 @@
|
||||
# NFOGuard Configuration Guide
|
||||
|
||||
## ⚠️ IMPORTANT: All paths must be configured via environment variables
|
||||
|
||||
NFOGuard has **zero hardcoded paths** - everything must be explicitly configured in your environment variables to match your specific setup.
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
### Media Paths (REQUIRED)
|
||||
```bash
|
||||
# Container paths (what NFOGuard sees inside the Docker container)
|
||||
MOVIE_PATHS=/your/container/movie/path1,/your/container/movie/path2
|
||||
TV_PATHS=/your/container/tv/path1,/your/container/tv/path2
|
||||
|
||||
# External service paths (what Radarr/Sonarr see on the host)
|
||||
RADARR_ROOT_FOLDERS=/host/radarr/path1,/host/radarr/path2
|
||||
SONARR_ROOT_FOLDERS=/host/sonarr/path1,/host/sonarr/path2
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
```bash
|
||||
# For a typical setup:
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6
|
||||
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv,/mnt/unionfs/Media/TV/tv6
|
||||
```
|
||||
|
||||
### Database & Logging
|
||||
```bash
|
||||
DB_PATH=/app/data/media_dates.db
|
||||
LOG_DIR=/app/data/logs
|
||||
```
|
||||
|
||||
## Path Mapping Logic
|
||||
1. Webhook receives path from Radarr: `/mnt/unionfs/Media/Movies/movies6/Movie...`
|
||||
2. Matches against `RADARR_ROOT_FOLDERS[1]`: `/mnt/unionfs/Media/Movies/movies6`
|
||||
3. Maps to corresponding `MOVIE_PATHS[1]`: `/media/Movies/movies6`
|
||||
4. Final container path: `/media/Movies/movies6/Movie...`
|
||||
|
||||
## Zero Hardcoded Paths
|
||||
- ✅ No default fallback paths
|
||||
- ✅ Application fails with clear error if paths not configured
|
||||
- ✅ Works with any folder structure
|
||||
- ✅ Completely portable between environments
|
||||
-181
@@ -1,181 +0,0 @@
|
||||
# NFOGuard Deployment Guide
|
||||
|
||||
## 🚀 Quick Start for Docker Deployment
|
||||
|
||||
### Step 1: Environment Configuration
|
||||
```bash
|
||||
# Copy the environment template
|
||||
cp .env.template .env
|
||||
|
||||
# Edit with your specific paths and database credentials
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Key variables to customize in `.env`**:
|
||||
```bash
|
||||
# YOUR MEDIA PATHS (adjust these to your setup)
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
HOST_MEDIA_PATH=/mnt/unionfs/Media
|
||||
|
||||
# YOUR RADARR DATABASE PASSWORD
|
||||
RADARR_DB_PASSWORD=your_actual_password_here
|
||||
```
|
||||
|
||||
### Step 2: Deploy with Docker Compose
|
||||
```bash
|
||||
# Start NFOGuard
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f nfoguard
|
||||
```
|
||||
|
||||
### Step 3: Test Database Connection
|
||||
```bash
|
||||
# Test Radarr database connectivity
|
||||
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||
|
||||
# Expected response:
|
||||
# {"status": "success", "movies_with_imdb": 1500, "database_type": "postgresql"}
|
||||
```
|
||||
|
||||
### Step 4: Test Movie Discovery
|
||||
```bash
|
||||
# Test that your movie paths are correctly configured
|
||||
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||
|
||||
# Expected response:
|
||||
# {"status": "success", "message": "Movie scan test found 1500 movies",
|
||||
# "path_results": [{"path": "/media/Movies/movies", "exists": true, "movies_found": 800}, ...]}
|
||||
```
|
||||
|
||||
### Step 5: Validate Database Performance
|
||||
```bash
|
||||
# Test the optimized database query performance
|
||||
curl "http://localhost:8080/debug/movie/tt1674782"
|
||||
|
||||
# Expected response with July 2025 date:
|
||||
# {"detected_import_date": "2025-07-08T03:30:04+00:00", "import_source": "radarr:history.import"}
|
||||
```
|
||||
|
||||
## 📊 Production Workflow
|
||||
|
||||
### Manual Operations (via webhooks)
|
||||
|
||||
**1. Manual Movie Scan**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||
```
|
||||
|
||||
**2. Bulk Update All Movies**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/bulk/update"
|
||||
```
|
||||
|
||||
**3. System Health Check**
|
||||
```bash
|
||||
curl "http://localhost:8080/health"
|
||||
```
|
||||
|
||||
**4. Database Statistics**
|
||||
```bash
|
||||
curl "http://localhost:8080/stats"
|
||||
```
|
||||
|
||||
**5. Debug Specific Movie**
|
||||
```bash
|
||||
curl "http://localhost:8080/debug/movie/tt1674782"
|
||||
```
|
||||
|
||||
### Radarr Webhook Integration
|
||||
|
||||
**Add to Radarr → Settings → Connect → Webhook:**
|
||||
- **Name**: NFOGuard
|
||||
- **URL**: `http://nfoguard:8080/webhook/radarr`
|
||||
- **Method**: POST
|
||||
- **Events**: On Download, On Upgrade, On Rename
|
||||
|
||||
NFOGuard will automatically process movies when Radarr sends webhooks.
|
||||
|
||||
## 🔧 Environment Variables Reference
|
||||
|
||||
### Required Variables
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `MOVIE_PATHS` | Comma-separated movie directories | `/media/Movies/movies,/media/Movies/movies6` |
|
||||
| `TV_PATHS` | Comma-separated TV directories | `/media/TV/tv,/media/TV/tv6` |
|
||||
| `RADARR_DB_HOST` | Radarr PostgreSQL host | `radarr-postgres` |
|
||||
| `RADARR_DB_PASSWORD` | Radarr database password | `your_password` |
|
||||
|
||||
### Optional Variables
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HOST_MEDIA_PATH` | `/mnt/unionfs/Media` | Host path mounted to `/media` |
|
||||
| `DB_PATH` | `/app/data/media_dates.db` | NFOGuard database location |
|
||||
| `MANAGE_NFO` | `true` | Create/update .nfo files |
|
||||
| `FIX_DIR_MTIMES` | `true` | Fix file modification times |
|
||||
| `MOVIE_PRIORITY` | `import_then_digital` | Date preference strategy |
|
||||
| `DEBUG` | `false` | Enable debug logging |
|
||||
|
||||
## 🏗 Docker Compose Customization
|
||||
|
||||
**For different media structures:**
|
||||
```yaml
|
||||
# Example: Different media structure
|
||||
services:
|
||||
nfoguard:
|
||||
environment:
|
||||
- TV_PATHS=/storage/tv_shows,/storage/anime
|
||||
- MOVIE_PATHS=/storage/movies
|
||||
volumes:
|
||||
- "/storage:/storage:rw"
|
||||
- "./data:/app/data"
|
||||
```
|
||||
|
||||
**For external database networks:**
|
||||
```yaml
|
||||
# Example: Connect to existing Radarr network
|
||||
services:
|
||||
nfoguard:
|
||||
networks:
|
||||
- radarr_network
|
||||
|
||||
networks:
|
||||
radarr_network:
|
||||
external: true
|
||||
```
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
### "0 movies processed"
|
||||
1. Check paths: `curl -X POST "http://localhost:8080/test/movie-scan"`
|
||||
2. Verify environment variables in `.env`
|
||||
3. Ensure directories exist and have IMDb tags: `Movie Name [imdb-tt1234567] (2023)`
|
||||
|
||||
### Database connection errors
|
||||
1. Test connection: `curl -X POST "http://localhost:8080/test/bulk-update"`
|
||||
2. Verify `RADARR_DB_*` variables
|
||||
3. Check Docker network connectivity
|
||||
|
||||
### Performance issues
|
||||
1. Check health: `curl "http://localhost:8080/health"`
|
||||
2. Monitor logs: `docker-compose logs -f nfoguard`
|
||||
3. Verify database query performance: `curl "http://localhost:8080/debug/movie/tt1674782"`
|
||||
|
||||
## 📈 Monitoring
|
||||
|
||||
**Health Check Endpoint**: `GET /health`
|
||||
- Server status and uptime
|
||||
- Database connectivity
|
||||
- Radarr database status
|
||||
|
||||
**Statistics Endpoint**: `GET /stats`
|
||||
- Movie/TV counts
|
||||
- Processing statistics
|
||||
|
||||
**Batch Queue Status**: `GET /batch/status`
|
||||
- Pending webhook events
|
||||
- Processing queue status
|
||||
|
||||
This deployment approach ensures NFOGuard can be easily configured for any media server setup while maintaining the database-only performance optimizations.
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8
|
||||
|
||||
# Install system dependencies including PostgreSQL client libraries
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app user and directory
|
||||
RUN useradd --create-home --shell /bin/bash app
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Copy DLL to a dedicated directory and create entrypoint script
|
||||
RUN mkdir -p /app/emby-plugin && \
|
||||
cp /app/Emby-DLL/NFOGuard.Emby.Plugin.dll /app/emby-plugin/ && \
|
||||
echo '#!/bin/bash' > /app/deploy-plugin.sh && \
|
||||
echo 'if [ -d "/emby-plugins" ]; then' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "Deploying NFOGuard Emby Plugin to mounted directory: /emby-plugins"' >> /app/deploy-plugin.sh && \
|
||||
echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll /emby-plugins/' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
|
||||
echo 'elif [ -n "$EMBY_PLUGINS_PATH" ] && [ -d "$EMBY_PLUGINS_PATH" ]; then' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "Deploying NFOGuard Emby Plugin to: $EMBY_PLUGINS_PATH"' >> /app/deploy-plugin.sh && \
|
||||
echo ' cp /app/emby-plugin/NFOGuard.Emby.Plugin.dll "$EMBY_PLUGINS_PATH/"' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "Plugin deployed successfully!"' >> /app/deploy-plugin.sh && \
|
||||
echo 'else' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "No Emby plugins directory found - skipping plugin deployment"' >> /app/deploy-plugin.sh && \
|
||||
echo ' echo "To enable plugin deployment, bind mount your Emby plugins directory to /emby-plugins"' >> /app/deploy-plugin.sh && \
|
||||
echo 'fi' >> /app/deploy-plugin.sh && \
|
||||
echo 'exec python nfoguard.py' >> /app/deploy-plugin.sh && \
|
||||
chmod +x /app/deploy-plugin.sh
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to app user
|
||||
USER app
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application with plugin deployment
|
||||
CMD ["/app/deploy-plugin.sh"]
|
||||
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 NFOGuard
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Commercial Licensing
|
||||
|
||||
For commercial use, enterprise features, or if the MIT license doesn't meet your needs,
|
||||
commercial licenses are available. Contact us for more information.
|
||||
|
||||
Future premium features (such as web UI, advanced analytics, enterprise support)
|
||||
may be offered under separate commercial licensing terms.
|
||||
|
||||
## Third-Party Licenses
|
||||
|
||||
This software includes third-party libraries with their own licenses.
|
||||
See requirements.txt and the respective package documentation for details.
|
||||
-360
@@ -1,360 +0,0 @@
|
||||
# NFOGuard Project Overview
|
||||
|
||||
## 🎯 Three-Component System Architecture
|
||||
|
||||
**NFOGuard** is a comprehensive media date management ecosystem consisting of three integrated repositories:
|
||||
|
||||
### 1. **NFOGuard (Docker Service)** [THIS REPOSITORY]
|
||||
- **Purpose**: Main webhook service that listens for Sonarr/Radarr events and manages NFO files
|
||||
- **Version**: v1.6.0
|
||||
- **Technology**: Python 3, Docker, PostgreSQL integration
|
||||
- **Role**: Records import dates, creates/updates NFO files, manages filesystem timestamps
|
||||
|
||||
### 2. **NFOGuard-Emby-Plugin** (Companion DLL)
|
||||
- **Purpose**: Emby Server plugin that reads NFO dateadded values and syncs to Emby's DateCreated field
|
||||
- **Version**: v0.6.1
|
||||
- **Technology**: C# .NET Framework 4.8, Emby SDK
|
||||
- **Role**: Real-time date synchronization within Emby, license validation
|
||||
|
||||
### 3. **nfoguard-license-server** (License Management)
|
||||
- **Purpose**: License validation server with admin interface for plugin access control
|
||||
- **Version**: v1.3.1
|
||||
- **Technology**: Node.js, Express, SQLite
|
||||
- **Role**: 7-day trials, license validation, user management dashboard
|
||||
|
||||
## Project Purpose
|
||||
NFOGuard is an automated NFO file management system that integrates with Radarr and Sonarr to maintain accurate metadata and release dates in media library NFO files. It serves as a webhook-driven middleware that preserves existing metadata while adding clean, standardized date information.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Technology Stack
|
||||
- **Language**: Python 3
|
||||
- **Database**: PostgreSQL (direct database access to Radarr/Sonarr)
|
||||
- **Deployment**: Docker containers with Docker Compose
|
||||
- **API Framework**: Built-in HTTP webhook server
|
||||
- **File Processing**: XML parsing with xml.etree.ElementTree
|
||||
- **Logging**: Custom logging system with configurable debug levels
|
||||
|
||||
### Key Components
|
||||
1. **nfoguard.py** - Main application with webhook server and processing logic
|
||||
- Lines 975-990: Webhook date decision logic (fixed in v1.5.4)
|
||||
- Lines 1429, 1479: Event type filtering for Sonarr/Radarr webhooks
|
||||
- Lines 366, 555, 607, 813: Episode NFO creation calls
|
||||
- Lines 586-592: Episode parsing from filename logic
|
||||
2. **core/nfo_manager.py** - NFO file creation, parsing, and management
|
||||
- Lines 336-375: `_find_existing_episode_nfo()` - Smart NFO detection (v1.6.0)
|
||||
- Lines 377-491: `create_episode_nfo()` - Episode NFO creation with renaming
|
||||
- Lines 341: Episode filename pattern `S{season_num:02d}E{episode_num:02d}.nfo`
|
||||
- Lines 480-487: NFO renaming logic (removes old files after rename)
|
||||
- Lines 85-149: `parse_imdb_from_path_or_nfo()` - Dual identification system
|
||||
3. **core/database.py** - Database operations and date management
|
||||
- Lines 173-186: `upsert_movie_dates()` - CRITICAL FIX: INSERT OR REPLACE (v1.5.5)
|
||||
- Database connection handling for Radarr/Sonarr PostgreSQL
|
||||
4. **core/logging.py** - Centralized logging system with `_log()` function
|
||||
5. **core/config.py** - Configuration management
|
||||
|
||||
## Primary Features
|
||||
|
||||
### Webhook Integration
|
||||
- **Radarr Webhooks**: `/webhook/radarr` - Handles movie import, upgrade, rename events
|
||||
- **Sonarr Webhooks**: `/webhook/sonarr` - Handles TV episode import, upgrade, rename events
|
||||
- **Event Filtering**: Processes Download, Upgrade, Rename, Import events only
|
||||
- **Batch Processing**: Configurable delays to handle multiple rapid events
|
||||
|
||||
### Media Identification (Dual Method System)
|
||||
1. **Primary**: Directory names with IMDb IDs in brackets `[tt1234567]` or `[imdb-tt1234567]`
|
||||
2. **Fallback**: NFO file parsing for IMDb IDs using XML tags:
|
||||
- `<uniqueid type="imdb">tt1234567</uniqueid>`
|
||||
- `<imdbid>tt1234567</imdbid>`
|
||||
- `<imdb>tt1234567</imdb>`
|
||||
|
||||
### NFO File Management
|
||||
|
||||
#### Movies
|
||||
- **File**: `movie.nfo` in movie directory
|
||||
- **Metadata**: Preserves existing Radarr metadata, adds release dates at bottom
|
||||
- **Date Priority**: Digital → Physical → Theatrical release dates
|
||||
- **Locking**: Adds `<lockdata>true</lockdata>` to prevent overwrites
|
||||
|
||||
#### TV Shows
|
||||
- **Series File**: `tvshow.nfo` in series root directory
|
||||
- **Episode Files**: Standardized `S##E##.nfo` format (e.g., `S01E01.nfo`)
|
||||
- **Smart Renaming**: Detects existing non-standard NFO files, extracts metadata, renames to standard format
|
||||
- **Metadata**: Preserves existing metadata, adds air dates and import dates
|
||||
|
||||
### Database Integration
|
||||
- **Direct Access**: Connects to Radarr/Sonarr PostgreSQL databases
|
||||
- **Date Tracking**: Maintains `dateadded` timestamps for import dates
|
||||
- **Upsert Operations**: Proper `INSERT OR REPLACE` for data integrity
|
||||
- **History Queries**: Retrieves earliest import dates from application databases
|
||||
|
||||
## Directory Structure Requirements
|
||||
|
||||
### Movies
|
||||
```
|
||||
/movies/
|
||||
└── Movie Title (2024) [tt1234567]/
|
||||
├── movie.mkv
|
||||
└── movie.nfo (created/updated by NFOGuard)
|
||||
```
|
||||
|
||||
### TV Shows
|
||||
```
|
||||
/tv/
|
||||
└── Series Title (2024) [tt1234567]/
|
||||
├── Season 01/
|
||||
│ ├── Series S01E01.mkv
|
||||
│ ├── S01E01.nfo (created/updated by NFOGuard)
|
||||
│ └── S01E02.nfo
|
||||
├── Season 02/
|
||||
└── tvshow.nfo (created/updated by NFOGuard)
|
||||
```
|
||||
|
||||
## Configuration System
|
||||
|
||||
### Environment Files
|
||||
- **`.env`**: Main configuration (paths, behavior, non-sensitive options)
|
||||
- **`.env.secrets`**: Sensitive data (API keys, database credentials)
|
||||
|
||||
### Key Settings
|
||||
- **Media Paths**: Container and application path mappings
|
||||
- **Release Date Priority**: Configurable date preference order
|
||||
- **Debug Modes**: `DEBUG`, `PATH_DEBUG`, `SUPPRESS_TVDB_WARNINGS`
|
||||
- **Database Credentials**: PostgreSQL connection details
|
||||
- **API Keys**: TMDB, TVDB integration (TVDB optional)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Health check and status |
|
||||
| `/webhook/radarr` | POST | Radarr webhook handler |
|
||||
| `/webhook/sonarr` | POST | Sonarr webhook handler |
|
||||
| `/manual/scan` | POST | Manual media scanning |
|
||||
| `/bulk/update` | POST | Bulk movie updates from Radarr DB |
|
||||
|
||||
## Critical Bug Fixes & Technical Details
|
||||
|
||||
### Database Upsert Bug (v1.5.5) - CRITICAL
|
||||
- **Location**: `core/database.py:173-186`
|
||||
- **Problem**: `upsert_movie_dates()` was UPDATE-only, not proper upsert
|
||||
- **Symptoms**: Manual scans weren't saving `dateadded`, webhooks fell back to current timestamp
|
||||
- **Fix**: Changed to `INSERT OR REPLACE` with `COALESCE` for path preservation
|
||||
- **Impact**: Fixed webhook date accuracy and database integrity
|
||||
|
||||
### Webhook Date Logic Bug (v1.5.4)
|
||||
- **Location**: `nfoguard.py:975-990`
|
||||
- **Problem**: Upgrade webhooks bypassed full date decision logic
|
||||
- **Symptoms**: Used current timestamp instead of existing Radarr import dates
|
||||
- **Fix**: Webhook mode now uses same date priority as manual scans
|
||||
- **Flow**: Database → Radarr/Sonarr API → Release dates → Current timestamp
|
||||
|
||||
### Radarr Event Filtering (v1.5.3)
|
||||
- **Location**: `nfoguard.py:1479`
|
||||
- **Problem**: Missing event type filtering (Sonarr had it, Radarr didn't)
|
||||
- **Fix**: Added `if event_type not in ["Download", "Upgrade", "Rename"]:`
|
||||
- **Result**: Both systems now follow identical webhook processing
|
||||
|
||||
### NFO-Based Identification System (v1.6.0)
|
||||
- **Location**: `core/nfo_manager.py:85-149`
|
||||
- **Method**: `parse_imdb_from_path_or_nfo()` tries directory first, then NFO files
|
||||
- **Supports**: `<uniqueid type="imdb">`, `<imdbid>`, `<imdb>` XML tags
|
||||
- **Files**: movie.nfo and tvshow.nfo parsing
|
||||
|
||||
### Smart Episode NFO Renaming (v1.6.0)
|
||||
- **Location**: `core/nfo_manager.py:336-491`
|
||||
- **Detection**: `_find_existing_episode_nfo()` scans season directories
|
||||
- **Process**: Extract metadata → Write to standard format → Remove old file
|
||||
- **Pattern**: Always creates `S##E##.nfo` format
|
||||
- **Safety**: Only removes old file after successful write
|
||||
|
||||
## Development History & Major Fixes
|
||||
|
||||
### v1.6.0 (September 16, 2025) - Current
|
||||
- **NFO-Based Identification**: Added dual identification system (directory + NFO parsing)
|
||||
- **Smart NFO Renaming**: Detects and renames non-standard episode NFO files to `S##E##.nfo` format
|
||||
- **Metadata Preservation**: Maintains existing metadata during NFO operations
|
||||
- **Enhanced Documentation**: Comprehensive README and project documentation
|
||||
|
||||
### v1.5.5 (September 15, 2025)
|
||||
- **CRITICAL DATABASE FIX**: Fixed `upsert_movie_dates()` from UPDATE-only to proper `INSERT OR REPLACE`
|
||||
- **Data Integrity**: Manual scans now properly save `dateadded` to database
|
||||
- **Webhook Fix**: Webhooks now find existing database entries correctly
|
||||
|
||||
### v1.5.4 (September 15, 2025)
|
||||
- **Webhook Date Logic Fix**: Fixed webhook processing to use proper date decision logic
|
||||
- **Smart Fallback**: Database → Radarr import dates → release dates → timestamp priority
|
||||
|
||||
### v1.5.3 (September 15, 2025)
|
||||
- **Radarr Rename Fix**: Added missing event type filtering to Radarr webhooks
|
||||
- **Consistency**: Radarr now matches Sonarr's event handling
|
||||
|
||||
### v1.5.2
|
||||
- **TVDB Warning Suppression**: Added `SUPPRESS_TVDB_WARNINGS` for cleaner logs
|
||||
- **Production Ready**: Reduced log noise for production environments
|
||||
|
||||
## Date Decision Logic
|
||||
|
||||
### Movies
|
||||
1. **Database Check**: Existing NFOGuard database entry
|
||||
2. **Radarr History**: Earliest import date from Radarr database
|
||||
3. **Release Dates**: Digital → Physical → Theatrical (configurable priority)
|
||||
4. **File Dates**: File system timestamps (fallback)
|
||||
5. **Current Timestamp**: Absolute last resort
|
||||
|
||||
### TV Shows
|
||||
1. **Database Check**: Existing NFOGuard database entry
|
||||
2. **Sonarr History**: Earliest import date from Sonarr database
|
||||
3. **Air Dates**: Episode air date from metadata
|
||||
4. **File Dates**: File system timestamps (fallback)
|
||||
5. **Current Timestamp**: Absolute last resort
|
||||
|
||||
## Workflow Patterns
|
||||
|
||||
### Webhook Processing
|
||||
1. **Event Validation**: Filter supported event types
|
||||
2. **Media Detection**: Parse file paths and identify media
|
||||
3. **Database Lookup**: Check for existing entries
|
||||
4. **Date Resolution**: Apply decision logic hierarchy
|
||||
5. **NFO Processing**: Create/update NFO files with metadata
|
||||
6. **Database Update**: Store processed information
|
||||
|
||||
### Manual Scanning
|
||||
1. **Directory Traversal**: Scan configured media paths
|
||||
2. **Media Identification**: Directory names and NFO files
|
||||
3. **Batch Processing**: Process multiple items efficiently
|
||||
4. **Database Persistence**: Store all discovered media
|
||||
5. **NFO Creation**: Generate/update all NFO files
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
- **Radarr**: Movie management, webhook triggers, database queries
|
||||
- **Sonarr**: TV show management, webhook triggers, database queries
|
||||
- **TMDB**: Movie metadata and release dates (primary)
|
||||
- **TVDB**: TV show metadata (optional, failures suppressed)
|
||||
- **Emby/Jellyfin**: NFO consumption for media servers
|
||||
|
||||
### File System
|
||||
- **Read Access**: Media directories for scanning and identification
|
||||
- **Write Access**: NFO file creation and updates
|
||||
- **Path Mapping**: Container paths vs application paths
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Code Organization
|
||||
- **Modular Design**: Separate concerns (database, NFO, logging, config)
|
||||
- **Error Handling**: Comprehensive exception handling with logging
|
||||
- **Configuration Driven**: Environment-based configuration management
|
||||
- **Docker Native**: Designed for containerized deployment
|
||||
|
||||
### Testing Strategy
|
||||
- **Manual Testing**: API endpoints and webhook simulation
|
||||
- **Real-world Integration**: Actual Radarr/Sonarr webhook testing
|
||||
- **Database Validation**: Direct database query verification
|
||||
- **File System Testing**: NFO file creation and parsing validation
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Path Mapping
|
||||
- **Problem**: Container paths don't match application paths
|
||||
- **Solution**: Proper volume mapping and path configuration in `.env`
|
||||
- **Debug**: Enable `PATH_DEBUG=true` to see path resolution
|
||||
|
||||
### Database Connectivity
|
||||
- **Problem**: Cannot connect to Radarr/Sonarr databases
|
||||
- **Solution**: Verify credentials in `.env.secrets` and network connectivity
|
||||
- **Check**: Database host, port, username, password in secrets file
|
||||
|
||||
### Permission Issues
|
||||
- **Problem**: Cannot write NFO files
|
||||
- **Solution**: Ensure proper file permissions and volume mount configurations
|
||||
- **Fix**: `chmod 755` on media directories, `rw` mount permissions
|
||||
|
||||
### TVDB Failures
|
||||
- **Problem**: TVDB API errors causing log noise
|
||||
- **Solution**: Set `SUPPRESS_TVDB_WARNINGS=true` (system works without TVDB)
|
||||
- **Note**: TVDB is optional - IMDb and Sonarr data are primary sources
|
||||
|
||||
### Webhook Issues
|
||||
- **Problem**: Webhooks not triggering NFO updates
|
||||
- **Debug Steps**:
|
||||
1. Check webhook URLs: `http://nfoguard:8080/webhook/radarr|sonarr`
|
||||
2. Verify event types: Download, Upgrade, Rename only
|
||||
3. Enable `DEBUG=true` for detailed webhook logging
|
||||
4. Check database entries with manual scan first
|
||||
|
||||
### Date Problems
|
||||
- **Problem**: Wrong dates in NFO files (current timestamp instead of import date)
|
||||
- **Root Cause**: Usually database upsert issues or missing database entries
|
||||
- **Solution**: Run manual scan first to populate database, then test webhooks
|
||||
- **Priority**: Database → API → Release dates → File dates → Current timestamp
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- **Movie NFO Renaming**: Similar to TV show episode renaming
|
||||
- **Multiple NFO Formats**: Support for different media server preferences
|
||||
- **Metadata Enhancement**: Additional metadata sources and fields
|
||||
- **Performance Optimization**: Caching and batch processing improvements
|
||||
|
||||
### Monitoring & Maintenance
|
||||
- **Health Checks**: Regular endpoint monitoring
|
||||
- **Log Analysis**: Debug mode for troubleshooting
|
||||
- **Database Maintenance**: Regular cleanup of old entries
|
||||
- **Version Management**: Semantic versioning and change tracking
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Python 3.x development environment
|
||||
- Access to Radarr/Sonarr instances with PostgreSQL
|
||||
- Git workflow familiarity
|
||||
|
||||
### Development Workflow
|
||||
1. **Local Testing**: Use development Docker image
|
||||
2. **Code Changes**: Implement features with proper logging
|
||||
3. **Version Updates**: Update VERSION file and SUMMARY.md
|
||||
4. **Documentation**: Update README.md and Project.md
|
||||
5. **Deployment**: Push to dev branch, then production
|
||||
|
||||
## Quick Context Recovery
|
||||
|
||||
### When Starting a New Session
|
||||
1. **Read Project.md** - This file for full context
|
||||
2. **Check SUMMARY.md** - Latest changes and current version
|
||||
3. **Review VERSION** - Current version number (v1.6.0)
|
||||
4. **Key Files to Understand**:
|
||||
- `nfoguard.py` - Main logic, webhook handlers
|
||||
- `core/nfo_manager.py` - NFO creation, parsing, renaming
|
||||
- `core/database.py` - Database operations (watch the upsert bug fix!)
|
||||
|
||||
### Critical Code Locations to Remember
|
||||
- **Database Upsert**: `core/database.py:173-186` (fixed UPDATE → INSERT OR REPLACE)
|
||||
- **Webhook Date Logic**: `nfoguard.py:975-990` (fixed fallback behavior)
|
||||
- **Event Filtering**: `nfoguard.py:1429,1479` (Download/Upgrade/Rename only)
|
||||
- **NFO Renaming**: `core/nfo_manager.py:336-491` (smart episode renaming)
|
||||
- **Dual Identification**: `core/nfo_manager.py:85-149` (directory + NFO parsing)
|
||||
|
||||
### Project Context (Updated 2025-09-17)
|
||||
- **Current Status**: Production-ready system with 3-component architecture
|
||||
- **Version**: v1.6.0 (NFOGuard Docker), v1.1.18 (Emby Plugin), v1.3.2 (License Server)
|
||||
- **User Environment**: Docker deployment, PostgreSQL databases, Unmanic integration
|
||||
- **Focus Areas**: Webhook accuracy, NFO standardization, date integrity, lifetime license management
|
||||
- **Testing Method**: Real webhooks from Radarr/Sonarr, manual scans for validation
|
||||
- **Recent Updates**: Fixed lifetime license display issue with explicit "never" expires handling
|
||||
|
||||
### Three-Repository System Overview
|
||||
1. **NFOGuard (Docker)** - Main webhook service and NFO management
|
||||
2. **NFOGuard-Emby-Plugin** - DLL for real-time Emby date synchronization
|
||||
3. **nfoguard-license-server** - License validation and user management
|
||||
|
||||
### Recent Work Pattern
|
||||
- User reports issues with specific examples (log snippets, webhook behavior)
|
||||
- We debug by analyzing code at specific line numbers
|
||||
- Implement fixes with proper error handling and logging
|
||||
- Update documentation (SUMMARY.md, README.md, Project.md) across all repositories
|
||||
- Version bump and prepare for deployment
|
||||
|
||||
This project represents a mature, production-ready system for automated NFO management with comprehensive webhook integration, intelligent metadata handling, and integrated license management.
|
||||
@@ -1,390 +0,0 @@
|
||||
# NFOGuard
|
||||
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
|
||||
**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
||||
>
|
||||
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
||||
>
|
||||
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||
>
|
||||
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
||||
>
|
||||
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
|
||||
>
|
||||
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
|
||||
|
||||
---
|
||||
|
||||
NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎬 **Movie & TV Support** - Works with both Radarr and Sonarr
|
||||
- 📅 **Smart Date Handling** - Prioritizes digital, physical, and theatrical release dates
|
||||
- 🔄 **Webhook Integration** - Triggers automatically on import, upgrade, and rename
|
||||
- 🗄️ **Database Integration** - Direct PostgreSQL access for better performance
|
||||
- 📝 **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
|
||||
- 🔒 **Metadata Locking** - Prevents overwrites with lockdata tags
|
||||
- ⚡ **Batch Processing** - Efficient handling of multiple files
|
||||
- 🐳 **Docker Ready** - Easy deployment with Docker Compose
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Download Configuration Files
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy and edit main configuration
|
||||
cp .env.template .env
|
||||
nano .env
|
||||
|
||||
# Copy and edit secrets (API keys, passwords)
|
||||
cp .env.secrets.template .env.secrets
|
||||
nano .env.secrets
|
||||
chmod 600 .env.secrets
|
||||
|
||||
# Copy and edit Docker Compose
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
nano docker-compose.yml
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
# Create data directory
|
||||
mkdir -p ./data
|
||||
|
||||
# Start NFOGuard
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f nfoguard
|
||||
|
||||
# Verify health
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose | Contains |
|
||||
|------|---------|----------|
|
||||
| `.env` | Main configuration | Paths, behavior settings, non-sensitive options |
|
||||
| `.env.secrets` | Sensitive data | API keys, passwords, database credentials |
|
||||
|
||||
### Key Configuration Options
|
||||
|
||||
**Media Paths** (Required):
|
||||
```bash
|
||||
# Container paths (what NFOGuard sees)
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
|
||||
# *arr application paths (what your apps see)
|
||||
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
|
||||
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv
|
||||
```
|
||||
|
||||
**Release Date Priority**:
|
||||
```bash
|
||||
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||
```
|
||||
|
||||
**Debug Mode**:
|
||||
```bash
|
||||
DEBUG=false # Clean production logs
|
||||
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
|
||||
```
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
### Production (Stable)
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:latest
|
||||
```
|
||||
|
||||
### Development (Latest Features)
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:dev
|
||||
```
|
||||
|
||||
### Specific Version
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:v1.5.5
|
||||
```
|
||||
|
||||
## 🔗 Webhook Setup
|
||||
|
||||
Configure these webhook URLs in your applications:
|
||||
|
||||
**Radarr**: `http://nfoguard:8080/webhook/radarr`
|
||||
**Sonarr**: `http://nfoguard:8080/webhook/sonarr`
|
||||
|
||||
**Triggers**: On Import, On Upgrade, On Rename
|
||||
|
||||
## 🔄 Manual Operations
|
||||
|
||||
### Manual Scanning
|
||||
|
||||
Trigger manual scans via API endpoints:
|
||||
|
||||
```bash
|
||||
# Manual scan all media (movies and TV)
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=both"
|
||||
|
||||
# Manual scan TV only
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=tv"
|
||||
|
||||
# Manual scan movies only
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||
|
||||
# Manual scan specific path
|
||||
curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
||||
|
||||
# Bulk update all movies from Radarr database
|
||||
curl -X POST "http://localhost:8080/bulk/update"
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/webhook/radarr` | POST | Radarr webhook handler |
|
||||
| `/webhook/sonarr` | POST | Sonarr webhook handler |
|
||||
| `/manual/scan` | POST | Manual media scanning |
|
||||
| `/bulk/update` | POST | Bulk movie updates from Radarr DB |
|
||||
|
||||
### Manual Scan Parameters
|
||||
|
||||
- `scan_type`: `both`, `movies`, `tv`
|
||||
- `path`: Specific directory path to scan
|
||||
- Use for initial setup or fixing existing media
|
||||
|
||||
## 📁 Volume Mapping
|
||||
|
||||
Critical: Update `docker-compose.yml` with your actual paths:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # NFOGuard data
|
||||
- /your/movies/path:/media/Movies/movies:rw # Movie library
|
||||
- /your/tv/path:/media/TV/tv:rw # TV library
|
||||
```
|
||||
|
||||
### Common Examples
|
||||
|
||||
**Unraid**:
|
||||
```yaml
|
||||
- /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||
- /mnt/user/Media/TV:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
**Synology**:
|
||||
```yaml
|
||||
- /volume1/Media/Movies:/media/Movies/movies:rw
|
||||
- /volume1/Media/TV:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
**Standard Linux**:
|
||||
```yaml
|
||||
- /home/user/media/movies:/media/Movies/movies:rw
|
||||
- /home/user/media/tv:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
```bash
|
||||
docker-compose logs -f nfoguard
|
||||
```
|
||||
|
||||
### Enable Debug Mode
|
||||
```bash
|
||||
# In .env file
|
||||
DEBUG=true
|
||||
PATH_DEBUG=true
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Permission Errors**: Ensure NFOGuard can write to mounted directories
|
||||
2. **Path Mapping**: Verify container paths match `.env` configuration
|
||||
3. **Webhooks**: Check URLs and ensure port 8080 is accessible
|
||||
4. **Database**: Verify PostgreSQL credentials in `.env.secrets`
|
||||
|
||||
## 📊 What NFOGuard Does
|
||||
|
||||
### Before
|
||||
```xml
|
||||
<movie>
|
||||
<title>Movie Title</title>
|
||||
<year>2023</year>
|
||||
<!-- Existing Radarr metadata -->
|
||||
</movie>
|
||||
```
|
||||
|
||||
### After
|
||||
```xml
|
||||
<movie>
|
||||
<title>Movie Title</title>
|
||||
<year>2023</year>
|
||||
<!-- Existing Radarr metadata preserved -->
|
||||
|
||||
<!-- NFOGuard additions at bottom -->
|
||||
<digital_release_date>2023-03-15</digital_release_date>
|
||||
<lockdata>true</lockdata>
|
||||
<!-- Manager: NFOGuard -->
|
||||
</movie>
|
||||
```
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Radarr and/or Sonarr
|
||||
- Media files in accessible directories
|
||||
- Network connectivity between services
|
||||
|
||||
## 📁 Directory Structure Requirements
|
||||
|
||||
NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions:
|
||||
|
||||
### 🎬 **Movies**
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
/movies/
|
||||
└── Movie Title (2024) [tt1234567]/
|
||||
├── movie.mkv
|
||||
└── movie.nfo (created by NFOGuard)
|
||||
```
|
||||
|
||||
**Identification Methods:**
|
||||
1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]`
|
||||
2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below)
|
||||
3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`
|
||||
4. **Case insensitive** - `[TT1234567]` works too
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
✅ Action Film (2024) [tt1234567]/
|
||||
✅ Drama Movie [imdb-tt7654321]/
|
||||
✅ SciFi Thriller (2023) [TT9876543]/
|
||||
❌ Missing IMDB Directory/
|
||||
```
|
||||
|
||||
### 📺 **TV Shows**
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
/tv/
|
||||
└── Series Title (2024) [tt1234567]/
|
||||
├── Season 01/
|
||||
│ ├── Series S01E01.mkv
|
||||
│ ├── Series S01E02.mkv
|
||||
│ ├── S01E01.nfo (created by NFOGuard)
|
||||
│ └── S01E02.nfo (created by NFOGuard)
|
||||
├── Season 02/
|
||||
└── tvshow.nfo (created by NFOGuard)
|
||||
```
|
||||
|
||||
**Identification Methods:**
|
||||
1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]`
|
||||
2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below)
|
||||
3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc.
|
||||
4. **Episode files** must contain season/episode info:
|
||||
- **SxxExx format**: `S01E01`, `S1E1`, `s01e01`
|
||||
- **Dot format**: `1.1`, `01.01`
|
||||
5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts`
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
✅ Drama Series (2024) [tt1234567]/
|
||||
├── Season 01/
|
||||
│ ├── Drama S01E01.mkv
|
||||
│ └── Drama S01E02.mkv
|
||||
└── Season 02/
|
||||
|
||||
✅ Comedy Show [tt7654321]/
|
||||
└── Season 1/
|
||||
├── Comedy 1.1.mkv
|
||||
└── Comedy 1.2.mkv
|
||||
|
||||
❌ Series Without IMDB []/
|
||||
❌ Series [tt1234567]/Episode01.mkv (no season directory)
|
||||
❌ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern)
|
||||
```
|
||||
|
||||
### 📄 **NFO File Identification Format**
|
||||
|
||||
NFOGuard can extract IMDb IDs from existing NFO files using these XML tags:
|
||||
|
||||
**Movie NFO (movie.nfo):**
|
||||
```xml
|
||||
<!-- Method 1: uniqueid tag (preferred) -->
|
||||
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||
|
||||
<!-- Method 2: imdbid tag -->
|
||||
<imdbid>tt1234567</imdbid>
|
||||
|
||||
<!-- Method 3: imdb tag -->
|
||||
<imdb>tt1234567</imdb>
|
||||
```
|
||||
|
||||
**TV Show NFO (tvshow.nfo):**
|
||||
```xml
|
||||
<!-- Same format as movies -->
|
||||
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||
<imdbid>tt1234567</imdbid>
|
||||
<imdb>tt1234567</imdb>
|
||||
```
|
||||
|
||||
**Episode NFO Files:**
|
||||
NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`:
|
||||
- `S01E01.nfo` for Season 1, Episode 1
|
||||
- `S02E05.nfo` for Season 2, Episode 5
|
||||
- Always zero-padded format (S01E01, not S1E1)
|
||||
- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format
|
||||
|
||||
### 🚫 **What Gets Skipped**
|
||||
NFOGuard will ignore:
|
||||
- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs
|
||||
- Directories without video files
|
||||
- TV episodes without recognizable season/episode patterns
|
||||
- Season directories that don't match "Season X" format
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
||||
- **Documentation**: See `SETUP.md` for detailed instructions
|
||||
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Commercial licensing and enterprise features may be available separately. Contact us for more information.
|
||||
|
||||
---
|
||||
|
||||
**NFOGuard** - Keeping your media metadata clean and organized! 🎯
|
||||
@@ -1,37 +0,0 @@
|
||||
## NFOGuard v1.5.5 - Complete CI/CD & Docker Hub Integration! 🚀
|
||||
|
||||
### Major Updates in v1.5.5:
|
||||
- **🐋 DOCKER HUB**: Full integration with `sbcrumb/nfoguard` repository
|
||||
- **🔧 CI/CD FIXED**: Both main and dev pipelines working perfectly
|
||||
- **🐘 POSTGRESQL**: Added PostgreSQL dependencies for psycopg2-binary
|
||||
- **🐍 PYTHON 3.11**: Stable Python 3.11-slim base image
|
||||
- **📦 DUAL REGISTRY**: Pushes to both local Gitea and Docker Hub
|
||||
- **🎯 DEV PIPELINE**: Separate dev builds with `sbcrumb/nfoguard:dev`
|
||||
|
||||
### CI/CD Pipeline Status:
|
||||
✅ **Main Branch**: Builds and pushes `sbcrumb/nfoguard:latest`
|
||||
✅ **Dev Branch**: Builds and pushes `sbcrumb/nfoguard:dev`
|
||||
✅ **Version Tags**: Automatic semantic versioning (v1.5.5)
|
||||
✅ **Docker Dependencies**: PostgreSQL, libpq-dev, gcc for psycopg2
|
||||
|
||||
### Available Images:
|
||||
```bash
|
||||
# Stable release
|
||||
docker pull sbcrumb/nfoguard:latest
|
||||
|
||||
# Development version
|
||||
docker pull sbcrumb/nfoguard:dev
|
||||
|
||||
# Specific version
|
||||
docker pull sbcrumb/nfoguard:v1.5.5
|
||||
```
|
||||
|
||||
### Fixed Issues:
|
||||
- ✅ Docker build permission errors
|
||||
- ✅ Missing PostgreSQL dependencies
|
||||
- ✅ YAML syntax errors in dev CI
|
||||
- ✅ Python version compatibility
|
||||
- ✅ TVDB warning suppression
|
||||
- ✅ Debug logging controls
|
||||
|
||||
## 🎉 NFOGuard is now production-ready with full CI/CD automation! 🎉
|
||||
@@ -1,153 +0,0 @@
|
||||
# NFOGuard Setup Guide
|
||||
|
||||
## 🔐 Secure Configuration Setup
|
||||
|
||||
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
|
||||
|
||||
- **`.env`** - Main configuration (safe to share for debugging)
|
||||
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
|
||||
|
||||
### Step 1: Copy Configuration Templates
|
||||
|
||||
```bash
|
||||
# Copy main configuration
|
||||
cp .env.template .env
|
||||
|
||||
# Copy secrets configuration
|
||||
cp .env.secrets.template .env.secrets
|
||||
```
|
||||
|
||||
### Step 2: Configure Main Settings
|
||||
|
||||
Edit your `.env` file with your specific paths and preferences:
|
||||
|
||||
```bash
|
||||
# Media paths (adjust to your directory structure)
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
|
||||
# Database connection details
|
||||
RADARR_DB_HOST=radarr-postgres
|
||||
RADARR_DB_PORT=5432
|
||||
RADARR_DB_NAME=radarr
|
||||
RADARR_DB_USER=radarr
|
||||
|
||||
# Processing preferences
|
||||
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||
ALLOW_FILE_DATE_FALLBACK=false
|
||||
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||
|
||||
# TV webhook processing mode (v0.6.0+)
|
||||
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||
```
|
||||
|
||||
### Step 3: Configure Secrets
|
||||
|
||||
Edit your `.env.secrets` file with your actual API keys and passwords:
|
||||
|
||||
```bash
|
||||
# Database password
|
||||
RADARR_DB_PASSWORD=your_actual_radarr_password
|
||||
|
||||
# TMDB API key (required for release date detection)
|
||||
TMDB_API_KEY=your_actual_tmdb_api_key
|
||||
|
||||
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||
SONARR_API_KEY=your_actual_sonarr_api_key
|
||||
|
||||
# Optional API keys
|
||||
RADARR_API_KEY=your_radarr_api_key
|
||||
OMDB_API_KEY=your_omdb_api_key
|
||||
```
|
||||
|
||||
### Step 4: Verify Configuration
|
||||
|
||||
Test your setup:
|
||||
|
||||
```bash
|
||||
# Test database connections
|
||||
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||
|
||||
# Test movie scanning
|
||||
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||
|
||||
# Check system health
|
||||
curl "http://localhost:8080/health"
|
||||
```
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### API Key Masking
|
||||
All API keys and passwords are automatically masked in logs:
|
||||
```
|
||||
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
|
||||
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
|
||||
```
|
||||
|
||||
### Sensitive Data Separation
|
||||
- **Main `.env`**: Paths, preferences, URLs (safe to share)
|
||||
- **`.env.secrets`**: API keys, passwords (never commit to version control)
|
||||
- **Automatic loading**: Both files loaded automatically at startup
|
||||
|
||||
### Git Protection
|
||||
The `.gitignore` file prevents accidental commits:
|
||||
```
|
||||
.env
|
||||
.env.secrets
|
||||
.env.local
|
||||
```
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
### "Environment files not loaded" Warning
|
||||
Install python-dotenv:
|
||||
```bash
|
||||
pip install python-dotenv==1.0.0
|
||||
# or
|
||||
docker-compose build # rebuilds with updated requirements.txt
|
||||
```
|
||||
|
||||
### Sharing Configuration for Help
|
||||
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
|
||||
|
||||
### Migration from Old Setup
|
||||
If you have an existing `.env` with API keys:
|
||||
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
|
||||
2. Remove sensitive data from `.env`
|
||||
3. Restart NFOGuard
|
||||
|
||||
## 🎯 Docker Compose Example
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
nfoguard:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
container_name: nfoguard
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /path/to/your/media:/media:rw
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro # Main configuration
|
||||
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||
environment:
|
||||
- PORT=8080
|
||||
depends_on:
|
||||
- radarr-postgres
|
||||
```
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### Main Configuration (.env)
|
||||
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
|
||||
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
|
||||
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
|
||||
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
|
||||
|
||||
### Secrets Configuration (.env.secrets)
|
||||
- **Database**: `RADARR_DB_PASSWORD`
|
||||
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
|
||||
- **Optional**: `JELLYSEERR_API_KEY`
|
||||
|
||||
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
|
||||
-496
@@ -1,496 +0,0 @@
|
||||
# NFOGuard Project Summary
|
||||
|
||||
## Overview
|
||||
NFOGuard is a Python-based tool that manages NFO (metadata) files for movies and TV shows in Plex/Emby media servers. It integrates with Radarr and Sonarr to automatically manage metadata, ensuring proper dates and metadata consistency.
|
||||
|
||||
## 🎯 CURRENT SESSION ACCOMPLISHMENTS (September 21, 2025)
|
||||
|
||||
### ✅ MAJOR ISSUES RESOLVED
|
||||
1. **Database Save Bug**: Identified and resolved "phantom" database save issue (was logging interpretation error)
|
||||
2. **Enhanced Movie Detection**: Movies without IMDb IDs in directory names now detected via filenames/NFO content
|
||||
3. **Enhanced Logging**: Crystal-clear source attribution showing NFOGuard DB vs Radarr vs External APIs
|
||||
4. **Priority Flow Confirmed**: Perfect implementation of user's requested flow
|
||||
|
||||
### ✅ KEY IMPROVEMENTS MADE
|
||||
|
||||
**🔍 Enhanced Movie Detection**:
|
||||
- Added `find_movie_imdb_id()` function with comprehensive detection
|
||||
- Supports directory names, filenames, and NFO file content parsing
|
||||
- Movies like "Ick (2024)" now properly processed without directory IMDb tags
|
||||
|
||||
**📊 Crystal-Clear Logging System**:
|
||||
- Added emoji-based logging for easy visual scanning
|
||||
- Clear source attribution: `nfoguard_db:`, `radarr:db.`, `tmdb:`, etc.
|
||||
- Priority flow display shows exact decision path
|
||||
- Final completion logs show chosen date and source
|
||||
|
||||
**🔄 Perfect Priority Flow**:
|
||||
1. NFOGuard Database (cached entries) →
|
||||
2. Radarr Import History (real import dates) →
|
||||
3. External APIs (TMDB/OMDb with configurable priority) →
|
||||
4. Save to NFOGuard Database (cache for future)
|
||||
|
||||
**🚀 Manual Scan Commands**:
|
||||
- Movies only: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=movies"`
|
||||
- TV only: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=tv"`
|
||||
- Both: `curl -X POST "http://nfoguard:8080/manual/scan?scan_type=both"`
|
||||
|
||||
### ✅ CONFIGURATION MAINTAINED
|
||||
All existing .env settings fully preserved and respected:
|
||||
- `RELEASE_DATE_PRIORITY=digital,physical,theatrical`
|
||||
- `MOVIE_PRIORITY=import_then_digital`
|
||||
- `PREFER_RELEASE_DATES_OVER_FILE_DATES=true`
|
||||
- `ALLOW_FILE_DATE_FALLBACK=false`
|
||||
|
||||
### ✅ DEBUGGING COMPLETED
|
||||
- **Root Cause**: "Phantom database save" was actually two different movie executions in logs
|
||||
- **Database Save**: Working correctly in both early-exit and full-processing paths
|
||||
- **NFO Processing**: Fixed to execute before date validation (preserves existing metadata)
|
||||
- **File Detection**: Enhanced to handle movies without directory IMDb tags
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Main Components
|
||||
- **nfoguard.py**: Main application file containing the core logic
|
||||
- **core/nfo_manager.py**: Handles NFO file creation and updates
|
||||
- **clients/**: External API integrations (Radarr, Sonarr, etc.)
|
||||
|
||||
### Key Features
|
||||
- Creates and updates movie.nfo files with proper metadata
|
||||
- Manages TV show NFO files (tvshow.nfo, season.nfo, episode NFOs)
|
||||
- Preserves existing metadata while adding NFOGuard-managed fields
|
||||
- Supports date management from multiple sources (Radarr, file dates, etc.)
|
||||
- Webhook support for real-time processing
|
||||
|
||||
## Current Issue Analysis
|
||||
|
||||
### Problem
|
||||
When a movie.nfo file already exists (e.g., from Radarr), NFOGuard is **not** processing the file to:
|
||||
1. Move date fields to the bottom
|
||||
2. Add NFOGuard comment
|
||||
3. Add NFOGuard-managed fields (dateadded, lockdata, etc.)
|
||||
|
||||
### Root Cause Identified
|
||||
The issue is in `nfoguard.py:1009-1012`. The processing logic has a premature exit condition:
|
||||
|
||||
```python
|
||||
# Skip processing if no valid date found and file dates disabled
|
||||
if dateadded is None:
|
||||
_log("WARNING", f"Skipping movie {movie_path.name} - no valid date source available")
|
||||
self.db.upsert_movie_dates(imdb_id, released, None, source, True)
|
||||
return # <-- This exits BEFORE NFO processing
|
||||
```
|
||||
|
||||
**The NFO processing code (lines 1015-1018) never executes when dateadded is None.**
|
||||
|
||||
### Analysis of NFO Processing Logic
|
||||
The `create_movie_nfo` function in `core/nfo_manager.py:52-147` is actually well-designed:
|
||||
- Lines 60-78: Properly loads and preserves existing NFO content
|
||||
- Lines 69-78: Removes NFOGuard-managed fields to re-add them at the bottom
|
||||
- Lines 96-123: Adds all NFOGuard fields at the end
|
||||
- Lines 124-141: Adds NFOGuard comment and proper formatting
|
||||
|
||||
**The NFO manager code is correct - it's just not being called due to the early exit.**
|
||||
|
||||
## Files That Need Updates
|
||||
1. `nfoguard.py` - Fix the early exit logic around line 1009-1012
|
||||
|
||||
## Status
|
||||
- ✅ NFO Processing Issue: Fixed in nfoguard.py:1008-1025
|
||||
- 🔍 **NEW ISSUE**: Database not persisting dateadded values on manual scans
|
||||
|
||||
## Fix Summary
|
||||
|
||||
### Movie NFO Processing (✅ Fixed)
|
||||
**Problem**: NFO processing was skipped entirely when `dateadded` was None due to early return.
|
||||
|
||||
**Solution**: Moved NFO processing (`create_movie_nfo`) to execute **before** the `dateadded` check, ensuring that:
|
||||
1. Existing NFO files are always processed and standardized
|
||||
2. NFOGuard comment is added
|
||||
3. Date fields are moved to bottom
|
||||
4. lockdata is added if configured
|
||||
5. Only file mtime updates are skipped when no valid date is found
|
||||
|
||||
### TV Episode NFO Processing (✅ Enhanced)
|
||||
**Enhancement**: Added smart episode NFO migration for long-named files.
|
||||
|
||||
**New Functionality**:
|
||||
1. **Detection**: `find_existing_episode_nfo()` scans for non-standard NFO files that match season/episode
|
||||
2. **Migration**: Extracts metadata from long-named files (e.g., `Episode.Name.Here.S01E05.nfo`)
|
||||
3. **Standardization**: Creates standard `S01E05.nfo` with preserved metadata + NFOGuard enhancements
|
||||
4. **Cleanup**: Automatically deletes old long-named NFO files after successful migration
|
||||
5. **Preservation**: All existing metadata is preserved, dates moved to bottom, NFOGuard fields added
|
||||
|
||||
## 🔍 DATABASE SAVE BUG RESOLVED! ✅
|
||||
|
||||
### Problem Analysis COMPLETED
|
||||
**Root Cause Identified**: The "phantom database save bug" was actually a **logging interpretation error**.
|
||||
|
||||
### What Actually Happened
|
||||
The debug logs showing these two lines:
|
||||
```
|
||||
DEBUG: Movie processing reached post-mtime section: Dear Santa (2024) [imdb-tt2396431]
|
||||
INFO: Completed processing movie: Dear Santa (2024) [imdb-tt2396431] (source: tmdb:digital)
|
||||
```
|
||||
|
||||
**Cannot be from the same function execution!** Here's why:
|
||||
|
||||
### The Real Execution Flow
|
||||
**Line 1014-1018**: Early exit when `dateadded is None`:
|
||||
```python
|
||||
if 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 # <-- EXITS HERE, never reaches line 1029!
|
||||
```
|
||||
|
||||
**Line 1029**: Debug log that only executes when `dateadded` has a valid value:
|
||||
```python
|
||||
_log("DEBUG", f"Movie processing reached post-mtime section: {movie_path.name}")
|
||||
```
|
||||
|
||||
**Line 1045**: Completion log that only executes after successful database save:
|
||||
```python
|
||||
_log("INFO", f"Completed processing movie: {movie_path.name} (source: {source})")
|
||||
```
|
||||
|
||||
### Key Insight
|
||||
**The logs are from TWO different executions:**
|
||||
1. **First run**: `dateadded = None` → early exit at line 1018 → database save executes BUT for NULL value
|
||||
2. **Second run**: `dateadded` has value → reaches line 1029 → full processing → line 1045
|
||||
|
||||
### Database Save Status
|
||||
✅ **Database saves ARE working correctly!**
|
||||
- Early exit path (line 1018): Saves NULL dateadded when no valid date found
|
||||
- Full processing path (lines 1038-1043): Saves valid dateadded when available
|
||||
- Both paths call `upsert_movie_dates()` appropriately
|
||||
|
||||
### Investigation Status
|
||||
✅ **Enhanced Movie Detection**: Working (Ick 2024 now processed)
|
||||
✅ **NFO Processing**: Working (files get updated)
|
||||
✅ **File Timestamp Updates**: Working (mtimes get set)
|
||||
✅ **Database Persistence**: **WORKING CORRECTLY** - saves happen in both execution paths
|
||||
|
||||
## 📊 ENHANCED LOGGING SYSTEM (September 21, 2025) ✅
|
||||
|
||||
### New Crystal-Clear Source Attribution
|
||||
**Problem**: Logs didn't clearly show whether dates came from NFOGuard DB vs Radarr vs external APIs.
|
||||
|
||||
**Solution**: Added comprehensive logging with emojis and clear source identification.
|
||||
|
||||
### New Logging Format
|
||||
**Priority Flow Display**:
|
||||
```
|
||||
🔄 PRIORITY FLOW: NFOGuard DB → Radarr import → External APIs (digital,physical,theatrical) → Save to NFOGuard DB
|
||||
```
|
||||
|
||||
**NFOGuard Database Lookups**:
|
||||
```
|
||||
🔍 NFOGuard DB lookup for tt1234567: FOUND - dateadded=2025-06-15T11:10:31-04:00, source=radarr:db.history.import
|
||||
✅ MANUAL SCAN: Using existing NFOGuard database entry: 2025-06-15T11:10:31-04:00 (original source: radarr:db.history.import)
|
||||
```
|
||||
|
||||
**External Source Queries**:
|
||||
```
|
||||
🔍 RADARR DATABASE: Checking import history for movie ID 123 (tt1234567)...
|
||||
📦 RADARR DATABASE: Found import date=2025-07-20T14:30:00Z, source=radarr:db.history.import
|
||||
🔍 EXTERNAL APIs: Trying digital release date fallback...
|
||||
🌐 EXTERNAL APIs: Found digital release date=2025-06-15T00:00:00+00:00, source=tmdb:digital
|
||||
```
|
||||
|
||||
**Final Decision Tracking**:
|
||||
```
|
||||
✅ FINAL CHOICE: Using Radarr import date 2025-07-20T14:30:00-04:00 from radarr:db.history.import
|
||||
🎬 COMPLETED: Movie Name (2024) | Final date: 2025-07-20T14:30:00-04:00 | Final source: radarr:db.history.import
|
||||
```
|
||||
|
||||
### Source Attribution System
|
||||
**NFOGuard Database**: `nfoguard_db:radarr:db.history.import` (shows original source)
|
||||
**Radarr Database**: `radarr:db.history.import` (fresh query)
|
||||
**External APIs**: `tmdb:digital`, `omdb:dvd`, etc.
|
||||
|
||||
### Perfect Priority Flow Confirmed
|
||||
1. ✅ **NFOGuard DB First**: Check cached entries, use if available
|
||||
2. ✅ **Radarr Import History**: Query for real import dates if not cached
|
||||
3. ✅ **External APIs**: TMDB/OMDb fallback with configurable priority (`RELEASE_DATE_PRIORITY`)
|
||||
4. ✅ **Save to NFOGuard DB**: Cache result for future lookups
|
||||
|
||||
**This ensures we don't repeatedly query Radarr/external APIs for the same movies!**
|
||||
|
||||
## 🚀 MANUAL SCAN API COMMANDS
|
||||
|
||||
### Available Scan Types
|
||||
**Movies Only:**
|
||||
```bash
|
||||
curl -X POST "http://nfoguard:8080/manual/scan?scan_type=movies"
|
||||
```
|
||||
|
||||
**TV Shows Only:**
|
||||
```bash
|
||||
curl -X POST "http://nfoguard:8080/manual/scan?scan_type=tv"
|
||||
```
|
||||
|
||||
**Both Movies AND TV Shows (Default):**
|
||||
```bash
|
||||
curl -X POST "http://nfoguard:8080/manual/scan?scan_type=both"
|
||||
# OR simply:
|
||||
curl -X POST "http://nfoguard:8080/manual/scan"
|
||||
```
|
||||
|
||||
**Specific Path Scan:**
|
||||
```bash
|
||||
curl -X POST "http://nfoguard:8080/manual/scan?path=/media/Movies/Movie.Name.2024&scan_type=movies"
|
||||
```
|
||||
|
||||
### What Each Scan Does
|
||||
- **Movies**: Processes all movie directories in `MOVIE_PATHS`, follows NFOGuard priority flow
|
||||
- **TV**: Processes all TV series in `TV_PATHS`, updates episode NFO files with proper dates
|
||||
- **Both**: Processes both movies and TV shows in sequence (recommended for full refresh)
|
||||
|
||||
All scans run in background and return immediately with status confirmation.
|
||||
|
||||
### Movie IMDb ID Detection (✅ Enhanced)
|
||||
**Enhancement**: Added comprehensive IMDb ID detection for movies without directory tags.
|
||||
|
||||
**New Detection Methods**:
|
||||
1. **Directory Name**: `[imdb-tt123456]` format (existing)
|
||||
2. **Filename Patterns**: IMDb ID in any video file name `Movie.Name.2024.[tt123456].mkv`
|
||||
3. **NFO File Content**: `<uniqueid type="imdb">tt123456</uniqueid>` (NEW)
|
||||
4. **Legacy NFO Tags**: `<imdbid>` and `<imdb>` tags (NEW)
|
||||
|
||||
**Implementation**:
|
||||
- Added `find_movie_imdb_id()` function that tries all methods in priority order
|
||||
- Extended `parse_imdb_from_nfo()` to handle XML parsing
|
||||
- Updated movie processing to use comprehensive detection
|
||||
|
||||
**Example**: Ick (2024) directory without IMDb in folder name will now be detected via NFO file content.
|
||||
|
||||
|
||||
# NFOGuard Development Summary
|
||||
|
||||
## Current Status: v1.6.6 - Docker Plugin Deployment! 🐳
|
||||
|
||||
### Latest Updates (v1.6.6) - September 19, 2025
|
||||
- **🐳 DOCKER PLUGIN DEPLOYMENT**: Added automatic Emby plugin deployment via Docker
|
||||
- **📦 DLL PACKAGING**: NFOGuard.Emby.Plugin.dll now packaged with Docker image
|
||||
- **🔗 BIND MOUNT SUPPORT**: Primary method using bind mount to Emby plugins directory
|
||||
- **⚙️ ENVIRONMENT FALLBACK**: Secondary method using EMBY_PLUGINS_PATH environment variable
|
||||
- **🚀 AUTO-UPDATE**: Plugin automatically updates when new Docker image is deployed
|
||||
- **📋 CONFIGURATION**: Added .env.example with plugin deployment settings
|
||||
- **🔄 STARTUP SCRIPT**: Container automatically deploys plugin on startup
|
||||
- **🎯 WORKFLOW INTEGRATION**: Compatible with Gitea → DockerHub deployment pipeline
|
||||
|
||||
### Docker Plugin Deployment Flow
|
||||
**Bind Mount Method (Recommended):**
|
||||
1. Set `EMBY_PLUGINS_PATH=/path/to/emby/plugins` in .env
|
||||
2. Docker bind mounts host directory to `/emby-plugins` in container
|
||||
3. On startup, NFOGuard copies DLL to mounted directory
|
||||
4. Plugin updates automatically with each new image deployment
|
||||
|
||||
**Benefits:**
|
||||
- Works with separate Emby/NFOGuard containers
|
||||
- Compatible with native Emby installations
|
||||
- Secure - only exposes plugins directory
|
||||
- Automatic updates via CI/CD pipeline
|
||||
|
||||
## Previous Status: v1.6.0 - NFO-Based Identification! 📄
|
||||
|
||||
### Latest Updates (v1.6.0) - September 16, 2025
|
||||
- **📄 NFO IDENTIFICATION**: Added support for identifying movies/TV shows from NFO files
|
||||
- **🔄 DUAL METHOD**: Directory name IMDb IDs (primary) + NFO file IMDb IDs (fallback)
|
||||
- **📋 NFO FORMATS**: Supports `<uniqueid type="imdb">`, `<imdbid>`, and `<imdb>` XML tags
|
||||
- **🎬 MOVIE SUPPORT**: Reads movie.nfo files for IMDb ID extraction
|
||||
- **📺 TV SUPPORT**: Reads tvshow.nfo files for series identification
|
||||
- **✅ SMART FALLBACK**: Only checks NFO files when directory name lacks IMDb ID
|
||||
- **📝 DOCUMENTATION**: Updated README with NFO identification examples and formats
|
||||
- **🔄 NFO RENAMING**: Added smart episode NFO file renaming to standardized `S##E##.nfo` format
|
||||
- **🎯 SONARR INTEGRATION**: Detects existing NFO files created by Sonarr/other tools, extracts metadata, and renames to standard format
|
||||
- **📁 METADATA PRESERVATION**: Preserves all existing metadata when renaming non-standard NFO files
|
||||
- **🧹 CLEANUP**: Removes old non-standard NFO files after successful rename to prevent duplicates
|
||||
|
||||
### Previous Updates (v1.5.5) - September 15, 2025
|
||||
- **🔧 DATABASE BUG FIX**: Fixed `upsert_movie_dates` to be a proper upsert operation
|
||||
- **💾 DATA INTEGRITY**: Manual scans now properly save `dateadded` to database
|
||||
- **🎯 ROOT CAUSE**: Fixed UPDATE-only operation that was leaving `dateadded` fields NULL
|
||||
- **✅ WEBHOOK FIX**: Webhooks now find existing database entries correctly
|
||||
- **📺 TV SHOWS**: NOT affected - already using proper `INSERT OR REPLACE` operations
|
||||
|
||||
### Previous Updates (v1.5.4) - September 15, 2025
|
||||
- **🎯 WEBHOOK DATE FIX**: Fixed webhook processing to use proper date decision logic
|
||||
- **🔍 SMART FALLBACK**: Webhooks now check database → Radarr import dates → release dates → timestamp
|
||||
- **❌ ISSUE RESOLVED**: Upgrade webhooks no longer incorrectly use current timestamp when database exists
|
||||
- **✅ PROPER FLOW**: Webhook mode now uses same date logic as manual scans for missing entries
|
||||
|
||||
### Previous Updates (v1.5.3) - September 15, 2025
|
||||
- **🔧 RADARR RENAME FIX**: Added missing event type filtering to Radarr webhooks
|
||||
- **✅ CONSISTENCY**: Radarr now matches Sonarr's event handling (Download, Upgrade, Rename)
|
||||
- **🎯 FIXED ISSUE**: Radarr rename events now properly trigger NFO updates with correct dates
|
||||
- **🔄 WORKFLOW**: Both Sonarr and Radarr now follow identical webhook processing flows
|
||||
|
||||
### Previous Updates (v1.5.2)
|
||||
- **🔇 TVDB WARNINGS**: Added SUPPRESS_TVDB_WARNINGS configuration option
|
||||
- **✅ CLEAN LOGS**: Can now suppress non-critical TVDB API failure warnings
|
||||
- **🧹 PRODUCTION**: Cleaner logs for production monitoring
|
||||
- **🎯 FUNCTIONAL**: System works perfectly without TVDB (uses IMDb/Sonarr data)
|
||||
|
||||
### TVDB Warning Suppression
|
||||
**Added new environment variable:**
|
||||
```bash
|
||||
# Suppress TVDB API warnings (true/false) - TVDB failures are common and non-critical
|
||||
SUPPRESS_TVDB_WARNINGS=true
|
||||
```
|
||||
|
||||
### Why Suppress TVDB Warnings?
|
||||
- **❌ Common Failures**: Many series don't exist in TVDB database
|
||||
- **✅ Non-Critical**: NFOGuard works perfectly without TVDB data
|
||||
- **📊 Primary Data**: Uses IMDb IDs and Sonarr metadata (more reliable)
|
||||
- **🧹 Log Noise**: Reduces repetitive warning messages
|
||||
|
||||
### Before vs After
|
||||
|
||||
**Before (noisy):**
|
||||
```
|
||||
INFO: All episodes found in cache
|
||||
WARNING: GET https://api4.thetvdb.com/v4/search/remoteid?remoteId=tt0804484&type=series failed: HTTP Error 400: Bad Request
|
||||
INFO: Completed processing TV series: Foundation (2021) [imdb-tt0804484]
|
||||
WARNING: GET https://api4.thetvdb.com/v4/search/remoteid?remoteId=tt9737326&type=series failed: HTTP Error 400: Bad Request
|
||||
```
|
||||
|
||||
**After (clean):**
|
||||
```
|
||||
INFO: All episodes found in cache
|
||||
INFO: Completed processing TV series: Foundation (2021) [imdb-tt0804484]
|
||||
INFO: Completed processing TV series: Invasion (2021) [imdb-tt9737326]
|
||||
```
|
||||
|
||||
### Your Configuration
|
||||
```bash
|
||||
DEBUG=false # Clean production logging
|
||||
PATH_DEBUG=false # No path mapping debug
|
||||
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical TVDB failures
|
||||
```
|
||||
|
||||
### System Status
|
||||
- ✅ **Processing**: All TV series processing successfully
|
||||
- ✅ **Episodes**: All episodes found and processed
|
||||
- ✅ **NFO Files**: Being created/updated with proper metadata
|
||||
- ✅ **Functionality**: TVDB failures don't affect operation
|
||||
|
||||
## 🔇 NFOGuard v1.5.2 - Ultra-clean production logs! 🔇
|
||||
|
||||
### Copy SUPPRESS_TVDB_WARNINGS=true to your server .env file and restart!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 FIXED: Database Upsert Bug (September 15, 2025) - **CRITICAL**
|
||||
|
||||
### Issue Discovery
|
||||
**The Real Root Cause**: Manual scans were NOT actually saving `dateadded` to database due to faulty upsert operation!
|
||||
|
||||
### Problem Details
|
||||
**Database Method Bug** in `core/database.py:173-182`:
|
||||
- `upsert_movie_dates()` was doing UPDATE-only, not proper upsert
|
||||
- If movie row didn't exist properly, UPDATE would affect 0 rows
|
||||
- `dateadded` field remained NULL even after manual scan
|
||||
- Webhooks found database entries but with NULL `dateadded` → fell back to current timestamp
|
||||
|
||||
### 🔧 **Fix Applied**
|
||||
- **Location**: `core/database.py:173-186`
|
||||
- **Changed**: UPDATE-only → proper `INSERT OR REPLACE` upsert
|
||||
- **Result**: Manual scans now properly save `dateadded` to database
|
||||
- **Webhook Impact**: Now finds existing dates correctly, no more false timestamps
|
||||
|
||||
### Before vs After Database Operations
|
||||
**Before (BROKEN):**
|
||||
```sql
|
||||
UPDATE movies SET dateadded = '2025-06-15...' WHERE imdb_id = 'tt123';
|
||||
-- If row doesn't exist properly: 0 rows affected, dateadded stays NULL
|
||||
```
|
||||
|
||||
**After (FIXED):**
|
||||
```sql
|
||||
INSERT OR REPLACE INTO movies (...) VALUES (...);
|
||||
-- Always works: Creates row OR replaces with all data including dateadded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 FIXED: Webhook Date Logic Issue (September 15, 2025)
|
||||
|
||||
### Issue Discovery
|
||||
Upgrade webhooks were incorrectly using current timestamp (`webhook:first_seen`) instead of checking Radarr for proper import dates when no database entry existed.
|
||||
|
||||
**Example Problem:**
|
||||
- Database populated via manual scan: `2025-06-15T11:10:31-04:00` (radarr:db.history.import)
|
||||
- Upgrade webhook comes in: `2025-09-15T12:04:23-04:00` (webhook:first_seen) ❌ **WRONG!**
|
||||
|
||||
### Root Cause
|
||||
Webhook mode was bypassing the full date decision logic (`_decide_movie_dates`) and jumping straight to current timestamp as fallback.
|
||||
|
||||
### ✅ **Fix Applied**
|
||||
- **Location**: `nfoguard.py:975-990`
|
||||
- **Logic**: Webhook mode now uses same date priority as manual scans:
|
||||
1. **Database first** (if exists)
|
||||
2. **Full date logic** (Radarr import → release dates → fallbacks)
|
||||
3. **Current timestamp** (only as absolute last resort)
|
||||
|
||||
### Result
|
||||
Webhook upgrades now properly find and use existing Radarr import dates instead of creating false "first seen" timestamps.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 FIXED: Radarr Rename Event Issue (September 15, 2025)
|
||||
|
||||
### Issue Resolution
|
||||
**COMPLETED**: Fixed missing Radarr rename event handling by adding proper event type filtering.
|
||||
|
||||
### ✅ **Both Sonarr & Radarr Webhook Handling** - **NOW WORKING CORRECTLY**
|
||||
- **Sonarr Location**: `nfoguard.py:1429`
|
||||
- **Radarr Location**: `nfoguard.py:1479` **(FIXED)**
|
||||
- **Event Filtering**: `if event_type not in ["Download", "Upgrade", "Rename"]:`
|
||||
- **✅ STATUS**: **Rename events ARE processed for both systems**
|
||||
- **✅ BEHAVIOR**: Both systems now update NFO files with proper date metadata on rename
|
||||
|
||||
### What Was Fixed
|
||||
1. **Added Event Type Filtering**: Radarr webhooks now explicitly filter for supported events
|
||||
2. **Consistent Processing**: Both Sonarr and Radarr now follow identical webhook workflows
|
||||
3. **Proper Rename Handling**: Rename events trigger full NFO processing pipeline
|
||||
|
||||
---
|
||||
|
||||
## 📋 NFOGuard Webhook Workflow Documentation
|
||||
|
||||
### 🎬 **Radarr Webhook Workflow**
|
||||
1. **Import Event**:
|
||||
- Check database - if not seen before, current timestamp = truth of first import
|
||||
- Update movie.nfo with proper date metadata
|
||||
|
||||
2. **Rename Event**:
|
||||
- Check database - if we have the date, use existing date
|
||||
- Update movie.nfo file as always (preserving original import date)
|
||||
|
||||
3. **Rename Event (No Database Entry)**:
|
||||
- Check Radarr database for earliest import date
|
||||
- If found, use earliest import date → update database + movie.nfo
|
||||
- If no import date found, use digital/theatrical release date (fallback logic)
|
||||
|
||||
### 📺 **Sonarr Webhook Workflow**
|
||||
1. **Import Event**:
|
||||
- Check database - if not seen before, current timestamp = truth of first import
|
||||
- Update episode.nfo with proper date metadata
|
||||
|
||||
2. **Rename Event**:
|
||||
- Check database - if we have the date, use existing date
|
||||
- Update episode.nfo file as always (preserving original import date)
|
||||
|
||||
3. **Rename Event (No Database Entry)**:
|
||||
- Check Sonarr API for earliest import date
|
||||
- If found, use earliest import date → update database + episode.nfo
|
||||
- If no import date found, use air date (fallback logic)
|
||||
|
||||
### 🔄 **Key Processing Logic**
|
||||
- **Database First**: Always check NFOGuard database for existing dates
|
||||
- **Import Truth**: Webhook import events establish "source of truth" timestamps
|
||||
- **Date Preservation**: Rename events preserve original import dates, never overwrite
|
||||
- **Smart Fallbacks**: API queries → Release dates → Air dates → File dates (configurable)
|
||||
- **Batch Processing**: All webhooks go through batching system with configurable delays
|
||||
-183
@@ -1,183 +0,0 @@
|
||||
# NFOGuard Testing Guide
|
||||
|
||||
## 🧪 Test Scripts Overview
|
||||
|
||||
NFOGuard includes 3 specialized testing scripts to validate different parts of the workflow. These are designed to be run via webhooks or manual API calls, not directly on the command line.
|
||||
|
||||
### 1. `test_bulk_update.py` - Database Connection Validator
|
||||
**Purpose**: Tests that the bulk update system can connect to databases without modifying any data.
|
||||
|
||||
**What it tests**:
|
||||
- Radarr database connection
|
||||
- NFOGuard database connection
|
||||
- Query execution (reads first 5 movies)
|
||||
- Import detection logic
|
||||
|
||||
**When to use**: Before running the full bulk update to ensure everything is configured correctly.
|
||||
|
||||
**How to run**:
|
||||
```bash
|
||||
# Via webhook/API inside container
|
||||
curl -X POST "http://nfoguard:8080/test/bulk-update"
|
||||
|
||||
# Or manually inside container
|
||||
python3 test_bulk_update.py
|
||||
```
|
||||
|
||||
### 2. `test_movie_scan.py` - Directory Structure Validator
|
||||
**Purpose**: Tests the movie directory scanning logic to ensure movies will be found.
|
||||
|
||||
**What it tests**:
|
||||
- Path configuration validation
|
||||
- IMDb ID parsing from directory names
|
||||
- Movie discovery logic
|
||||
- Directory structure understanding
|
||||
|
||||
**When to use**: When manual scans report "0 movies found" to debug path issues.
|
||||
|
||||
**Sample output**:
|
||||
```
|
||||
🔍 Testing Movie Directory Scanning
|
||||
✅ '/media/Movies/movies' -> Found 150 movies with IMDb IDs
|
||||
✅ 'The Dark Knight [imdb-tt0468569] (2008)' -> tt0468569
|
||||
❌ 'Invalid Movie (2020)' -> No IMDb ID found
|
||||
```
|
||||
|
||||
### 3. `test_end_to_end.py` - Complete Workflow Validator
|
||||
**Purpose**: Tests the entire NFOGuard workflow from database queries to NFO creation.
|
||||
|
||||
**What it tests**:
|
||||
- Server health and connectivity
|
||||
- Database performance (your July 2025 date test)
|
||||
- Manual scan trigger
|
||||
- Batch processing status
|
||||
- Statistics endpoints
|
||||
|
||||
**When to use**: After deployment to verify the complete system works end-to-end.
|
||||
|
||||
**How to run**:
|
||||
```bash
|
||||
# Via webhook inside container
|
||||
curl -X POST "http://nfoguard:8080/test/end-to-end"
|
||||
```
|
||||
|
||||
## 🚀 Testing Workflow for Docker Deployment
|
||||
|
||||
### Step 1: Deploy with Environment Variables
|
||||
```bash
|
||||
# Copy your environment template
|
||||
cp .env.example .env
|
||||
# Edit .env with your actual database password
|
||||
nano .env
|
||||
|
||||
# Deploy
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Step 2: Validate Database Connections
|
||||
```bash
|
||||
# Test database connectivity
|
||||
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||
|
||||
# Should show:
|
||||
# ✅ Radarr database connection successful
|
||||
# ✅ NFOGuard database connection successful
|
||||
```
|
||||
|
||||
### Step 3: Test Movie Discovery
|
||||
```bash
|
||||
# Test that movies will be found
|
||||
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||
|
||||
# Should show paths and sample movies found
|
||||
```
|
||||
|
||||
### Step 4: Run Full End-to-End Test
|
||||
```bash
|
||||
# Complete workflow test
|
||||
curl -X POST "http://localhost:8080/test/end-to-end"
|
||||
|
||||
# Should show all systems operational
|
||||
```
|
||||
|
||||
### Step 5: Production Testing
|
||||
```bash
|
||||
# Test database performance (your working July date)
|
||||
curl "http://localhost:8080/debug/movie/tt1674782"
|
||||
|
||||
# Should return: 2025-07-08T03:30:04+00:00
|
||||
|
||||
# Run manual scan
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||
|
||||
# Run bulk update
|
||||
curl -X POST "http://localhost:8080/bulk/update"
|
||||
```
|
||||
|
||||
### Step 6: Test Enhanced TV Processing (v0.6.0+)
|
||||
```bash
|
||||
# Test Sonarr API integration
|
||||
curl "http://localhost:8080/health"
|
||||
# Check that Sonarr connection shows up
|
||||
|
||||
# Test single season processing (URL-safe)
|
||||
curl -X POST "http://localhost:8080/tv/scan-season" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"series_path": "/media/TV/tv/Your Show [imdb-tt1234567]",
|
||||
"season_name": "Season 01"
|
||||
}'
|
||||
|
||||
# Test single episode processing (URL-safe)
|
||||
curl -X POST "http://localhost:8080/tv/scan-episode" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"series_path": "/media/TV/tv/Your Show [imdb-tt1234567]",
|
||||
"season_name": "Season 01",
|
||||
"episode_name": "S01E01.mkv"
|
||||
}'
|
||||
|
||||
# Verify enhanced NFO was created with metadata
|
||||
find /media/TV/tv -name "S01E*.nfo" | head -1 | xargs cat
|
||||
# Should show: <title>, <plot>, <rating>, <runtime>, timestamps
|
||||
|
||||
# Test TV webhook processing modes
|
||||
export TV_WEBHOOK_PROCESSING_MODE=targeted # or series
|
||||
# Download episode in Sonarr and check logs for processing mode
|
||||
```
|
||||
|
||||
### Step 7: Test TV Webhook Processing Modes (v0.6.0+)
|
||||
```bash
|
||||
# Test targeted mode (default - processes only webhook episodes)
|
||||
echo "TV_WEBHOOK_PROCESSING_MODE=targeted" >> .env
|
||||
docker restart nfoguard
|
||||
|
||||
# Download single episode in Sonarr, check logs should show:
|
||||
# "Using targeted episode processing for 1 episodes"
|
||||
# "Completed targeted processing: 1/1 episodes processed"
|
||||
|
||||
# Test series mode (processes entire series)
|
||||
echo "TV_WEBHOOK_PROCESSING_MODE=series" >> .env
|
||||
docker restart nfoguard
|
||||
|
||||
# Download single episode in Sonarr, check logs should show:
|
||||
# "Using series processing mode (fallback or configured)"
|
||||
# "Processing TV series: Show Name"
|
||||
# "Found X episodes on disk"
|
||||
```
|
||||
|
||||
## 🛠 Why These Tests Are Needed
|
||||
|
||||
1. **Docker Environment Isolation**: Tests run in the same environment as the production app
|
||||
2. **Database-Only Validation**: Ensures the optimized database queries work correctly
|
||||
3. **Path Configuration**: Validates that your specific directory structure is properly configured
|
||||
4. **Webhook Integration**: Tests run via HTTP calls, same as Radarr webhooks
|
||||
5. **CI/CD Integration**: Can be automated in deployment pipelines
|
||||
|
||||
## 🔍 Troubleshooting with Tests
|
||||
|
||||
**"0 movies processed"** → Run `test_movie_scan.py` to check path configuration
|
||||
**Database errors** → Run `test_bulk_update.py` to validate connections
|
||||
**Workflow issues** → Run `test_end_to_end.py` for comprehensive diagnosis
|
||||
|
||||
These tests are designed to catch issues before they impact your media processing workflow.
|
||||
@@ -1,144 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bulk update all movies in NFOguard database with import dates from Radarr
|
||||
Run this script to populate/update all movies at once
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from clients.radarr_db_client import RadarrDbClient
|
||||
from core.database import NFOGuardDatabase
|
||||
from core.logging import _log
|
||||
|
||||
def bulk_update_all_movies():
|
||||
"""Update all movies in Radarr database"""
|
||||
|
||||
# Initialize database clients
|
||||
radarr_db = RadarrDbClient.from_env()
|
||||
if not radarr_db:
|
||||
print("❌ Radarr database connection required")
|
||||
return False
|
||||
|
||||
nfo_db = NFOGuardDatabase(Path(os.environ.get("DB_PATH", "/app/data/media_dates.db")))
|
||||
|
||||
print("🔍 Getting all movies from Radarr database...")
|
||||
|
||||
# Get all movies with IMDb IDs
|
||||
query = """
|
||||
SELECT
|
||||
mm."ImdbId" as imdb_id,
|
||||
m."Id" as movie_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
m."Path" as path
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" IS NOT NULL AND mm."ImdbId" != ''
|
||||
ORDER BY mm."Title"
|
||||
"""
|
||||
|
||||
try:
|
||||
with radarr_db._get_connection() as conn:
|
||||
if radarr_db.db_type == "postgresql":
|
||||
import psycopg2.extras
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query)
|
||||
movies = cursor.fetchall()
|
||||
|
||||
print(f"📽️ Found {len(movies)} movies with IMDb IDs")
|
||||
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for i, movie in enumerate(movies):
|
||||
if radarr_db.db_type == "postgresql":
|
||||
imdb_id, movie_id, title, year, path = movie['imdb_id'], movie['movie_id'], movie['title'], movie['year'], movie['path']
|
||||
else:
|
||||
imdb_id, movie_id, title, year, path = movie[0], movie[1], movie[2], movie[3], movie[4]
|
||||
|
||||
print(f"\n[{i+1}/{len(movies)}] Processing: {title} ({year}) - {imdb_id}")
|
||||
|
||||
try:
|
||||
# Check if we already have this movie
|
||||
existing_dates = nfo_db.get_movie_dates(imdb_id)
|
||||
if existing_dates and existing_dates.get('dateadded'):
|
||||
print(f" ⏭️ Already exists: {existing_dates['dateadded']} ({existing_dates['source']})")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Get import date from Radarr database
|
||||
import_date, source = radarr_db.get_movie_import_date_optimized(movie_id, fallback_to_file_date=True)
|
||||
|
||||
if import_date:
|
||||
# Store in NFOGuard database
|
||||
nfo_db.upsert_movie(imdb_id, path)
|
||||
nfo_db.upsert_movie_dates(
|
||||
imdb_id=imdb_id,
|
||||
dateadded=import_date,
|
||||
source=source,
|
||||
has_video_file=True
|
||||
)
|
||||
print(f" ✅ Updated: {import_date} ({source})")
|
||||
updated_count += 1
|
||||
else:
|
||||
print(f" ⚠️ No import date found")
|
||||
error_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
error_count += 1
|
||||
|
||||
print(f"\n🎯 Results:")
|
||||
print(f" ✅ Updated: {updated_count}")
|
||||
print(f" ⏭️ Skipped: {skipped_count}")
|
||||
print(f" ❌ Errors: {error_count}")
|
||||
print(f" 📊 Total: {len(movies)}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to get movies: {e}")
|
||||
return False
|
||||
|
||||
def bulk_get_imdb_ids():
|
||||
"""Get all IMDb IDs for bulk processing"""
|
||||
radarr_db = RadarrDbClient.from_env()
|
||||
if not radarr_db:
|
||||
print("❌ Radarr database connection required")
|
||||
return []
|
||||
|
||||
query = 'SELECT mm."ImdbId" FROM "Movies" m JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id" WHERE mm."ImdbId" IS NOT NULL'
|
||||
|
||||
try:
|
||||
with radarr_db._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
return [row[0] for row in results]
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to get IMDb IDs: {e}")
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("NFOGuard Bulk Movie Updater")
|
||||
print("=" * 40)
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--list-imdb":
|
||||
imdb_ids = bulk_get_imdb_ids()
|
||||
print(f"Found {len(imdb_ids)} movies:")
|
||||
for imdb_id in imdb_ids[:20]: # Show first 20
|
||||
print(f" {imdb_id}")
|
||||
if len(imdb_ids) > 20:
|
||||
print(f" ... and {len(imdb_ids) - 20} more")
|
||||
else:
|
||||
success = bulk_update_all_movies()
|
||||
if success:
|
||||
print("\n✅ Bulk update completed!")
|
||||
else:
|
||||
print("\n❌ Bulk update failed!")
|
||||
sys.exit(1)
|
||||
@@ -1,597 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
External API clients for TMDB, OMDb, and Jellyseerr
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, quote
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request and return JSON"""
|
||||
try:
|
||||
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
# Handle specific HTTP errors more gracefully
|
||||
if suppress_404 and e.code in [400, 404]:
|
||||
_log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected")
|
||||
return None
|
||||
else:
|
||||
_log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("WARNING", f"GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_to_iso(date_str: str) -> Optional[str]:
|
||||
"""Parse various date formats to ISO string"""
|
||||
if not date_str or date_str == "N/A":
|
||||
return None
|
||||
try:
|
||||
if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
|
||||
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
return dt.isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class TVDBClient:
|
||||
"""The TV Database API client for IMDB to TVDB ID conversion"""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.environ.get("TVDB_API_KEY", "")
|
||||
self.base_url = "https://api4.thetvdb.com/v4"
|
||||
self._token = None
|
||||
self._token_expires = 0
|
||||
|
||||
def _get_token(self) -> Optional[str]:
|
||||
"""Get TVDB auth token (cached)"""
|
||||
if not self.api_key:
|
||||
_log("DEBUG", "TVDB: No API key provided")
|
||||
return None
|
||||
|
||||
if time.time() < self._token_expires and self._token:
|
||||
return self._token
|
||||
|
||||
try:
|
||||
_log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...")
|
||||
req = UrlRequest(
|
||||
f"{self.base_url}/login",
|
||||
data=json.dumps({"apikey": self.api_key}).encode('utf-8'),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
_log("DEBUG", f"TVDB login response: {data}")
|
||||
if data.get("status") == "success":
|
||||
self._token = data["data"]["token"]
|
||||
self._token_expires = time.time() + 3600 # 1 hour
|
||||
_log("INFO", f"✅ TVDB: Authentication successful")
|
||||
return self._token
|
||||
else:
|
||||
_log("WARNING", f"TVDB login failed: {data}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"TVDB login failed: {e}")
|
||||
return None
|
||||
|
||||
def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||
"""Convert IMDB ID to TVDB series ID using TVDB v4 API"""
|
||||
token = self._get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try the official v4 search endpoint first
|
||||
# According to docs: /search?query=imdb_id&type=series
|
||||
url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
_log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint")
|
||||
data = _get_json(url, headers=headers, suppress_404=True)
|
||||
|
||||
if data and data.get("status") == "success" and data.get("data"):
|
||||
series_list = data["data"]
|
||||
_log("DEBUG", f"TVDB search response: found {len(series_list)} results")
|
||||
|
||||
# Look for exact IMDB match in results
|
||||
for series in series_list:
|
||||
# Check if this series has the IMDB ID we're looking for
|
||||
remote_ids = series.get("remote_ids", [])
|
||||
for remote in remote_ids:
|
||||
if (remote.get("source_name") == "IMDB" and
|
||||
remote.get("remote_id") == imdb_id):
|
||||
tvdb_id = series.get("tvdb_id") or series.get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If no exact match, try the first result if it looks promising
|
||||
if series_list:
|
||||
first_result = series_list[0]
|
||||
tvdb_id = first_result.get("tvdb_id") or first_result.get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (first result)")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If search didn't work, try the legacy remoteid endpoint
|
||||
_log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}")
|
||||
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
||||
data = _get_json(url, headers=headers, suppress_404=True)
|
||||
|
||||
if data and data.get("status") == "success" and data.get("data"):
|
||||
series_list = data["data"]
|
||||
if series_list and len(series_list) > 0:
|
||||
tvdb_id = series_list[0].get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (legacy endpoint)")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If we get here, the series wasn't found in TVDB
|
||||
_log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"TVDB API error for {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class TMDBClient:
|
||||
"""The Movie Database API client"""
|
||||
|
||||
def __init__(self, api_key: str = None, primary_country: str = "US"):
|
||||
self.api_key = api_key or os.environ.get("TMDB_API_KEY", "")
|
||||
self.primary_country = primary_country.upper()
|
||||
self.enabled = bool(self.api_key)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request to TMDB API"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
params = params or {}
|
||||
params["api_key"] = self.api_key
|
||||
url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}"
|
||||
return _get_json(url, timeout=20)
|
||||
|
||||
def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find movie by IMDb ID"""
|
||||
result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"})
|
||||
if result and result.get("movie_results"):
|
||||
return result["movie_results"][0]
|
||||
return None
|
||||
|
||||
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed movie information"""
|
||||
return self._get(f"/movie/{tmdb_id}")
|
||||
|
||||
def get_digital_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get digital release date for a movie"""
|
||||
_log("INFO", f"🔍 TMDB: Looking for digital release date for {imdb_id}")
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
_log("WARNING", f"❌ TMDB: Movie not found for {imdb_id}")
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
_log("INFO", f"✅ TMDB: Found movie ID {tmdb_id} for {imdb_id}")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
_log("WARNING", f"❌ TMDB: No release dates data for movie {tmdb_id}")
|
||||
return None
|
||||
|
||||
_log("INFO", f"🔍 TMDB: Got release dates data, looking for {self.primary_country} digital releases")
|
||||
|
||||
# Debug: Show all available countries
|
||||
countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])]
|
||||
_log("INFO", f"📍 TMDB: Available countries: {countries}")
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
country = entry.get("iso_3166_1", "").upper()
|
||||
if country != self.primary_country:
|
||||
continue
|
||||
|
||||
_log("INFO", f"🎯 TMDB: Found {country} release data")
|
||||
releases = entry.get("release_dates", [])
|
||||
_log("INFO", f"🎬 TMDB: Release types available: {[r.get('type') for r in releases]}")
|
||||
|
||||
# Collect all available releases with their types and dates
|
||||
available_releases = []
|
||||
for release in releases:
|
||||
release_type = release.get("type")
|
||||
release_date = release.get("release_date")
|
||||
_log("INFO", f"📅 TMDB: Type {release_type}, Date: {release_date}")
|
||||
|
||||
if release_date:
|
||||
parsed_date = _parse_date_to_iso(release_date)
|
||||
if parsed_date:
|
||||
available_releases.append((release_type, parsed_date))
|
||||
|
||||
# Apply TMDB release type priority order
|
||||
tmdb_priority = self._get_tmdb_type_priority()
|
||||
for preferred_type in tmdb_priority:
|
||||
for release_type, parsed_date in available_releases:
|
||||
if release_type == preferred_type:
|
||||
release_type_names = {
|
||||
1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical",
|
||||
4: "Digital", 5: "Physical", 6: "TV Premiere"
|
||||
}
|
||||
type_name = release_type_names.get(release_type, f"Type {release_type}")
|
||||
_log("INFO", f"✅ TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})")
|
||||
return parsed_date
|
||||
|
||||
_log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in {self.primary_country}")
|
||||
return None
|
||||
|
||||
def _get_tmdb_type_priority(self) -> List[int]:
|
||||
"""Get TMDB release type priority order from environment"""
|
||||
# Default priority: Digital first, then Physical, then Theatrical, then others
|
||||
default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere
|
||||
|
||||
priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority)
|
||||
try:
|
||||
# Parse comma-separated numbers
|
||||
priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()]
|
||||
if priority_list:
|
||||
_log("DEBUG", f"TMDB type priority: {priority_list}")
|
||||
return priority_list
|
||||
else:
|
||||
_log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default")
|
||||
return [4, 5, 3, 2, 6, 1]
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default")
|
||||
return [4, 5, 3, 2, 6, 1]
|
||||
|
||||
def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get theatrical release date for a movie"""
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
return None
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||
continue
|
||||
|
||||
for release in entry.get("release_dates", []):
|
||||
if release.get("type") == 3 and release.get("release_date"): # Theatrical release
|
||||
return _parse_date_to_iso(release["release_date"])
|
||||
|
||||
return None
|
||||
|
||||
def get_physical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get physical release date (DVD/Blu-ray) for a movie"""
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
return None
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||
continue
|
||||
|
||||
for release in entry.get("release_dates", []):
|
||||
if release.get("type") == 5 and release.get("release_date"): # Physical release
|
||||
return _parse_date_to_iso(release["release_date"])
|
||||
|
||||
return None
|
||||
|
||||
def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]:
|
||||
"""Get episode air dates for a TV season"""
|
||||
result = self._get(f"/tv/{tv_id}/season/{season_number}")
|
||||
episodes = {}
|
||||
|
||||
if result:
|
||||
for episode in result.get("episodes", []):
|
||||
ep_num = episode.get("episode_number")
|
||||
air_date = episode.get("air_date")
|
||||
if isinstance(ep_num, int) and air_date:
|
||||
episodes[ep_num] = air_date
|
||||
|
||||
return episodes
|
||||
|
||||
|
||||
class OMDbClient:
|
||||
"""Open Movie Database API client"""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.environ.get("OMDB_API_KEY", "")
|
||||
self.enabled = bool(self.api_key)
|
||||
|
||||
def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get movie details from OMDb"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
params = {"i": imdb_id, "apikey": self.api_key}
|
||||
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||
result = _get_json(url, timeout=15)
|
||||
|
||||
if result and result.get("Response") == "True":
|
||||
return result
|
||||
return None
|
||||
|
||||
def get_dvd_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get DVD/digital release date"""
|
||||
details = self.get_movie_details(imdb_id)
|
||||
if not details:
|
||||
return None
|
||||
|
||||
dvd_date = details.get("DVD") or details.get("Released")
|
||||
if not dvd_date or dvd_date == "N/A":
|
||||
return None
|
||||
|
||||
# Try to parse various date formats
|
||||
for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]:
|
||||
"""Get episode release dates for a TV season"""
|
||||
if not self.enabled:
|
||||
return {}
|
||||
|
||||
params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key}
|
||||
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||
result = _get_json(url, timeout=15)
|
||||
|
||||
episodes = {}
|
||||
if result and result.get("Response") == "True":
|
||||
for episode in result.get("Episodes", []):
|
||||
try:
|
||||
ep_num = int(episode.get("Episode", 0))
|
||||
released = episode.get("Released")
|
||||
if ep_num and released and released != "N/A":
|
||||
episodes[ep_num] = released
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return episodes
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""Jellyseerr API client"""
|
||||
|
||||
def __init__(self, base_url: str = None, api_key: str = None):
|
||||
self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/")
|
||||
self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "")
|
||||
self.enabled = bool(self.base_url and self.api_key)
|
||||
|
||||
def _get(self, path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request to Jellyseerr API"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/v1{path}"
|
||||
headers = {"X-Api-Key": self.api_key, "Accept": "application/json"}
|
||||
return _get_json(url, timeout=20, headers=headers)
|
||||
|
||||
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get movie details from Jellyseerr"""
|
||||
return self._get(f"/movie/{tmdb_id}")
|
||||
|
||||
def get_digital_release_dates(self, tmdb_id: int) -> List[str]:
|
||||
"""Get digital release date candidates from Jellyseerr"""
|
||||
details = self.get_movie_details(tmdb_id)
|
||||
if not details:
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
|
||||
# Check direct fields
|
||||
for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"):
|
||||
value = details.get(field)
|
||||
if value:
|
||||
iso_date = _parse_date_to_iso(value)
|
||||
if iso_date:
|
||||
candidates.append(iso_date)
|
||||
|
||||
# Check release dates array
|
||||
for array_field in ("releaseDates", "releases", "dates"):
|
||||
release_array = details.get(array_field)
|
||||
if not isinstance(release_array, list):
|
||||
continue
|
||||
|
||||
for release in release_array:
|
||||
if not isinstance(release, dict):
|
||||
continue
|
||||
|
||||
release_type = (release.get("type") or release.get("label") or "").lower()
|
||||
release_date = release.get("date") or release.get("releaseDate")
|
||||
|
||||
if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type):
|
||||
iso_date = _parse_date_to_iso(release_date)
|
||||
if iso_date:
|
||||
candidates.append(iso_date)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
class ExternalClientManager:
|
||||
"""Manager for all external API clients"""
|
||||
|
||||
def __init__(self):
|
||||
# Get country from environment, default to US
|
||||
tmdb_country = os.environ.get("TMDB_COUNTRY", "US")
|
||||
_log("INFO", f"🌍 TMDB: Initializing with country: {tmdb_country}")
|
||||
|
||||
self.tmdb = TMDBClient(primary_country=tmdb_country)
|
||||
self.omdb = OMDbClient()
|
||||
self.jellyseerr = JellyseerrClient()
|
||||
self.tvdb = TVDBClient()
|
||||
|
||||
def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]:
|
||||
"""Get release date using configurable priority order with smart date validation"""
|
||||
|
||||
# Get all possible release dates
|
||||
release_options = {}
|
||||
|
||||
if self.tmdb.enabled:
|
||||
# Digital release
|
||||
digital_date = self.tmdb.get_digital_release_date(imdb_id)
|
||||
if digital_date:
|
||||
release_options["digital"] = (digital_date, "tmdb:digital")
|
||||
|
||||
# Physical release
|
||||
physical_date = self.tmdb.get_physical_release_date(imdb_id)
|
||||
if physical_date:
|
||||
release_options["physical"] = (physical_date, "tmdb:physical")
|
||||
|
||||
# Theatrical release
|
||||
theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id)
|
||||
if theatrical_date:
|
||||
release_options["theatrical"] = (theatrical_date, "tmdb:theatrical")
|
||||
|
||||
# Add OMDb options
|
||||
if self.omdb.enabled:
|
||||
omdb_date = self.omdb.get_dvd_release_date(imdb_id)
|
||||
if omdb_date and "physical" not in release_options:
|
||||
release_options["physical"] = (omdb_date, "omdb:dvd")
|
||||
|
||||
# Add Jellyseerr digital releases
|
||||
if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options:
|
||||
tmdb_movie = self.tmdb.find_by_imdb(imdb_id)
|
||||
if tmdb_movie:
|
||||
tmdb_id = tmdb_movie.get("id")
|
||||
if tmdb_id:
|
||||
jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id)
|
||||
if jellyseerr_dates:
|
||||
earliest_jellyseerr = min(jellyseerr_dates)
|
||||
release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital")
|
||||
|
||||
# Smart date validation: Check if priority order makes sense given the actual dates
|
||||
if enable_smart_validation and len(release_options) > 1:
|
||||
validated_choice = self._validate_date_choice(release_options, priority_order)
|
||||
if validated_choice:
|
||||
return validated_choice
|
||||
|
||||
# Return first available option according to priority (fallback behavior)
|
||||
for priority in priority_order:
|
||||
if priority in release_options:
|
||||
return release_options[priority]
|
||||
|
||||
return None
|
||||
|
||||
def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]:
|
||||
"""Validate date choice and prefer theatrical if digital/physical are unreasonably late"""
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
# Get configuration for maximum gap (default: 10 years)
|
||||
max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10"))
|
||||
|
||||
# Parse all available dates
|
||||
parsed_dates = {}
|
||||
for release_type, (date_str, source) in release_options.items():
|
||||
try:
|
||||
parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not parsed_dates or "theatrical" not in parsed_dates:
|
||||
return None # No smart validation possible without theatrical date
|
||||
|
||||
theatrical_date, theatrical_source = parsed_dates["theatrical"]
|
||||
|
||||
# Check each priority option against theatrical date
|
||||
for priority in priority_order:
|
||||
if priority == "theatrical":
|
||||
continue # Skip theatrical in this validation
|
||||
|
||||
if priority in parsed_dates:
|
||||
priority_date, priority_source = parsed_dates[priority]
|
||||
|
||||
# Calculate the gap in years
|
||||
gap = (priority_date - theatrical_date).days / 365.25
|
||||
|
||||
# If the gap is too large, skip this priority and continue
|
||||
if gap > max_reasonable_gap_years:
|
||||
print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical")
|
||||
continue
|
||||
|
||||
# This priority option is reasonable, use it
|
||||
return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)")
|
||||
|
||||
# If all priority options are unreasonable, fall back to theatrical
|
||||
if "theatrical" in release_options:
|
||||
theatrical_date_str, theatrical_source = release_options["theatrical"]
|
||||
return (theatrical_date_str, f"{theatrical_source} (smart fallback)")
|
||||
|
||||
return None
|
||||
|
||||
def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]:
|
||||
"""Get digital release date candidates from all sources (legacy method)"""
|
||||
candidates = []
|
||||
|
||||
# Try the new priority system with digital-first fallback
|
||||
result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"])
|
||||
if result:
|
||||
candidates.append(result)
|
||||
|
||||
return candidates
|
||||
|
||||
def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Get the earliest digital release date (legacy method)"""
|
||||
candidates = self.get_digital_release_candidates(imdb_id)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get TVDB series ID from IMDB ID"""
|
||||
# Check if TVDB lookups are disabled
|
||||
if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]:
|
||||
_log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable")
|
||||
return None
|
||||
|
||||
if not self.tvdb.api_key:
|
||||
_log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)")
|
||||
return None
|
||||
|
||||
return self.tvdb.imdb_to_tvdb_series_id(imdb_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the clients
|
||||
manager = ExternalClientManager()
|
||||
|
||||
test_imdb = "tt1596343" # Example IMDb ID
|
||||
digital_candidates = manager.get_digital_release_candidates(test_imdb)
|
||||
print(f"Digital release candidates for {test_imdb}: {digital_candidates}")
|
||||
|
||||
earliest = manager.get_earliest_digital_release(test_imdb)
|
||||
if earliest:
|
||||
print(f"Earliest digital release: {earliest[0]} ({earliest[1]})")
|
||||
else:
|
||||
print("No digital release dates found")
|
||||
@@ -1,534 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
# Import path mapper for proper path handling
|
||||
try:
|
||||
from core.path_mapper import path_mapper
|
||||
except ImportError:
|
||||
# Fallback for standalone testing
|
||||
class DummyPathMapper:
|
||||
def analyze_import_source_path(self, path):
|
||||
return "/downloads/" in path.lower(), "basic_check"
|
||||
|
||||
def is_download_path(self, path):
|
||||
return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
|
||||
|
||||
path_mapper = DummyPathMapper()
|
||||
|
||||
# Import database client for enhanced performance
|
||||
try:
|
||||
from clients.radarr_db_client import RadarrDbClient
|
||||
except ImportError:
|
||||
RadarrDbClient = None
|
||||
|
||||
|
||||
class RadarrClient:
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
# Radarr History API event types (HistoryEventType enum)
|
||||
# From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
|
||||
EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
|
||||
EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
|
||||
EVENT_TYPE_FAILED = 4 # Download or import failed
|
||||
EVENT_TYPE_RETAGGED = 6 # Files were tagged
|
||||
EVENT_TYPE_RENAMED = 8 # Files were renamed
|
||||
|
||||
# Event types that indicate real imports
|
||||
REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
|
||||
|
||||
# These are now handled by path_mapper, but keeping for backward compatibility
|
||||
DOWNLOAD_PATH_INDICATORS = [
|
||||
'/downloads/', '/download/', '/completed/', '/importing/',
|
||||
'/nzbs/', '/torrents/', '/temp/', '/tmp/',
|
||||
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
|
||||
'usenet', 'torrent', 'radarr', 'completed', 'processing'
|
||||
]
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
|
||||
# Initialize database client - REQUIRED for operation
|
||||
self.db_client = None
|
||||
if RadarrDbClient:
|
||||
try:
|
||||
self.db_client = RadarrDbClient.from_env()
|
||||
if self.db_client:
|
||||
_log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled")
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}")
|
||||
self.db_client = None
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Radarr API with retries"""
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt <= self.retries:
|
||||
try:
|
||||
params = params or {}
|
||||
params["apikey"] = self.api_key
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
|
||||
if params:
|
||||
url = url + ("&" if "?" in url else "?") + urlencode(params)
|
||||
|
||||
_log("DEBUG", f"Radarr API Request: {url}")
|
||||
req = UrlRequest(url, headers={"Accept": "application/json"})
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data)
|
||||
return result
|
||||
|
||||
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
|
||||
time.sleep(min(2 ** attempt, 5)) # Exponential backoff
|
||||
attempt += 1
|
||||
|
||||
_log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
|
||||
return None
|
||||
|
||||
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find movie by IMDb ID - DATABASE ONLY mode"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
|
||||
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
movie = self.db_client.get_movie_by_imdb(imdb_id)
|
||||
if movie:
|
||||
_log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})")
|
||||
return movie
|
||||
else:
|
||||
_log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database lookup failed: {e}")
|
||||
return None
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for movie lookup - API mode disabled")
|
||||
return None
|
||||
|
||||
def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Analyze a history event to determine if it's a real import.
|
||||
|
||||
Args:
|
||||
event: The history event to analyze
|
||||
movie_info: Optional movie information to validate paths against
|
||||
|
||||
Returns:
|
||||
(is_real_import, reason, date_iso)
|
||||
"""
|
||||
event_type = event.get("eventType")
|
||||
date_str = event.get("date")
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Parse date
|
||||
date_iso = None
|
||||
if date_str:
|
||||
try:
|
||||
date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
date_iso = None
|
||||
|
||||
if not date_iso:
|
||||
return False, "no_valid_date", None
|
||||
|
||||
# Convert event type to int if needed
|
||||
try:
|
||||
event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
|
||||
except (ValueError, TypeError):
|
||||
event_type_int = None
|
||||
|
||||
# Check if event type indicates import
|
||||
if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
|
||||
return False, f"event_type_not_import({event_type})", date_iso
|
||||
|
||||
# Get all possible source paths/titles
|
||||
source_items = []
|
||||
|
||||
# Get both sourcePath and importedPath if available
|
||||
if event_data:
|
||||
for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
|
||||
if event_data.get(key):
|
||||
source_items.append(event_data[key])
|
||||
|
||||
# Also check event root for these fields
|
||||
for key in ['sourcePath', 'sourceTitle', 'importedPath']:
|
||||
if event.get(key):
|
||||
source_items.append(event[key])
|
||||
|
||||
# Clean up and make unique
|
||||
source_items = [str(s).lower().strip() for s in source_items if s]
|
||||
source_items = list(set(source_items)) # Remove duplicates
|
||||
|
||||
if not source_items:
|
||||
return False, "no_source_paths", date_iso
|
||||
|
||||
# If we have movie info, look for title/year match
|
||||
if movie_info:
|
||||
movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
|
||||
movie_year = str(movie_info.get('year', ''))
|
||||
|
||||
for source in source_items:
|
||||
# Clean up source text for comparison
|
||||
source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
|
||||
|
||||
# Check if both title and year are in the source
|
||||
if movie_title and movie_year:
|
||||
if movie_title in source_clean and movie_year in source_clean:
|
||||
_log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}")
|
||||
return True, "matched_title_and_year", date_iso
|
||||
|
||||
# Also check for downloads path as secondary validation
|
||||
if path_mapper.is_download_path(source):
|
||||
_log("DEBUG", f"Source is from downloads: {source}")
|
||||
return True, "from_downloads_path", date_iso
|
||||
|
||||
_log("DEBUG", f"⚠️ No match found in sources: {source_items}")
|
||||
return False, "no_title_year_match", date_iso
|
||||
|
||||
# Fallback to basic path validation if no movie info
|
||||
for source in source_items:
|
||||
if path_mapper.is_download_path(source):
|
||||
return True, "basic_download_path_match", date_iso
|
||||
|
||||
return False, "no_download_path_match", date_iso
|
||||
|
||||
def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Find earliest real import event with optimized querying.
|
||||
Stops as soon as we find valid import events instead of loading everything.
|
||||
"""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
# Get movie info for path validation
|
||||
movie_info = self._get(f"/api/v3/movie/{movie_id}")
|
||||
if not movie_info or not isinstance(movie_info, dict):
|
||||
_log("ERROR", f"Could not get movie info for ID {movie_id}")
|
||||
return None
|
||||
|
||||
earliest_real_import = None
|
||||
first_grab = None
|
||||
page = 1
|
||||
page_size = 50 # Smaller pages for faster iteration
|
||||
total_processed = 0
|
||||
|
||||
while page <= 20: # Safety limit
|
||||
# Get history in chronological order
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
items = data if isinstance(data, list) else data.get("records", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Page {page}: Processing {len(items)} events")
|
||||
|
||||
for event in items:
|
||||
total_processed += 1
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Convert event type to int or handle string types
|
||||
try:
|
||||
if isinstance(event_type, str):
|
||||
# Map string event types to numeric values
|
||||
string_to_numeric = {
|
||||
"grabbed": self.EVENT_TYPE_GRABBED,
|
||||
"downloadFolderImported": self.EVENT_TYPE_IMPORTED,
|
||||
"movieFileImported": self.EVENT_TYPE_IMPORTED,
|
||||
"downloadFailed": self.EVENT_TYPE_FAILED,
|
||||
"movieFileRenamed": self.EVENT_TYPE_RENAMED,
|
||||
"movieFileDeleted": 5 # Not in our constants but common
|
||||
}
|
||||
event_type = string_to_numeric.get(event_type, 0)
|
||||
else:
|
||||
event_type = int(event_type)
|
||||
except (ValueError, TypeError):
|
||||
_log("DEBUG", f"Unknown event type: {event_type}")
|
||||
continue
|
||||
|
||||
# Check for grab events (type 1) - but validate it's a real download
|
||||
if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
|
||||
if event.get("date"):
|
||||
try:
|
||||
# Get event data to check if this is a real grab with download info
|
||||
event_data = event.get("data", {})
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
event_data = {}
|
||||
|
||||
# Check if this grab has actual download/indexer info
|
||||
source_title = event_data.get("sourceTitle", "")
|
||||
indexer = event_data.get("indexer", "")
|
||||
|
||||
# Only count grabs that have actual download metadata
|
||||
if source_title or indexer:
|
||||
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
|
||||
else:
|
||||
_log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only process import events (type 3)
|
||||
if event_type != self.EVENT_TYPE_IMPORTED:
|
||||
continue
|
||||
|
||||
# Get imported path from event data
|
||||
imported_path = None
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Handle both string and dict data
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
_log("DEBUG", f"Failed to parse event data JSON: {e}")
|
||||
continue
|
||||
elif not isinstance(event_data, dict):
|
||||
continue
|
||||
|
||||
imported_path = event_data.get("importedPath", "")
|
||||
if not imported_path:
|
||||
continue
|
||||
|
||||
imported_path = imported_path.lower()
|
||||
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
movie_title = (movie_info.get("title", "") or "").lower()
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
|
||||
# First try IMDb ID match
|
||||
# First try IMDb ID match
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in imported_path or
|
||||
f"[{movie_imdb}]" in imported_path or
|
||||
movie_imdb in imported_path
|
||||
):
|
||||
_log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Then try title/year match with fuzzy path cleaning
|
||||
if movie_title and movie_year:
|
||||
# Clean strings for comparison
|
||||
clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
|
||||
clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
|
||||
|
||||
# Look for both title and year in the path
|
||||
if clean_title in clean_path and movie_year in clean_path:
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
|
||||
_log("INFO", f"✅ FOUND IMPORT at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Fallback to normal import analysis
|
||||
is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
|
||||
if is_real and date_iso:
|
||||
source_path = (event.get("data", {}).get("sourcePath", "") or
|
||||
event.get("data", {}).get("droppedPath", "") or
|
||||
event.get("sourcePath", "") or
|
||||
event.get("data", {}).get("importedPath", "") or
|
||||
event.get("importedPath", "") or "").lower()
|
||||
|
||||
if source_path:
|
||||
# Check for path match
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in source_path or
|
||||
f"[{movie_imdb}]" in source_path or
|
||||
movie_imdb in source_path
|
||||
):
|
||||
_log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Check for title/year match
|
||||
movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
|
||||
_log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
elif event_type == 3:
|
||||
_log("DEBUG", f"⚠️ Skipped import event: {reason}")
|
||||
|
||||
# If we found a real import, no need to continue
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
# If we got less than page size, we've seen all events
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_processed} events across {page-1} pages")
|
||||
|
||||
if earliest_real_import:
|
||||
_log("INFO", f"✅ Using earliest real import: {earliest_real_import}")
|
||||
return earliest_real_import
|
||||
|
||||
if first_grab:
|
||||
_log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}")
|
||||
return first_grab
|
||||
|
||||
_log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}")
|
||||
return None
|
||||
|
||||
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get movie files for a movie - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
_log("INFO", "Using database for movie files lookup")
|
||||
# Database handles this internally in get_movie_file_date()
|
||||
return []
|
||||
|
||||
_log("ERROR", "Database client required for movie files - API mode disabled")
|
||||
return []
|
||||
|
||||
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
|
||||
"""Get earliest file dateAdded - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
try:
|
||||
return self.db_client.get_movie_file_date(movie_id)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database file date query failed: {e}")
|
||||
return None
|
||||
|
||||
_log("ERROR", "Database client required for file dates - API mode disabled")
|
||||
return None
|
||||
|
||||
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie - DATABASE ONLY mode.
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
else:
|
||||
_log("WARNING", f"No import date found in database for movie_id {movie_id}")
|
||||
return None, "radarr:db.no_date_found"
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database import date query failed: {e}")
|
||||
return None, "radarr:db.error"
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for import date detection - API mode disabled")
|
||||
return None, "radarr:db.not_configured"
|
||||
|
||||
def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
|
||||
"""Get the earliest import date from Radarr history."""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
earliest_real_import = None
|
||||
earliest_grab_date = None
|
||||
page = 1
|
||||
page_size = 50
|
||||
total_events = 0
|
||||
|
||||
# Get full movie history
|
||||
while True:
|
||||
# Get page of history
|
||||
history_data = self._get_movie_history_page(movie_id, page, page_size)
|
||||
if not history_data:
|
||||
break
|
||||
|
||||
# Process events on this page
|
||||
for event in history_data:
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Parse event date
|
||||
event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# Track earliest grab date as fallback (EventType 1)
|
||||
if event_type == 1 and not earliest_grab_date:
|
||||
earliest_grab_date = event_date
|
||||
_log("DEBUG", f"Found first grab event at {earliest_grab_date}")
|
||||
continue
|
||||
|
||||
# Look for import events (EventType 3)
|
||||
if event_type == 3:
|
||||
try:
|
||||
data = json.loads(event.get("data", "{}"))
|
||||
if data.get("importedPath"):
|
||||
_log("INFO", f"✅ FOUND IMPORT at {event_date}")
|
||||
earliest_real_import = event_date
|
||||
break
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Break if we found an import
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
total_events += len(history_data)
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_events} events across {page}")
|
||||
|
||||
if earliest_real_import:
|
||||
return earliest_real_import
|
||||
if earliest_grab_date:
|
||||
_log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
|
||||
return earliest_grab_date
|
||||
return None
|
||||
|
||||
def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
|
||||
"""Get a page of movie history."""
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
return data if isinstance(data, list) else data.get("records", [])
|
||||
@@ -1,646 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct Radarr Database Client for NFOGuard
|
||||
Provides high-performance access to Radarr's SQLite/PostgreSQL database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class RadarrDbClient:
|
||||
"""Direct database client for Radarr's SQLite or PostgreSQL database"""
|
||||
|
||||
def __init__(self,
|
||||
db_type: str = "sqlite",
|
||||
db_path: Optional[str] = None,
|
||||
db_host: Optional[str] = None,
|
||||
db_port: Optional[int] = None,
|
||||
db_name: Optional[str] = None,
|
||||
db_user: Optional[str] = None,
|
||||
db_password: Optional[str] = None):
|
||||
"""
|
||||
Initialize Radarr database client
|
||||
|
||||
Args:
|
||||
db_type: "sqlite" or "postgresql"
|
||||
db_path: Path to SQLite database file
|
||||
db_host: PostgreSQL host
|
||||
db_port: PostgreSQL port
|
||||
db_name: PostgreSQL database name
|
||||
db_user: PostgreSQL username
|
||||
db_password: PostgreSQL password
|
||||
"""
|
||||
self.db_type = db_type.lower()
|
||||
self.db_path = db_path
|
||||
self.db_host = db_host
|
||||
self.db_port = db_port or 5432
|
||||
self.db_name = db_name
|
||||
self.db_user = db_user
|
||||
self.db_password = db_password
|
||||
|
||||
self._test_connection()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Optional['RadarrDbClient']:
|
||||
"""Create client from environment variables"""
|
||||
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||
|
||||
if not db_type:
|
||||
return None
|
||||
|
||||
if db_type == "sqlite":
|
||||
db_path = os.environ.get("RADARR_DB_PATH")
|
||||
if not db_path or not Path(db_path).exists():
|
||||
_log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
|
||||
return None
|
||||
return cls(db_type="sqlite", db_path=db_path)
|
||||
|
||||
elif db_type == "postgresql":
|
||||
# Support both individual vars and connection string
|
||||
db_url = os.environ.get("RADARR_DB_URL")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=parsed.hostname,
|
||||
db_port=parsed.port or 5432,
|
||||
db_name=parsed.path.lstrip('/'),
|
||||
db_user=parsed.username,
|
||||
db_password=parsed.password
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=os.environ.get("RADARR_DB_HOST"),
|
||||
db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
|
||||
db_name=os.environ.get("RADARR_DB_NAME"),
|
||||
db_user=os.environ.get("RADARR_DB_USER"),
|
||||
db_password=os.environ.get("RADARR_DB_PASSWORD")
|
||||
)
|
||||
else:
|
||||
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||
return None
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Test database connection on initialization"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
if conn:
|
||||
conn.close()
|
||||
_log("INFO", f"Connected to Radarr {self.db_type} database successfully")
|
||||
else:
|
||||
raise Exception("Failed to create connection")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to connect to Radarr database: {e}")
|
||||
raise
|
||||
|
||||
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||
"""Get database connection"""
|
||||
if self.db_type == "sqlite":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
elif self.db_type == "postgresql":
|
||||
conn = psycopg2.connect(
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
user=self.db_user,
|
||||
password=self.db_password
|
||||
)
|
||||
return conn
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||
|
||||
def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find movie by IMDb ID using database query
|
||||
|
||||
Returns:
|
||||
Dictionary with movie info including id, imdbId, title, year, path
|
||||
"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m."Id" as id,
|
||||
m."Path" as path,
|
||||
m."Added" as added,
|
||||
mm."ImdbId" as imdb_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
mm."DigitalRelease" as digital_release
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (imdb_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row) if self.db_type == "sqlite" else row
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date from History table, accounting for upgrade scenarios
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# If first event is rename, all subsequent imports are upgrades - skip them
|
||||
if self.is_first_event_rename_based(movie_id):
|
||||
_log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
|
||||
return None, "radarr:db.upgrade_imports_skipped"
|
||||
|
||||
# Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
|
||||
import_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 3
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
# Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
|
||||
grab_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 1
|
||||
AND h."Data" IS NOT NULL
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
import_query = import_query.replace("%s", "?")
|
||||
grab_query = grab_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try import events first
|
||||
cursor.execute(import_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.import"
|
||||
|
||||
# Fallback to grab events
|
||||
cursor.execute(grab_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.grab"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie {movie_id}: {e}")
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def is_first_event_rename_based(self, movie_id: int) -> bool:
|
||||
"""
|
||||
Check if the first event in history is rename-based (not a true import)
|
||||
|
||||
This helps identify movies where:
|
||||
- First event: movieFileRenamed (EventType = 8)
|
||||
- Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
|
||||
|
||||
In such cases, we should prefer release dates over the upgrade date
|
||||
"""
|
||||
query = """
|
||||
SELECT h."EventType" as event_type, h."Date" as event_date
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
_log("INFO", f"Movie {movie_id} history debug - first 5 events:")
|
||||
for i, row in enumerate(rows):
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
|
||||
_log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
|
||||
|
||||
first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
|
||||
# EventType 8 = movieFileRenamed
|
||||
# Also check for EventType 7 = movieFileRenamed in some Radarr versions
|
||||
is_rename_first = first_event_type in [7, 8]
|
||||
_log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
|
||||
|
||||
if is_rename_first:
|
||||
_log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
|
||||
|
||||
return is_rename_first
|
||||
else:
|
||||
_log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def get_movie_file_date(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get earliest file dateAdded as fallback
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
ISO date string or None
|
||||
"""
|
||||
query = """
|
||||
SELECT MIN(mf."DateAdded") as earliest_date
|
||||
FROM "MovieFiles" mf
|
||||
WHERE mf."MovieId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
|
||||
if date_value:
|
||||
if isinstance(date_value, str):
|
||||
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = date_value.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie using optimized database queries
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
fallback_to_file_date: Whether to fall back to file dateAdded
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Try history first - this handles upgrade detection internally
|
||||
date_iso, source = self.get_earliest_import_date(movie_id)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
|
||||
# Check if we skipped upgrades and should prefer release dates
|
||||
if source == "radarr:db.upgrade_imports_skipped":
|
||||
_log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
|
||||
return None, "radarr:db.prefer_release_dates"
|
||||
|
||||
# Fallback to file date if requested
|
||||
if fallback_to_file_date:
|
||||
file_date = self.get_movie_file_date(movie_id)
|
||||
if file_date:
|
||||
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||
return file_date, "radarr:db.file.dateAdded"
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Get import dates for multiple movies in a single query
|
||||
|
||||
Args:
|
||||
imdb_ids: List of IMDb IDs
|
||||
|
||||
Returns:
|
||||
Dictionary mapping imdb_id -> (date_iso, source)
|
||||
"""
|
||||
if not imdb_ids:
|
||||
return {}
|
||||
|
||||
# Ensure all IMDb IDs have tt prefix
|
||||
clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_imdb_ids))
|
||||
if self.db_type == "sqlite":
|
||||
placeholders = ",".join(["?"] * len(clean_imdb_ids))
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
mm."ImdbId" as imdb_id,
|
||||
m."Id" as movie_id,
|
||||
MIN(h."Date") as earliest_import
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
|
||||
WHERE mm."ImdbId" IN ({placeholders})
|
||||
GROUP BY mm."ImdbId", m."Id"
|
||||
"""
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, clean_imdb_ids)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if self.db_type == "postgresql":
|
||||
imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
|
||||
else:
|
||||
imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
|
||||
|
||||
if earliest_import:
|
||||
if isinstance(earliest_import, str):
|
||||
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
results[imdb_id] = (date_iso, "radarr:db.bulk.import")
|
||||
else:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.no_import")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Bulk query error: {e}")
|
||||
# Return empty results for failed queries
|
||||
for imdb_id in clean_imdb_ids:
|
||||
if imdb_id not in results:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.error")
|
||||
|
||||
return results
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic statistics about the Radarr database"""
|
||||
stats = {}
|
||||
|
||||
queries = {
|
||||
"total_movies": 'SELECT COUNT(*) FROM "Movies"',
|
||||
"total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
|
||||
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for stat_name, query in queries.items():
|
||||
cursor.execute(query)
|
||||
result = cursor.fetchone()
|
||||
stats[stat_name] = result[0] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Stats query error: {e}")
|
||||
stats["error"] = str(e)
|
||||
|
||||
return stats
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive health check for the Radarr database connection
|
||||
|
||||
Returns:
|
||||
Dictionary with health status, connection info, and basic functionality tests
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"database_type": self.db_type,
|
||||
"connection": "ok",
|
||||
"readable": False,
|
||||
"writable": False,
|
||||
"tables_exist": False,
|
||||
"sample_data": False,
|
||||
"issues": [],
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
|
||||
try:
|
||||
# Test 1: Basic connection
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test 2: Check if we can read (basic query)
|
||||
try:
|
||||
cursor.execute('SELECT 1')
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
health["readable"] = True
|
||||
health["connection"] = "readable"
|
||||
else:
|
||||
health["issues"].append("Basic SELECT query failed")
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Read test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 3: Check required tables exist
|
||||
required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
|
||||
existing_tables = []
|
||||
|
||||
try:
|
||||
if self.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
else: # SQLite
|
||||
cursor.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
existing_tables = [row[0] for row in rows]
|
||||
|
||||
if len(existing_tables) == len(required_tables):
|
||||
health["tables_exist"] = True
|
||||
else:
|
||||
missing = set(required_tables) - set(existing_tables)
|
||||
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
health["existing_tables"] = existing_tables
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Table check failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 4: Check for sample data
|
||||
if health["tables_exist"]:
|
||||
try:
|
||||
cursor.execute('SELECT COUNT(*) FROM "Movies"')
|
||||
movie_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
if movie_count > 0 and history_count > 0:
|
||||
health["sample_data"] = True
|
||||
health["movie_count"] = movie_count
|
||||
health["history_count"] = history_count
|
||||
else:
|
||||
health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Sample data check failed: {e}")
|
||||
|
||||
# Test 5: Test a real query (movie with IMDb lookup)
|
||||
if health["sample_data"]:
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" IS NOT NULL
|
||||
""")
|
||||
imdb_movies = cursor.fetchone()[0]
|
||||
health["movies_with_imdb"] = imdb_movies
|
||||
|
||||
if imdb_movies > 0:
|
||||
health["functional"] = True
|
||||
else:
|
||||
health["issues"].append("No movies with IMDb IDs found")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Functional test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
except Exception as e:
|
||||
health["status"] = "error"
|
||||
health["connection"] = "failed"
|
||||
health["issues"].append(f"Connection failed: {e}")
|
||||
_log("ERROR", f"Database health check failed: {e}")
|
||||
|
||||
# Overall status determination
|
||||
if health["issues"]:
|
||||
if health["status"] == "healthy":
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Add connection details (safe info only)
|
||||
health["connection_info"] = {
|
||||
"type": self.db_type,
|
||||
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||
"path": self.db_path if self.db_type == "sqlite" else None
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the database client
|
||||
print("Testing RadarrDbClient...")
|
||||
|
||||
# Test with environment variables
|
||||
client = RadarrDbClient.from_env()
|
||||
if client:
|
||||
print("✅ Connected to Radarr database")
|
||||
|
||||
# Test stats
|
||||
stats = client.get_database_stats()
|
||||
print(f"Database stats: {stats}")
|
||||
|
||||
# Test movie lookup
|
||||
test_movie = client.get_movie_by_imdb("tt1596343")
|
||||
if test_movie:
|
||||
print(f"Found test movie: {test_movie}")
|
||||
|
||||
# Test import date
|
||||
movie_id = test_movie['id']
|
||||
date_iso, source = client.get_movie_import_date_optimized(movie_id)
|
||||
print(f"Import date: {date_iso} (source: {source})")
|
||||
else:
|
||||
print("Test movie not found")
|
||||
else:
|
||||
print("❌ Could not connect to database - check environment variables")
|
||||
@@ -1,286 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Sonarr API client for TV show metadata and episode management
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class SonarrClient:
|
||||
"""Enhanced Sonarr API client for TV series and episode management"""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
self.enabled = bool(self.base_url and self.api_key)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Sonarr API with retries"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/v3{path}"
|
||||
if params:
|
||||
url += "?" + urlencode(params)
|
||||
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
|
||||
for attempt in range(self.retries):
|
||||
try:
|
||||
_log("DEBUG", f"Sonarr API Request: {url}")
|
||||
req = UrlRequest(url, headers=headers)
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data) if data else None
|
||||
return result
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 401:
|
||||
_log("ERROR", "Sonarr authentication failed - check API key")
|
||||
return None
|
||||
elif e.code == 429:
|
||||
wait_time = (attempt + 1) * 2
|
||||
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
|
||||
|
||||
if attempt < self.retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
|
||||
return None
|
||||
|
||||
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by IMDb ID using lookup endpoint"""
|
||||
search_term = f"imdbid:{imdb_id}"
|
||||
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": search_term})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
|
||||
|
||||
# Log all results for debugging
|
||||
for i, series in enumerate(result):
|
||||
series_imdb = series.get("imdbId", "")
|
||||
series_title = series.get("title", "")
|
||||
series_id = series.get("id", "")
|
||||
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
|
||||
|
||||
# Find exact IMDb match (case insensitive)
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Try partial match as fallback
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if target_imdb in series_imdb or series_imdb in target_imdb:
|
||||
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
|
||||
return None
|
||||
|
||||
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Search for series by title as fallback when IMDb lookup fails"""
|
||||
_log("DEBUG", f"Searching Sonarr by title: {title}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": title})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr title search for: {title}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
|
||||
|
||||
title_lower = title.lower()
|
||||
|
||||
# Look for exact title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if series_title == title_lower:
|
||||
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Look for partial title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if title_lower in series_title or series_title in title_lower:
|
||||
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No title match found for: {title}")
|
||||
return None
|
||||
|
||||
def get_all_series(self) -> List[Dict[str, Any]]:
|
||||
"""Get all series from Sonarr"""
|
||||
return self._get("/series") or []
|
||||
|
||||
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
|
||||
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
|
||||
all_series = self.get_all_series()
|
||||
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in all_series:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
return self._get("/episode", {"seriesId": series_id}) or []
|
||||
|
||||
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get episode file details"""
|
||||
return self._get(f"/episodefile/{episode_file_id}")
|
||||
|
||||
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get the original import date from history with enhanced detection.
|
||||
Focuses on finding the earliest REAL import, not upgrades.
|
||||
"""
|
||||
all_records = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
# Collect all history records for this episode
|
||||
while True:
|
||||
history = self._get("/history", {
|
||||
"episodeId": episode_id,
|
||||
"sortKey": "date",
|
||||
"sortDir": "asc",
|
||||
"page": page,
|
||||
"pageSize": page_size
|
||||
})
|
||||
|
||||
if not history:
|
||||
break
|
||||
|
||||
records = history.get("records", []) if isinstance(history, dict) else []
|
||||
if not records:
|
||||
break
|
||||
|
||||
all_records.extend(records)
|
||||
|
||||
if len(records) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
if page > 10: # Safety valve
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
|
||||
|
||||
# Categorize events
|
||||
import_events = []
|
||||
grabbed_events = []
|
||||
rename_events = []
|
||||
|
||||
for event in all_records:
|
||||
event_type = event.get("eventType", "").lower()
|
||||
date = event.get("date")
|
||||
|
||||
if not date:
|
||||
continue
|
||||
|
||||
_log("DEBUG", f"History event: {event_type} at {date}")
|
||||
|
||||
if event_type == "downloadfolderimported":
|
||||
import_events.append({"date": date, "event": event})
|
||||
elif event_type == "grabbed":
|
||||
grabbed_events.append({"date": date, "event": event})
|
||||
elif event_type == "episodefilerenamed":
|
||||
rename_events.append({"date": date, "event": event})
|
||||
|
||||
# Use the earliest real import event
|
||||
if import_events:
|
||||
earliest_import = min(import_events, key=lambda x: x["date"])
|
||||
import_date = earliest_import["date"]
|
||||
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||
|
||||
# Check if this looks like an upgrade by comparing to renames
|
||||
if rename_events:
|
||||
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||
rename_date = earliest_rename["date"]
|
||||
|
||||
try:
|
||||
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||
days_diff = (import_dt - rename_dt).days
|
||||
|
||||
# If import is significantly after rename, prefer rename date
|
||||
if days_diff > 30:
|
||||
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||
return rename_date
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error comparing dates: {e}")
|
||||
|
||||
return import_date
|
||||
|
||||
# Fallback to grab event
|
||||
if grabbed_events:
|
||||
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
|
||||
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
|
||||
return earliest_grab["date"]
|
||||
|
||||
_log("WARNING", f"No reliable import events found for episode {episode_id}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the client
|
||||
import os
|
||||
|
||||
base_url = os.environ.get("SONARR_URL", "")
|
||||
api_key = os.environ.get("SONARR_API_KEY", "")
|
||||
|
||||
if base_url and api_key:
|
||||
client = SonarrClient(base_url, api_key)
|
||||
|
||||
# Test with a known series
|
||||
test_imdb = "tt2085059" # Example
|
||||
series = client.series_by_imdb(test_imdb)
|
||||
|
||||
if series:
|
||||
series_id = series.get("id")
|
||||
print(f"Found series: {series.get('title')} (ID: {series_id})")
|
||||
|
||||
# Get episodes
|
||||
episodes = client.episodes_for_series(series_id)
|
||||
print(f"Found {len(episodes)} episodes")
|
||||
|
||||
# Test import date for first episode
|
||||
if episodes:
|
||||
first_episode = episodes[0]
|
||||
episode_id = first_episode.get("id")
|
||||
import_date = client.get_episode_import_history(episode_id)
|
||||
print(f"First episode import date: {import_date}")
|
||||
else:
|
||||
print(f"Series not found: {test_imdb}")
|
||||
else:
|
||||
print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing")
|
||||
@@ -1,274 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database management for NFOGuard
|
||||
Handles SQLite database operations for tracking media dates and processing history
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
from contextlib import contextmanager
|
||||
|
||||
class NFOGuardDatabase:
|
||||
"""Manages NFOGuard SQLite database operations"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._local = threading.local()
|
||||
self._init_database()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get thread-local database connection"""
|
||||
if not hasattr(self._local, 'connection'):
|
||||
self._local.connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
check_same_thread=False,
|
||||
timeout=30.0
|
||||
)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""Context manager for database connections"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize database tables with migration support"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
last_updated TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Episodes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
imdb_id TEXT NOT NULL,
|
||||
season INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
aired TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL,
|
||||
PRIMARY KEY (imdb_id, season, episode),
|
||||
FOREIGN KEY (imdb_id) REFERENCES series(imdb_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Movies table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
released TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Processing history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imdb_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL,
|
||||
details TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Add missing columns if they don't exist (migration)
|
||||
# Check current schema and add missing columns
|
||||
cursor.execute("PRAGMA table_info(movies)")
|
||||
movie_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute("PRAGMA table_info(episodes)")
|
||||
episode_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
# Add missing columns to movies table
|
||||
if 'path' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT")
|
||||
cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL")
|
||||
|
||||
if 'has_video_file' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Add missing columns to episodes table
|
||||
if 'has_video_file' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Create indexes
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
|
||||
|
||||
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
|
||||
aired: Optional[str], dateadded: Optional[str],
|
||||
source: str, has_video_file: bool = False):
|
||||
"""Insert or update episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO episodes
|
||||
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||
|
||||
def upsert_movie(self, imdb_id: str, path: str):
|
||||
"""Insert or update movie record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
|
||||
VALUES (?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat()))
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no column named path" in str(e):
|
||||
# Fallback for databases without path column - just insert imdb_id
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, last_updated)
|
||||
VALUES (?, ?)
|
||||
""", (imdb_id, datetime.utcnow().isoformat()))
|
||||
else:
|
||||
raise
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||
"""Insert or update movie date record"""
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Use INSERT OR REPLACE to ensure we always update the dates properly
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (
|
||||
?,
|
||||
COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'),
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||
result = cursor.fetchone()
|
||||
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}")
|
||||
|
||||
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||
"""Get all episodes for a series"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM episodes WHERE imdb_id = ?"
|
||||
params = [imdb_id]
|
||||
|
||||
if has_video_file_only:
|
||||
query += " AND has_video_file = TRUE"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||
"""Get episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM episodes
|
||||
WHERE imdb_id = ? AND season = ? AND episode = ?
|
||||
""", (imdb_id, season, episode))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""Get movie date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
|
||||
"""Add processing history entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||
json.dumps(details) if details else None))
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series stats
|
||||
cursor.execute("SELECT COUNT(*) FROM series")
|
||||
series_count = cursor.fetchone()[0]
|
||||
|
||||
# Episode stats
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||
episodes_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
|
||||
episodes_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Movie stats
|
||||
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||
movies_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
|
||||
movies_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Processing history
|
||||
cursor.execute("SELECT COUNT(*) FROM processing_history")
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"series_count": series_count,
|
||||
"episodes_total": episodes_total,
|
||||
"episodes_with_video": episodes_with_video,
|
||||
"movies_total": movies_total,
|
||||
"movies_with_video": movies_with_video,
|
||||
"processing_history_count": history_count,
|
||||
"database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Logging utilities for NFOguard"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
def _get_local_timezone():
|
||||
"""Get the local timezone, respecting TZ environment variable"""
|
||||
tz_name = os.environ.get('TZ', 'UTC')
|
||||
|
||||
try:
|
||||
# Try zoneinfo first (Python 3.9+)
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(tz_name)
|
||||
except ImportError:
|
||||
# Fallback for older Python versions
|
||||
try:
|
||||
import pytz
|
||||
return pytz.timezone(tz_name)
|
||||
except:
|
||||
# Final fallback to UTC
|
||||
return timezone.utc
|
||||
except:
|
||||
# If zone name is invalid, fallback to UTC
|
||||
return timezone.utc
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Basic logging function that writes to console"""
|
||||
tz = _get_local_timezone()
|
||||
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
|
||||
|
||||
def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||
"""Convert UTC ISO timestamp to local timezone timestamp"""
|
||||
if not utc_iso_string:
|
||||
return utc_iso_string
|
||||
|
||||
try:
|
||||
# Parse UTC timestamp
|
||||
if utc_iso_string.endswith('Z'):
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
|
||||
elif '+00:00' in utc_iso_string:
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string)
|
||||
else:
|
||||
# Assume UTC if no timezone info
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Convert to local timezone
|
||||
local_tz = _get_local_timezone()
|
||||
dt_local = dt_utc.astimezone(local_tz)
|
||||
|
||||
return dt_local.isoformat(timespec='seconds')
|
||||
except Exception:
|
||||
# If conversion fails, return original
|
||||
return utc_iso_string
|
||||
@@ -1,565 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NFO Manager for creating and managing metadata files
|
||||
Handles NFO creation for movies, TV shows, seasons, and episodes
|
||||
"""
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import re
|
||||
|
||||
|
||||
class NFOManager:
|
||||
"""Manages NFO file creation and updates"""
|
||||
|
||||
def __init__(self, manager_brand: str = "NFOGuard"):
|
||||
self.manager_brand = manager_brand
|
||||
|
||||
def parse_imdb_from_path(self, path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from directory path or filename"""
|
||||
# Look for various IMDb patterns in both directory and file names
|
||||
path_str = str(path).lower()
|
||||
|
||||
# Try [imdb-ttXXXXXXX] format first (most explicit)
|
||||
match = re.search(r'\[imdb-?(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try standalone [ttXXXXXXX] format in brackets
|
||||
match = re.search(r'\[(tt\d+)\]', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try {imdb-ttXXXXXXX} format with curly braces
|
||||
match = re.search(r'\{imdb-?(tt\d+)\}', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try (imdb-ttXXXXXXX) format with parentheses
|
||||
match = re.search(r'\(imdb-?(tt\d+)\)', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# Try ttXXXXXXX at end of filename/dirname (common pattern)
|
||||
match = re.search(r'[-_\s](tt\d+)$', path_str)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def parse_imdb_from_nfo(self, nfo_path: Path) -> Optional[str]:
|
||||
"""Extract IMDb ID from NFO file content"""
|
||||
if not nfo_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Check for <uniqueid type="imdb">ttXXXXXX</uniqueid>
|
||||
imdb_uniqueid = root.find('.//uniqueid[@type="imdb"]')
|
||||
if imdb_uniqueid is not None and imdb_uniqueid.text:
|
||||
imdb_id = imdb_uniqueid.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdbid>ttXXXXXX</imdbid>
|
||||
imdbid_elem = root.find('.//imdbid')
|
||||
if imdbid_elem is not None and imdbid_elem.text:
|
||||
imdb_id = imdbid_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
# Check for legacy <imdb>ttXXXXXX</imdb>
|
||||
imdb_elem = root.find('.//imdb')
|
||||
if imdb_elem is not None and imdb_elem.text:
|
||||
imdb_id = imdb_elem.text.strip()
|
||||
if imdb_id.startswith('tt'):
|
||||
return imdb_id
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def find_movie_imdb_id(self, movie_dir: Path) -> Optional[str]:
|
||||
"""Find IMDb ID from directory name, filenames, or NFO file"""
|
||||
# First try directory name
|
||||
imdb_id = self.parse_imdb_from_path(movie_dir)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Try all files in the directory for IMDb ID patterns
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
imdb_id = self.parse_imdb_from_path(file_path)
|
||||
if imdb_id:
|
||||
return imdb_id
|
||||
|
||||
# Finally, try NFO file content
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
imdb_id = self.parse_imdb_from_nfo(nfo_path)
|
||||
if imdb_id:
|
||||
print(f"🔍 Found IMDb ID in NFO file: {imdb_id} from {nfo_path}")
|
||||
return imdb_id
|
||||
|
||||
return None
|
||||
|
||||
def _parse_nfo_with_tolerance(self, nfo_path: Path):
|
||||
"""Parse NFO file with tolerance for URLs appended after XML"""
|
||||
try:
|
||||
# First try normal parsing
|
||||
tree = ET.parse(nfo_path)
|
||||
return tree.getroot()
|
||||
except ET.ParseError as e:
|
||||
# If parsing fails, try to extract just the XML part
|
||||
try:
|
||||
with open(nfo_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find the last </movie> tag and truncate after it
|
||||
last_movie_end = content.rfind('</movie>')
|
||||
if last_movie_end != -1:
|
||||
xml_content = content[:last_movie_end + 8] # +8 for </movie>
|
||||
|
||||
# Try parsing the truncated content
|
||||
root = ET.fromstring(xml_content)
|
||||
print(f"✅ Successfully parsed NFO after removing trailing content: {nfo_path.name}")
|
||||
return root
|
||||
else:
|
||||
# Re-raise original error if we can't find </movie>
|
||||
raise e
|
||||
except Exception:
|
||||
# Re-raise original error if our fix attempt fails
|
||||
raise e
|
||||
|
||||
def create_movie_nfo(self, movie_dir: Path, imdb_id: str, dateadded: str,
|
||||
released: Optional[str] = None, source: str = "unknown",
|
||||
lock_metadata: bool = True) -> None:
|
||||
"""Create or update movie.nfo file preserving existing content"""
|
||||
nfo_path = movie_dir / "movie.nfo"
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
# Try to parse the XML, handling URLs appended after </movie>
|
||||
movie = self._parse_nfo_with_tolerance(nfo_path)
|
||||
|
||||
# Ensure root element is <movie>
|
||||
if movie.tag != "movie":
|
||||
raise ValueError("Root element is not <movie>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the very bottom
|
||||
nfoguard_fields = ["dateadded", "lockdata", "premiered", "year"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = movie.find(tag)
|
||||
if existing is not None:
|
||||
# Store the value before removing (for premiered/year)
|
||||
if tag == "premiered" and not released:
|
||||
released = existing.text # Preserve existing premiered date
|
||||
movie.remove(existing)
|
||||
|
||||
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||
# We'll add a clean one at the bottom
|
||||
for uniqueid in movie.findall("uniqueid[@type='imdb']"):
|
||||
movie.remove(uniqueid)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean NFO file to replace corrupted one")
|
||||
movie = ET.Element("movie")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
movie = ET.Element("movie")
|
||||
|
||||
# Now append ALL NFOGuard and date fields at the VERY END of the file
|
||||
# This ensures they appear after all existing content including actors
|
||||
|
||||
# Add IMDb uniqueid at the end (after all existing content)
|
||||
uniqueid = ET.SubElement(movie, "uniqueid", type="imdb", default="true")
|
||||
uniqueid.text = imdb_id
|
||||
|
||||
# Add premiered date at the bottom if we have it
|
||||
if released:
|
||||
premiered_elem = ET.SubElement(movie, "premiered")
|
||||
premiered_elem.text = released[:10] if len(released) >= 10 else released
|
||||
|
||||
# Extract year from premiered date for consistency
|
||||
try:
|
||||
year_value = released[:4] if len(released) >= 4 else None
|
||||
if year_value and year_value.isdigit():
|
||||
year_elem = ET.SubElement(movie, "year")
|
||||
year_elem.text = year_value
|
||||
except:
|
||||
pass # Skip year if we can't extract it
|
||||
|
||||
# Add dateadded at the end
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(movie, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(movie, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(movie)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
import xml.etree.ElementTree as ET_temp
|
||||
xml_str = ET.tostring(movie, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated movie NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}, Date Added: {dateadded}, Source: {source}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating movie NFO {nfo_path}: {e}")
|
||||
|
||||
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str, tvdb_id: Optional[str] = None) -> None:
|
||||
"""Create or update tvshow.nfo file preserving existing content"""
|
||||
nfo_path = series_dir / "tvshow.nfo"
|
||||
|
||||
try:
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
tvshow = tree.getroot()
|
||||
|
||||
# Ensure root element is <tvshow>
|
||||
if tvshow.tag != "tvshow":
|
||||
raise ValueError("Root element is not <tvshow>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the bottom
|
||||
for tag in ["lockdata"]:
|
||||
existing = tvshow.find(tag)
|
||||
if existing is not None:
|
||||
tvshow.remove(existing)
|
||||
|
||||
# Remove ALL existing uniqueid with type="imdb" regardless of attributes
|
||||
for uniqueid in tvshow.findall("uniqueid[@type='imdb']"):
|
||||
tvshow.remove(uniqueid)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted TV show NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean tvshow.nfo file to replace corrupted one")
|
||||
tvshow = ET.Element("tvshow")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
tvshow = ET.Element("tvshow")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Add IMDb uniqueid at the end
|
||||
imdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="imdb", default="true")
|
||||
imdb_uniqueid.text = imdb_id
|
||||
|
||||
# Add TVDB ID if available (preserve existing or add new)
|
||||
if tvdb_id and not tvshow.find("uniqueid[@type='tvdb']"):
|
||||
tvdb_uniqueid = ET.SubElement(tvshow, "uniqueid", type="tvdb")
|
||||
tvdb_uniqueid.text = tvdb_id
|
||||
|
||||
# Add lockdata at the very end
|
||||
lockdata = ET.SubElement(tvshow, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(tvshow)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(tvshow, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated TV show NFO: {nfo_path}")
|
||||
print(f" IMDb ID: {imdb_id}" + (f", TVDB ID: {tvdb_id}" if tvdb_id else ""))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating tvshow NFO {nfo_path}: {e}")
|
||||
|
||||
def create_season_nfo(self, season_dir: Path, season_number: int) -> None:
|
||||
"""Create or update season.nfo file preserving existing content"""
|
||||
nfo_path = season_dir / "season.nfo"
|
||||
|
||||
try:
|
||||
season_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Try to load existing NFO file
|
||||
if nfo_path.exists():
|
||||
try:
|
||||
tree = ET.parse(nfo_path)
|
||||
season = tree.getroot()
|
||||
|
||||
# Ensure root element is <season>
|
||||
if season.tag != "season":
|
||||
raise ValueError("Root element is not <season>")
|
||||
|
||||
# Remove existing NFOGuard-managed elements
|
||||
for tag in ["seasonnumber", "lockdata"]:
|
||||
existing = season.find(tag)
|
||||
if existing is not None:
|
||||
season.remove(existing)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted season NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean season.nfo file to replace corrupted one")
|
||||
season = ET.Element("season")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
season = ET.Element("season")
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
seasonnumber = ET.SubElement(season, "seasonnumber")
|
||||
seasonnumber.text = str(season_number)
|
||||
|
||||
# Add lockdata at the end
|
||||
lockdata = ET.SubElement(season, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(season)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(season, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated season NFO: {nfo_path}")
|
||||
print(f" Season: {season_number}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating season NFO {nfo_path}: {e}")
|
||||
|
||||
def find_existing_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int) -> Optional[Path]:
|
||||
"""Find existing episode NFO file that matches season/episode but isn't standardized name"""
|
||||
if not season_dir.exists():
|
||||
return None
|
||||
|
||||
# Standard filename pattern we're looking to create
|
||||
standard_pattern = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
|
||||
# Look for NFO files in the season directory
|
||||
for nfo_file in season_dir.glob("*.nfo"):
|
||||
# Skip if it's already the standard format
|
||||
if nfo_file.name == standard_pattern:
|
||||
continue
|
||||
|
||||
# Check if this NFO contains the right season/episode
|
||||
try:
|
||||
tree = ET.parse(nfo_file)
|
||||
root = tree.getroot()
|
||||
|
||||
if root.tag == "episodedetails":
|
||||
# Check for season/episode elements
|
||||
season_elem = root.find("season")
|
||||
episode_elem = root.find("episode")
|
||||
|
||||
if (season_elem is not None and episode_elem is not None and
|
||||
season_elem.text and episode_elem.text):
|
||||
try:
|
||||
file_season = int(season_elem.text)
|
||||
file_episode = int(episode_elem.text)
|
||||
|
||||
if file_season == season_num and file_episode == episode_num:
|
||||
print(f"🔍 Found existing episode NFO: {nfo_file.name} -> will migrate to {standard_pattern}")
|
||||
return nfo_file
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
except (ET.ParseError, Exception):
|
||||
# Skip corrupted or non-XML files
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def create_episode_nfo(self, season_dir: Path, season_num: int, episode_num: int,
|
||||
aired: Optional[str], dateadded: Optional[str], source: str,
|
||||
lock_metadata: bool = True, enhanced_metadata: Optional[Dict[str, Any]] = None) -> None:
|
||||
"""Create or update episode NFO file preserving existing content"""
|
||||
# Generate episode filename pattern
|
||||
episode_filename = f"S{season_num:02d}E{episode_num:02d}.nfo"
|
||||
nfo_path = season_dir / episode_filename
|
||||
|
||||
# Track if we need to delete an old long-named NFO file
|
||||
old_nfo_to_delete = None
|
||||
|
||||
try:
|
||||
# First, check for existing long-named NFO files that need migration
|
||||
existing_long_nfo = self.find_existing_episode_nfo(season_dir, season_num, episode_num)
|
||||
|
||||
# Try to load existing NFO file (either standard or long-named)
|
||||
source_nfo_path = nfo_path if nfo_path.exists() else existing_long_nfo
|
||||
|
||||
if source_nfo_path:
|
||||
try:
|
||||
tree = ET.parse(source_nfo_path)
|
||||
episode = tree.getroot()
|
||||
|
||||
# Ensure root element is <episodedetails>
|
||||
if episode.tag != "episodedetails":
|
||||
raise ValueError("Root element is not <episodedetails>")
|
||||
|
||||
# If we're migrating from a long-named file, mark it for deletion
|
||||
if existing_long_nfo and source_nfo_path == existing_long_nfo:
|
||||
old_nfo_to_delete = existing_long_nfo
|
||||
print(f"📦 Migrating episode NFO: {existing_long_nfo.name} -> {episode_filename}")
|
||||
|
||||
# Remove existing NFOGuard-managed elements to avoid duplicates
|
||||
# These will be re-added at the bottom
|
||||
nfoguard_fields = ["aired", "dateadded", "lockdata", "season", "episode"]
|
||||
for tag in nfoguard_fields:
|
||||
existing = episode.find(tag)
|
||||
if existing is not None:
|
||||
# Store the aired value before removing
|
||||
if tag == "aired" and not aired:
|
||||
aired = existing.text # Preserve existing aired date
|
||||
episode.remove(existing)
|
||||
|
||||
except (ET.ParseError, ValueError) as e:
|
||||
print(f"⚠️ Corrupted episode NFO detected: {nfo_path} - {str(e)[:100]}...")
|
||||
print(f" Creating new clean episode NFO file to replace corrupted one")
|
||||
episode = ET.Element("episodedetails")
|
||||
else:
|
||||
# Create new NFO structure
|
||||
episode = ET.Element("episodedetails")
|
||||
|
||||
# Enhanced metadata should be preserved - only add if not already present
|
||||
if enhanced_metadata:
|
||||
if enhanced_metadata.get("title") and not episode.find("title"):
|
||||
title_elem = ET.SubElement(episode, "title")
|
||||
title_elem.text = enhanced_metadata["title"]
|
||||
|
||||
if enhanced_metadata.get("overview") and not episode.find("plot"):
|
||||
plot_elem = ET.SubElement(episode, "plot")
|
||||
plot_elem.text = enhanced_metadata["overview"]
|
||||
|
||||
if enhanced_metadata.get("runtime") and not episode.find("runtime"):
|
||||
runtime_elem = ET.SubElement(episode, "runtime")
|
||||
runtime_elem.text = str(enhanced_metadata["runtime"])
|
||||
|
||||
# Add NFOGuard fields at the bottom
|
||||
|
||||
# Basic episode info at the end
|
||||
season_elem = ET.SubElement(episode, "season")
|
||||
season_elem.text = str(season_num)
|
||||
|
||||
episode_elem = ET.SubElement(episode, "episode")
|
||||
episode_elem.text = str(episode_num)
|
||||
|
||||
# Dates at the end
|
||||
if aired:
|
||||
aired_elem = ET.SubElement(episode, "aired")
|
||||
aired_elem.text = aired[:10] if len(aired) >= 10 else aired
|
||||
|
||||
if dateadded:
|
||||
dateadded_elem = ET.SubElement(episode, "dateadded")
|
||||
dateadded_elem.text = dateadded
|
||||
|
||||
# Add lockdata at the very end
|
||||
if lock_metadata:
|
||||
lockdata = ET.SubElement(episode, "lockdata")
|
||||
lockdata.text = "true"
|
||||
|
||||
# Add NFOGuard comment at the beginning
|
||||
comment_text = f" Created by {self.manager_brand} - Source: {source} "
|
||||
|
||||
# Write file with proper formatting
|
||||
tree = ET.ElementTree(episode)
|
||||
ET.indent(tree, space=" ", level=0)
|
||||
|
||||
# Write to string first to add comment
|
||||
xml_str = ET.tostring(episode, encoding='unicode')
|
||||
|
||||
# Add XML declaration and comment
|
||||
full_xml = f'<?xml version="1.0" encoding="utf-8"?>\n<!--{comment_text}-->\n{xml_str}'
|
||||
|
||||
# Write to file
|
||||
with open(nfo_path, 'w', encoding='utf-8') as f:
|
||||
f.write(full_xml)
|
||||
|
||||
print(f"✅ Successfully created/updated episode NFO: {nfo_path}")
|
||||
print(f" S{season_num:02d}E{episode_num:02d}, Aired: {aired}, Date Added: {dateadded}")
|
||||
|
||||
# Clean up old long-named NFO file if we migrated from it
|
||||
if old_nfo_to_delete and old_nfo_to_delete.exists():
|
||||
try:
|
||||
old_nfo_to_delete.unlink()
|
||||
print(f"🗑️ Cleaned up old NFO file: {old_nfo_to_delete.name}")
|
||||
except Exception as cleanup_error:
|
||||
print(f"⚠️ Warning: Could not delete old NFO file {old_nfo_to_delete.name}: {cleanup_error}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating/updating episode NFO {nfo_path}: {e}")
|
||||
|
||||
def set_file_mtime(self, file_path: Path, iso_timestamp: str) -> None:
|
||||
"""Set file modification time to match import date"""
|
||||
try:
|
||||
# Parse ISO timestamp
|
||||
if iso_timestamp.endswith('Z'):
|
||||
dt = datetime.fromisoformat(iso_timestamp.replace('Z', '+00:00'))
|
||||
elif '+' in iso_timestamp or 'T' in iso_timestamp:
|
||||
dt = datetime.fromisoformat(iso_timestamp)
|
||||
else:
|
||||
# Assume it's already a simple date
|
||||
dt = datetime.fromisoformat(iso_timestamp + 'T00:00:00')
|
||||
|
||||
# Convert to timestamp
|
||||
timestamp = dt.timestamp()
|
||||
|
||||
# Set both access and modification times
|
||||
os.utime(file_path, (timestamp, timestamp))
|
||||
print(f"✅ Updated file timestamp: {file_path.name} -> {iso_timestamp}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error setting mtime for {file_path}: {e}")
|
||||
|
||||
def update_movie_files_mtime(self, movie_dir: Path, iso_timestamp: str) -> None:
|
||||
"""Update modification times for all video files in movie directory"""
|
||||
video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v")
|
||||
updated_files = []
|
||||
|
||||
for file_path in movie_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in video_exts:
|
||||
self.set_file_mtime(file_path, iso_timestamp)
|
||||
updated_files.append(file_path.name)
|
||||
|
||||
if updated_files:
|
||||
print(f"✅ Updated {len(updated_files)} video file timestamps in {movie_dir.name}")
|
||||
else:
|
||||
print(f"⚠️ No video files found to update in {movie_dir.name}")
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path mapping utilities for NFOGuard
|
||||
Handles conversion between external service paths and container paths
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
class PathMapper:
|
||||
"""Handles path mapping between different environments"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize path mapper with configuration"""
|
||||
# Use environment variables directly since config attribute names are unclear
|
||||
import os
|
||||
|
||||
radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
|
||||
sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
|
||||
movie_paths_str = os.getenv('MOVIE_PATHS', '')
|
||||
tv_paths_str = os.getenv('TV_PATHS', '')
|
||||
|
||||
self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
|
||||
self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
|
||||
self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
|
||||
self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
|
||||
|
||||
# Check if path debugging is enabled
|
||||
self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: PathMapper initialized with:")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
|
||||
"""Convert Sonarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Sonarr root folders (longest first)
|
||||
for original_index, sonarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
|
||||
if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding TV path
|
||||
if original_index < len(self.tv_paths):
|
||||
container_root = self.tv_paths[original_index]
|
||||
relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return sonarr_path
|
||||
|
||||
def radarr_path_to_container_path(self, radarr_path: str) -> str:
|
||||
"""Convert Radarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Radarr root folders (longest first)
|
||||
for original_index, radarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
|
||||
if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding movie path
|
||||
if original_index < len(self.movie_paths):
|
||||
container_root = self.movie_paths[original_index]
|
||||
relative_path = radarr_path[len(radarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return radarr_path
|
||||
|
||||
def container_path_to_host_path(self, container_path: str) -> str:
|
||||
"""Convert container path back to host path if needed"""
|
||||
# This might be needed for file operations
|
||||
return container_path
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug path mapping configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def check_path_mapping():
|
||||
"""Check the current path mapping configuration"""
|
||||
print("🔍 Path Mapping Configuration Debug")
|
||||
print("=" * 50)
|
||||
|
||||
# Check environment variables
|
||||
movie_paths = os.environ.get("MOVIE_PATHS", "NOT SET")
|
||||
radarr_paths = os.environ.get("RADARR_ROOT_FOLDERS", "NOT SET")
|
||||
|
||||
print(f"MOVIE_PATHS: {movie_paths}")
|
||||
print(f"RADARR_ROOT_FOLDERS: {radarr_paths}")
|
||||
print()
|
||||
|
||||
# Parse paths
|
||||
if movie_paths != "NOT SET":
|
||||
container_paths = [p.strip() for p in movie_paths.split(",") if p.strip()]
|
||||
print("Container paths:")
|
||||
for i, path in enumerate(container_paths):
|
||||
exists = Path(path).exists()
|
||||
print(f" {i+1}. {path} {'✅' if exists else '❌ (does not exist)'}")
|
||||
|
||||
if radarr_paths != "NOT SET":
|
||||
radarr_root_paths = [p.strip() for p in radarr_paths.split(",") if p.strip()]
|
||||
print("\nRadarr root paths:")
|
||||
for i, path in enumerate(radarr_root_paths):
|
||||
print(f" {i+1}. {path}")
|
||||
|
||||
print("\n🔍 Expected Path Mappings:")
|
||||
if movie_paths != "NOT SET" and radarr_paths != "NOT SET":
|
||||
container_list = [p.strip() for p in movie_paths.split(",") if p.strip()]
|
||||
radarr_list = [p.strip() for p in radarr_paths.split(",") if p.strip()]
|
||||
|
||||
for i, container_path in enumerate(container_list):
|
||||
if i < len(radarr_list):
|
||||
radarr_path = radarr_list[i]
|
||||
print(f" {radarr_path} → {container_path}")
|
||||
else:
|
||||
print(f" ❌ No Radarr mapping for {container_path}")
|
||||
|
||||
print("\n🧪 Test Case:")
|
||||
test_radarr_path = "/mnt/unionfs/Media/Movies/movies6/Annabelle Comes Home (2019) [tt8350360]"
|
||||
print(f"Radarr path: {test_radarr_path}")
|
||||
|
||||
# Simulate mapping
|
||||
if radarr_paths != "NOT SET" and movie_paths != "NOT SET":
|
||||
container_list = [p.strip() for p in movie_paths.split(",") if p.strip()]
|
||||
radarr_list = [p.strip() for p in radarr_paths.split(",") if p.strip()]
|
||||
|
||||
for i, radarr_root in enumerate(radarr_list):
|
||||
if test_radarr_path.startswith(radarr_root):
|
||||
container_root = container_list[i] if i < len(container_list) else container_list[0]
|
||||
relative_path = test_radarr_path[len(radarr_root):].lstrip("/")
|
||||
mapped_path = f"{container_root.rstrip('/')}/{relative_path}" if relative_path else container_root.rstrip("/")
|
||||
print(f"Expected mapping: {mapped_path}")
|
||||
|
||||
# Check if mapped path exists
|
||||
if Path(mapped_path).exists():
|
||||
print("✅ Mapped path exists!")
|
||||
else:
|
||||
print("❌ Mapped path does not exist!")
|
||||
print(f" This could be why the wrong movie is being processed.")
|
||||
break
|
||||
else:
|
||||
print("❌ No mapping found for test path")
|
||||
|
||||
print("\n🛠️ Recommended Fix:")
|
||||
print("Make sure your MOVIE_PATHS and RADARR_ROOT_FOLDERS match exactly:")
|
||||
print("Example:")
|
||||
print(" MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6")
|
||||
print(" RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies,/mnt/unionfs/Media/Movies/movies6")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_path_mapping()
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for the Annabelle → Harry Potter webhook processing bug
|
||||
Tests the webhook isolation fixes in v1.3.1
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
def test_webhook_isolation():
|
||||
"""Test that movie webhooks process the correct movie"""
|
||||
print("🔍 Testing Webhook Isolation Fixes (v1.3.1)")
|
||||
print("=" * 60)
|
||||
|
||||
# Test case from the bug report
|
||||
test_cases = [
|
||||
{
|
||||
"imdb_id": "tt3322940",
|
||||
"title": "Annabelle (2014)",
|
||||
"path": "/mnt/unionfs/Media/Movies/movies6/Annabelle (2014) [tt3322940]",
|
||||
"expected_container_path": "/media/Movies/movies6/Annabelle (2014) [tt3322940]"
|
||||
},
|
||||
{
|
||||
"imdb_id": "tt8350360",
|
||||
"title": "Annabelle Comes Home (2019)",
|
||||
"path": "/mnt/unionfs/Media/Movies/movies6/Annabelle Comes Home (2019) [tt8350360]",
|
||||
"expected_container_path": "/media/Movies/movies6/Annabelle Comes Home (2019) [tt8350360]"
|
||||
}
|
||||
]
|
||||
|
||||
for i, test_case in enumerate(test_cases, 1):
|
||||
print(f"\n{i}. Testing {test_case['title']} ({test_case['imdb_id']})")
|
||||
print("-" * 50)
|
||||
|
||||
# Create webhook payload
|
||||
webhook_payload = {
|
||||
"eventType": "Download",
|
||||
"movie": {
|
||||
"id": i,
|
||||
"title": test_case["title"],
|
||||
"imdbId": test_case["imdb_id"],
|
||||
"path": test_case["path"]
|
||||
},
|
||||
"movieFile": {
|
||||
"id": i,
|
||||
"path": f"{test_case['path']}/movie.mkv"
|
||||
}
|
||||
}
|
||||
|
||||
print(f"📤 Sending webhook...")
|
||||
print(f" Radarr Path: {test_case['path']}")
|
||||
print(f" Expected Container Path: {test_case['expected_container_path']}")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
"http://localhost:8080/webhook/radarr",
|
||||
json=webhook_payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f" ✅ Webhook accepted: {result.get('message', 'Success')}")
|
||||
|
||||
# Check if batch key uses new prefixed format
|
||||
if 'movie:' in result.get('message', ''):
|
||||
print(f" ✅ Using prefixed batch key (isolation working)")
|
||||
else:
|
||||
print(f" ⚠️ Batch key format unclear")
|
||||
|
||||
else:
|
||||
print(f" ❌ Webhook rejected: {response.status_code}")
|
||||
print(f" Error: {response.text}")
|
||||
|
||||
# This might be expected if path doesn't exist (validation working)
|
||||
if "does not exist" in response.text:
|
||||
print(f" ✅ Path validation working (rejects non-existent paths)")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error: {e}")
|
||||
|
||||
# Small delay between tests
|
||||
time.sleep(1)
|
||||
|
||||
print(f"\n📋 Next Steps:")
|
||||
print(f"1. Check NFOGuard logs to see which movies were actually processed")
|
||||
print(f"2. Verify logs show 'Batch validation passed' messages")
|
||||
print(f"3. Ensure no 'BATCH VALIDATION FAILED' errors")
|
||||
print(f"4. Confirm correct movies are processed (not Harry Potter)")
|
||||
|
||||
# Get batch status
|
||||
print(f"\n📊 Current Batch Status:")
|
||||
try:
|
||||
response = requests.get("http://localhost:8080/batch/status", timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f" Pending: {data.get('pending_count', 0)}")
|
||||
print(f" Processing: {data.get('processing_count', 0)}")
|
||||
if data.get('pending_items'):
|
||||
print(f" Keys: {data['pending_items']}")
|
||||
else:
|
||||
print(f" Could not get batch status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f" Batch status error: {e}")
|
||||
|
||||
def test_path_mapping():
|
||||
"""Test path mapping configuration"""
|
||||
print(f"\n🗺️ Testing Path Mapping")
|
||||
print("=" * 30)
|
||||
|
||||
test_paths = [
|
||||
"/mnt/unionfs/Media/Movies/movies6/Annabelle (2014) [tt3322940]",
|
||||
"/mnt/unionfs/Media/Movies/movies6/Annabelle Comes Home (2019) [tt8350360]",
|
||||
"/mnt/unionfs/Media/Movies/movies/Some Movie (2023) [tt1234567]"
|
||||
]
|
||||
|
||||
for path in test_paths:
|
||||
print(f"Testing: {path}")
|
||||
# This would need to be implemented in NFOGuard as a debug endpoint
|
||||
print(f" → Expected: /media/Movies/movies6/... (if movies6 mapping exists)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_webhook_isolation()
|
||||
test_path_mapping()
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script for webhook processing issue
|
||||
Helps identify why wrong movies are being processed
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
def check_batch_status():
|
||||
"""Check the current batch queue status"""
|
||||
try:
|
||||
response = requests.get("http://localhost:8080/batch/status")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"[{datetime.now().isoformat()}] Batch Status:")
|
||||
print(f" Pending batches: {data.get('pending_batches', [])}")
|
||||
print(f" Active timers: {data.get('active_timers', [])}")
|
||||
if 'batch_details' in data:
|
||||
print(" Batch details:")
|
||||
for key, details in data['batch_details'].items():
|
||||
print(f" {key}: {details['movie_title']} (IMDb: {details['imdb_id']})")
|
||||
print()
|
||||
else:
|
||||
print(f"Failed to get batch status: {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"Error checking batch status: {e}")
|
||||
|
||||
def simulate_radarr_webhook(imdb_id, title, movie_path):
|
||||
"""Simulate a Radarr webhook"""
|
||||
webhook_payload = {
|
||||
"eventType": "Download",
|
||||
"movie": {
|
||||
"id": 1,
|
||||
"title": title,
|
||||
"imdbId": imdb_id,
|
||||
"path": movie_path
|
||||
},
|
||||
"movieFile": {
|
||||
"id": 1,
|
||||
"path": f"{movie_path}/movie.mkv"
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"[{datetime.now().isoformat()}] Sending webhook for {title} ({imdb_id})")
|
||||
response = requests.post(
|
||||
"http://localhost:8080/webhook/radarr",
|
||||
json=webhook_payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f" ✅ Webhook accepted: {result}")
|
||||
else:
|
||||
print(f" ❌ Webhook failed: {response.status_code} - {response.text}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error sending webhook: {e}")
|
||||
|
||||
def main():
|
||||
print("🔍 NFOGuard Webhook Debugging Tool")
|
||||
print("=" * 50)
|
||||
|
||||
# Check initial batch status
|
||||
print("1. Initial batch queue status:")
|
||||
check_batch_status()
|
||||
|
||||
# Send test webhook for Annabelle Comes Home
|
||||
print("2. Sending test webhook for Annabelle Comes Home:")
|
||||
simulate_radarr_webhook(
|
||||
"tt8350360",
|
||||
"Annabelle Comes Home",
|
||||
"/mnt/unionfs/Media/Movies/movies6/Annabelle Comes Home (2019) [tt8350360]"
|
||||
)
|
||||
|
||||
# Check batch status immediately after
|
||||
print("3. Batch status immediately after webhook:")
|
||||
check_batch_status()
|
||||
|
||||
# Wait for processing
|
||||
print("4. Waiting 6 seconds for processing...")
|
||||
time.sleep(6)
|
||||
|
||||
# Check final batch status
|
||||
print("5. Final batch status:")
|
||||
check_batch_status()
|
||||
|
||||
print("\n🔍 Check the NFOGuard logs to see which movie was actually processed!")
|
||||
print("Expected: Annabelle Comes Home (2019) [tt8350360]")
|
||||
print("If you see a different movie, there's a batching bug.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,165 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nfoguard:
|
||||
# Use the official image from Docker Hub
|
||||
image: sbcrumb/nfoguard:latest
|
||||
|
||||
# Alternative: Use specific version
|
||||
# image: sbcrumb/nfoguard:v1.5.5
|
||||
|
||||
# Alternative: Use development version
|
||||
# image: sbcrumb/nfoguard:dev
|
||||
|
||||
container_name: nfoguard
|
||||
|
||||
# Restart policy
|
||||
restart: unless-stopped
|
||||
|
||||
# Environment files
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
|
||||
# Environment variables
|
||||
environment:
|
||||
- TZ=America/New_York # Set to your local timezone
|
||||
# Alternative examples:
|
||||
# - TZ=Europe/London
|
||||
# - TZ=America/Los_Angeles
|
||||
# - TZ=Australia/Sydney
|
||||
# - TZ=UTC
|
||||
|
||||
# Port mapping (adjust if needed)
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
# Volume mounts - CRITICAL: These must match your media setup
|
||||
volumes:
|
||||
# NFOGuard data directory (database, logs, cache)
|
||||
- ./data:/app/data
|
||||
|
||||
# Bind mount your Emby plugins directory for automatic plugin deployment
|
||||
- ${EMBY_PLUGINS_PATH}:/emby-plugins
|
||||
|
||||
# Movie libraries - Mount your movie directories here
|
||||
# Format: host_path:container_path:rw
|
||||
- /path/to/your/movies:/media/Movies/movies:rw
|
||||
- /path/to/your/movies2:/media/Movies/movies6:rw
|
||||
|
||||
# TV libraries - Mount your TV show directories here
|
||||
- /path/to/your/tv:/media/TV/tv:rw
|
||||
- /path/to/your/tv2:/media/TV/tv6:rw
|
||||
|
||||
# Optional: Mount download directories for detection
|
||||
# - /path/to/downloads:/downloads:ro
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Resource limits (optional)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
|
||||
# Network (optional - use if you have a custom network)
|
||||
# networks:
|
||||
# - media-network
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Optional: Custom network for media services
|
||||
# networks:
|
||||
# media-network:
|
||||
# external: true
|
||||
|
||||
# ===========================================
|
||||
# SETUP INSTRUCTIONS
|
||||
# ===========================================
|
||||
# 1. Copy this file to docker-compose.yml
|
||||
# 2. Update volume mounts to match your actual media paths
|
||||
# 3. Copy .env.template to .env and configure
|
||||
# 4. Copy .env.secrets.template to .env.secrets and add credentials
|
||||
# 5. Create data directory: mkdir -p ./data
|
||||
# 6. Run: docker-compose up -d
|
||||
# 7. Check logs: docker-compose logs -f nfoguard
|
||||
# 8. Access health check: curl http://localhost:8080/health
|
||||
|
||||
# ===========================================
|
||||
# VOLUME MAPPING EXAMPLES
|
||||
# ===========================================
|
||||
# Common setups:
|
||||
#
|
||||
# Unraid:
|
||||
# - /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||
# - /mnt/user/Media/TV:/media/TV/tv:rw
|
||||
#
|
||||
# Synology:
|
||||
# - /volume1/Media/Movies:/media/Movies/movies:rw
|
||||
# - /volume1/Media/TV:/media/TV/tv:rw
|
||||
#
|
||||
# Standard Linux:
|
||||
# - /home/user/media/movies:/media/Movies/movies:rw
|
||||
# - /home/user/media/tv:/media/TV/tv:rw
|
||||
#
|
||||
# Windows (Docker Desktop):
|
||||
# - C:\Media\Movies:/media/Movies/movies:rw
|
||||
# - C:\Media\TV:/media/TV/tv:rw
|
||||
|
||||
# ===========================================
|
||||
# WEBHOOK CONFIGURATION
|
||||
# ===========================================
|
||||
# Configure these webhook URLs in your *arr applications:
|
||||
#
|
||||
# Radarr webhook URL:
|
||||
# http://nfoguard:8080/webhook/radarr
|
||||
# (or http://localhost:8080/webhook/radarr if not using Docker network)
|
||||
#
|
||||
# Sonarr webhook URL:
|
||||
# http://nfoguard:8080/webhook/sonarr
|
||||
# (or http://localhost:8080/webhook/sonarr if not using Docker network)
|
||||
#
|
||||
# Webhook triggers:
|
||||
# - On Import
|
||||
# - On Upgrade
|
||||
# - On Rename (recommended)
|
||||
|
||||
# ===========================================
|
||||
# TROUBLESHOOTING
|
||||
# ===========================================
|
||||
# Common issues and solutions:
|
||||
#
|
||||
# 1. Permission denied errors:
|
||||
# - Ensure NFOGuard container can write to mounted directories
|
||||
# - Check file ownership and permissions
|
||||
# - Consider using PUID/PGID environment variables
|
||||
#
|
||||
# 2. Path mapping issues:
|
||||
# - Verify container paths match .env configuration
|
||||
# - Ensure *arr applications can see the same files
|
||||
# - Check RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS in .env
|
||||
#
|
||||
# 3. Database connection issues:
|
||||
# - Verify PostgreSQL credentials in .env.secrets
|
||||
# - Check network connectivity to database
|
||||
# - Ensure database exists and user has permissions
|
||||
#
|
||||
# 4. Webhook not triggering:
|
||||
# - Verify webhook URLs in *arr applications
|
||||
# - Check NFOGuard logs for incoming requests
|
||||
# - Ensure port 8080 is accessible
|
||||
@@ -1,40 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nfoguard:
|
||||
image: ghcr.io/jskala/nfoguard:latest
|
||||
container_name: nfoguard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config
|
||||
- ${MEDIA_PATH}:/media:ro
|
||||
# Bind mount your Emby plugins directory for automatic plugin deployment
|
||||
- ${EMBY_PLUGINS_PATH}:/emby-plugins
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Optional: PostgreSQL database
|
||||
# postgres:
|
||||
# image: postgres:15
|
||||
# container_name: nfoguard-db
|
||||
# restart: unless-stopped
|
||||
# environment:
|
||||
# POSTGRES_DB: nfoguard
|
||||
# POSTGRES_USER: ${DB_USER:-nfoguard}
|
||||
# POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
# volumes:
|
||||
# - postgres_data:/var/lib/postgresql/data
|
||||
|
||||
# volumes:
|
||||
# postgres_data:
|
||||
@@ -1,89 +0,0 @@
|
||||
# Docker Hub Integration Setup
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Add these secrets to your Gitea repository settings:
|
||||
|
||||
### 1. DOCKER_HUB_USERNAME
|
||||
- Your Docker Hub username (e.g., "jskala")
|
||||
|
||||
### 2. DOCKER_HUB_TOKEN
|
||||
- Your Docker Hub access token (NOT your password)
|
||||
|
||||
## Creating a Docker Hub Access Token
|
||||
|
||||
1. Go to [Docker Hub](https://hub.docker.com/)
|
||||
2. Sign in to your account
|
||||
3. Click your avatar → **Account Settings**
|
||||
4. Go to **Security** tab
|
||||
5. Click **New Access Token**
|
||||
6. Name it: `NFOGuard-CI`
|
||||
7. Permissions: **Read, Write, Delete**
|
||||
8. Click **Generate**
|
||||
9. **Copy the token immediately** (you won't see it again)
|
||||
|
||||
## Adding Secrets to Gitea
|
||||
|
||||
1. Go to your NFOguard repository in Gitea
|
||||
2. Click **Settings** → **Secrets**
|
||||
3. Add the following secrets:
|
||||
- **Name**: `DOCKER_HUB_USERNAME`
|
||||
- **Value**: Your Docker Hub username
|
||||
|
||||
- **Name**: `DOCKER_HUB_TOKEN`
|
||||
- **Value**: The access token you generated
|
||||
|
||||
## What Gets Published
|
||||
|
||||
### Main Branch (Production)
|
||||
- `sbcrumb/nfoguard:latest`
|
||||
- `sbcrumb/nfoguard:v1.5.2` (version from VERSION file)
|
||||
- `sbcrumb/nfoguard:sha-abc123` (git commit SHA)
|
||||
|
||||
### Dev Branch
|
||||
- `sbcrumb/nfoguard:dev`
|
||||
- `sbcrumb/nfoguard:dev-sha-abc123`
|
||||
|
||||
## Usage Examples
|
||||
|
||||
Once set up, users can pull from either registry:
|
||||
|
||||
```bash
|
||||
# From your local Gitea registry (fast for you)
|
||||
docker pull 192.168.253.221:3000/jskala/nfoguard:latest
|
||||
|
||||
# From Docker Hub (public access)
|
||||
docker pull sbcrumb/nfoguard:latest
|
||||
|
||||
# Dev versions
|
||||
docker pull sbcrumb/nfoguard:dev
|
||||
```
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
After adding the secrets, push a commit to trigger the CI:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add Docker Hub CI integration"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Watch the Actions tab in Gitea to see both registries being pushed to successfully!
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Local**: Ultra-fast local builds and deployment
|
||||
- **Public**: Anyone can use `docker pull yourusername/nfoguard:latest`
|
||||
- **Redundancy**: Multiple image sources
|
||||
- **Versioning**: Proper semantic versioning across both registries
|
||||
|
||||
## Quick Usage
|
||||
|
||||
```bash
|
||||
# Pull the latest stable release
|
||||
docker pull sbcrumb/nfoguard:latest
|
||||
|
||||
# Pull development version
|
||||
docker pull sbcrumb/nfoguard:dev
|
||||
```
|
||||
-2218
File diff suppressed because it is too large
Load Diff
@@ -1,321 +0,0 @@
|
||||
# NFOGuard Project Summary
|
||||
|
||||
## Project Overview
|
||||
NFOGuard is a comprehensive solution to protect and preserve date metadata for TV shows and movies across Plex, Emby, and Jellyfin media servers. The core issue is that these servers often display scan dates instead of the original import dates from NFO files.
|
||||
|
||||
## Core Problem
|
||||
- **TV episodes show scan dates instead of import dates in Emby**
|
||||
- **Root Cause**: Emby displays `DateCreated` field, not NFO `<dateadded>`
|
||||
- **Impact**: Users lose track of when content was actually imported/acquired
|
||||
|
||||
## Project Components
|
||||
|
||||
### 1. NFOGuard Core (Python) - v0.6.1 ✅
|
||||
**Status**: Completed and working
|
||||
- Processes TV shows and movies
|
||||
- Writes import date from NFO `<dateadded>` to `<aired>` field
|
||||
- `<aired>` maps to Emby's `PremiereDate` field
|
||||
- **Current Fix**: Writing import date to `<aired>` field (goes to PremiereDate)
|
||||
|
||||
### 2. NFOGuard Emby Plugin (C# DLL) - v2.0.0-automatic ✅
|
||||
**Status**: Production-ready with automatic subscription system
|
||||
- **Purpose**: Automatically sync `PremiereDate` → `DateCreated` in Emby
|
||||
- **Approach**: Real-time processing + scheduled tasks with licensing tiers
|
||||
- **Architecture**: Professional plugin with automatic subscription validation
|
||||
|
||||
## What We've Tried
|
||||
|
||||
### ❌ Failed Approaches
|
||||
1. **Direct NFO `<dateadded>` reading** - Emby ignores this field
|
||||
2. **Waiting for auto-sync** - PremiereDate doesn't auto-sync to DateCreated
|
||||
3. **Manual database updates** - Too complex, risky
|
||||
|
||||
### ✅ Working Solutions
|
||||
1. **NFOGuard Core**: Successfully writes to `<aired>` field
|
||||
2. **Plugin approach**: Direct database updates via Emby API (`UpdateToRepository()`)
|
||||
|
||||
## Current Implementation Status
|
||||
|
||||
### NFOGuard Core Features ✅
|
||||
- TV show processing with configurable webhook modes
|
||||
- Movie processing
|
||||
- NFO date field manipulation
|
||||
- Comprehensive logging and error handling
|
||||
- CI/CD pipeline with automated testing
|
||||
|
||||
### Emby Plugin Features ✅ - PRODUCTION READY
|
||||
- **Real-time processing**: Library event hooks (`ItemAdded`/`ItemUpdated`)
|
||||
- **Scheduled task mode**: Weekly batch processing with free tier limits
|
||||
- **Percentage-based free tier**: 2% of TV episodes + 5% of movies per month
|
||||
- **Automatic subscription system**: Patreon/PayPal integration (no manual keys)
|
||||
- **Machine-based activation**: Unique installation IDs prevent piracy
|
||||
- **Usage tracking**: Monthly quotas with automatic reset
|
||||
- **TV + Movie support**: Full media library coverage
|
||||
- **Core sync logic**: `PremiereDate` → `DateCreated` synchronization
|
||||
- **Professional licensing**: TimeLord-style automatic activation
|
||||
- **Offline mode**: Graceful fallback to free tier
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Key Discovery from TimeLord Analysis
|
||||
```csharp
|
||||
// Core sync operation (standard Emby API)
|
||||
episode.DateCreated = episode.PremiereDate.Value;
|
||||
episode.UpdateToRepository(ItemUpdateType.MetadataEdit);
|
||||
```
|
||||
|
||||
### Our Implementation
|
||||
- Uses standard Emby interfaces: `ILibraryManager`, `IItemRepository`
|
||||
- Event-driven processing vs TimeLord's scheduled tasks
|
||||
- Clean dependency injection and configuration management
|
||||
|
||||
## Development Environment
|
||||
- **Platform**: Windows with WSL (Ubuntu)
|
||||
- **Build Tools**: .NET SDK 6.0 in WSL
|
||||
- **Target Framework**: .NET Framework 4.8
|
||||
- **IDE**: VS Code in WSL
|
||||
- **Emby Server**: Windows installation (can access DLLs directly)
|
||||
|
||||
## Next Steps - Priority Order
|
||||
|
||||
### Immediate (This Session) - ✅ COMPLETED
|
||||
1. **✅ Create project summary** (this file)
|
||||
2. **✅ Get Emby SDK DLLs** - From Windows Emby installation
|
||||
3. **✅ Build plugin** - First compilation test SUCCESS!
|
||||
4. **✅ Disable licensing** - For testing phase
|
||||
5. **✅ Add settings UI** - Configuration page with options:
|
||||
- ✅ Enable/disable real-time processing
|
||||
- ✅ Enable/disable scheduled task mode
|
||||
- ✅ Verbose logging toggle
|
||||
- ✅ Processing mode selection (realtime/scheduled/both)
|
||||
6. **✅ Add scheduled task** - Weekly batch processing option
|
||||
7. **✅ Build final plugin** - All features integrated and working
|
||||
|
||||
### Testing Phase - 🎉 MASSIVE SUCCESS!
|
||||
1. **✅ Deploy to test Emby server** - PLUGIN WORKING PERFECTLY:
|
||||
- **✅ PLUGIN LOADS**: Successfully loads and registers with Emby
|
||||
- **🚀 SCHEDULED TASK SUCCESS**: Processed 15,039 episodes, updated 14,661 (97.5% success!)
|
||||
- **⚡ BLAZING FAST**: 1,500 episodes per second processing speed
|
||||
- **🆕 MOVIES SUPPORT**: Ready for movies (0 movies in test library)
|
||||
- **✅ REAL-TIME + SCHEDULED**: Both modes working simultaneously
|
||||
- **STATUS**: Core functionality 100% successful - ready for production!
|
||||
- **Source**: `plugin-release/NFOGuard.Emby.Plugin.dll` (auto-copied after build)
|
||||
- **Destination**: `C:\ProgramData\Emby-Server\plugins\NFOGuard.Emby.Plugin.dll`
|
||||
- **Restart** Emby Server
|
||||
2. **Configure plugin** - Dashboard → Plugins → NFOGuard → Settings
|
||||
3. **Test with new TV episode import**
|
||||
4. **Verify database sync**: `DateCreated` = `PremiereDate`
|
||||
5. **Monitor logs for proper operation**
|
||||
|
||||
### Plugin Version History
|
||||
- **v1.0.0-working**: Basic functionality, unlimited processing (TESTED ✅)
|
||||
- **v1.1.0-free-tier**: Fixed 500 items/month, no real-time
|
||||
- **v1.2.0-production**: Unlimited with manual license keys
|
||||
- **v1.3.0-unified**: Single DLL with license key activation
|
||||
- **v2.0.0-automatic**: Professional subscription system (CURRENT 🚀)
|
||||
|
||||
### Version Recommendations
|
||||
- **Development/Testing**: Use v1.0.0-working (unlimited, no licensing)
|
||||
- **Production/Commercial**: Use v2.0.0-automatic (percentage-based free tier + subscriptions)
|
||||
|
||||
### API Requirements for v2.0.0-automatic
|
||||
```
|
||||
Domain: nfoguard.com
|
||||
POST /api/register # Machine registration
|
||||
GET /api/patreon/check?machine_id=XXX # Patreon validation
|
||||
GET /api/paypal/check?machine_id=XXX # PayPal validation
|
||||
```
|
||||
|
||||
## File Structure
|
||||
```
|
||||
NFOguard/
|
||||
├── projectsummary.md (this file)
|
||||
├── README.md
|
||||
├── VERSION
|
||||
├── plugin-release/ ⭐ NEW - Auto-generated
|
||||
│ └── NFOGuard.Emby.Plugin.dll (ready for deployment)
|
||||
├── NFOGuard.Emby.Plugin/
|
||||
│ ├── Plugin.cs (main plugin class)
|
||||
│ ├── PluginEntryPoint.cs (initialization)
|
||||
│ ├── LibraryEventHandler.cs (core logic)
|
||||
│ ├── NFOGuardScheduledTask.cs (batch processing)
|
||||
│ ├── PluginConfigurationPage.cs (settings UI)
|
||||
│ ├── Configuration/configPage.html (embedded UI)
|
||||
│ ├── LicenseManager.cs (licensing framework)
|
||||
│ ├── NFOGuard.Emby.Plugin.csproj
|
||||
│ ├── build.sh / build.bat (auto-copies to plugin-release/)
|
||||
│ ├── WSL_SETUP.md
|
||||
│ └── DEPLOYMENT.md
|
||||
├── EmbySDK/
|
||||
│ ├── MediaBrowser.Common.dll
|
||||
│ ├── MediaBrowser.Controller.dll
|
||||
│ └── MediaBrowser.Model.dll
|
||||
└── decompiled-TimeLord/ (reference only)
|
||||
```
|
||||
|
||||
## Current Branch Status
|
||||
- **Branch**: `dev`
|
||||
- **Main branch**: `main` (for PRs)
|
||||
- **Modified files**: `README.md`, `VERSION`
|
||||
- **New files**: Complete Emby plugin structure
|
||||
|
||||
## Success Criteria
|
||||
1. **Core functionality**: TV episodes show import dates, not scan dates
|
||||
2. **User experience**: Seamless, automatic operation
|
||||
3. **Reliability**: Handles errors gracefully, doesn't break Emby
|
||||
4. **Performance**: Minimal impact on library operations
|
||||
5. **Maintainability**: Clean, well-documented code
|
||||
|
||||
## Known Limitations & Future Considerations
|
||||
- **Movies**: May need separate handling if date sync issues occur
|
||||
- **Bulk processing**: Currently real-time only, may need batch mode
|
||||
- **Multi-server**: Plugin needs to be installed per Emby instance
|
||||
- **Backup compatibility**: Ensure database changes don't break backups
|
||||
|
||||
## Key Learnings
|
||||
- **Emby architecture**: Library events are reliable for real-time processing
|
||||
- **Database operations**: `UpdateToRepository()` is the proper way to persist changes
|
||||
- **Plugin standards**: Modern SDK-style projects work better in WSL
|
||||
- **TimeLord insights**: Custom licensing systems are common but not required
|
||||
|
||||
## Latest Session Updates (2025-01-12 Part 2) - Repository Architecture & TVDB Integration
|
||||
|
||||
### Major Accomplishments - Repository Split ✅
|
||||
1. **✅ Repository Separation**: Split into focused repositories:
|
||||
- **NFOGuard (main)**: Clean webhook Docker application
|
||||
- **NFOGuard-Emby-Plugin**: Separate plugin development repository
|
||||
- **Rationale**: Different audiences, release cycles, and deployment methods
|
||||
|
||||
2. **✅ Repository Cleanup**: NFOGuard webhook repo cleaned for public use:
|
||||
- **Removed**: CI/development artifacts (gitea configs, test files)
|
||||
- **Kept**: 21 essential files (core app, docs, utilities)
|
||||
- **Docker**: Industry standard `docker-compose.example.yml`
|
||||
- **Configuration**: Updated `.env.secrets.template` with TVDB_API_KEY
|
||||
|
||||
### Critical Technical Improvement - TVDB Integration ✅
|
||||
3. **✅ Root Cause Fix**: Solved Emby NFO reading issues:
|
||||
- **Problem**: Emby showing "PremiereDate: NULL" despite valid NFO files
|
||||
- **Root Cause**: tvshow.nfo files only had IMDB IDs, but Emby's TVDB plugin expects numeric TVDB series IDs
|
||||
- **Solution**: Added TVDB v4 API integration for IMDB→TVDB ID conversion
|
||||
|
||||
4. **✅ TVDB Client Implementation**:
|
||||
```python
|
||||
# New TVDBClient class in external_clients.py
|
||||
def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
||||
```
|
||||
|
||||
5. **✅ Enhanced NFO Structure**: Updated tvshow.nfo creation:
|
||||
```xml
|
||||
<!-- Before (IMDB only - caused Emby failures) -->
|
||||
<uniqueid type="imdb" default="true">tt1234567</uniqueid>
|
||||
|
||||
<!-- After (TVDB + IMDB - Emby compatible) -->
|
||||
<uniqueid type="tvdb" default="true">12345</uniqueid>
|
||||
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||
```
|
||||
|
||||
6. **✅ Docker Timezone Support**: Added configurable timezone via TZ environment variable
|
||||
- Users can set `TZ=Europe/London` in docker-compose for local timestamps
|
||||
|
||||
### Repository Structure Changes
|
||||
```
|
||||
# OLD - Monorepo
|
||||
NFOguard/
|
||||
├── NFOGuard.Emby.Plugin/ (plugin code)
|
||||
├── core/ (webhook code)
|
||||
├── decompiled-TimeLord/ (mixed reference)
|
||||
└── releases/ (mixed artifacts)
|
||||
|
||||
# NEW - Separated Repositories
|
||||
NFOguard/ (webhook only - 21 files)
|
||||
├── core/, clients/ (webhook application)
|
||||
├── docker-compose.example.yml (public example)
|
||||
├── nfoguard.py, requirements.txt, Dockerfile
|
||||
└── documentation (focused on webhook)
|
||||
|
||||
NFOGuard-Emby-Plugin/ (plugin only)
|
||||
├── NFOGuard.Emby.Plugin/ (C# source)
|
||||
├── EmbySDK/ (build dependencies)
|
||||
├── decompiled-TimeLord/ (reference code)
|
||||
├── releases/ (plugin DLL versions)
|
||||
└── documentation (focused on plugin)
|
||||
```
|
||||
|
||||
### Technical Impact
|
||||
- **Emby Compatibility**: TVDB IDs should resolve NFO reading issues
|
||||
- **User Experience**: Single docker-compose.example.yml for easy deployment
|
||||
- **Developer Experience**: Separate repos allow independent development
|
||||
- **Public Readiness**: Clean webhook repo ready for open source distribution
|
||||
|
||||
### Configuration Updates
|
||||
```bash
|
||||
# NEW - Added to .env.secrets.template
|
||||
TVDB_API_KEY=your_tvdb_api_key # Free registration at thetvdb.com
|
||||
```
|
||||
|
||||
### 🔄 TVDB Integration Reversion Guide (If Needed)
|
||||
|
||||
**If TVDB integration causes issues, here's how to revert:**
|
||||
|
||||
#### Files Modified for TVDB:
|
||||
1. **`clients/external_clients.py`**:
|
||||
```python
|
||||
# REVERT: Remove TVDBClient class (lines ~200-280)
|
||||
# REVERT: Remove from ExternalClientManager.__init__(): self.tvdb = TVDBClient()
|
||||
# REVERT: Remove get_tvdb_series_id() method
|
||||
```
|
||||
|
||||
2. **`core/nfo_manager.py`**:
|
||||
```python
|
||||
# REVERT: Change create_tvshow_nfo() back to single parameter:
|
||||
def create_tvshow_nfo(self, series_dir: Path, imdb_id: str): # Remove tvdb_id param
|
||||
|
||||
# REVERT: tvshow.nfo content back to IMDB-only:
|
||||
uniqueid_elements = [f' <uniqueid type="imdb" default="true">{imdb_id}</uniqueid>']
|
||||
```
|
||||
|
||||
3. **`nfoguard.py`** (3 locations):
|
||||
```python
|
||||
# REVERT: Remove TVDB lookup calls, change back to:
|
||||
self.nfo_manager.create_tvshow_nfo(series_path, imdb_id) # Remove tvdb_id param
|
||||
|
||||
# REVERT LOCATIONS:
|
||||
# - TV processor method (~line 450)
|
||||
# - Season processor method (~line 380)
|
||||
# - Targeted webhook processing (~line 520)
|
||||
```
|
||||
|
||||
4. **`.env.secrets.template`**:
|
||||
```bash
|
||||
# REVERT: Remove this line:
|
||||
TVDB_API_KEY=your_tvdb_api_key
|
||||
```
|
||||
|
||||
#### Quick Revert Commands:
|
||||
```bash
|
||||
# If you need to quickly revert TVDB integration:
|
||||
git log --oneline | grep -i tvdb # Find TVDB commits
|
||||
git revert <commit_hash> # Revert specific commits
|
||||
# OR
|
||||
git checkout <pre-tvdb-commit> -- clients/external_clients.py core/nfo_manager.py nfoguard.py .env.secrets.template
|
||||
```
|
||||
|
||||
#### Why You Might Need to Revert:
|
||||
- **TVDB API issues**: Rate limiting, authentication problems
|
||||
- **NFO compatibility**: Some media servers prefer IMDB-only uniqueids
|
||||
- **Performance**: TVDB lookups add API call overhead
|
||||
- **Dependency**: Removes external API dependency if not needed
|
||||
|
||||
#### Testing Strategy:
|
||||
1. **Test with TVDB first**: Deploy and check if Emby reads NFO files properly
|
||||
2. **Monitor logs**: Look for "TVDB API key not configured" warnings
|
||||
3. **Check NFO output**: Verify both TVDB and IMDB uniqueid elements are created
|
||||
4. **Emby behavior**: Confirm PremiereDate is no longer NULL
|
||||
5. **If issues**: Use revert guide above
|
||||
|
||||
**Current Status**: ⚠️ **UNTESTED - TVDB integration implemented but needs validation**
|
||||
|
||||
---
|
||||
*Last Updated: 2025-01-12*
|
||||
*Major Session: Repository architecture redesign + TVDB integration for Emby compatibility*
|
||||
@@ -1,8 +0,0 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
psycopg2-binary==2.9.7
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.0
|
||||
Reference in New Issue
Block a user