initial updates to mirror github files
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
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.
@@ -0,0 +1,36 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 NFOGuard
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## Commercial Licensing
|
||||
|
||||
For commercial use, enterprise features, or if the MIT license doesn't meet your needs,
|
||||
commercial licenses are available. Contact us for more information.
|
||||
|
||||
Future premium features (such as web UI, advanced analytics, enterprise support)
|
||||
may be offered under separate commercial licensing terms.
|
||||
|
||||
## Third-Party Licenses
|
||||
|
||||
This software includes third-party libraries with their own licenses.
|
||||
See requirements.txt and the respective package documentation for details.
|
||||
@@ -0,0 +1,390 @@
|
||||
# NFOGuard
|
||||
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
[](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
|
||||
**Automated NFO file management for Radarr and Sonarr with intelligent date handling**
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ ALPHA SOFTWARE NOTICE ⚠️**
|
||||
>
|
||||
> NFOGuard is currently in **Alpha** stage. While functional, it may have bugs or missing features.
|
||||
>
|
||||
> **🔌 Emby Plugin Included**: The Emby companion plugin is now bundled directly into the Docker image — no extra steps required.
|
||||
>
|
||||
> **💬 Community Feedback**: Join our Discord if you’d like to share feedback, test new features early, or discuss improvements with other users:
|
||||
>
|
||||
> **[Join Discord: https://discord.gg/bbD9Pmtr](https://discord.gg/bbD9Pmtr)**
|
||||
>
|
||||
> *If the Discord link has expired, please [open an issue](https://github.com/sbcrumb/NFOguard/issues) and we'll provide an updated link.*
|
||||
|
||||
---
|
||||
|
||||
NFOGuard automatically updates movie and TV show NFO files with proper release dates and metadata when triggered by Radarr/Sonarr webhooks. It preserves existing metadata while adding clean, accurate date information at the bottom of NFO files.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎬 **Movie & TV Support** - Works with both Radarr and Sonarr
|
||||
- 📅 **Smart Date Handling** - Prioritizes digital, physical, and theatrical release dates
|
||||
- 🔄 **Webhook Integration** - Triggers automatically on import, upgrade, and rename
|
||||
- 🗄️ **Database Integration** - Direct PostgreSQL access for better performance
|
||||
- 📝 **NFO Preservation** - Maintains existing metadata, adds fields cleanly at bottom
|
||||
- 🔒 **Metadata Locking** - Prevents overwrites with lockdata tags
|
||||
- ⚡ **Batch Processing** - Efficient handling of multiple files
|
||||
- 🐳 **Docker Ready** - Easy deployment with Docker Compose
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Download Configuration Files
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.template
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/.env.secrets.template
|
||||
wget https://raw.githubusercontent.com/sbcrumb/NFOguard/main/docker-compose.example.yml
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy and edit main configuration
|
||||
cp .env.template .env
|
||||
nano .env
|
||||
|
||||
# Copy and edit secrets (API keys, passwords)
|
||||
cp .env.secrets.template .env.secrets
|
||||
nano .env.secrets
|
||||
chmod 600 .env.secrets
|
||||
|
||||
# Copy and edit Docker Compose
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
nano docker-compose.yml
|
||||
```
|
||||
|
||||
### 3. Deploy
|
||||
|
||||
```bash
|
||||
# Create data directory
|
||||
mkdir -p ./data
|
||||
|
||||
# Start NFOGuard
|
||||
docker-compose up -d
|
||||
|
||||
# Check logs
|
||||
docker-compose logs -f nfoguard
|
||||
|
||||
# Verify health
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Files
|
||||
|
||||
| File | Purpose | Contains |
|
||||
|------|---------|----------|
|
||||
| `.env` | Main configuration | Paths, behavior settings, non-sensitive options |
|
||||
| `.env.secrets` | Sensitive data | API keys, passwords, database credentials |
|
||||
|
||||
### Key Configuration Options
|
||||
|
||||
**Media Paths** (Required):
|
||||
```bash
|
||||
# Container paths (what NFOGuard sees)
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
|
||||
# *arr application paths (what your apps see)
|
||||
RADARR_ROOT_FOLDERS=/mnt/unionfs/Media/Movies/movies
|
||||
SONARR_ROOT_FOLDERS=/mnt/unionfs/Media/TV/tv
|
||||
```
|
||||
|
||||
**Release Date Priority**:
|
||||
```bash
|
||||
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||
```
|
||||
|
||||
**Debug Mode**:
|
||||
```bash
|
||||
DEBUG=false # Clean production logs
|
||||
SUPPRESS_TVDB_WARNINGS=true # Hide non-critical API failures
|
||||
```
|
||||
|
||||
## 🐳 Docker Images
|
||||
|
||||
### Production (Stable)
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:latest
|
||||
```
|
||||
|
||||
### Development (Latest Features)
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:dev
|
||||
```
|
||||
|
||||
### Specific Version
|
||||
```yaml
|
||||
image: sbcrumb/nfoguard:v1.5.5
|
||||
```
|
||||
|
||||
## 🔗 Webhook Setup
|
||||
|
||||
Configure these webhook URLs in your applications:
|
||||
|
||||
**Radarr**: `http://nfoguard:8080/webhook/radarr`
|
||||
**Sonarr**: `http://nfoguard:8080/webhook/sonarr`
|
||||
|
||||
**Triggers**: On Import, On Upgrade, On Rename
|
||||
|
||||
## 🔄 Manual Operations
|
||||
|
||||
### Manual Scanning
|
||||
|
||||
Trigger manual scans via API endpoints:
|
||||
|
||||
```bash
|
||||
# Manual scan all media (movies and TV)
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=both"
|
||||
|
||||
# Manual scan TV only
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=tv"
|
||||
|
||||
# Manual scan movies only
|
||||
curl -X POST "http://localhost:8080/manual/scan?scan_type=movies"
|
||||
|
||||
# Manual scan specific path
|
||||
curl -X POST "http://localhost:8080/manual/scan?path=/media/movies"
|
||||
|
||||
# Bulk update all movies from Radarr database
|
||||
curl -X POST "http://localhost:8080/bulk/update"
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/webhook/radarr` | POST | Radarr webhook handler |
|
||||
| `/webhook/sonarr` | POST | Sonarr webhook handler |
|
||||
| `/manual/scan` | POST | Manual media scanning |
|
||||
| `/bulk/update` | POST | Bulk movie updates from Radarr DB |
|
||||
|
||||
### Manual Scan Parameters
|
||||
|
||||
- `scan_type`: `both`, `movies`, `tv`
|
||||
- `path`: Specific directory path to scan
|
||||
- Use for initial setup or fixing existing media
|
||||
|
||||
## 📁 Volume Mapping
|
||||
|
||||
Critical: Update `docker-compose.yml` with your actual paths:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # NFOGuard data
|
||||
- /your/movies/path:/media/Movies/movies:rw # Movie library
|
||||
- /your/tv/path:/media/TV/tv:rw # TV library
|
||||
```
|
||||
|
||||
### Common Examples
|
||||
|
||||
**Unraid**:
|
||||
```yaml
|
||||
- /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||
- /mnt/user/Media/TV:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
**Synology**:
|
||||
```yaml
|
||||
- /volume1/Media/Movies:/media/Movies/movies:rw
|
||||
- /volume1/Media/TV:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
**Standard Linux**:
|
||||
```yaml
|
||||
- /home/user/media/movies:/media/Movies/movies:rw
|
||||
- /home/user/media/tv:/media/TV/tv:rw
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Check Logs
|
||||
```bash
|
||||
docker-compose logs -f nfoguard
|
||||
```
|
||||
|
||||
### Enable Debug Mode
|
||||
```bash
|
||||
# In .env file
|
||||
DEBUG=true
|
||||
PATH_DEBUG=true
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Permission Errors**: Ensure NFOGuard can write to mounted directories
|
||||
2. **Path Mapping**: Verify container paths match `.env` configuration
|
||||
3. **Webhooks**: Check URLs and ensure port 8080 is accessible
|
||||
4. **Database**: Verify PostgreSQL credentials in `.env.secrets`
|
||||
|
||||
## 📊 What NFOGuard Does
|
||||
|
||||
### Before
|
||||
```xml
|
||||
<movie>
|
||||
<title>Movie Title</title>
|
||||
<year>2023</year>
|
||||
<!-- Existing Radarr metadata -->
|
||||
</movie>
|
||||
```
|
||||
|
||||
### After
|
||||
```xml
|
||||
<movie>
|
||||
<title>Movie Title</title>
|
||||
<year>2023</year>
|
||||
<!-- Existing Radarr metadata preserved -->
|
||||
|
||||
<!-- NFOGuard additions at bottom -->
|
||||
<digital_release_date>2023-03-15</digital_release_date>
|
||||
<lockdata>true</lockdata>
|
||||
<!-- Manager: NFOGuard -->
|
||||
</movie>
|
||||
```
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Radarr and/or Sonarr
|
||||
- Media files in accessible directories
|
||||
- Network connectivity between services
|
||||
|
||||
## 📁 Directory Structure Requirements
|
||||
|
||||
NFOGuard identifies movies and TV shows using two methods: directory names with IMDb IDs (primary) or NFO files with IMDb IDs (fallback). Your media should follow these conventions:
|
||||
|
||||
### 🎬 **Movies**
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
/movies/
|
||||
└── Movie Title (2024) [tt1234567]/
|
||||
├── movie.mkv
|
||||
└── movie.nfo (created by NFOGuard)
|
||||
```
|
||||
|
||||
**Identification Methods:**
|
||||
1. **Primary**: Directory name contains IMDb ID in brackets: `[tt1234567]` or `[imdb-tt1234567]`
|
||||
2. **Fallback**: NFO file with IMDb ID in movie.nfo file (see NFO format below)
|
||||
3. **Video file** required: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`
|
||||
4. **Case insensitive** - `[TT1234567]` works too
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
✅ Action Film (2024) [tt1234567]/
|
||||
✅ Drama Movie [imdb-tt7654321]/
|
||||
✅ SciFi Thriller (2023) [TT9876543]/
|
||||
❌ Missing IMDB Directory/
|
||||
```
|
||||
|
||||
### 📺 **TV Shows**
|
||||
|
||||
**Directory Structure:**
|
||||
```
|
||||
/tv/
|
||||
└── Series Title (2024) [tt1234567]/
|
||||
├── Season 01/
|
||||
│ ├── Series S01E01.mkv
|
||||
│ ├── Series S01E02.mkv
|
||||
│ ├── S01E01.nfo (created by NFOGuard)
|
||||
│ └── S01E02.nfo (created by NFOGuard)
|
||||
├── Season 02/
|
||||
└── tvshow.nfo (created by NFOGuard)
|
||||
```
|
||||
|
||||
**Identification Methods:**
|
||||
1. **Primary**: Series directory contains IMDb ID: `[tt1234567]` or `[imdb-tt1234567]`
|
||||
2. **Fallback**: NFO file with IMDb ID in tvshow.nfo file (see NFO format below)
|
||||
3. **Season directories** must match pattern: `Season 01`, `Season 1`, `season 01` etc.
|
||||
4. **Episode files** must contain season/episode info:
|
||||
- **SxxExx format**: `S01E01`, `S1E1`, `s01e01`
|
||||
- **Dot format**: `1.1`, `01.01`
|
||||
5. **Video extensions**: `.mkv`, `.mp4`, `.avi`, `.mov`, `.m4v`, `.ts`, `.m2ts`
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
✅ Drama Series (2024) [tt1234567]/
|
||||
├── Season 01/
|
||||
│ ├── Drama S01E01.mkv
|
||||
│ └── Drama S01E02.mkv
|
||||
└── Season 02/
|
||||
|
||||
✅ Comedy Show [tt7654321]/
|
||||
└── Season 1/
|
||||
├── Comedy 1.1.mkv
|
||||
└── Comedy 1.2.mkv
|
||||
|
||||
❌ Series Without IMDB []/
|
||||
❌ Series [tt1234567]/Episode01.mkv (no season directory)
|
||||
❌ Series [tt1234567]/Season 1/RandomName.mkv (no episode pattern)
|
||||
```
|
||||
|
||||
### 📄 **NFO File Identification Format**
|
||||
|
||||
NFOGuard can extract IMDb IDs from existing NFO files using these XML tags:
|
||||
|
||||
**Movie NFO (movie.nfo):**
|
||||
```xml
|
||||
<!-- Method 1: uniqueid tag (preferred) -->
|
||||
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||
|
||||
<!-- Method 2: imdbid tag -->
|
||||
<imdbid>tt1234567</imdbid>
|
||||
|
||||
<!-- Method 3: imdb tag -->
|
||||
<imdb>tt1234567</imdb>
|
||||
```
|
||||
|
||||
**TV Show NFO (tvshow.nfo):**
|
||||
```xml
|
||||
<!-- Same format as movies -->
|
||||
<uniqueid type="imdb">tt1234567</uniqueid>
|
||||
<imdbid>tt1234567</imdbid>
|
||||
<imdb>tt1234567</imdb>
|
||||
```
|
||||
|
||||
**Episode NFO Files:**
|
||||
NFOGuard creates standardized episode NFO files using the pattern `S##E##.nfo`:
|
||||
- `S01E01.nfo` for Season 1, Episode 1
|
||||
- `S02E05.nfo` for Season 2, Episode 5
|
||||
- Always zero-padded format (S01E01, not S1E1)
|
||||
- **Smart Rename**: NFOGuard will find existing NFO files (created by Sonarr/other tools), extract their metadata, and rename them to the standard format
|
||||
|
||||
### 🚫 **What Gets Skipped**
|
||||
NFOGuard will ignore:
|
||||
- Directories without IMDb IDs in brackets AND no NFO files with IMDb IDs
|
||||
- Directories without video files
|
||||
- TV episodes without recognizable season/episode patterns
|
||||
- Season directories that don't match "Season X" format
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/sbcrumb/NFOguard/issues)
|
||||
- **Documentation**: See `SETUP.md` for detailed instructions
|
||||
- **Docker Hub**: [`sbcrumb/nfoguard`](https://hub.docker.com/r/sbcrumb/nfoguard)
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Commercial licensing and enterprise features may be available separately. Contact us for more information.
|
||||
|
||||
---
|
||||
|
||||
**NFOGuard** - Keeping your media metadata clean and organized! 🎯
|
||||
@@ -0,0 +1,153 @@
|
||||
# NFOGuard Setup Guide
|
||||
|
||||
## 🔐 Secure Configuration Setup
|
||||
|
||||
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
|
||||
|
||||
- **`.env`** - Main configuration (safe to share for debugging)
|
||||
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
|
||||
|
||||
### Step 1: Copy Configuration Templates
|
||||
|
||||
```bash
|
||||
# Copy main configuration
|
||||
cp .env.template .env
|
||||
|
||||
# Copy secrets configuration
|
||||
cp .env.secrets.template .env.secrets
|
||||
```
|
||||
|
||||
### Step 2: Configure Main Settings
|
||||
|
||||
Edit your `.env` file with your specific paths and preferences:
|
||||
|
||||
```bash
|
||||
# Media paths (adjust to your directory structure)
|
||||
TV_PATHS=/media/TV/tv,/media/TV/tv6
|
||||
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
|
||||
|
||||
# Database connection details
|
||||
RADARR_DB_HOST=radarr-postgres
|
||||
RADARR_DB_PORT=5432
|
||||
RADARR_DB_NAME=radarr
|
||||
RADARR_DB_USER=radarr
|
||||
|
||||
# Processing preferences
|
||||
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
|
||||
ALLOW_FILE_DATE_FALLBACK=false
|
||||
RELEASE_DATE_PRIORITY=digital,physical,theatrical
|
||||
|
||||
# TV webhook processing mode (v0.6.0+)
|
||||
TV_WEBHOOK_PROCESSING_MODE=targeted
|
||||
```
|
||||
|
||||
### Step 3: Configure Secrets
|
||||
|
||||
Edit your `.env.secrets` file with your actual API keys and passwords:
|
||||
|
||||
```bash
|
||||
# Database password
|
||||
RADARR_DB_PASSWORD=your_actual_radarr_password
|
||||
|
||||
# TMDB API key (required for release date detection)
|
||||
TMDB_API_KEY=your_actual_tmdb_api_key
|
||||
|
||||
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
|
||||
SONARR_API_KEY=your_actual_sonarr_api_key
|
||||
|
||||
# Optional API keys
|
||||
RADARR_API_KEY=your_radarr_api_key
|
||||
OMDB_API_KEY=your_omdb_api_key
|
||||
```
|
||||
|
||||
### Step 4: Verify Configuration
|
||||
|
||||
Test your setup:
|
||||
|
||||
```bash
|
||||
# Test database connections
|
||||
curl -X POST "http://localhost:8080/test/bulk-update"
|
||||
|
||||
# Test movie scanning
|
||||
curl -X POST "http://localhost:8080/test/movie-scan"
|
||||
|
||||
# Check system health
|
||||
curl "http://localhost:8080/health"
|
||||
```
|
||||
|
||||
## 🔒 Security Features
|
||||
|
||||
### API Key Masking
|
||||
All API keys and passwords are automatically masked in logs:
|
||||
```
|
||||
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
|
||||
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
|
||||
```
|
||||
|
||||
### Sensitive Data Separation
|
||||
- **Main `.env`**: Paths, preferences, URLs (safe to share)
|
||||
- **`.env.secrets`**: API keys, passwords (never commit to version control)
|
||||
- **Automatic loading**: Both files loaded automatically at startup
|
||||
|
||||
### Git Protection
|
||||
The `.gitignore` file prevents accidental commits:
|
||||
```
|
||||
.env
|
||||
.env.secrets
|
||||
.env.local
|
||||
```
|
||||
|
||||
## 🛠 Troubleshooting
|
||||
|
||||
### "Environment files not loaded" Warning
|
||||
Install python-dotenv:
|
||||
```bash
|
||||
pip install python-dotenv==1.0.0
|
||||
# or
|
||||
docker-compose build # rebuilds with updated requirements.txt
|
||||
```
|
||||
|
||||
### Sharing Configuration for Help
|
||||
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
|
||||
|
||||
### Migration from Old Setup
|
||||
If you have an existing `.env` with API keys:
|
||||
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
|
||||
2. Remove sensitive data from `.env`
|
||||
3. Restart NFOGuard
|
||||
|
||||
## 🎯 Docker Compose Example
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
nfoguard:
|
||||
image: sbcrumb/nfoguard:latest
|
||||
container_name: nfoguard
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /path/to/your/media:/media:rw
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro # Main configuration
|
||||
- ./.env.secrets:/app/.env.secrets:ro # Secrets
|
||||
environment:
|
||||
- PORT=8080
|
||||
depends_on:
|
||||
- radarr-postgres
|
||||
```
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### Main Configuration (.env)
|
||||
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
|
||||
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
|
||||
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
|
||||
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
|
||||
|
||||
### Secrets Configuration (.env.secrets)
|
||||
- **Database**: `RADARR_DB_PASSWORD`
|
||||
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
|
||||
- **Optional**: `JELLYSEERR_API_KEY`
|
||||
|
||||
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
|
||||
+496
@@ -0,0 +1,496 @@
|
||||
# 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
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
External API clients for TMDB, OMDb, and Jellyseerr
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, quote
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
def _get_json(url: str, timeout: int = 20, headers: Dict[str, str] = None, suppress_404: bool = False) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request and return JSON"""
|
||||
try:
|
||||
req = UrlRequest(url, headers=headers or {"Accept": "application/json"})
|
||||
with urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
# Handle specific HTTP errors more gracefully
|
||||
if suppress_404 and e.code in [400, 404]:
|
||||
_log("DEBUG", f"TVDB API: {url} - item not found (HTTP {e.code}) - this is expected")
|
||||
return None
|
||||
else:
|
||||
_log("WARNING", f"GET {url} failed: HTTP Error {e.code}: {e.reason}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("WARNING", f"GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date_to_iso(date_str: str) -> Optional[str]:
|
||||
"""Parse various date formats to ISO string"""
|
||||
if not date_str or date_str == "N/A":
|
||||
return None
|
||||
try:
|
||||
if len(date_str) == 10 and date_str[4] == "-": # YYYY-MM-DD
|
||||
dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
return dt.isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class TVDBClient:
|
||||
"""The TV Database API client for IMDB to TVDB ID conversion"""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.environ.get("TVDB_API_KEY", "")
|
||||
self.base_url = "https://api4.thetvdb.com/v4"
|
||||
self._token = None
|
||||
self._token_expires = 0
|
||||
|
||||
def _get_token(self) -> Optional[str]:
|
||||
"""Get TVDB auth token (cached)"""
|
||||
if not self.api_key:
|
||||
_log("DEBUG", "TVDB: No API key provided")
|
||||
return None
|
||||
|
||||
if time.time() < self._token_expires and self._token:
|
||||
return self._token
|
||||
|
||||
try:
|
||||
_log("DEBUG", f"TVDB: Authenticating with API key: {self.api_key[:8]}...")
|
||||
req = UrlRequest(
|
||||
f"{self.base_url}/login",
|
||||
data=json.dumps({"apikey": self.api_key}).encode('utf-8'),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
_log("DEBUG", f"TVDB login response: {data}")
|
||||
if data.get("status") == "success":
|
||||
self._token = data["data"]["token"]
|
||||
self._token_expires = time.time() + 3600 # 1 hour
|
||||
_log("INFO", f"✅ TVDB: Authentication successful")
|
||||
return self._token
|
||||
else:
|
||||
_log("WARNING", f"TVDB login failed: {data}")
|
||||
except Exception as e:
|
||||
_log("WARNING", f"TVDB login failed: {e}")
|
||||
return None
|
||||
|
||||
def imdb_to_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||
"""Convert IMDB ID to TVDB series ID using TVDB v4 API"""
|
||||
token = self._get_token()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try the official v4 search endpoint first
|
||||
# According to docs: /search?query=imdb_id&type=series
|
||||
url = f"{self.base_url}/search?query={imdb_id}&type=series&meta=translations"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
_log("DEBUG", f"TVDB: Searching for {imdb_id} using /search endpoint")
|
||||
data = _get_json(url, headers=headers, suppress_404=True)
|
||||
|
||||
if data and data.get("status") == "success" and data.get("data"):
|
||||
series_list = data["data"]
|
||||
_log("DEBUG", f"TVDB search response: found {len(series_list)} results")
|
||||
|
||||
# Look for exact IMDB match in results
|
||||
for series in series_list:
|
||||
# Check if this series has the IMDB ID we're looking for
|
||||
remote_ids = series.get("remote_ids", [])
|
||||
for remote in remote_ids:
|
||||
if (remote.get("source_name") == "IMDB" and
|
||||
remote.get("remote_id") == imdb_id):
|
||||
tvdb_id = series.get("tvdb_id") or series.get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id}")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If no exact match, try the first result if it looks promising
|
||||
if series_list:
|
||||
first_result = series_list[0]
|
||||
tvdb_id = first_result.get("tvdb_id") or first_result.get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (first result)")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If search didn't work, try the legacy remoteid endpoint
|
||||
_log("DEBUG", f"TVDB: Trying legacy remoteid endpoint for {imdb_id}")
|
||||
url = f"{self.base_url}/search/remoteid?remoteId={imdb_id}&type=series"
|
||||
data = _get_json(url, headers=headers, suppress_404=True)
|
||||
|
||||
if data and data.get("status") == "success" and data.get("data"):
|
||||
series_list = data["data"]
|
||||
if series_list and len(series_list) > 0:
|
||||
tvdb_id = series_list[0].get("id")
|
||||
if tvdb_id:
|
||||
_log("INFO", f"✅ TVDB: Found series {imdb_id} → {tvdb_id} (legacy endpoint)")
|
||||
return str(tvdb_id)
|
||||
|
||||
# If we get here, the series wasn't found in TVDB
|
||||
_log("DEBUG", f"TVDB: No series found for IMDb {imdb_id} (not available in TVDB)")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"TVDB API error for {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class TMDBClient:
|
||||
"""The Movie Database API client"""
|
||||
|
||||
def __init__(self, api_key: str = None, primary_country: str = "US"):
|
||||
self.api_key = api_key or os.environ.get("TMDB_API_KEY", "")
|
||||
self.primary_country = primary_country.upper()
|
||||
self.enabled = bool(self.api_key)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request to TMDB API"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
params = params or {}
|
||||
params["api_key"] = self.api_key
|
||||
url = f"https://api.themoviedb.org/3{path}?{urlencode(params)}"
|
||||
return _get_json(url, timeout=20)
|
||||
|
||||
def find_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find movie by IMDb ID"""
|
||||
result = self._get(f"/find/{quote(imdb_id)}", {"external_source": "imdb_id"})
|
||||
if result and result.get("movie_results"):
|
||||
return result["movie_results"][0]
|
||||
return None
|
||||
|
||||
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get detailed movie information"""
|
||||
return self._get(f"/movie/{tmdb_id}")
|
||||
|
||||
def get_digital_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get digital release date for a movie"""
|
||||
_log("INFO", f"🔍 TMDB: Looking for digital release date for {imdb_id}")
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
_log("WARNING", f"❌ TMDB: Movie not found for {imdb_id}")
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
_log("INFO", f"✅ TMDB: Found movie ID {tmdb_id} for {imdb_id}")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
_log("WARNING", f"❌ TMDB: No release dates data for movie {tmdb_id}")
|
||||
return None
|
||||
|
||||
_log("INFO", f"🔍 TMDB: Got release dates data, looking for {self.primary_country} digital releases")
|
||||
|
||||
# Debug: Show all available countries
|
||||
countries = [entry.get("iso_3166_1") for entry in release_dates.get("results", [])]
|
||||
_log("INFO", f"📍 TMDB: Available countries: {countries}")
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
country = entry.get("iso_3166_1", "").upper()
|
||||
if country != self.primary_country:
|
||||
continue
|
||||
|
||||
_log("INFO", f"🎯 TMDB: Found {country} release data")
|
||||
releases = entry.get("release_dates", [])
|
||||
_log("INFO", f"🎬 TMDB: Release types available: {[r.get('type') for r in releases]}")
|
||||
|
||||
# Collect all available releases with their types and dates
|
||||
available_releases = []
|
||||
for release in releases:
|
||||
release_type = release.get("type")
|
||||
release_date = release.get("release_date")
|
||||
_log("INFO", f"📅 TMDB: Type {release_type}, Date: {release_date}")
|
||||
|
||||
if release_date:
|
||||
parsed_date = _parse_date_to_iso(release_date)
|
||||
if parsed_date:
|
||||
available_releases.append((release_type, parsed_date))
|
||||
|
||||
# Apply TMDB release type priority order
|
||||
tmdb_priority = self._get_tmdb_type_priority()
|
||||
for preferred_type in tmdb_priority:
|
||||
for release_type, parsed_date in available_releases:
|
||||
if release_type == preferred_type:
|
||||
release_type_names = {
|
||||
1: "Premiere", 2: "Limited Theatrical", 3: "Theatrical",
|
||||
4: "Digital", 5: "Physical", 6: "TV Premiere"
|
||||
}
|
||||
type_name = release_type_names.get(release_type, f"Type {release_type}")
|
||||
_log("INFO", f"✅ TMDB: Selected {type_name} release date: {parsed_date} (priority: {tmdb_priority})")
|
||||
return parsed_date
|
||||
|
||||
_log("WARNING", f"❌ TMDB: No release dates found for {imdb_id} in {self.primary_country}")
|
||||
return None
|
||||
|
||||
def _get_tmdb_type_priority(self) -> List[int]:
|
||||
"""Get TMDB release type priority order from environment"""
|
||||
# Default priority: Digital first, then Physical, then Theatrical, then others
|
||||
default_priority = "4,5,3,2,6,1" # digital,physical,theatrical,limited,tv,premiere
|
||||
|
||||
priority_str = os.environ.get("TMDB_TYPE_PRIORITY", default_priority)
|
||||
try:
|
||||
# Parse comma-separated numbers
|
||||
priority_list = [int(x.strip()) for x in priority_str.split(",") if x.strip().isdigit()]
|
||||
if priority_list:
|
||||
_log("DEBUG", f"TMDB type priority: {priority_list}")
|
||||
return priority_list
|
||||
else:
|
||||
_log("WARNING", f"Invalid TMDB_TYPE_PRIORITY '{priority_str}', using default")
|
||||
return [4, 5, 3, 2, 6, 1]
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Error parsing TMDB_TYPE_PRIORITY: {e}, using default")
|
||||
return [4, 5, 3, 2, 6, 1]
|
||||
|
||||
def get_theatrical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get theatrical release date for a movie"""
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
return None
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||
continue
|
||||
|
||||
for release in entry.get("release_dates", []):
|
||||
if release.get("type") == 3 and release.get("release_date"): # Theatrical release
|
||||
return _parse_date_to_iso(release["release_date"])
|
||||
|
||||
return None
|
||||
|
||||
def get_physical_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get physical release date (DVD/Blu-ray) for a movie"""
|
||||
movie = self.find_by_imdb(imdb_id)
|
||||
if not movie:
|
||||
return None
|
||||
|
||||
tmdb_id = movie.get("id")
|
||||
if not tmdb_id:
|
||||
return None
|
||||
|
||||
release_dates = self._get(f"/movie/{tmdb_id}/release_dates")
|
||||
if not release_dates:
|
||||
return None
|
||||
|
||||
for entry in release_dates.get("results", []):
|
||||
if entry.get("iso_3166_1", "").upper() != self.primary_country:
|
||||
continue
|
||||
|
||||
for release in entry.get("release_dates", []):
|
||||
if release.get("type") == 5 and release.get("release_date"): # Physical release
|
||||
return _parse_date_to_iso(release["release_date"])
|
||||
|
||||
return None
|
||||
|
||||
def get_tv_season_episodes(self, tv_id: int, season_number: int) -> Dict[int, str]:
|
||||
"""Get episode air dates for a TV season"""
|
||||
result = self._get(f"/tv/{tv_id}/season/{season_number}")
|
||||
episodes = {}
|
||||
|
||||
if result:
|
||||
for episode in result.get("episodes", []):
|
||||
ep_num = episode.get("episode_number")
|
||||
air_date = episode.get("air_date")
|
||||
if isinstance(ep_num, int) and air_date:
|
||||
episodes[ep_num] = air_date
|
||||
|
||||
return episodes
|
||||
|
||||
|
||||
class OMDbClient:
|
||||
"""Open Movie Database API client"""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.environ.get("OMDB_API_KEY", "")
|
||||
self.enabled = bool(self.api_key)
|
||||
|
||||
def get_movie_details(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get movie details from OMDb"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
params = {"i": imdb_id, "apikey": self.api_key}
|
||||
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||
result = _get_json(url, timeout=15)
|
||||
|
||||
if result and result.get("Response") == "True":
|
||||
return result
|
||||
return None
|
||||
|
||||
def get_dvd_release_date(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get DVD/digital release date"""
|
||||
details = self.get_movie_details(imdb_id)
|
||||
if not details:
|
||||
return None
|
||||
|
||||
dvd_date = details.get("DVD") or details.get("Released")
|
||||
if not dvd_date or dvd_date == "N/A":
|
||||
return None
|
||||
|
||||
# Try to parse various date formats
|
||||
for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d"):
|
||||
try:
|
||||
dt = datetime.strptime(dvd_date, fmt).replace(tzinfo=timezone.utc)
|
||||
return dt.isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def get_tv_season_episodes(self, imdb_id: str, season_number: int) -> Dict[int, str]:
|
||||
"""Get episode release dates for a TV season"""
|
||||
if not self.enabled:
|
||||
return {}
|
||||
|
||||
params = {"i": imdb_id, "Season": str(season_number), "apikey": self.api_key}
|
||||
url = f"http://www.omdbapi.com/?{urlencode(params)}"
|
||||
result = _get_json(url, timeout=15)
|
||||
|
||||
episodes = {}
|
||||
if result and result.get("Response") == "True":
|
||||
for episode in result.get("Episodes", []):
|
||||
try:
|
||||
ep_num = int(episode.get("Episode", 0))
|
||||
released = episode.get("Released")
|
||||
if ep_num and released and released != "N/A":
|
||||
episodes[ep_num] = released
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return episodes
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""Jellyseerr API client"""
|
||||
|
||||
def __init__(self, base_url: str = None, api_key: str = None):
|
||||
self.base_url = (base_url or os.environ.get("JELLYSEERR_URL", "")).rstrip("/")
|
||||
self.api_key = api_key or os.environ.get("JELLYSEERR_API_KEY", "")
|
||||
self.enabled = bool(self.base_url and self.api_key)
|
||||
|
||||
def _get(self, path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Make GET request to Jellyseerr API"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/v1{path}"
|
||||
headers = {"X-Api-Key": self.api_key, "Accept": "application/json"}
|
||||
return _get_json(url, timeout=20, headers=headers)
|
||||
|
||||
def get_movie_details(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get movie details from Jellyseerr"""
|
||||
return self._get(f"/movie/{tmdb_id}")
|
||||
|
||||
def get_digital_release_dates(self, tmdb_id: int) -> List[str]:
|
||||
"""Get digital release date candidates from Jellyseerr"""
|
||||
details = self.get_movie_details(tmdb_id)
|
||||
if not details:
|
||||
return []
|
||||
|
||||
candidates = []
|
||||
|
||||
# Check direct fields
|
||||
for field in ("digitalReleaseDate", "physicalReleaseDate", "vodReleaseDate"):
|
||||
value = details.get(field)
|
||||
if value:
|
||||
iso_date = _parse_date_to_iso(value)
|
||||
if iso_date:
|
||||
candidates.append(iso_date)
|
||||
|
||||
# Check release dates array
|
||||
for array_field in ("releaseDates", "releases", "dates"):
|
||||
release_array = details.get(array_field)
|
||||
if not isinstance(release_array, list):
|
||||
continue
|
||||
|
||||
for release in release_array:
|
||||
if not isinstance(release, dict):
|
||||
continue
|
||||
|
||||
release_type = (release.get("type") or release.get("label") or "").lower()
|
||||
release_date = release.get("date") or release.get("releaseDate")
|
||||
|
||||
if release_date and ("digital" in release_type or "vod" in release_type or "stream" in release_type):
|
||||
iso_date = _parse_date_to_iso(release_date)
|
||||
if iso_date:
|
||||
candidates.append(iso_date)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
class ExternalClientManager:
|
||||
"""Manager for all external API clients"""
|
||||
|
||||
def __init__(self):
|
||||
# Get country from environment, default to US
|
||||
tmdb_country = os.environ.get("TMDB_COUNTRY", "US")
|
||||
_log("INFO", f"🌍 TMDB: Initializing with country: {tmdb_country}")
|
||||
|
||||
self.tmdb = TMDBClient(primary_country=tmdb_country)
|
||||
self.omdb = OMDbClient()
|
||||
self.jellyseerr = JellyseerrClient()
|
||||
self.tvdb = TVDBClient()
|
||||
|
||||
def get_release_date_by_priority(self, imdb_id: str, priority_order: List[str], enable_smart_validation: bool = True) -> Optional[Tuple[str, str]]:
|
||||
"""Get release date using configurable priority order with smart date validation"""
|
||||
|
||||
# Get all possible release dates
|
||||
release_options = {}
|
||||
|
||||
if self.tmdb.enabled:
|
||||
# Digital release
|
||||
digital_date = self.tmdb.get_digital_release_date(imdb_id)
|
||||
if digital_date:
|
||||
release_options["digital"] = (digital_date, "tmdb:digital")
|
||||
|
||||
# Physical release
|
||||
physical_date = self.tmdb.get_physical_release_date(imdb_id)
|
||||
if physical_date:
|
||||
release_options["physical"] = (physical_date, "tmdb:physical")
|
||||
|
||||
# Theatrical release
|
||||
theatrical_date = self.tmdb.get_theatrical_release_date(imdb_id)
|
||||
if theatrical_date:
|
||||
release_options["theatrical"] = (theatrical_date, "tmdb:theatrical")
|
||||
|
||||
# Add OMDb options
|
||||
if self.omdb.enabled:
|
||||
omdb_date = self.omdb.get_dvd_release_date(imdb_id)
|
||||
if omdb_date and "physical" not in release_options:
|
||||
release_options["physical"] = (omdb_date, "omdb:dvd")
|
||||
|
||||
# Add Jellyseerr digital releases
|
||||
if self.jellyseerr.enabled and self.tmdb.enabled and "digital" not in release_options:
|
||||
tmdb_movie = self.tmdb.find_by_imdb(imdb_id)
|
||||
if tmdb_movie:
|
||||
tmdb_id = tmdb_movie.get("id")
|
||||
if tmdb_id:
|
||||
jellyseerr_dates = self.jellyseerr.get_digital_release_dates(tmdb_id)
|
||||
if jellyseerr_dates:
|
||||
earliest_jellyseerr = min(jellyseerr_dates)
|
||||
release_options["digital"] = (earliest_jellyseerr, "jellyseerr:digital")
|
||||
|
||||
# Smart date validation: Check if priority order makes sense given the actual dates
|
||||
if enable_smart_validation and len(release_options) > 1:
|
||||
validated_choice = self._validate_date_choice(release_options, priority_order)
|
||||
if validated_choice:
|
||||
return validated_choice
|
||||
|
||||
# Return first available option according to priority (fallback behavior)
|
||||
for priority in priority_order:
|
||||
if priority in release_options:
|
||||
return release_options[priority]
|
||||
|
||||
return None
|
||||
|
||||
def _validate_date_choice(self, release_options: Dict[str, Tuple[str, str]], priority_order: List[str]) -> Optional[Tuple[str, str]]:
|
||||
"""Validate date choice and prefer theatrical if digital/physical are unreasonably late"""
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
# Get configuration for maximum gap (default: 10 years)
|
||||
max_reasonable_gap_years = int(os.environ.get("MAX_RELEASE_DATE_GAP_YEARS", "10"))
|
||||
|
||||
# Parse all available dates
|
||||
parsed_dates = {}
|
||||
for release_type, (date_str, source) in release_options.items():
|
||||
try:
|
||||
parsed_dates[release_type] = (datetime.fromisoformat(date_str.replace('Z', '+00:00')), source)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not parsed_dates or "theatrical" not in parsed_dates:
|
||||
return None # No smart validation possible without theatrical date
|
||||
|
||||
theatrical_date, theatrical_source = parsed_dates["theatrical"]
|
||||
|
||||
# Check each priority option against theatrical date
|
||||
for priority in priority_order:
|
||||
if priority == "theatrical":
|
||||
continue # Skip theatrical in this validation
|
||||
|
||||
if priority in parsed_dates:
|
||||
priority_date, priority_source = parsed_dates[priority]
|
||||
|
||||
# Calculate the gap in years
|
||||
gap = (priority_date - theatrical_date).days / 365.25
|
||||
|
||||
# If the gap is too large, skip this priority and continue
|
||||
if gap > max_reasonable_gap_years:
|
||||
print(f"[SMART VALIDATION] {priority} date {priority_date.strftime('%Y-%m-%d')} is {gap:.1f} years after theatrical {theatrical_date.strftime('%Y-%m-%d')}, preferring theatrical")
|
||||
continue
|
||||
|
||||
# This priority option is reasonable, use it
|
||||
return (priority_date.isoformat(timespec="seconds"), f"{priority_source} (validated)")
|
||||
|
||||
# If all priority options are unreasonable, fall back to theatrical
|
||||
if "theatrical" in release_options:
|
||||
theatrical_date_str, theatrical_source = release_options["theatrical"]
|
||||
return (theatrical_date_str, f"{theatrical_source} (smart fallback)")
|
||||
|
||||
return None
|
||||
|
||||
def get_digital_release_candidates(self, imdb_id: str) -> List[Tuple[str, str]]:
|
||||
"""Get digital release date candidates from all sources (legacy method)"""
|
||||
candidates = []
|
||||
|
||||
# Try the new priority system with digital-first fallback
|
||||
result = self.get_release_date_by_priority(imdb_id, ["digital", "physical", "theatrical"])
|
||||
if result:
|
||||
candidates.append(result)
|
||||
|
||||
return candidates
|
||||
|
||||
def get_earliest_digital_release(self, imdb_id: str) -> Optional[Tuple[str, str]]:
|
||||
"""Get the earliest digital release date (legacy method)"""
|
||||
candidates = self.get_digital_release_candidates(imdb_id)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
def get_tvdb_series_id(self, imdb_id: str) -> Optional[str]:
|
||||
"""Get TVDB series ID from IMDB ID"""
|
||||
# Check if TVDB lookups are disabled
|
||||
if os.environ.get("DISABLE_TVDB", "false").lower() in ["true", "1", "yes"]:
|
||||
_log("DEBUG", "TVDB lookups disabled via DISABLE_TVDB environment variable")
|
||||
return None
|
||||
|
||||
if not self.tvdb.api_key:
|
||||
_log("INFO", "TVDB API key not configured, skipping TVDB ID lookup (set TVDB_API_KEY to enable)")
|
||||
return None
|
||||
|
||||
return self.tvdb.imdb_to_tvdb_series_id(imdb_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the clients
|
||||
manager = ExternalClientManager()
|
||||
|
||||
test_imdb = "tt1596343" # Example IMDb ID
|
||||
digital_candidates = manager.get_digital_release_candidates(test_imdb)
|
||||
print(f"Digital release candidates for {test_imdb}: {digital_candidates}")
|
||||
|
||||
earliest = manager.get_earliest_digital_release(test_imdb)
|
||||
if earliest:
|
||||
print(f"Earliest digital release: {earliest[0]} ({earliest[1]})")
|
||||
else:
|
||||
print("No digital release dates found")
|
||||
@@ -0,0 +1,534 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from urllib.parse import urlencode, urljoin
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
# Import path mapper for proper path handling
|
||||
try:
|
||||
from core.path_mapper import path_mapper
|
||||
except ImportError:
|
||||
# Fallback for standalone testing
|
||||
class DummyPathMapper:
|
||||
def analyze_import_source_path(self, path):
|
||||
return "/downloads/" in path.lower(), "basic_check"
|
||||
|
||||
def is_download_path(self, path):
|
||||
return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
|
||||
|
||||
path_mapper = DummyPathMapper()
|
||||
|
||||
# Import database client for enhanced performance
|
||||
try:
|
||||
from clients.radarr_db_client import RadarrDbClient
|
||||
except ImportError:
|
||||
RadarrDbClient = None
|
||||
|
||||
|
||||
class RadarrClient:
|
||||
"""Enhanced Radarr API client with improved import date detection"""
|
||||
|
||||
# Radarr History API event types (HistoryEventType enum)
|
||||
# From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
|
||||
EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
|
||||
EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
|
||||
EVENT_TYPE_FAILED = 4 # Download or import failed
|
||||
EVENT_TYPE_RETAGGED = 6 # Files were tagged
|
||||
EVENT_TYPE_RENAMED = 8 # Files were renamed
|
||||
|
||||
# Event types that indicate real imports
|
||||
REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
|
||||
|
||||
# These are now handled by path_mapper, but keeping for backward compatibility
|
||||
DOWNLOAD_PATH_INDICATORS = [
|
||||
'/downloads/', '/download/', '/completed/', '/importing/',
|
||||
'/nzbs/', '/torrents/', '/temp/', '/tmp/',
|
||||
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
|
||||
'usenet', 'torrent', 'radarr', 'completed', 'processing'
|
||||
]
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
|
||||
# Initialize database client - REQUIRED for operation
|
||||
self.db_client = None
|
||||
if RadarrDbClient:
|
||||
try:
|
||||
self.db_client = RadarrDbClient.from_env()
|
||||
if self.db_client:
|
||||
_log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled")
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}")
|
||||
self.db_client = None
|
||||
else:
|
||||
_log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Radarr API with retries"""
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
attempt = 0
|
||||
last_err = None
|
||||
|
||||
while attempt <= self.retries:
|
||||
try:
|
||||
params = params or {}
|
||||
params["apikey"] = self.api_key
|
||||
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
|
||||
|
||||
if params:
|
||||
url = url + ("&" if "?" in url else "?") + urlencode(params)
|
||||
|
||||
_log("DEBUG", f"Radarr API Request: {url}")
|
||||
req = UrlRequest(url, headers={"Accept": "application/json"})
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data)
|
||||
return result
|
||||
|
||||
except (URLError, HTTPError, json.JSONDecodeError) as e:
|
||||
last_err = e
|
||||
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
|
||||
time.sleep(min(2 ** attempt, 5)) # Exponential backoff
|
||||
attempt += 1
|
||||
|
||||
_log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
|
||||
return None
|
||||
|
||||
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find movie by IMDb ID - DATABASE ONLY mode"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
|
||||
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
movie = self.db_client.get_movie_by_imdb(imdb_id)
|
||||
if movie:
|
||||
_log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})")
|
||||
return movie
|
||||
else:
|
||||
_log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database lookup failed: {e}")
|
||||
return None
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for movie lookup - API mode disabled")
|
||||
return None
|
||||
|
||||
def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Analyze a history event to determine if it's a real import.
|
||||
|
||||
Args:
|
||||
event: The history event to analyze
|
||||
movie_info: Optional movie information to validate paths against
|
||||
|
||||
Returns:
|
||||
(is_real_import, reason, date_iso)
|
||||
"""
|
||||
event_type = event.get("eventType")
|
||||
date_str = event.get("date")
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Parse date
|
||||
date_iso = None
|
||||
if date_str:
|
||||
try:
|
||||
date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
except Exception:
|
||||
date_iso = None
|
||||
|
||||
if not date_iso:
|
||||
return False, "no_valid_date", None
|
||||
|
||||
# Convert event type to int if needed
|
||||
try:
|
||||
event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
|
||||
except (ValueError, TypeError):
|
||||
event_type_int = None
|
||||
|
||||
# Check if event type indicates import
|
||||
if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
|
||||
return False, f"event_type_not_import({event_type})", date_iso
|
||||
|
||||
# Get all possible source paths/titles
|
||||
source_items = []
|
||||
|
||||
# Get both sourcePath and importedPath if available
|
||||
if event_data:
|
||||
for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
|
||||
if event_data.get(key):
|
||||
source_items.append(event_data[key])
|
||||
|
||||
# Also check event root for these fields
|
||||
for key in ['sourcePath', 'sourceTitle', 'importedPath']:
|
||||
if event.get(key):
|
||||
source_items.append(event[key])
|
||||
|
||||
# Clean up and make unique
|
||||
source_items = [str(s).lower().strip() for s in source_items if s]
|
||||
source_items = list(set(source_items)) # Remove duplicates
|
||||
|
||||
if not source_items:
|
||||
return False, "no_source_paths", date_iso
|
||||
|
||||
# If we have movie info, look for title/year match
|
||||
if movie_info:
|
||||
movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
|
||||
movie_year = str(movie_info.get('year', ''))
|
||||
|
||||
for source in source_items:
|
||||
# Clean up source text for comparison
|
||||
source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
|
||||
|
||||
# Check if both title and year are in the source
|
||||
if movie_title and movie_year:
|
||||
if movie_title in source_clean and movie_year in source_clean:
|
||||
_log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}")
|
||||
return True, "matched_title_and_year", date_iso
|
||||
|
||||
# Also check for downloads path as secondary validation
|
||||
if path_mapper.is_download_path(source):
|
||||
_log("DEBUG", f"Source is from downloads: {source}")
|
||||
return True, "from_downloads_path", date_iso
|
||||
|
||||
_log("DEBUG", f"⚠️ No match found in sources: {source_items}")
|
||||
return False, "no_title_year_match", date_iso
|
||||
|
||||
# Fallback to basic path validation if no movie info
|
||||
for source in source_items:
|
||||
if path_mapper.is_download_path(source):
|
||||
return True, "basic_download_path_match", date_iso
|
||||
|
||||
return False, "no_download_path_match", date_iso
|
||||
|
||||
def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Find earliest real import event with optimized querying.
|
||||
Stops as soon as we find valid import events instead of loading everything.
|
||||
"""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
# Get movie info for path validation
|
||||
movie_info = self._get(f"/api/v3/movie/{movie_id}")
|
||||
if not movie_info or not isinstance(movie_info, dict):
|
||||
_log("ERROR", f"Could not get movie info for ID {movie_id}")
|
||||
return None
|
||||
|
||||
earliest_real_import = None
|
||||
first_grab = None
|
||||
page = 1
|
||||
page_size = 50 # Smaller pages for faster iteration
|
||||
total_processed = 0
|
||||
|
||||
while page <= 20: # Safety limit
|
||||
# Get history in chronological order
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
|
||||
if not data:
|
||||
break
|
||||
|
||||
items = data if isinstance(data, list) else data.get("records", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Page {page}: Processing {len(items)} events")
|
||||
|
||||
for event in items:
|
||||
total_processed += 1
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Convert event type to int or handle string types
|
||||
try:
|
||||
if isinstance(event_type, str):
|
||||
# Map string event types to numeric values
|
||||
string_to_numeric = {
|
||||
"grabbed": self.EVENT_TYPE_GRABBED,
|
||||
"downloadFolderImported": self.EVENT_TYPE_IMPORTED,
|
||||
"movieFileImported": self.EVENT_TYPE_IMPORTED,
|
||||
"downloadFailed": self.EVENT_TYPE_FAILED,
|
||||
"movieFileRenamed": self.EVENT_TYPE_RENAMED,
|
||||
"movieFileDeleted": 5 # Not in our constants but common
|
||||
}
|
||||
event_type = string_to_numeric.get(event_type, 0)
|
||||
else:
|
||||
event_type = int(event_type)
|
||||
except (ValueError, TypeError):
|
||||
_log("DEBUG", f"Unknown event type: {event_type}")
|
||||
continue
|
||||
|
||||
# Check for grab events (type 1) - but validate it's a real download
|
||||
if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
|
||||
if event.get("date"):
|
||||
try:
|
||||
# Get event data to check if this is a real grab with download info
|
||||
event_data = event.get("data", {})
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
event_data = {}
|
||||
|
||||
# Check if this grab has actual download/indexer info
|
||||
source_title = event_data.get("sourceTitle", "")
|
||||
indexer = event_data.get("indexer", "")
|
||||
|
||||
# Only count grabs that have actual download metadata
|
||||
if source_title or indexer:
|
||||
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
|
||||
else:
|
||||
_log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Only process import events (type 3)
|
||||
if event_type != self.EVENT_TYPE_IMPORTED:
|
||||
continue
|
||||
|
||||
# Get imported path from event data
|
||||
imported_path = None
|
||||
event_data = event.get("data", {})
|
||||
|
||||
# Handle both string and dict data
|
||||
if isinstance(event_data, str):
|
||||
try:
|
||||
event_data = json.loads(event_data)
|
||||
except (json.JSONDecodeError, AttributeError) as e:
|
||||
_log("DEBUG", f"Failed to parse event data JSON: {e}")
|
||||
continue
|
||||
elif not isinstance(event_data, dict):
|
||||
continue
|
||||
|
||||
imported_path = event_data.get("importedPath", "")
|
||||
if not imported_path:
|
||||
continue
|
||||
|
||||
imported_path = imported_path.lower()
|
||||
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
movie_title = (movie_info.get("title", "") or "").lower()
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
|
||||
# First try IMDb ID match
|
||||
# First try IMDb ID match
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in imported_path or
|
||||
f"[{movie_imdb}]" in imported_path or
|
||||
movie_imdb in imported_path
|
||||
):
|
||||
_log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Then try title/year match with fuzzy path cleaning
|
||||
if movie_title and movie_year:
|
||||
# Clean strings for comparison
|
||||
clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
|
||||
clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
|
||||
|
||||
# Look for both title and year in the path
|
||||
if clean_title in clean_path and movie_year in clean_path:
|
||||
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
|
||||
_log("INFO", f"✅ FOUND IMPORT at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Fallback to normal import analysis
|
||||
is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
|
||||
if is_real and date_iso:
|
||||
source_path = (event.get("data", {}).get("sourcePath", "") or
|
||||
event.get("data", {}).get("droppedPath", "") or
|
||||
event.get("sourcePath", "") or
|
||||
event.get("data", {}).get("importedPath", "") or
|
||||
event.get("importedPath", "") or "").lower()
|
||||
|
||||
if source_path:
|
||||
# Check for path match
|
||||
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
|
||||
if movie_imdb and (
|
||||
f"[imdb-{movie_imdb}]" in source_path or
|
||||
f"[{movie_imdb}]" in source_path or
|
||||
movie_imdb in source_path
|
||||
):
|
||||
_log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
|
||||
# Check for title/year match
|
||||
movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
|
||||
movie_year = str(movie_info.get("year", ""))
|
||||
if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
|
||||
_log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}")
|
||||
earliest_real_import = date_iso
|
||||
break
|
||||
elif event_type == 3:
|
||||
_log("DEBUG", f"⚠️ Skipped import event: {reason}")
|
||||
|
||||
# If we found a real import, no need to continue
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
# If we got less than page size, we've seen all events
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_processed} events across {page-1} pages")
|
||||
|
||||
if earliest_real_import:
|
||||
_log("INFO", f"✅ Using earliest real import: {earliest_real_import}")
|
||||
return earliest_real_import
|
||||
|
||||
if first_grab:
|
||||
_log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}")
|
||||
return first_grab
|
||||
|
||||
_log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}")
|
||||
return None
|
||||
|
||||
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get movie files for a movie - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
_log("INFO", "Using database for movie files lookup")
|
||||
# Database handles this internally in get_movie_file_date()
|
||||
return []
|
||||
|
||||
_log("ERROR", "Database client required for movie files - API mode disabled")
|
||||
return []
|
||||
|
||||
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
|
||||
"""Get earliest file dateAdded - DATABASE ONLY mode"""
|
||||
if self.db_client:
|
||||
try:
|
||||
return self.db_client.get_movie_file_date(movie_id)
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database file date query failed: {e}")
|
||||
return None
|
||||
|
||||
_log("ERROR", "Database client required for file dates - API mode disabled")
|
||||
return None
|
||||
|
||||
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie - DATABASE ONLY mode.
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Database required - no API fallback
|
||||
if self.db_client:
|
||||
try:
|
||||
date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
else:
|
||||
_log("WARNING", f"No import date found in database for movie_id {movie_id}")
|
||||
return None, "radarr:db.no_date_found"
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database import date query failed: {e}")
|
||||
return None, "radarr:db.error"
|
||||
|
||||
# No database client available
|
||||
_log("ERROR", "Database client required for import date detection - API mode disabled")
|
||||
return None, "radarr:db.not_configured"
|
||||
|
||||
def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
|
||||
"""Get the earliest import date from Radarr history."""
|
||||
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
|
||||
|
||||
earliest_real_import = None
|
||||
earliest_grab_date = None
|
||||
page = 1
|
||||
page_size = 50
|
||||
total_events = 0
|
||||
|
||||
# Get full movie history
|
||||
while True:
|
||||
# Get page of history
|
||||
history_data = self._get_movie_history_page(movie_id, page, page_size)
|
||||
if not history_data:
|
||||
break
|
||||
|
||||
# Process events on this page
|
||||
for event in history_data:
|
||||
event_type = event.get("eventType")
|
||||
if not event_type:
|
||||
continue
|
||||
|
||||
# Parse event date
|
||||
event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
# Track earliest grab date as fallback (EventType 1)
|
||||
if event_type == 1 and not earliest_grab_date:
|
||||
earliest_grab_date = event_date
|
||||
_log("DEBUG", f"Found first grab event at {earliest_grab_date}")
|
||||
continue
|
||||
|
||||
# Look for import events (EventType 3)
|
||||
if event_type == 3:
|
||||
try:
|
||||
data = json.loads(event.get("data", "{}"))
|
||||
if data.get("importedPath"):
|
||||
_log("INFO", f"✅ FOUND IMPORT at {event_date}")
|
||||
earliest_real_import = event_date
|
||||
break
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Break if we found an import
|
||||
if earliest_real_import:
|
||||
break
|
||||
|
||||
total_events += len(history_data)
|
||||
page += 1
|
||||
|
||||
_log("INFO", f"Processed {total_events} events across {page}")
|
||||
|
||||
if earliest_real_import:
|
||||
return earliest_real_import
|
||||
if earliest_grab_date:
|
||||
_log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
|
||||
return earliest_grab_date
|
||||
return None
|
||||
|
||||
def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
|
||||
"""Get a page of movie history."""
|
||||
data = self._get("/api/v3/history", {
|
||||
"movieId": str(movie_id),
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"sortKey": "date",
|
||||
"sortDirection": "ascending"
|
||||
})
|
||||
return data if isinstance(data, list) else data.get("records", [])
|
||||
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct Radarr Database Client for NFOGuard
|
||||
Provides high-performance access to Radarr's SQLite/PostgreSQL database
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class RadarrDbClient:
|
||||
"""Direct database client for Radarr's SQLite or PostgreSQL database"""
|
||||
|
||||
def __init__(self,
|
||||
db_type: str = "sqlite",
|
||||
db_path: Optional[str] = None,
|
||||
db_host: Optional[str] = None,
|
||||
db_port: Optional[int] = None,
|
||||
db_name: Optional[str] = None,
|
||||
db_user: Optional[str] = None,
|
||||
db_password: Optional[str] = None):
|
||||
"""
|
||||
Initialize Radarr database client
|
||||
|
||||
Args:
|
||||
db_type: "sqlite" or "postgresql"
|
||||
db_path: Path to SQLite database file
|
||||
db_host: PostgreSQL host
|
||||
db_port: PostgreSQL port
|
||||
db_name: PostgreSQL database name
|
||||
db_user: PostgreSQL username
|
||||
db_password: PostgreSQL password
|
||||
"""
|
||||
self.db_type = db_type.lower()
|
||||
self.db_path = db_path
|
||||
self.db_host = db_host
|
||||
self.db_port = db_port or 5432
|
||||
self.db_name = db_name
|
||||
self.db_user = db_user
|
||||
self.db_password = db_password
|
||||
|
||||
self._test_connection()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> Optional['RadarrDbClient']:
|
||||
"""Create client from environment variables"""
|
||||
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
|
||||
|
||||
if not db_type:
|
||||
return None
|
||||
|
||||
if db_type == "sqlite":
|
||||
db_path = os.environ.get("RADARR_DB_PATH")
|
||||
if not db_path or not Path(db_path).exists():
|
||||
_log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
|
||||
return None
|
||||
return cls(db_type="sqlite", db_path=db_path)
|
||||
|
||||
elif db_type == "postgresql":
|
||||
# Support both individual vars and connection string
|
||||
db_url = os.environ.get("RADARR_DB_URL")
|
||||
if db_url:
|
||||
parsed = urlparse(db_url)
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=parsed.hostname,
|
||||
db_port=parsed.port or 5432,
|
||||
db_name=parsed.path.lstrip('/'),
|
||||
db_user=parsed.username,
|
||||
db_password=parsed.password
|
||||
)
|
||||
else:
|
||||
return cls(
|
||||
db_type="postgresql",
|
||||
db_host=os.environ.get("RADARR_DB_HOST"),
|
||||
db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
|
||||
db_name=os.environ.get("RADARR_DB_NAME"),
|
||||
db_user=os.environ.get("RADARR_DB_USER"),
|
||||
db_password=os.environ.get("RADARR_DB_PASSWORD")
|
||||
)
|
||||
else:
|
||||
_log("ERROR", f"Unsupported database type: {db_type}")
|
||||
return None
|
||||
|
||||
def _test_connection(self) -> None:
|
||||
"""Test database connection on initialization"""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
if conn:
|
||||
conn.close()
|
||||
_log("INFO", f"Connected to Radarr {self.db_type} database successfully")
|
||||
else:
|
||||
raise Exception("Failed to create connection")
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Failed to connect to Radarr database: {e}")
|
||||
raise
|
||||
|
||||
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
|
||||
"""Get database connection"""
|
||||
if self.db_type == "sqlite":
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
elif self.db_type == "postgresql":
|
||||
conn = psycopg2.connect(
|
||||
host=self.db_host,
|
||||
port=self.db_port,
|
||||
database=self.db_name,
|
||||
user=self.db_user,
|
||||
password=self.db_password
|
||||
)
|
||||
return conn
|
||||
else:
|
||||
raise ValueError(f"Unsupported database type: {self.db_type}")
|
||||
|
||||
def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Find movie by IMDb ID using database query
|
||||
|
||||
Returns:
|
||||
Dictionary with movie info including id, imdbId, title, year, path
|
||||
"""
|
||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
m."Id" as id,
|
||||
m."Path" as path,
|
||||
m."Added" as added,
|
||||
mm."ImdbId" as imdb_id,
|
||||
mm."Title" as title,
|
||||
mm."Year" as year,
|
||||
mm."DigitalRelease" as digital_release
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (imdb_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return dict(row) if self.db_type == "sqlite" else row
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get earliest import date from History table, accounting for upgrade scenarios
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# If first event is rename, all subsequent imports are upgrades - skip them
|
||||
if self.is_first_event_rename_based(movie_id):
|
||||
_log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
|
||||
return None, "radarr:db.upgrade_imports_skipped"
|
||||
|
||||
# Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
|
||||
import_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 3
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
# Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
|
||||
grab_query = """
|
||||
SELECT
|
||||
h."Date" as event_date,
|
||||
h."Data" as event_data,
|
||||
h."EventType" as event_type
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
AND h."EventType" = 1
|
||||
AND h."Data" IS NOT NULL
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
import_query = import_query.replace("%s", "?")
|
||||
grab_query = grab_query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try import events first
|
||||
cursor.execute(import_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.import"
|
||||
|
||||
# Fallback to grab events
|
||||
cursor.execute(grab_query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
|
||||
if isinstance(event_date, str):
|
||||
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = event_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
_log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
|
||||
return date_iso, "radarr:db.history.grab"
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie {movie_id}: {e}")
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def is_first_event_rename_based(self, movie_id: int) -> bool:
|
||||
"""
|
||||
Check if the first event in history is rename-based (not a true import)
|
||||
|
||||
This helps identify movies where:
|
||||
- First event: movieFileRenamed (EventType = 8)
|
||||
- Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
|
||||
|
||||
In such cases, we should prefer release dates over the upgrade date
|
||||
"""
|
||||
query = """
|
||||
SELECT h."EventType" as event_type, h."Date" as event_date
|
||||
FROM "History" h
|
||||
WHERE h."MovieId" = %s
|
||||
ORDER BY h."Date" ASC
|
||||
LIMIT 5
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
rows = cursor.fetchall()
|
||||
|
||||
if rows:
|
||||
_log("INFO", f"Movie {movie_id} history debug - first 5 events:")
|
||||
for i, row in enumerate(rows):
|
||||
event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
|
||||
event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
|
||||
_log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
|
||||
|
||||
first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
|
||||
# EventType 8 = movieFileRenamed
|
||||
# Also check for EventType 7 = movieFileRenamed in some Radarr versions
|
||||
is_rename_first = first_event_type in [7, 8]
|
||||
_log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
|
||||
|
||||
if is_rename_first:
|
||||
_log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
|
||||
|
||||
return is_rename_first
|
||||
else:
|
||||
_log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def get_movie_file_date(self, movie_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get earliest file dateAdded as fallback
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
|
||||
Returns:
|
||||
ISO date string or None
|
||||
"""
|
||||
query = """
|
||||
SELECT MIN(mf."DateAdded") as earliest_date
|
||||
FROM "MovieFiles" mf
|
||||
WHERE mf."MovieId" = %s
|
||||
"""
|
||||
|
||||
if self.db_type == "sqlite":
|
||||
query = query.replace("%s", "?")
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, (movie_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
|
||||
if date_value:
|
||||
if isinstance(date_value, str):
|
||||
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = date_value.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
Get the best import date for a movie using optimized database queries
|
||||
|
||||
Args:
|
||||
movie_id: Radarr movie ID
|
||||
fallback_to_file_date: Whether to fall back to file dateAdded
|
||||
|
||||
Returns:
|
||||
(date_iso, source_description)
|
||||
"""
|
||||
# Try history first - this handles upgrade detection internally
|
||||
date_iso, source = self.get_earliest_import_date(movie_id)
|
||||
if date_iso:
|
||||
return date_iso, source
|
||||
|
||||
# Check if we skipped upgrades and should prefer release dates
|
||||
if source == "radarr:db.upgrade_imports_skipped":
|
||||
_log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
|
||||
return None, "radarr:db.prefer_release_dates"
|
||||
|
||||
# Fallback to file date if requested
|
||||
if fallback_to_file_date:
|
||||
file_date = self.get_movie_file_date(movie_id)
|
||||
if file_date:
|
||||
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||
return file_date, "radarr:db.file.dateAdded"
|
||||
|
||||
return None, "radarr:db.no_date_found"
|
||||
|
||||
def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
|
||||
"""
|
||||
Get import dates for multiple movies in a single query
|
||||
|
||||
Args:
|
||||
imdb_ids: List of IMDb IDs
|
||||
|
||||
Returns:
|
||||
Dictionary mapping imdb_id -> (date_iso, source)
|
||||
"""
|
||||
if not imdb_ids:
|
||||
return {}
|
||||
|
||||
# Ensure all IMDb IDs have tt prefix
|
||||
clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_imdb_ids))
|
||||
if self.db_type == "sqlite":
|
||||
placeholders = ",".join(["?"] * len(clean_imdb_ids))
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
mm."ImdbId" as imdb_id,
|
||||
m."Id" as movie_id,
|
||||
MIN(h."Date") as earliest_import
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
|
||||
WHERE mm."ImdbId" IN ({placeholders})
|
||||
GROUP BY mm."ImdbId", m."Id"
|
||||
"""
|
||||
|
||||
results = {}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
if self.db_type == "postgresql":
|
||||
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
else:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(query, clean_imdb_ids)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
if self.db_type == "postgresql":
|
||||
imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
|
||||
else:
|
||||
imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
|
||||
|
||||
if earliest_import:
|
||||
if isinstance(earliest_import, str):
|
||||
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
|
||||
else:
|
||||
dt = earliest_import.replace(tzinfo=timezone.utc)
|
||||
|
||||
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
|
||||
results[imdb_id] = (date_iso, "radarr:db.bulk.import")
|
||||
else:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.no_import")
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Bulk query error: {e}")
|
||||
# Return empty results for failed queries
|
||||
for imdb_id in clean_imdb_ids:
|
||||
if imdb_id not in results:
|
||||
results[imdb_id] = (None, "radarr:db.bulk.error")
|
||||
|
||||
return results
|
||||
|
||||
def get_database_stats(self) -> Dict[str, Any]:
|
||||
"""Get basic statistics about the Radarr database"""
|
||||
stats = {}
|
||||
|
||||
queries = {
|
||||
"total_movies": 'SELECT COUNT(*) FROM "Movies"',
|
||||
"total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
|
||||
"total_history_events": 'SELECT COUNT(*) FROM "History"',
|
||||
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
|
||||
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
|
||||
}
|
||||
|
||||
try:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for stat_name, query in queries.items():
|
||||
cursor.execute(query)
|
||||
result = cursor.fetchone()
|
||||
stats[stat_name] = result[0] if result else 0
|
||||
|
||||
except Exception as e:
|
||||
_log("ERROR", f"Stats query error: {e}")
|
||||
stats["error"] = str(e)
|
||||
|
||||
return stats
|
||||
|
||||
def health_check(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Comprehensive health check for the Radarr database connection
|
||||
|
||||
Returns:
|
||||
Dictionary with health status, connection info, and basic functionality tests
|
||||
"""
|
||||
health = {
|
||||
"status": "healthy",
|
||||
"database_type": self.db_type,
|
||||
"connection": "ok",
|
||||
"readable": False,
|
||||
"writable": False,
|
||||
"tables_exist": False,
|
||||
"sample_data": False,
|
||||
"issues": [],
|
||||
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
}
|
||||
|
||||
try:
|
||||
# Test 1: Basic connection
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Test 2: Check if we can read (basic query)
|
||||
try:
|
||||
cursor.execute('SELECT 1')
|
||||
result = cursor.fetchone()
|
||||
if result and result[0] == 1:
|
||||
health["readable"] = True
|
||||
health["connection"] = "readable"
|
||||
else:
|
||||
health["issues"].append("Basic SELECT query failed")
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Read test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 3: Check required tables exist
|
||||
required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
|
||||
existing_tables = []
|
||||
|
||||
try:
|
||||
if self.db_type == "postgresql":
|
||||
cursor.execute("""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
else: # SQLite
|
||||
cursor.execute("""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type='table'
|
||||
AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
existing_tables = [row[0] for row in rows]
|
||||
|
||||
if len(existing_tables) == len(required_tables):
|
||||
health["tables_exist"] = True
|
||||
else:
|
||||
missing = set(required_tables) - set(existing_tables)
|
||||
health["issues"].append(f"Missing tables: {list(missing)}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
health["existing_tables"] = existing_tables
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Table check failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Test 4: Check for sample data
|
||||
if health["tables_exist"]:
|
||||
try:
|
||||
cursor.execute('SELECT COUNT(*) FROM "Movies"')
|
||||
movie_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute('SELECT COUNT(*) FROM "History"')
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
if movie_count > 0 and history_count > 0:
|
||||
health["sample_data"] = True
|
||||
health["movie_count"] = movie_count
|
||||
health["history_count"] = history_count
|
||||
else:
|
||||
health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Sample data check failed: {e}")
|
||||
|
||||
# Test 5: Test a real query (movie with IMDb lookup)
|
||||
if health["sample_data"]:
|
||||
try:
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM "Movies" m
|
||||
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
|
||||
WHERE mm."ImdbId" IS NOT NULL
|
||||
""")
|
||||
imdb_movies = cursor.fetchone()[0]
|
||||
health["movies_with_imdb"] = imdb_movies
|
||||
|
||||
if imdb_movies > 0:
|
||||
health["functional"] = True
|
||||
else:
|
||||
health["issues"].append("No movies with IMDb IDs found")
|
||||
|
||||
except Exception as e:
|
||||
health["issues"].append(f"Functional test failed: {e}")
|
||||
health["status"] = "degraded"
|
||||
|
||||
except Exception as e:
|
||||
health["status"] = "error"
|
||||
health["connection"] = "failed"
|
||||
health["issues"].append(f"Connection failed: {e}")
|
||||
_log("ERROR", f"Database health check failed: {e}")
|
||||
|
||||
# Overall status determination
|
||||
if health["issues"]:
|
||||
if health["status"] == "healthy":
|
||||
health["status"] = "degraded"
|
||||
|
||||
# Add connection details (safe info only)
|
||||
health["connection_info"] = {
|
||||
"type": self.db_type,
|
||||
"host": self.db_host if self.db_type == "postgresql" else None,
|
||||
"port": self.db_port if self.db_type == "postgresql" else None,
|
||||
"database": self.db_name if self.db_type == "postgresql" else None,
|
||||
"path": self.db_path if self.db_type == "sqlite" else None
|
||||
}
|
||||
|
||||
return health
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the database client
|
||||
print("Testing RadarrDbClient...")
|
||||
|
||||
# Test with environment variables
|
||||
client = RadarrDbClient.from_env()
|
||||
if client:
|
||||
print("✅ Connected to Radarr database")
|
||||
|
||||
# Test stats
|
||||
stats = client.get_database_stats()
|
||||
print(f"Database stats: {stats}")
|
||||
|
||||
# Test movie lookup
|
||||
test_movie = client.get_movie_by_imdb("tt1596343")
|
||||
if test_movie:
|
||||
print(f"Found test movie: {test_movie}")
|
||||
|
||||
# Test import date
|
||||
movie_id = test_movie['id']
|
||||
date_iso, source = client.get_movie_import_date_optimized(movie_id)
|
||||
print(f"Import date: {date_iso} (source: {source})")
|
||||
else:
|
||||
print("Test movie not found")
|
||||
else:
|
||||
print("❌ Could not connect to database - check environment variables")
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Sonarr API client for TV show metadata and episode management
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request as UrlRequest, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from core.logging import _log
|
||||
|
||||
|
||||
class SonarrClient:
|
||||
"""Enhanced Sonarr API client for TV series and episode management"""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.retries = max(0, retries)
|
||||
self.enabled = bool(self.base_url and self.api_key)
|
||||
|
||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||
"""Make GET request to Sonarr API with retries"""
|
||||
if not self.enabled:
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/api/v3{path}"
|
||||
if params:
|
||||
url += "?" + urlencode(params)
|
||||
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
|
||||
for attempt in range(self.retries):
|
||||
try:
|
||||
_log("DEBUG", f"Sonarr API Request: {url}")
|
||||
req = UrlRequest(url, headers=headers)
|
||||
|
||||
with urlopen(req, timeout=self.timeout) as resp:
|
||||
data = resp.read().decode("utf-8")
|
||||
result = json.loads(data) if data else None
|
||||
return result
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 401:
|
||||
_log("ERROR", "Sonarr authentication failed - check API key")
|
||||
return None
|
||||
elif e.code == 429:
|
||||
wait_time = (attempt + 1) * 2
|
||||
_log("WARNING", f"Sonarr rate limited, waiting {wait_time}s (attempt {attempt+1}/{self.retries})")
|
||||
time.sleep(wait_time)
|
||||
else:
|
||||
_log("WARNING", f"Sonarr HTTP {e.code} error on attempt {attempt+1}/{self.retries}: {e.reason}")
|
||||
|
||||
except Exception as e:
|
||||
_log("WARNING", f"Sonarr API attempt {attempt+1}/{self.retries} failed: {e}")
|
||||
|
||||
if attempt < self.retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
_log("ERROR", f"Sonarr API failed after {self.retries} attempts: {url}")
|
||||
return None
|
||||
|
||||
def series_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by IMDb ID using lookup endpoint"""
|
||||
search_term = f"imdbid:{imdb_id}"
|
||||
_log("DEBUG", f"Searching Sonarr with term: {search_term}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": search_term})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr lookup for: {search_term}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr lookup returned {len(result)} results")
|
||||
|
||||
# Log all results for debugging
|
||||
for i, series in enumerate(result):
|
||||
series_imdb = series.get("imdbId", "")
|
||||
series_title = series.get("title", "")
|
||||
series_id = series.get("id", "")
|
||||
_log("DEBUG", f"Result {i+1}: Title='{series_title}', IMDb='{series_imdb}', ID={series_id}")
|
||||
|
||||
# Find exact IMDb match (case insensitive)
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found exact IMDb match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Try partial match as fallback
|
||||
for series in result:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if target_imdb in series_imdb or series_imdb in target_imdb:
|
||||
_log("WARNING", f"Found partial IMDb match: {series.get('title')} (Expected: {imdb_id}, Found: {series.get('imdbId')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No IMDb match found in {len(result)} results for {imdb_id}")
|
||||
return None
|
||||
|
||||
def series_by_title(self, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""Search for series by title as fallback when IMDb lookup fails"""
|
||||
_log("DEBUG", f"Searching Sonarr by title: {title}")
|
||||
|
||||
result = self._get("/series/lookup", {"term": title})
|
||||
if not result:
|
||||
_log("WARNING", f"No results from Sonarr title search for: {title}")
|
||||
return None
|
||||
|
||||
_log("DEBUG", f"Sonarr title search returned {len(result)} results")
|
||||
|
||||
title_lower = title.lower()
|
||||
|
||||
# Look for exact title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if series_title == title_lower:
|
||||
_log("INFO", f"Found exact title match: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
# Look for partial title match
|
||||
for series in result:
|
||||
series_title = (series.get("title") or "").lower()
|
||||
if title_lower in series_title or series_title in title_lower:
|
||||
_log("INFO", f"Found partial title match: '{series.get('title')}' for search '{title}' (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No title match found for: {title}")
|
||||
return None
|
||||
|
||||
def get_all_series(self) -> List[Dict[str, Any]]:
|
||||
"""Get all series from Sonarr"""
|
||||
return self._get("/series") or []
|
||||
|
||||
def series_by_imdb_direct(self, imdb_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find series by scanning all series for IMDb match (slower but more reliable)"""
|
||||
_log("DEBUG", f"Direct series lookup for IMDb: {imdb_id}")
|
||||
all_series = self.get_all_series()
|
||||
|
||||
target_imdb = imdb_id.lower()
|
||||
for series in all_series:
|
||||
series_imdb = (series.get("imdbId") or "").lower()
|
||||
if series_imdb == target_imdb:
|
||||
_log("INFO", f"Found series via direct lookup: {series.get('title')} (ID: {series.get('id')})")
|
||||
return series
|
||||
|
||||
_log("WARNING", f"No series found with IMDb ID via direct lookup: {imdb_id}")
|
||||
return None
|
||||
|
||||
def episodes_for_series(self, series_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all episodes for a series"""
|
||||
return self._get("/episode", {"seriesId": series_id}) or []
|
||||
|
||||
def episode_file(self, episode_file_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get episode file details"""
|
||||
return self._get(f"/episodefile/{episode_file_id}")
|
||||
|
||||
def get_episode_import_history(self, episode_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get the original import date from history with enhanced detection.
|
||||
Focuses on finding the earliest REAL import, not upgrades.
|
||||
"""
|
||||
all_records = []
|
||||
page = 1
|
||||
page_size = 100
|
||||
|
||||
# Collect all history records for this episode
|
||||
while True:
|
||||
history = self._get("/history", {
|
||||
"episodeId": episode_id,
|
||||
"sortKey": "date",
|
||||
"sortDir": "asc",
|
||||
"page": page,
|
||||
"pageSize": page_size
|
||||
})
|
||||
|
||||
if not history:
|
||||
break
|
||||
|
||||
records = history.get("records", []) if isinstance(history, dict) else []
|
||||
if not records:
|
||||
break
|
||||
|
||||
all_records.extend(records)
|
||||
|
||||
if len(records) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
if page > 10: # Safety valve
|
||||
break
|
||||
|
||||
_log("DEBUG", f"Got {len(all_records)} history records for episode {episode_id}")
|
||||
|
||||
# Categorize events
|
||||
import_events = []
|
||||
grabbed_events = []
|
||||
rename_events = []
|
||||
|
||||
for event in all_records:
|
||||
event_type = event.get("eventType", "").lower()
|
||||
date = event.get("date")
|
||||
|
||||
if not date:
|
||||
continue
|
||||
|
||||
_log("DEBUG", f"History event: {event_type} at {date}")
|
||||
|
||||
if event_type == "downloadfolderimported":
|
||||
import_events.append({"date": date, "event": event})
|
||||
elif event_type == "grabbed":
|
||||
grabbed_events.append({"date": date, "event": event})
|
||||
elif event_type == "episodefilerenamed":
|
||||
rename_events.append({"date": date, "event": event})
|
||||
|
||||
# Use the earliest real import event
|
||||
if import_events:
|
||||
earliest_import = min(import_events, key=lambda x: x["date"])
|
||||
import_date = earliest_import["date"]
|
||||
_log("INFO", f"Found import date: {import_date} for episode {episode_id}")
|
||||
|
||||
# Check if this looks like an upgrade by comparing to renames
|
||||
if rename_events:
|
||||
earliest_rename = min(rename_events, key=lambda x: x["date"])
|
||||
rename_date = earliest_rename["date"]
|
||||
|
||||
try:
|
||||
import_dt = datetime.fromisoformat(import_date.replace("Z", "+00:00"))
|
||||
rename_dt = datetime.fromisoformat(rename_date.replace("Z", "+00:00"))
|
||||
days_diff = (import_dt - rename_dt).days
|
||||
|
||||
# If import is significantly after rename, prefer rename date
|
||||
if days_diff > 30:
|
||||
_log("WARNING", f"Import {import_date} is {days_diff} days after rename {rename_date} - using rename date")
|
||||
return rename_date
|
||||
|
||||
except Exception as e:
|
||||
_log("DEBUG", f"Error comparing dates: {e}")
|
||||
|
||||
return import_date
|
||||
|
||||
# Fallback to grab event
|
||||
if grabbed_events:
|
||||
earliest_grab = min(grabbed_events, key=lambda x: x["date"])
|
||||
_log("WARNING", f"No import events, using grab date: {earliest_grab['date']} for episode {episode_id}")
|
||||
return earliest_grab["date"]
|
||||
|
||||
_log("WARNING", f"No reliable import events found for episode {episode_id}")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the client
|
||||
import os
|
||||
|
||||
base_url = os.environ.get("SONARR_URL", "")
|
||||
api_key = os.environ.get("SONARR_API_KEY", "")
|
||||
|
||||
if base_url and api_key:
|
||||
client = SonarrClient(base_url, api_key)
|
||||
|
||||
# Test with a known series
|
||||
test_imdb = "tt2085059" # Example
|
||||
series = client.series_by_imdb(test_imdb)
|
||||
|
||||
if series:
|
||||
series_id = series.get("id")
|
||||
print(f"Found series: {series.get('title')} (ID: {series_id})")
|
||||
|
||||
# Get episodes
|
||||
episodes = client.episodes_for_series(series_id)
|
||||
print(f"Found {len(episodes)} episodes")
|
||||
|
||||
# Test import date for first episode
|
||||
if episodes:
|
||||
first_episode = episodes[0]
|
||||
episode_id = first_episode.get("id")
|
||||
import_date = client.get_episode_import_history(episode_id)
|
||||
print(f"First episode import date: {import_date}")
|
||||
else:
|
||||
print(f"Series not found: {test_imdb}")
|
||||
else:
|
||||
print("Please set SONARR_URL and SONARR_API_KEY environment variables for testing")
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database management for NFOGuard
|
||||
Handles SQLite database operations for tracking media dates and processing history
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, List, Any
|
||||
from contextlib import contextmanager
|
||||
|
||||
class NFOGuardDatabase:
|
||||
"""Manages NFOGuard SQLite database operations"""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._local = threading.local()
|
||||
self._init_database()
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get thread-local database connection"""
|
||||
if not hasattr(self._local, 'connection'):
|
||||
self._local.connection = sqlite3.connect(
|
||||
self.db_path,
|
||||
check_same_thread=False,
|
||||
timeout=30.0
|
||||
)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
@contextmanager
|
||||
def get_connection(self):
|
||||
"""Context manager for database connections"""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
def _init_database(self):
|
||||
"""Initialize database tables with migration support"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
last_updated TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Episodes table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
imdb_id TEXT NOT NULL,
|
||||
season INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
aired TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL,
|
||||
PRIMARY KEY (imdb_id, season, episode),
|
||||
FOREIGN KEY (imdb_id) REFERENCES series(imdb_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Movies table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS movies (
|
||||
imdb_id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL,
|
||||
released TEXT,
|
||||
dateadded TEXT,
|
||||
source TEXT,
|
||||
last_updated TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Processing history table
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
imdb_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL,
|
||||
details TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Add missing columns if they don't exist (migration)
|
||||
# Check current schema and add missing columns
|
||||
cursor.execute("PRAGMA table_info(movies)")
|
||||
movie_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute("PRAGMA table_info(episodes)")
|
||||
episode_columns = [row[1] for row in cursor.fetchall()]
|
||||
|
||||
# Add missing columns to movies table
|
||||
if 'path' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN path TEXT")
|
||||
cursor.execute("UPDATE movies SET path = '/unknown/path/' || imdb_id WHERE path IS NULL")
|
||||
|
||||
if 'has_video_file' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in movie_columns:
|
||||
cursor.execute("ALTER TABLE movies ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE movies SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Add missing columns to episodes table
|
||||
if 'has_video_file' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN has_video_file BOOLEAN DEFAULT FALSE")
|
||||
|
||||
if 'last_updated' not in episode_columns:
|
||||
cursor.execute("ALTER TABLE episodes ADD COLUMN last_updated TEXT")
|
||||
cursor.execute("UPDATE episodes SET last_updated = datetime('now') WHERE last_updated IS NULL")
|
||||
|
||||
# Create indexes
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_imdb ON episodes(imdb_id)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_episodes_video ON episodes(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_movies_video ON movies(has_video_file)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_history_imdb ON processing_history(imdb_id)")
|
||||
|
||||
def upsert_series(self, imdb_id: str, path: str, metadata: Optional[Dict] = None):
|
||||
"""Insert or update series record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat(), json.dumps(metadata) if metadata else None))
|
||||
|
||||
def upsert_episode_date(self, imdb_id: str, season: int, episode: int,
|
||||
aired: Optional[str], dateadded: Optional[str],
|
||||
source: str, has_video_file: bool = False):
|
||||
"""Insert or update episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO episodes
|
||||
(imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (imdb_id, season, episode, aired, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||
|
||||
def upsert_movie(self, imdb_id: str, path: str):
|
||||
"""Insert or update movie record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, last_updated)
|
||||
VALUES (?, ?, ?)
|
||||
""", (imdb_id, path, datetime.utcnow().isoformat()))
|
||||
except sqlite3.OperationalError as e:
|
||||
if "no column named path" in str(e):
|
||||
# Fallback for databases without path column - just insert imdb_id
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, last_updated)
|
||||
VALUES (?, ?)
|
||||
""", (imdb_id, datetime.utcnow().isoformat()))
|
||||
else:
|
||||
raise
|
||||
|
||||
def upsert_movie_dates(self, imdb_id: str, released: Optional[str],
|
||||
dateadded: Optional[str], source: str, has_video_file: bool = False):
|
||||
"""Insert or update movie date record"""
|
||||
print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}")
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
# Use INSERT OR REPLACE to ensure we always update the dates properly
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated)
|
||||
VALUES (
|
||||
?,
|
||||
COALESCE((SELECT path FROM movies WHERE imdb_id = ?), 'unknown'),
|
||||
?, ?, ?, ?, ?
|
||||
)
|
||||
""", (imdb_id, imdb_id, released, dateadded, source, has_video_file, datetime.utcnow().isoformat()))
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||
result = cursor.fetchone()
|
||||
print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}")
|
||||
|
||||
def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]:
|
||||
"""Get all episodes for a series"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM episodes WHERE imdb_id = ?"
|
||||
params = [imdb_id]
|
||||
|
||||
if has_video_file_only:
|
||||
query += " AND has_video_file = TRUE"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_episode_date(self, imdb_id: str, season: int, episode: int) -> Optional[Dict]:
|
||||
"""Get episode date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT * FROM episodes
|
||||
WHERE imdb_id = ? AND season = ? AND episode = ?
|
||||
""", (imdb_id, season, episode))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_movie_dates(self, imdb_id: str) -> Optional[Dict]:
|
||||
"""Get movie date record"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM movies WHERE imdb_id = ?", (imdb_id,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def add_processing_history(self, imdb_id: str, media_type: str, event_type: str, details: Optional[Dict] = None):
|
||||
"""Add processing history entry"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (imdb_id, media_type, event_type, datetime.utcnow().isoformat(),
|
||||
json.dumps(details) if details else None))
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
with self.get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Series stats
|
||||
cursor.execute("SELECT COUNT(*) FROM series")
|
||||
series_count = cursor.fetchone()[0]
|
||||
|
||||
# Episode stats
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes")
|
||||
episodes_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE")
|
||||
episodes_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Movie stats
|
||||
cursor.execute("SELECT COUNT(*) FROM movies")
|
||||
movies_total = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE")
|
||||
movies_with_video = cursor.fetchone()[0]
|
||||
|
||||
# Processing history
|
||||
cursor.execute("SELECT COUNT(*) FROM processing_history")
|
||||
history_count = cursor.fetchone()[0]
|
||||
|
||||
return {
|
||||
"series_count": series_count,
|
||||
"episodes_total": episodes_total,
|
||||
"episodes_with_video": episodes_with_video,
|
||||
"movies_total": movies_total,
|
||||
"movies_with_video": movies_with_video,
|
||||
"processing_history_count": history_count,
|
||||
"database_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Logging utilities for NFOguard"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import os
|
||||
|
||||
def _get_local_timezone():
|
||||
"""Get the local timezone, respecting TZ environment variable"""
|
||||
tz_name = os.environ.get('TZ', 'UTC')
|
||||
|
||||
try:
|
||||
# Try zoneinfo first (Python 3.9+)
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(tz_name)
|
||||
except ImportError:
|
||||
# Fallback for older Python versions
|
||||
try:
|
||||
import pytz
|
||||
return pytz.timezone(tz_name)
|
||||
except:
|
||||
# Final fallback to UTC
|
||||
return timezone.utc
|
||||
except:
|
||||
# If zone name is invalid, fallback to UTC
|
||||
return timezone.utc
|
||||
|
||||
def _log(level: str, msg: str):
|
||||
"""Basic logging function that writes to console"""
|
||||
tz = _get_local_timezone()
|
||||
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
|
||||
|
||||
def convert_utc_to_local(utc_iso_string: str) -> str:
|
||||
"""Convert UTC ISO timestamp to local timezone timestamp"""
|
||||
if not utc_iso_string:
|
||||
return utc_iso_string
|
||||
|
||||
try:
|
||||
# Parse UTC timestamp
|
||||
if utc_iso_string.endswith('Z'):
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
|
||||
elif '+00:00' in utc_iso_string:
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string)
|
||||
else:
|
||||
# Assume UTC if no timezone info
|
||||
dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
|
||||
|
||||
# Convert to local timezone
|
||||
local_tz = _get_local_timezone()
|
||||
dt_local = dt_utc.astimezone(local_tz)
|
||||
|
||||
return dt_local.isoformat(timespec='seconds')
|
||||
except Exception:
|
||||
# If conversion fails, return original
|
||||
return utc_iso_string
|
||||
@@ -0,0 +1,565 @@
|
||||
#!/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}")
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Path mapping utilities for NFOGuard
|
||||
Handles conversion between external service paths and container paths
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
class PathMapper:
|
||||
"""Handles path mapping between different environments"""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize path mapper with configuration"""
|
||||
# Use environment variables directly since config attribute names are unclear
|
||||
import os
|
||||
|
||||
radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
|
||||
sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
|
||||
movie_paths_str = os.getenv('MOVIE_PATHS', '')
|
||||
tv_paths_str = os.getenv('TV_PATHS', '')
|
||||
|
||||
self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
|
||||
self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
|
||||
self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
|
||||
self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
|
||||
|
||||
# Check if path debugging is enabled
|
||||
self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: PathMapper initialized with:")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
|
||||
"""Convert Sonarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
|
||||
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
|
||||
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Sonarr root folders (longest first)
|
||||
for original_index, sonarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
|
||||
if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding TV path
|
||||
if original_index < len(self.tv_paths):
|
||||
container_root = self.tv_paths[original_index]
|
||||
relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return sonarr_path
|
||||
|
||||
def radarr_path_to_container_path(self, radarr_path: str) -> str:
|
||||
"""Convert Radarr path to container path using environment mappings"""
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
|
||||
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
|
||||
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
|
||||
|
||||
# Sort roots by length (longest first) to avoid substring matching issues
|
||||
indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
|
||||
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
# Try to match against configured Radarr root folders (longest first)
|
||||
for original_index, radarr_root in indexed_roots:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
|
||||
if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Match found! Index {original_index}")
|
||||
# Map to corresponding movie path
|
||||
if original_index < len(self.movie_paths):
|
||||
container_root = self.movie_paths[original_index]
|
||||
relative_path = radarr_path[len(radarr_root):].lstrip('/')
|
||||
result = str(Path(container_root) / relative_path) if relative_path else container_root
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: Mapped to: {result}")
|
||||
return result
|
||||
|
||||
if self.path_debug:
|
||||
print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
|
||||
# No fallback - if path mapping fails, return original and let validation catch it
|
||||
return radarr_path
|
||||
|
||||
def container_path_to_host_path(self, container_path: str) -> str:
|
||||
"""Convert container path back to host path if needed"""
|
||||
# This might be needed for file operations
|
||||
return container_path
|
||||
@@ -0,0 +1,165 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
nfoguard:
|
||||
# Use the official image from Docker Hub
|
||||
image: sbcrumb/nfoguard:latest
|
||||
|
||||
# Alternative: Use specific version
|
||||
# image: sbcrumb/nfoguard:v1.5.5
|
||||
|
||||
# Alternative: Use development version
|
||||
# image: sbcrumb/nfoguard:dev
|
||||
|
||||
container_name: nfoguard
|
||||
|
||||
# Restart policy
|
||||
restart: unless-stopped
|
||||
|
||||
# Environment files
|
||||
env_file:
|
||||
- .env
|
||||
- .env.secrets
|
||||
|
||||
# Environment variables
|
||||
environment:
|
||||
- TZ=America/New_York # Set to your local timezone
|
||||
# Alternative examples:
|
||||
# - TZ=Europe/London
|
||||
# - TZ=America/Los_Angeles
|
||||
# - TZ=Australia/Sydney
|
||||
# - TZ=UTC
|
||||
|
||||
# Port mapping (adjust if needed)
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
# Volume mounts - CRITICAL: These must match your media setup
|
||||
volumes:
|
||||
# NFOGuard data directory (database, logs, cache)
|
||||
- ./data:/app/data
|
||||
|
||||
# Bind mount your Emby plugins directory for automatic plugin deployment
|
||||
- ${EMBY_PLUGINS_PATH}:/emby-plugins
|
||||
|
||||
# Movie libraries - Mount your movie directories here
|
||||
# Format: host_path:container_path:rw
|
||||
- /path/to/your/movies:/media/Movies/movies:rw
|
||||
- /path/to/your/movies2:/media/Movies/movies6:rw
|
||||
|
||||
# TV libraries - Mount your TV show directories here
|
||||
- /path/to/your/tv:/media/TV/tv:rw
|
||||
- /path/to/your/tv2:/media/TV/tv6:rw
|
||||
|
||||
# Optional: Mount download directories for detection
|
||||
# - /path/to/downloads:/downloads:ro
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Resource limits (optional)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
|
||||
# Network (optional - use if you have a custom network)
|
||||
# networks:
|
||||
# - media-network
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# Optional: Custom network for media services
|
||||
# networks:
|
||||
# media-network:
|
||||
# external: true
|
||||
|
||||
# ===========================================
|
||||
# SETUP INSTRUCTIONS
|
||||
# ===========================================
|
||||
# 1. Copy this file to docker-compose.yml
|
||||
# 2. Update volume mounts to match your actual media paths
|
||||
# 3. Copy .env.template to .env and configure
|
||||
# 4. Copy .env.secrets.template to .env.secrets and add credentials
|
||||
# 5. Create data directory: mkdir -p ./data
|
||||
# 6. Run: docker-compose up -d
|
||||
# 7. Check logs: docker-compose logs -f nfoguard
|
||||
# 8. Access health check: curl http://localhost:8080/health
|
||||
|
||||
# ===========================================
|
||||
# VOLUME MAPPING EXAMPLES
|
||||
# ===========================================
|
||||
# Common setups:
|
||||
#
|
||||
# Unraid:
|
||||
# - /mnt/user/Media/Movies:/media/Movies/movies:rw
|
||||
# - /mnt/user/Media/TV:/media/TV/tv:rw
|
||||
#
|
||||
# Synology:
|
||||
# - /volume1/Media/Movies:/media/Movies/movies:rw
|
||||
# - /volume1/Media/TV:/media/TV/tv:rw
|
||||
#
|
||||
# Standard Linux:
|
||||
# - /home/user/media/movies:/media/Movies/movies:rw
|
||||
# - /home/user/media/tv:/media/TV/tv:rw
|
||||
#
|
||||
# Windows (Docker Desktop):
|
||||
# - C:\Media\Movies:/media/Movies/movies:rw
|
||||
# - C:\Media\TV:/media/TV/tv:rw
|
||||
|
||||
# ===========================================
|
||||
# WEBHOOK CONFIGURATION
|
||||
# ===========================================
|
||||
# Configure these webhook URLs in your *arr applications:
|
||||
#
|
||||
# Radarr webhook URL:
|
||||
# http://nfoguard:8080/webhook/radarr
|
||||
# (or http://localhost:8080/webhook/radarr if not using Docker network)
|
||||
#
|
||||
# Sonarr webhook URL:
|
||||
# http://nfoguard:8080/webhook/sonarr
|
||||
# (or http://localhost:8080/webhook/sonarr if not using Docker network)
|
||||
#
|
||||
# Webhook triggers:
|
||||
# - On Import
|
||||
# - On Upgrade
|
||||
# - On Rename (recommended)
|
||||
|
||||
# ===========================================
|
||||
# TROUBLESHOOTING
|
||||
# ===========================================
|
||||
# Common issues and solutions:
|
||||
#
|
||||
# 1. Permission denied errors:
|
||||
# - Ensure NFOGuard container can write to mounted directories
|
||||
# - Check file ownership and permissions
|
||||
# - Consider using PUID/PGID environment variables
|
||||
#
|
||||
# 2. Path mapping issues:
|
||||
# - Verify container paths match .env configuration
|
||||
# - Ensure *arr applications can see the same files
|
||||
# - Check RADARR_ROOT_FOLDERS and SONARR_ROOT_FOLDERS in .env
|
||||
#
|
||||
# 3. Database connection issues:
|
||||
# - Verify PostgreSQL credentials in .env.secrets
|
||||
# - Check network connectivity to database
|
||||
# - Ensure database exists and user has permissions
|
||||
#
|
||||
# 4. Webhook not triggering:
|
||||
# - Verify webhook URLs in *arr applications
|
||||
# - Check NFOGuard logs for incoming requests
|
||||
# - Ensure port 8080 is accessible
|
||||
+2218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
psycopg2-binary==2.9.7
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.0
|
||||
Reference in New Issue
Block a user