feat: upload intial code

Uploading intial Project Code
This commit is contained in:
2025-09-17 21:43:45 -04:00
committed by sbcrumb
parent 86235142a0
commit cbfd21c4ee
13 changed files with 5315 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
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 . .
# 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
CMD ["python", "nfoguard.py"]
+153
View File
@@ -0,0 +1,153 @@
# NFOGuard Setup Guide
## 🔐 Secure Configuration Setup
NFOGuard now uses a **two-file configuration system** for better security and easier troubleshooting:
- **`.env`** - Main configuration (safe to share for debugging)
- **`.env.secrets`** - Sensitive API keys and passwords (never commit to git)
### Step 1: Copy Configuration Templates
```bash
# Copy main configuration
cp .env.template .env
# Copy secrets configuration
cp .env.secrets.template .env.secrets
```
### Step 2: Configure Main Settings
Edit your `.env` file with your specific paths and preferences:
```bash
# Media paths (adjust to your directory structure)
TV_PATHS=/media/TV/tv,/media/TV/tv6
MOVIE_PATHS=/media/Movies/movies,/media/Movies/movies6
# Database connection details
RADARR_DB_HOST=radarr-postgres
RADARR_DB_PORT=5432
RADARR_DB_NAME=radarr
RADARR_DB_USER=radarr
# Processing preferences
PREFER_RELEASE_DATES_OVER_FILE_DATES=true
ALLOW_FILE_DATE_FALLBACK=false
RELEASE_DATE_PRIORITY=digital,physical,theatrical
# TV webhook processing mode (v0.6.0+)
TV_WEBHOOK_PROCESSING_MODE=targeted
```
### Step 3: Configure Secrets
Edit your `.env.secrets` file with your actual API keys and passwords:
```bash
# Database password
RADARR_DB_PASSWORD=your_actual_radarr_password
# TMDB API key (required for release date detection)
TMDB_API_KEY=your_actual_tmdb_api_key
# Sonarr API key (REQUIRED for v0.6.0+ Enhanced TV NFO Generation)
SONARR_API_KEY=your_actual_sonarr_api_key
# Optional API keys
RADARR_API_KEY=your_radarr_api_key
OMDB_API_KEY=your_omdb_api_key
```
### Step 4: Verify Configuration
Test your setup:
```bash
# Test database connections
curl -X POST "http://localhost:8080/test/bulk-update"
# Test movie scanning
curl -X POST "http://localhost:8080/test/movie-scan"
# Check system health
curl "http://localhost:8080/health"
```
## 🔒 Security Features
### API Key Masking
All API keys and passwords are automatically masked in logs:
```
[2025-09-09T12:34:56] INFO: TMDB API call with key=***masked***
[2025-09-09T12:34:56] INFO: Database connection password=***masked***
```
### Sensitive Data Separation
- **Main `.env`**: Paths, preferences, URLs (safe to share)
- **`.env.secrets`**: API keys, passwords (never commit to version control)
- **Automatic loading**: Both files loaded automatically at startup
### Git Protection
The `.gitignore` file prevents accidental commits:
```
.env
.env.secrets
.env.local
```
## 🛠 Troubleshooting
### "Environment files not loaded" Warning
Install python-dotenv:
```bash
pip install python-dotenv==1.0.0
# or
docker-compose build # rebuilds with updated requirements.txt
```
### Sharing Configuration for Help
You can safely share your `.env` file for debugging since it contains no sensitive data. The `.env.secrets` file should never be shared.
### Migration from Old Setup
If you have an existing `.env` with API keys:
1. Move all `*_API_KEY` and `*_PASSWORD` variables to `.env.secrets`
2. Remove sensitive data from `.env`
3. Restart NFOGuard
## 🎯 Docker Compose Example
```yaml
version: '3.8'
services:
nfoguard:
image: ghcr.io/your-org/nfoguard:latest
container_name: nfoguard
ports:
- "8080:8080"
volumes:
- /path/to/your/media:/media:rw
- ./data:/app/data
- ./.env:/app/.env:ro # Main configuration
- ./.env.secrets:/app/.env.secrets:ro # Secrets
environment:
- PORT=8080
depends_on:
- radarr-postgres
```
## 📋 Configuration Reference
### Main Configuration (.env)
- **Paths**: `TV_PATHS`, `MOVIE_PATHS`, `DB_PATH`
- **Processing**: `MOVIE_PRIORITY`, `RELEASE_DATE_PRIORITY`
- **Features**: `MANAGE_NFO`, `FIX_DIR_MTIMES`, `LOCK_METADATA`
- **URLs**: `RADARR_URL`, `SONARR_URL`, `JELLYSEERR_URL`
### Secrets Configuration (.env.secrets)
- **Database**: `RADARR_DB_PASSWORD`
- **APIs**: `TMDB_API_KEY`, `OMDB_API_KEY`, `RADARR_API_KEY`, `SONARR_API_KEY`
- **Optional**: `JELLYSEERR_API_KEY`
This setup makes NFOGuard more secure while keeping configuration manageable for troubleshooting and deployment.
+1
View File
@@ -0,0 +1 @@
1.6.5
+597
View File
@@ -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")
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env python3
"""Enhanced Radarr API client with improved import date detection"""
import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple
from urllib.parse import urlencode, urljoin
from urllib.request import Request as UrlRequest, urlopen
from urllib.error import URLError, HTTPError
from core.logging import _log
# Import path mapper for proper path handling
try:
from core.path_mapper import path_mapper
except ImportError:
# Fallback for standalone testing
class DummyPathMapper:
def analyze_import_source_path(self, path):
return "/downloads/" in path.lower(), "basic_check"
def is_download_path(self, path):
return "/downloads/" in str(path).lower() or "/completed/" in str(path).lower()
path_mapper = DummyPathMapper()
# Import database client for enhanced performance
try:
from clients.radarr_db_client import RadarrDbClient
except ImportError:
RadarrDbClient = None
class RadarrClient:
"""Enhanced Radarr API client with improved import date detection"""
# Radarr History API event types (HistoryEventType enum)
# From: https://github.com/Radarr/Radarr/blob/develop/src/NzbDrone.Core/History/HistoryEventType.cs
EVENT_TYPE_GRABBED = 1 # Movie was grabbed from indexer
EVENT_TYPE_IMPORTED = 3 # Movie was imported to final library
EVENT_TYPE_FAILED = 4 # Download or import failed
EVENT_TYPE_RETAGGED = 6 # Files were tagged
EVENT_TYPE_RENAMED = 8 # Files were renamed
# Event types that indicate real imports
REAL_IMPORT_EVENT_TYPES = [EVENT_TYPE_IMPORTED] # Only trust actual "imported" events
# These are now handled by path_mapper, but keeping for backward compatibility
DOWNLOAD_PATH_INDICATORS = [
'/downloads/', '/download/', '/completed/', '/importing/',
'/nzbs/', '/torrents/', '/temp/', '/tmp/',
'sabnzbd', 'nzbget', 'deluge', 'qbittorrent', 'transmission',
'usenet', 'torrent', 'radarr', 'completed', 'processing'
]
def __init__(self, base_url: str, api_key: str, timeout: int = 45, retries: int = 3):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.retries = max(0, retries)
# Initialize database client - REQUIRED for operation
self.db_client = None
if RadarrDbClient:
try:
self.db_client = RadarrDbClient.from_env()
if self.db_client:
_log("INFO", "✅ DATABASE ONLY MODE: Direct database access enabled")
else:
_log("ERROR", "❌ DATABASE ONLY MODE: Database configuration required - API mode disabled")
except Exception as e:
_log("ERROR", f"❌ DATABASE ONLY MODE: Failed to initialize database client: {e}")
self.db_client = None
else:
_log("ERROR", "❌ DATABASE ONLY MODE: RadarrDbClient not available - check dependencies")
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
"""Make GET request to Radarr API with retries"""
if not self.api_key:
return None
attempt = 0
last_err = None
while attempt <= self.retries:
try:
params = params or {}
params["apikey"] = self.api_key
url = urljoin(f"{self.base_url}/", path.lstrip("/"))
if params:
url = url + ("&" if "?" in url else "?") + urlencode(params)
_log("DEBUG", f"Radarr API Request: {url}")
req = UrlRequest(url, headers={"Accept": "application/json"})
with urlopen(req, timeout=self.timeout) as resp:
data = resp.read().decode("utf-8")
result = json.loads(data)
return result
except (URLError, HTTPError, json.JSONDecodeError) as e:
last_err = e
_log("DEBUG", f"Radarr API attempt {attempt + 1} failed: {e}")
time.sleep(min(2 ** attempt, 5)) # Exponential backoff
attempt += 1
_log("WARNING", f"Radarr GET {path} failed after {self.retries + 1} attempts: {last_err}")
return None
def movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""Find movie by IMDb ID - DATABASE ONLY mode"""
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
# Database required - no API fallback
if self.db_client:
try:
movie = self.db_client.get_movie_by_imdb(imdb_id)
if movie:
_log("INFO", f"✅ Found via database: {movie.get('title')} (ID: {movie.get('id')})")
return movie
else:
_log("WARNING", f"Movie not found in database for IMDb ID: {imdb_id}")
return None
except Exception as e:
_log("ERROR", f"Database lookup failed: {e}")
return None
# No database client available
_log("ERROR", "Database client required for movie lookup - API mode disabled")
return None
def _analyze_event_for_import(self, event: Dict[str, Any], movie_info: Dict[str, Any] = None) -> Tuple[bool, str, Optional[str]]:
"""
Analyze a history event to determine if it's a real import.
Args:
event: The history event to analyze
movie_info: Optional movie information to validate paths against
Returns:
(is_real_import, reason, date_iso)
"""
event_type = event.get("eventType")
date_str = event.get("date")
event_data = event.get("data", {})
# Parse date
date_iso = None
if date_str:
try:
date_iso = datetime.fromisoformat(date_str.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
except Exception:
date_iso = None
if not date_iso:
return False, "no_valid_date", None
# Convert event type to int if needed
try:
event_type_int = int(event_type) if isinstance(event_type, str) and event_type.isdigit() else event_type
except (ValueError, TypeError):
event_type_int = None
# Check if event type indicates import
if event_type_int not in self.REAL_IMPORT_EVENT_TYPES:
return False, f"event_type_not_import({event_type})", date_iso
# Get all possible source paths/titles
source_items = []
# Get both sourcePath and importedPath if available
if event_data:
for key in ['sourcePath', 'droppedPath', 'path', 'sourceTitle', 'importedPath']:
if event_data.get(key):
source_items.append(event_data[key])
# Also check event root for these fields
for key in ['sourcePath', 'sourceTitle', 'importedPath']:
if event.get(key):
source_items.append(event[key])
# Clean up and make unique
source_items = [str(s).lower().strip() for s in source_items if s]
source_items = list(set(source_items)) # Remove duplicates
if not source_items:
return False, "no_source_paths", date_iso
# If we have movie info, look for title/year match
if movie_info:
movie_title = movie_info.get('title', '').lower().replace(':', '.').replace(' ', '.')
movie_year = str(movie_info.get('year', ''))
for source in source_items:
# Clean up source text for comparison
source_clean = source.replace(' ', '.').replace('_', '.').replace('-', '.')
# Check if both title and year are in the source
if movie_title and movie_year:
if movie_title in source_clean and movie_year in source_clean:
_log("DEBUG", f"✅ Match found - Title: {movie_title}, Year: {movie_year}")
return True, "matched_title_and_year", date_iso
# Also check for downloads path as secondary validation
if path_mapper.is_download_path(source):
_log("DEBUG", f"Source is from downloads: {source}")
return True, "from_downloads_path", date_iso
_log("DEBUG", f"⚠️ No match found in sources: {source_items}")
return False, "no_title_year_match", date_iso
# Fallback to basic path validation if no movie info
for source in source_items:
if path_mapper.is_download_path(source):
return True, "basic_download_path_match", date_iso
return False, "no_download_path_match", date_iso
def earliest_import_event_optimized(self, movie_id: int) -> Optional[str]:
"""
Find earliest real import event with optimized querying.
Stops as soon as we find valid import events instead of loading everything.
"""
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
# Get movie info for path validation
movie_info = self._get(f"/api/v3/movie/{movie_id}")
if not movie_info or not isinstance(movie_info, dict):
_log("ERROR", f"Could not get movie info for ID {movie_id}")
return None
earliest_real_import = None
first_grab = None
page = 1
page_size = 50 # Smaller pages for faster iteration
total_processed = 0
while page <= 20: # Safety limit
# Get history in chronological order
data = self._get("/api/v3/history", {
"movieId": str(movie_id),
"page": page,
"pageSize": page_size,
"sortKey": "date",
"sortDirection": "ascending"
})
if not data:
break
items = data if isinstance(data, list) else data.get("records", [])
if not items:
break
_log("DEBUG", f"Page {page}: Processing {len(items)} events")
for event in items:
total_processed += 1
event_type = event.get("eventType")
if not event_type:
continue
# Convert event type to int or handle string types
try:
if isinstance(event_type, str):
# Map string event types to numeric values
string_to_numeric = {
"grabbed": self.EVENT_TYPE_GRABBED,
"downloadFolderImported": self.EVENT_TYPE_IMPORTED,
"movieFileImported": self.EVENT_TYPE_IMPORTED,
"downloadFailed": self.EVENT_TYPE_FAILED,
"movieFileRenamed": self.EVENT_TYPE_RENAMED,
"movieFileDeleted": 5 # Not in our constants but common
}
event_type = string_to_numeric.get(event_type, 0)
else:
event_type = int(event_type)
except (ValueError, TypeError):
_log("DEBUG", f"Unknown event type: {event_type}")
continue
# Check for grab events (type 1) - but validate it's a real download
if event_type == self.EVENT_TYPE_GRABBED and not first_grab:
if event.get("date"):
try:
# Get event data to check if this is a real grab with download info
event_data = event.get("data", {})
if isinstance(event_data, str):
try:
event_data = json.loads(event_data)
except (json.JSONDecodeError, AttributeError):
event_data = {}
# Check if this grab has actual download/indexer info
source_title = event_data.get("sourceTitle", "")
indexer = event_data.get("indexer", "")
# Only count grabs that have actual download metadata
if source_title or indexer:
first_grab = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
_log("DEBUG", f"Found real grab event with source '{source_title}' from '{indexer}' at {first_grab}")
else:
_log("DEBUG", f"Skipping grab event without download info at {event.get('date')}")
except Exception:
pass
# Only process import events (type 3)
if event_type != self.EVENT_TYPE_IMPORTED:
continue
# Get imported path from event data
imported_path = None
event_data = event.get("data", {})
# Handle both string and dict data
if isinstance(event_data, str):
try:
event_data = json.loads(event_data)
except (json.JSONDecodeError, AttributeError) as e:
_log("DEBUG", f"Failed to parse event data JSON: {e}")
continue
elif not isinstance(event_data, dict):
continue
imported_path = event_data.get("importedPath", "")
if not imported_path:
continue
imported_path = imported_path.lower()
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
movie_title = (movie_info.get("title", "") or "").lower()
movie_year = str(movie_info.get("year", ""))
# First try IMDb ID match
# First try IMDb ID match
if movie_imdb and (
f"[imdb-{movie_imdb}]" in imported_path or
f"[{movie_imdb}]" in imported_path or
movie_imdb in imported_path
):
_log("INFO", f"Found potential IMDb match in {event_type} event: {imported_path}")
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
_log("INFO", f"✅ FOUND IMPORT: exact IMDb match at {date_iso}")
earliest_real_import = date_iso
break
# Then try title/year match with fuzzy path cleaning
if movie_title and movie_year:
# Clean strings for comparison
clean_title = movie_title.replace(" ", ".").replace(":", ".").replace("-", ".").replace("_", ".").lower()
clean_path = imported_path.replace(" ", ".").replace("-", ".").replace("_", ".").replace("[", "").replace("]", "").lower()
# Look for both title and year in the path
if clean_title in clean_path and movie_year in clean_path:
date_iso = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
_log("INFO", f"Found potential title/year match for event type {event_type}: {clean_title} ({movie_year})")
_log("INFO", f"✅ FOUND IMPORT at {date_iso}")
earliest_real_import = date_iso
break
# Fallback to normal import analysis
is_real, reason, date_iso = self._analyze_event_for_import(event, movie_info)
if is_real and date_iso:
source_path = (event.get("data", {}).get("sourcePath", "") or
event.get("data", {}).get("droppedPath", "") or
event.get("sourcePath", "") or
event.get("data", {}).get("importedPath", "") or
event.get("importedPath", "") or "").lower()
if source_path:
# Check for path match
movie_imdb = (movie_info.get("imdbId", "") or "").lower()
if movie_imdb and (
f"[imdb-{movie_imdb}]" in source_path or
f"[{movie_imdb}]" in source_path or
movie_imdb in source_path
):
_log("INFO", f"✅ FOUND IMPORT: IMDb match in path at {date_iso}")
earliest_real_import = date_iso
break
# Check for title/year match
movie_title = (movie_info.get("title", "") or "").lower().replace(":", ".").replace(" ", ".")
movie_year = str(movie_info.get("year", ""))
if movie_title and movie_year and movie_title in source_path and movie_year in source_path:
_log("INFO", f"✅ FOUND IMPORT: Title/year match at {date_iso}")
earliest_real_import = date_iso
break
elif event_type == 3:
_log("DEBUG", f"⚠️ Skipped import event: {reason}")
# If we found a real import, no need to continue
if earliest_real_import:
break
# If we got less than page size, we've seen all events
if len(items) < page_size:
break
page += 1
_log("INFO", f"Processed {total_processed} events across {page-1} pages")
if earliest_real_import:
_log("INFO", f"✅ Using earliest real import: {earliest_real_import}")
return earliest_real_import
if first_grab:
_log("WARNING", f"⚠️ No real imports found, using grab date: {first_grab}")
return first_grab
_log("ERROR", f"❌ No import or grab events found for movie_id {movie_id}")
return None
def movie_files(self, movie_id: int) -> List[Dict[str, Any]]:
"""Get movie files for a movie - DATABASE ONLY mode"""
if self.db_client:
_log("INFO", "Using database for movie files lookup")
# Database handles this internally in get_movie_file_date()
return []
_log("ERROR", "Database client required for movie files - API mode disabled")
return []
def earliest_file_dateadded(self, movie_id: int) -> Optional[str]:
"""Get earliest file dateAdded - DATABASE ONLY mode"""
if self.db_client:
try:
return self.db_client.get_movie_file_date(movie_id)
except Exception as e:
_log("ERROR", f"Database file date query failed: {e}")
return None
_log("ERROR", "Database client required for file dates - API mode disabled")
return None
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
"""
Get the best import date for a movie - DATABASE ONLY mode.
Returns:
(date_iso, source_description)
"""
# Database required - no API fallback
if self.db_client:
try:
date_iso, source = self.db_client.get_movie_import_date_optimized(movie_id, fallback_to_file_date)
if date_iso:
return date_iso, source
else:
_log("WARNING", f"No import date found in database for movie_id {movie_id}")
return None, "radarr:db.no_date_found"
except Exception as e:
_log("ERROR", f"Database import date query failed: {e}")
return None, "radarr:db.error"
# No database client available
_log("ERROR", "Database client required for import date detection - API mode disabled")
return None, "radarr:db.not_configured"
def _get_earliest_import_date(self, movie_id: int, movie_info: Dict) -> Optional[str]:
"""Get the earliest import date from Radarr history."""
_log("INFO", f"Finding earliest import for movie_id {movie_id}")
earliest_real_import = None
earliest_grab_date = None
page = 1
page_size = 50
total_events = 0
# Get full movie history
while True:
# Get page of history
history_data = self._get_movie_history_page(movie_id, page, page_size)
if not history_data:
break
# Process events on this page
for event in history_data:
event_type = event.get("eventType")
if not event_type:
continue
# Parse event date
event_date = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).astimezone(timezone.utc).isoformat(timespec="seconds")
# Track earliest grab date as fallback (EventType 1)
if event_type == 1 and not earliest_grab_date:
earliest_grab_date = event_date
_log("DEBUG", f"Found first grab event at {earliest_grab_date}")
continue
# Look for import events (EventType 3)
if event_type == 3:
try:
data = json.loads(event.get("data", "{}"))
if data.get("importedPath"):
_log("INFO", f"✅ FOUND IMPORT at {event_date}")
earliest_real_import = event_date
break
except (json.JSONDecodeError, AttributeError):
continue
# Break if we found an import
if earliest_real_import:
break
total_events += len(history_data)
page += 1
_log("INFO", f"Processed {total_events} events across {page}")
if earliest_real_import:
return earliest_real_import
if earliest_grab_date:
_log("WARNING", f"⚠️ No EventType 3 (import) found, using grab date: {earliest_grab_date}")
return earliest_grab_date
return None
def _get_movie_history_page(self, movie_id: int, page: int, page_size: int) -> List[Dict[str, Any]]:
"""Get a page of movie history."""
data = self._get("/api/v3/history", {
"movieId": str(movie_id),
"page": page,
"pageSize": page_size,
"sortKey": "date",
"sortDirection": "ascending"
})
return data if isinstance(data, list) else data.get("records", [])
+646
View File
@@ -0,0 +1,646 @@
#!/usr/bin/env python3
"""
Direct Radarr Database Client for NFOGuard
Provides high-performance access to Radarr's SQLite/PostgreSQL database
"""
import os
import sqlite3
import psycopg2
import psycopg2.extras
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional, Tuple, Union
from urllib.parse import urlparse
from core.logging import _log
class RadarrDbClient:
"""Direct database client for Radarr's SQLite or PostgreSQL database"""
def __init__(self,
db_type: str = "sqlite",
db_path: Optional[str] = None,
db_host: Optional[str] = None,
db_port: Optional[int] = None,
db_name: Optional[str] = None,
db_user: Optional[str] = None,
db_password: Optional[str] = None):
"""
Initialize Radarr database client
Args:
db_type: "sqlite" or "postgresql"
db_path: Path to SQLite database file
db_host: PostgreSQL host
db_port: PostgreSQL port
db_name: PostgreSQL database name
db_user: PostgreSQL username
db_password: PostgreSQL password
"""
self.db_type = db_type.lower()
self.db_path = db_path
self.db_host = db_host
self.db_port = db_port or 5432
self.db_name = db_name
self.db_user = db_user
self.db_password = db_password
self._test_connection()
@classmethod
def from_env(cls) -> Optional['RadarrDbClient']:
"""Create client from environment variables"""
db_type = os.environ.get("RADARR_DB_TYPE", "").lower()
if not db_type:
return None
if db_type == "sqlite":
db_path = os.environ.get("RADARR_DB_PATH")
if not db_path or not Path(db_path).exists():
_log("WARNING", f"RADARR_DB_PATH not found or invalid: {db_path}")
return None
return cls(db_type="sqlite", db_path=db_path)
elif db_type == "postgresql":
# Support both individual vars and connection string
db_url = os.environ.get("RADARR_DB_URL")
if db_url:
parsed = urlparse(db_url)
return cls(
db_type="postgresql",
db_host=parsed.hostname,
db_port=parsed.port or 5432,
db_name=parsed.path.lstrip('/'),
db_user=parsed.username,
db_password=parsed.password
)
else:
return cls(
db_type="postgresql",
db_host=os.environ.get("RADARR_DB_HOST"),
db_port=int(os.environ.get("RADARR_DB_PORT", "5432")),
db_name=os.environ.get("RADARR_DB_NAME"),
db_user=os.environ.get("RADARR_DB_USER"),
db_password=os.environ.get("RADARR_DB_PASSWORD")
)
else:
_log("ERROR", f"Unsupported database type: {db_type}")
return None
def _test_connection(self) -> None:
"""Test database connection on initialization"""
try:
conn = self._get_connection()
if conn:
conn.close()
_log("INFO", f"Connected to Radarr {self.db_type} database successfully")
else:
raise Exception("Failed to create connection")
except Exception as e:
_log("ERROR", f"Failed to connect to Radarr database: {e}")
raise
def _get_connection(self) -> Union[sqlite3.Connection, psycopg2.extensions.connection]:
"""Get database connection"""
if self.db_type == "sqlite":
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
elif self.db_type == "postgresql":
conn = psycopg2.connect(
host=self.db_host,
port=self.db_port,
database=self.db_name,
user=self.db_user,
password=self.db_password
)
return conn
else:
raise ValueError(f"Unsupported database type: {self.db_type}")
def get_movie_by_imdb(self, imdb_id: str) -> Optional[Dict[str, Any]]:
"""
Find movie by IMDb ID using database query
Returns:
Dictionary with movie info including id, imdbId, title, year, path
"""
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
query = """
SELECT
m."Id" as id,
m."Path" as path,
m."Added" as added,
mm."ImdbId" as imdb_id,
mm."Title" as title,
mm."Year" as year,
mm."DigitalRelease" as digital_release
FROM "Movies" m
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
WHERE mm."ImdbId" = %s
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (imdb_id,))
row = cursor.fetchone()
if row:
return dict(row) if self.db_type == "sqlite" else row
except Exception as e:
_log("ERROR", f"Database query error for IMDb {imdb_id}: {e}")
return None
def get_earliest_import_date(self, movie_id: int) -> Tuple[Optional[str], str]:
"""
Get earliest import date from History table, accounting for upgrade scenarios
Args:
movie_id: Radarr movie ID
Returns:
(date_iso, source_description)
"""
# If first event is rename, all subsequent imports are upgrades - skip them
if self.is_first_event_rename_based(movie_id):
_log("INFO", f"Movie {movie_id} has rename-first history - all imports are upgrades, skipping")
return None, "radarr:db.upgrade_imports_skipped"
# Query for earliest import event - PostgreSQL uses INTEGER EventType (3 = import)
import_query = """
SELECT
h."Date" as event_date,
h."Data" as event_data,
h."EventType" as event_type
FROM "History" h
WHERE h."MovieId" = %s
AND h."EventType" = 3
ORDER BY h."Date" ASC
LIMIT 1
"""
# Fallback: earliest grab event - PostgreSQL uses INTEGER EventType (1 = grab)
grab_query = """
SELECT
h."Date" as event_date,
h."Data" as event_data,
h."EventType" as event_type
FROM "History" h
WHERE h."MovieId" = %s
AND h."EventType" = 1
AND h."Data" IS NOT NULL
ORDER BY h."Date" ASC
LIMIT 1
"""
if self.db_type == "sqlite":
import_query = import_query.replace("%s", "?")
grab_query = grab_query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
# Try import events first
cursor.execute(import_query, (movie_id,))
row = cursor.fetchone()
if row:
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
if isinstance(event_date, str):
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
else:
dt = event_date.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
_log("INFO", f"✅ Found import event ({event_type}) for movie {movie_id} at {date_iso}")
return date_iso, "radarr:db.history.import"
# Fallback to grab events
cursor.execute(grab_query, (movie_id,))
row = cursor.fetchone()
if row:
event_date = row['event_date'] if self.db_type == "postgresql" else row[0]
event_type = row['event_type'] if self.db_type == "postgresql" else row[2]
if isinstance(event_date, str):
dt = datetime.fromisoformat(event_date.replace("Z", "+00:00"))
else:
dt = event_date.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
_log("WARNING", f"⚠️ Using grab event ({event_type}) for movie {movie_id} at {date_iso}")
return date_iso, "radarr:db.history.grab"
except Exception as e:
_log("ERROR", f"Database query error for movie {movie_id}: {e}")
return None, "radarr:db.no_date_found"
def is_first_event_rename_based(self, movie_id: int) -> bool:
"""
Check if the first event in history is rename-based (not a true import)
This helps identify movies where:
- First event: movieFileRenamed (EventType = 8)
- Followed by: downloadFolderImported (EventType = 3) - this is an upgrade
In such cases, we should prefer release dates over the upgrade date
"""
query = """
SELECT h."EventType" as event_type, h."Date" as event_date
FROM "History" h
WHERE h."MovieId" = %s
ORDER BY h."Date" ASC
LIMIT 5
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (movie_id,))
rows = cursor.fetchall()
if rows:
_log("INFO", f"Movie {movie_id} history debug - first 5 events:")
for i, row in enumerate(rows):
event_type = row['event_type'] if self.db_type == "postgresql" else row[0]
event_date = row['event_date'] if self.db_type == "postgresql" else row[1]
_log("INFO", f" Event {i+1}: Type={event_type}, Date={event_date}")
first_event_type = rows[0]['event_type'] if self.db_type == "postgresql" else rows[0][0]
# EventType 8 = movieFileRenamed
# Also check for EventType 7 = movieFileRenamed in some Radarr versions
is_rename_first = first_event_type in [7, 8]
_log("INFO", f"Movie {movie_id}: First event type={first_event_type}, is_rename_first={is_rename_first}")
if is_rename_first:
_log("INFO", f"🎯 Movie {movie_id} detected as rename-first scenario - will prefer release dates over import dates")
return is_rename_first
else:
_log("WARNING", f"Movie {movie_id}: No history events found - this could indicate missing data")
except Exception as e:
_log("ERROR", f"Error checking first event type for movie {movie_id}: {e}")
return False
def get_movie_file_date(self, movie_id: int) -> Optional[str]:
"""
Get earliest file dateAdded as fallback
Args:
movie_id: Radarr movie ID
Returns:
ISO date string or None
"""
query = """
SELECT MIN(mf."DateAdded") as earliest_date
FROM "MovieFiles" mf
WHERE mf."MovieId" = %s
"""
if self.db_type == "sqlite":
query = query.replace("%s", "?")
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, (movie_id,))
row = cursor.fetchone()
if row:
date_value = row['earliest_date'] if self.db_type == "postgresql" else row[0]
if date_value:
if isinstance(date_value, str):
dt = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
else:
dt = date_value.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc).isoformat(timespec="seconds")
except Exception as e:
_log("ERROR", f"Database query error for movie file date {movie_id}: {e}")
return None
def get_movie_import_date_optimized(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
"""
Get the best import date for a movie using optimized database queries
Args:
movie_id: Radarr movie ID
fallback_to_file_date: Whether to fall back to file dateAdded
Returns:
(date_iso, source_description)
"""
# Try history first - this handles upgrade detection internally
date_iso, source = self.get_earliest_import_date(movie_id)
if date_iso:
return date_iso, source
# Check if we skipped upgrades and should prefer release dates
if source == "radarr:db.upgrade_imports_skipped":
_log("INFO", f"Movie {movie_id} upgrade scenario detected - signaling to prefer release dates")
return None, "radarr:db.prefer_release_dates"
# Fallback to file date if requested
if fallback_to_file_date:
file_date = self.get_movie_file_date(movie_id)
if file_date:
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
return file_date, "radarr:db.file.dateAdded"
return None, "radarr:db.no_date_found"
def bulk_import_dates(self, imdb_ids: List[str]) -> Dict[str, Tuple[Optional[str], str]]:
"""
Get import dates for multiple movies in a single query
Args:
imdb_ids: List of IMDb IDs
Returns:
Dictionary mapping imdb_id -> (date_iso, source)
"""
if not imdb_ids:
return {}
# Ensure all IMDb IDs have tt prefix
clean_imdb_ids = [imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}" for imdb_id in imdb_ids]
placeholders = ",".join(["%s"] * len(clean_imdb_ids))
if self.db_type == "sqlite":
placeholders = ",".join(["?"] * len(clean_imdb_ids))
query = f"""
SELECT
mm."ImdbId" as imdb_id,
m."Id" as movie_id,
MIN(h."Date") as earliest_import
FROM "Movies" m
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
LEFT JOIN "History" h ON m."Id" = h."MovieId" AND h."EventType" = 3
WHERE mm."ImdbId" IN ({placeholders})
GROUP BY mm."ImdbId", m."Id"
"""
results = {}
try:
with self._get_connection() as conn:
if self.db_type == "postgresql":
cursor = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
else:
cursor = conn.cursor()
cursor.execute(query, clean_imdb_ids)
rows = cursor.fetchall()
for row in rows:
if self.db_type == "postgresql":
imdb_id, movie_id, earliest_import = row['imdb_id'], row['movie_id'], row['earliest_import']
else:
imdb_id, movie_id, earliest_import = row[0], row[1], row[2]
if earliest_import:
if isinstance(earliest_import, str):
dt = datetime.fromisoformat(earliest_import.replace("Z", "+00:00"))
else:
dt = earliest_import.replace(tzinfo=timezone.utc)
date_iso = dt.astimezone(timezone.utc).isoformat(timespec="seconds")
results[imdb_id] = (date_iso, "radarr:db.bulk.import")
else:
results[imdb_id] = (None, "radarr:db.bulk.no_import")
except Exception as e:
_log("ERROR", f"Bulk query error: {e}")
# Return empty results for failed queries
for imdb_id in clean_imdb_ids:
if imdb_id not in results:
results[imdb_id] = (None, "radarr:db.bulk.error")
return results
def get_database_stats(self) -> Dict[str, Any]:
"""Get basic statistics about the Radarr database"""
stats = {}
queries = {
"total_movies": 'SELECT COUNT(*) FROM "Movies"',
"total_movie_files": 'SELECT COUNT(*) FROM "MovieFiles"',
"total_history_events": 'SELECT COUNT(*) FROM "History"',
"import_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 3',
"grab_events": 'SELECT COUNT(*) FROM "History" WHERE "EventType" = 1'
}
try:
with self._get_connection() as conn:
cursor = conn.cursor()
for stat_name, query in queries.items():
cursor.execute(query)
result = cursor.fetchone()
stats[stat_name] = result[0] if result else 0
except Exception as e:
_log("ERROR", f"Stats query error: {e}")
stats["error"] = str(e)
return stats
def health_check(self) -> Dict[str, Any]:
"""
Comprehensive health check for the Radarr database connection
Returns:
Dictionary with health status, connection info, and basic functionality tests
"""
health = {
"status": "healthy",
"database_type": self.db_type,
"connection": "ok",
"readable": False,
"writable": False,
"tables_exist": False,
"sample_data": False,
"issues": [],
"tested_at": datetime.now(timezone.utc).isoformat(timespec="seconds")
}
try:
# Test 1: Basic connection
with self._get_connection() as conn:
cursor = conn.cursor()
# Test 2: Check if we can read (basic query)
try:
cursor.execute('SELECT 1')
result = cursor.fetchone()
if result and result[0] == 1:
health["readable"] = True
health["connection"] = "readable"
else:
health["issues"].append("Basic SELECT query failed")
except Exception as e:
health["issues"].append(f"Read test failed: {e}")
health["status"] = "degraded"
# Test 3: Check required tables exist
required_tables = ["Movies", "MovieMetadata", "History", "MovieFiles"]
existing_tables = []
try:
if self.db_type == "postgresql":
cursor.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
""")
else: # SQLite
cursor.execute("""
SELECT name
FROM sqlite_master
WHERE type='table'
AND name IN ('Movies', 'MovieMetadata', 'History', 'MovieFiles')
""")
rows = cursor.fetchall()
existing_tables = [row[0] for row in rows]
if len(existing_tables) == len(required_tables):
health["tables_exist"] = True
else:
missing = set(required_tables) - set(existing_tables)
health["issues"].append(f"Missing tables: {list(missing)}")
health["status"] = "degraded"
health["existing_tables"] = existing_tables
except Exception as e:
health["issues"].append(f"Table check failed: {e}")
health["status"] = "degraded"
# Test 4: Check for sample data
if health["tables_exist"]:
try:
cursor.execute('SELECT COUNT(*) FROM "Movies"')
movie_count = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM "History"')
history_count = cursor.fetchone()[0]
if movie_count > 0 and history_count > 0:
health["sample_data"] = True
health["movie_count"] = movie_count
health["history_count"] = history_count
else:
health["issues"].append(f"Low data counts - Movies: {movie_count}, History: {history_count}")
except Exception as e:
health["issues"].append(f"Sample data check failed: {e}")
# Test 5: Test a real query (movie with IMDb lookup)
if health["sample_data"]:
try:
cursor.execute("""
SELECT COUNT(*)
FROM "Movies" m
JOIN "MovieMetadata" mm ON m."MovieMetadataId" = mm."Id"
WHERE mm."ImdbId" IS NOT NULL
""")
imdb_movies = cursor.fetchone()[0]
health["movies_with_imdb"] = imdb_movies
if imdb_movies > 0:
health["functional"] = True
else:
health["issues"].append("No movies with IMDb IDs found")
except Exception as e:
health["issues"].append(f"Functional test failed: {e}")
health["status"] = "degraded"
except Exception as e:
health["status"] = "error"
health["connection"] = "failed"
health["issues"].append(f"Connection failed: {e}")
_log("ERROR", f"Database health check failed: {e}")
# Overall status determination
if health["issues"]:
if health["status"] == "healthy":
health["status"] = "degraded"
# Add connection details (safe info only)
health["connection_info"] = {
"type": self.db_type,
"host": self.db_host if self.db_type == "postgresql" else None,
"port": self.db_port if self.db_type == "postgresql" else None,
"database": self.db_name if self.db_type == "postgresql" else None,
"path": self.db_path if self.db_type == "sqlite" else None
}
return health
if __name__ == "__main__":
# Test the database client
print("Testing RadarrDbClient...")
# Test with environment variables
client = RadarrDbClient.from_env()
if client:
print("✅ Connected to Radarr database")
# Test stats
stats = client.get_database_stats()
print(f"Database stats: {stats}")
# Test movie lookup
test_movie = client.get_movie_by_imdb("tt1596343")
if test_movie:
print(f"Found test movie: {test_movie}")
# Test import date
movie_id = test_movie['id']
date_iso, source = client.get_movie_import_date_optimized(movie_id)
print(f"Import date: {date_iso} (source: {source})")
else:
print("Test movie not found")
else:
print("❌ Could not connect to database - check environment variables")
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python3
"""
Enhanced Sonarr API client extracted from media_date_cache.py
"""
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")
+268
View File
@@ -0,0 +1,268 @@
#!/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"""
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()))
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)
}
+53
View File
@@ -0,0 +1,53 @@
"""Logging utilities for NFOguard"""
from datetime import datetime, timezone
import os
def _get_local_timezone():
"""Get the local timezone, respecting TZ environment variable"""
tz_name = os.environ.get('TZ', 'UTC')
try:
# Try zoneinfo first (Python 3.9+)
from zoneinfo import ZoneInfo
return ZoneInfo(tz_name)
except ImportError:
# Fallback for older Python versions
try:
import pytz
return pytz.timezone(tz_name)
except:
# Final fallback to UTC
return timezone.utc
except:
# If zone name is invalid, fallback to UTC
return timezone.utc
def _log(level: str, msg: str):
"""Basic logging function that writes to console"""
tz = _get_local_timezone()
print(f"[{datetime.now(tz).isoformat(timespec='seconds')}] {level}: {msg}")
def convert_utc_to_local(utc_iso_string: str) -> str:
"""Convert UTC ISO timestamp to local timezone timestamp"""
if not utc_iso_string:
return utc_iso_string
try:
# Parse UTC timestamp
if utc_iso_string.endswith('Z'):
dt_utc = datetime.fromisoformat(utc_iso_string.replace('Z', '+00:00'))
elif '+00:00' in utc_iso_string:
dt_utc = datetime.fromisoformat(utc_iso_string)
else:
# Assume UTC if no timezone info
dt_utc = datetime.fromisoformat(utc_iso_string).replace(tzinfo=timezone.utc)
# Convert to local timezone
local_tz = _get_local_timezone()
dt_local = dt_utc.astimezone(local_tz)
return dt_local.isoformat(timespec='seconds')
except Exception:
# If conversion fails, return original
return utc_iso_string
+415
View File
@@ -0,0 +1,415 @@
#!/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 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:
tree = ET.parse(nfo_path)
movie = tree.getroot()
# 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 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
try:
# Try to load existing NFO file
if nfo_path.exists():
try:
tree = ET.parse(nfo_path)
episode = tree.getroot()
# Ensure root element is <episodedetails>
if episode.tag != "episodedetails":
raise ValueError("Root element is not <episodedetails>")
# 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}")
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}")
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Path mapping utilities for NFOGuard
Handles conversion between external service paths and container paths
"""
import os
import re
from pathlib import Path
class PathMapper:
"""Handles path mapping between different environments"""
def __init__(self, config):
"""Initialize path mapper with configuration"""
# Use environment variables directly since config attribute names are unclear
import os
radarr_roots_str = os.getenv('RADARR_ROOT_FOLDERS', '')
sonarr_roots_str = os.getenv('SONARR_ROOT_FOLDERS', '')
movie_paths_str = os.getenv('MOVIE_PATHS', '')
tv_paths_str = os.getenv('TV_PATHS', '')
self.radarr_roots = [path.strip() for path in radarr_roots_str.split(',') if path.strip()]
self.sonarr_roots = [path.strip() for path in sonarr_roots_str.split(',') if path.strip()]
self.movie_paths = [path.strip() for path in movie_paths_str.split(',') if path.strip()]
self.tv_paths = [path.strip() for path in tv_paths_str.split(',') if path.strip()]
# Check if path debugging is enabled
self.path_debug = os.getenv('PATH_DEBUG', 'false').lower() == 'true'
if self.path_debug:
print(f"PATH_DEBUG: PathMapper initialized with:")
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
def sonarr_path_to_container_path(self, sonarr_path: str) -> str:
"""Convert Sonarr path to container path using environment mappings"""
if self.path_debug:
print(f"PATH_DEBUG: sonarr_path_to_container_path input: {sonarr_path}")
print(f"PATH_DEBUG: sonarr_roots: {self.sonarr_roots}")
print(f"PATH_DEBUG: tv_paths: {self.tv_paths}")
# Sort roots by length (longest first) to avoid substring matching issues
indexed_roots = [(i, root) for i, root in enumerate(self.sonarr_roots)]
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
# Try to match against configured Sonarr root folders (longest first)
for original_index, sonarr_root in indexed_roots:
if self.path_debug:
print(f"PATH_DEBUG: Checking sonarr_root[{original_index}]: {sonarr_root}")
if sonarr_path.startswith(sonarr_root + '/') or sonarr_path == sonarr_root:
if self.path_debug:
print(f"PATH_DEBUG: Match found! Index {original_index}")
# Map to corresponding TV path
if original_index < len(self.tv_paths):
container_root = self.tv_paths[original_index]
relative_path = sonarr_path[len(sonarr_root):].lstrip('/')
result = str(Path(container_root) / relative_path) if relative_path else container_root
if self.path_debug:
print(f"PATH_DEBUG: Mapped to: {result}")
return result
if self.path_debug:
print(f"PATH_DEBUG: No match found, returning original: {sonarr_path}")
# No fallback - if path mapping fails, return original and let validation catch it
return sonarr_path
def radarr_path_to_container_path(self, radarr_path: str) -> str:
"""Convert Radarr path to container path using environment mappings"""
if self.path_debug:
print(f"PATH_DEBUG: radarr_path_to_container_path input: {radarr_path}")
print(f"PATH_DEBUG: radarr_roots: {self.radarr_roots}")
print(f"PATH_DEBUG: movie_paths: {self.movie_paths}")
# Sort roots by length (longest first) to avoid substring matching issues
indexed_roots = [(i, root) for i, root in enumerate(self.radarr_roots)]
indexed_roots.sort(key=lambda x: len(x[1]), reverse=True)
# Try to match against configured Radarr root folders (longest first)
for original_index, radarr_root in indexed_roots:
if self.path_debug:
print(f"PATH_DEBUG: Checking radarr_root[{original_index}]: {radarr_root}")
if radarr_path.startswith(radarr_root + '/') or radarr_path == radarr_root:
if self.path_debug:
print(f"PATH_DEBUG: Match found! Index {original_index}")
# Map to corresponding movie path
if original_index < len(self.movie_paths):
container_root = self.movie_paths[original_index]
relative_path = radarr_path[len(radarr_root):].lstrip('/')
result = str(Path(container_root) / relative_path) if relative_path else container_root
if self.path_debug:
print(f"PATH_DEBUG: Mapped to: {result}")
return result
if self.path_debug:
print(f"PATH_DEBUG: No match found, returning original: {radarr_path}")
# No fallback - if path mapping fails, return original and let validation catch it
return radarr_path
def container_path_to_host_path(self, container_path: str) -> str:
"""Convert container path back to host path if needed"""
# This might be needed for file operations
return container_path
+2208
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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