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