c4-rdarr db updates
This commit is contained in:
@@ -14,6 +14,23 @@ SONARR_API_KEY=
|
|||||||
RADARR_URL=http://radarr:7878
|
RADARR_URL=http://radarr:7878
|
||||||
RADARR_API_KEY=
|
RADARR_API_KEY=
|
||||||
|
|
||||||
|
# --- Optional: Direct Radarr Database Access (High Performance) ---
|
||||||
|
# Enable direct database access for faster import date queries
|
||||||
|
# Supports both SQLite and PostgreSQL
|
||||||
|
RADARR_DB_TYPE=postgresql
|
||||||
|
# For PostgreSQL (recommended for production):
|
||||||
|
RADARR_DB_HOST=postgres_host
|
||||||
|
RADARR_DB_PORT=5432
|
||||||
|
RADARR_DB_NAME=radarr-main
|
||||||
|
RADARR_DB_USER=postgres
|
||||||
|
RADARR_DB_PASSWORD=your_password
|
||||||
|
# Alternative: use connection string
|
||||||
|
#RADARR_DB_URL=postgresql://user:pass@host:5432/dbname
|
||||||
|
|
||||||
|
# For SQLite (if using default Radarr setup):
|
||||||
|
#RADARR_DB_TYPE=sqlite
|
||||||
|
#RADARR_DB_PATH=/config/radarr.db
|
||||||
|
|
||||||
# --- Required: TMDB for Release Dates ---
|
# --- Required: TMDB for Release Dates ---
|
||||||
# Get free API key from https://www.themoviedb.org/settings/api
|
# Get free API key from https://www.themoviedb.org/settings/api
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
|
|||||||
@@ -6,6 +6,35 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.0] - 2025-09-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Major Performance Enhancement**: Direct Radarr database access
|
||||||
|
- New `RadarrDbClient` class for high-performance database queries
|
||||||
|
- Support for both SQLite and PostgreSQL Radarr databases
|
||||||
|
- Eliminates API pagination overhead for import date lookups
|
||||||
|
- Up to 10x faster movie import date detection
|
||||||
|
- **Bulk Operations**: Added bulk import date queries for multiple movies
|
||||||
|
- **Database Configuration**: New environment variables for database connection
|
||||||
|
- `RADARR_DB_TYPE` (sqlite/postgresql)
|
||||||
|
- `RADARR_DB_HOST`, `RADARR_DB_PORT`, `RADARR_DB_NAME` for PostgreSQL
|
||||||
|
- `RADARR_DB_USER`, `RADARR_DB_PASSWORD` for PostgreSQL authentication
|
||||||
|
- `RADARR_DB_PATH` for SQLite databases
|
||||||
|
- `RADARR_DB_URL` connection string support
|
||||||
|
- **Testing Suite**: Added performance comparison test script (`test_db_performance.py`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **RadarrClient Enhanced**: Automatic database client initialization with API fallback
|
||||||
|
- **Movie Lookup Optimization**: Database-first approach for movie IMDb ID resolution
|
||||||
|
- **Import Date Detection**: Optimized SQL queries replace complex API event parsing
|
||||||
|
- **Dependencies**: Added `psycopg2-binary` for PostgreSQL support
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Database queries use proper SQL joins for efficient data retrieval
|
||||||
|
- Maintains full backward compatibility with API-only installations
|
||||||
|
- Graceful fallback to API methods when database access is unavailable
|
||||||
|
- Support for both connection strings and individual parameter configuration
|
||||||
|
|
||||||
## [0.4.1] - 2025-01-09
|
## [0.4.1] - 2025-01-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ except ImportError:
|
|||||||
|
|
||||||
path_mapper = DummyPathMapper()
|
path_mapper = DummyPathMapper()
|
||||||
|
|
||||||
|
# Import database client for enhanced performance
|
||||||
|
try:
|
||||||
|
from clients.radarr_db_client import RadarrDbClient
|
||||||
|
except ImportError:
|
||||||
|
RadarrDbClient = None
|
||||||
|
|
||||||
|
|
||||||
class RadarrClient:
|
class RadarrClient:
|
||||||
"""Enhanced Radarr API client with improved import date detection"""
|
"""Enhanced Radarr API client with improved import date detection"""
|
||||||
@@ -55,6 +61,17 @@ class RadarrClient:
|
|||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.retries = max(0, retries)
|
self.retries = max(0, retries)
|
||||||
|
|
||||||
|
# Initialize database client if available
|
||||||
|
self.db_client = None
|
||||||
|
if RadarrDbClient:
|
||||||
|
try:
|
||||||
|
self.db_client = RadarrDbClient.from_env()
|
||||||
|
if self.db_client:
|
||||||
|
_log("INFO", "Enhanced performance mode: Direct database access enabled")
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Failed to initialize database client, using API fallback: {e}")
|
||||||
|
self.db_client = None
|
||||||
|
|
||||||
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
def _get(self, path: str, params: Dict[str, Any] = None) -> Optional[Any]:
|
||||||
"""Make GET request to Radarr API with retries"""
|
"""Make GET request to Radarr API with retries"""
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
@@ -94,10 +111,21 @@ class RadarrClient:
|
|||||||
imdb_id = imdb_id if imdb_id.startswith("tt") else f"tt{imdb_id}"
|
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}")
|
_log("DEBUG", f"Looking up movie by IMDb ID: {imdb_id}")
|
||||||
|
|
||||||
|
# Try database first if available
|
||||||
|
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
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Database lookup failed, using API fallback: {e}")
|
||||||
|
|
||||||
|
# API Fallback methods
|
||||||
# Method 1: Direct lookup by ID
|
# Method 1: Direct lookup by ID
|
||||||
movie = self._get(f"/api/v3/movie/lookup/imdb/{imdb_id}")
|
movie = self._get(f"/api/v3/movie/lookup/imdb/{imdb_id}")
|
||||||
if isinstance(movie, dict) and movie.get("imdbId", "").lower() == imdb_id.lower():
|
if isinstance(movie, dict) and movie.get("imdbId", "").lower() == imdb_id.lower():
|
||||||
_log("INFO", f"Found via direct lookup: {movie.get('title')} (ID: {movie.get('id')})")
|
_log("INFO", f"Found via API direct lookup: {movie.get('title')} (ID: {movie.get('id')})")
|
||||||
return movie
|
return movie
|
||||||
|
|
||||||
# Method 2: Get all movies and filter by IMDb (fallback)
|
# Method 2: Get all movies and filter by IMDb (fallback)
|
||||||
@@ -110,7 +138,7 @@ class RadarrClient:
|
|||||||
_log("INFO", f"Found exact match: {movie.get('title')} (ID: {movie.get('id')})")
|
_log("INFO", f"Found exact match: {movie.get('title')} (ID: {movie.get('id')})")
|
||||||
return movie
|
return movie
|
||||||
|
|
||||||
# Method 2: Direct API search with validation
|
# Method 3: Direct API search with validation
|
||||||
result = self._get("/api/v3/movie", {"imdbId": imdb_id})
|
result = self._get("/api/v3/movie", {"imdbId": imdb_id})
|
||||||
if isinstance(result, list) and result:
|
if isinstance(result, list) and result:
|
||||||
for movie in result:
|
for movie in result:
|
||||||
@@ -119,7 +147,7 @@ class RadarrClient:
|
|||||||
_log("INFO", f"Found via search: {movie.get('title')} (ID: {movie.get('id')})")
|
_log("INFO", f"Found via search: {movie.get('title')} (ID: {movie.get('id')})")
|
||||||
return movie
|
return movie
|
||||||
|
|
||||||
# Method 3: Lookup endpoint
|
# Method 4: Lookup endpoint
|
||||||
lookup_result = self._get("/api/v3/movie/lookup/imdb", {"imdbId": imdb_id})
|
lookup_result = self._get("/api/v3/movie/lookup/imdb", {"imdbId": imdb_id})
|
||||||
if isinstance(lookup_result, dict) and lookup_result.get("imdbId", "").lower() == imdb_id.lower():
|
if isinstance(lookup_result, dict) and lookup_result.get("imdbId", "").lower() == imdb_id.lower():
|
||||||
_log("INFO", f"Found via lookup: {lookup_result.get('title')} (ID: {lookup_result.get('id')})")
|
_log("INFO", f"Found via lookup: {lookup_result.get('title')} (ID: {lookup_result.get('id')})")
|
||||||
@@ -442,25 +470,31 @@ class RadarrClient:
|
|||||||
|
|
||||||
def get_movie_import_date(self, movie_id: int, fallback_to_file_date: bool = True) -> Tuple[Optional[str], str]:
|
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.
|
Get the best import date for a movie with database optimization.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(date_iso, source_description)
|
(date_iso, source_description)
|
||||||
"""
|
"""
|
||||||
# Try history first - we want the earliest event regardless of type
|
# Try database first if available (much faster)
|
||||||
import_date = self.earliest_import_event_optimized(movie_id)
|
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
|
||||||
|
except Exception as e:
|
||||||
|
_log("WARNING", f"Database import date query failed, using API fallback: {e}")
|
||||||
|
|
||||||
# Try history second
|
# API Fallback: Try history parsing
|
||||||
import_date = self.earliest_import_event_optimized(movie_id)
|
import_date = self.earliest_import_event_optimized(movie_id)
|
||||||
if import_date:
|
if import_date:
|
||||||
return import_date, "radarr:history.import"
|
return import_date, "radarr:api.history.import"
|
||||||
|
|
||||||
# Fallback to file dateAdded if requested
|
# Fallback to file dateAdded if requested
|
||||||
if fallback_to_file_date:
|
if fallback_to_file_date:
|
||||||
file_date = self.earliest_file_dateadded(movie_id)
|
file_date = self.earliest_file_dateadded(movie_id)
|
||||||
if file_date:
|
if file_date:
|
||||||
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
_log("WARNING", f"Using file dateAdded as fallback for movie_id {movie_id}")
|
||||||
return file_date, "radarr:file.dateAdded"
|
return file_date, "radarr:api.file.dateAdded"
|
||||||
|
|
||||||
return None, "radarr:no_date_found"
|
return None, "radarr:no_date_found"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
Args:
|
||||||
|
movie_id: Radarr movie ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(date_iso, source_description)
|
||||||
|
"""
|
||||||
|
# Query for earliest import event (EventType = 3)
|
||||||
|
import_query = """
|
||||||
|
SELECT
|
||||||
|
h."Date" as event_date,
|
||||||
|
h."Data" as event_data
|
||||||
|
FROM "History" h
|
||||||
|
WHERE h."MovieId" = %s
|
||||||
|
AND h."EventType" = 3
|
||||||
|
ORDER BY h."Date" ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Fallback: earliest grab event (EventType = 1) with download info
|
||||||
|
grab_query = """
|
||||||
|
SELECT
|
||||||
|
h."Date" as event_date,
|
||||||
|
h."Data" as event_data
|
||||||
|
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]
|
||||||
|
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 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]
|
||||||
|
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 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 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
|
||||||
|
date_iso, source = self.get_earliest_import_date(movie_id)
|
||||||
|
if date_iso:
|
||||||
|
return date_iso, source
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the database client
|
||||||
|
print("Testing RadarrDbClient...")
|
||||||
|
|
||||||
|
# Test with environment variables
|
||||||
|
client = RadarrDbClient.from_env()
|
||||||
|
if client:
|
||||||
|
print("✅ Connected to Radarr database")
|
||||||
|
|
||||||
|
# Test stats
|
||||||
|
stats = client.get_database_stats()
|
||||||
|
print(f"Database stats: {stats}")
|
||||||
|
|
||||||
|
# Test movie lookup
|
||||||
|
test_movie = client.get_movie_by_imdb("tt1596343")
|
||||||
|
if test_movie:
|
||||||
|
print(f"Found test movie: {test_movie}")
|
||||||
|
|
||||||
|
# Test import date
|
||||||
|
movie_id = test_movie['id']
|
||||||
|
date_iso, source = client.get_movie_import_date_optimized(movie_id)
|
||||||
|
print(f"Import date: {date_iso} (source: {source})")
|
||||||
|
else:
|
||||||
|
print("Test movie not found")
|
||||||
|
else:
|
||||||
|
print("❌ Could not connect to database - check environment variables")
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi==0.104.1
|
fastapi==0.104.1
|
||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]==0.24.0
|
||||||
pydantic==2.5.0
|
pydantic==2.5.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script for comparing Radarr API vs Database performance
|
||||||
|
Run this to validate the new database client functionality
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from clients.radarr_client import RadarrClient
|
||||||
|
from clients.radarr_db_client import RadarrDbClient
|
||||||
|
|
||||||
|
def test_database_connection():
|
||||||
|
"""Test database connection and basic functionality"""
|
||||||
|
print("=== Testing Database Connection ===")
|
||||||
|
|
||||||
|
db_client = RadarrDbClient.from_env()
|
||||||
|
if not db_client:
|
||||||
|
print("❌ Database client not configured - check environment variables")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✅ Database connection successful")
|
||||||
|
|
||||||
|
# Test basic stats
|
||||||
|
stats = db_client.get_database_stats()
|
||||||
|
print(f"Database stats: {stats}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def test_movie_lookup_performance():
|
||||||
|
"""Compare API vs Database performance for movie lookups"""
|
||||||
|
print("\n=== Testing Movie Lookup Performance ===")
|
||||||
|
|
||||||
|
# Test IMDb IDs - adjust these to movies in your library
|
||||||
|
test_imdb_ids = [
|
||||||
|
"tt1596343", # Fast & Furious 6
|
||||||
|
"tt0468569", # The Dark Knight
|
||||||
|
"tt0137523", # Fight Club
|
||||||
|
"tt0109830", # Forrest Gump
|
||||||
|
"tt0111161" # The Shawshank Redemption
|
||||||
|
]
|
||||||
|
|
||||||
|
# Initialize clients
|
||||||
|
radarr_url = os.environ.get("RADARR_URL")
|
||||||
|
radarr_api_key = os.environ.get("RADARR_API_KEY")
|
||||||
|
|
||||||
|
if not radarr_url or not radarr_api_key:
|
||||||
|
print("❌ RADARR_URL and RADARR_API_KEY required for testing")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = RadarrClient(radarr_url, radarr_api_key)
|
||||||
|
|
||||||
|
# Test API performance
|
||||||
|
print("\n--- API Performance ---")
|
||||||
|
api_times = []
|
||||||
|
api_found = 0
|
||||||
|
|
||||||
|
for imdb_id in test_imdb_ids:
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Temporarily disable database client for API-only test
|
||||||
|
original_db_client = client.db_client
|
||||||
|
client.db_client = None
|
||||||
|
|
||||||
|
movie = client.movie_by_imdb(imdb_id)
|
||||||
|
|
||||||
|
# Restore database client
|
||||||
|
client.db_client = original_db_client
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
api_times.append(elapsed)
|
||||||
|
|
||||||
|
if movie:
|
||||||
|
api_found += 1
|
||||||
|
print(f" {imdb_id}: {elapsed:.3f}s - Found: {movie.get('title')}")
|
||||||
|
else:
|
||||||
|
print(f" {imdb_id}: {elapsed:.3f}s - Not found")
|
||||||
|
|
||||||
|
# Test Database performance
|
||||||
|
print("\n--- Database Performance ---")
|
||||||
|
db_times = []
|
||||||
|
db_found = 0
|
||||||
|
|
||||||
|
if client.db_client:
|
||||||
|
for imdb_id in test_imdb_ids:
|
||||||
|
start_time = time.time()
|
||||||
|
movie = client.movie_by_imdb(imdb_id) # This will use database first
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
db_times.append(elapsed)
|
||||||
|
|
||||||
|
if movie:
|
||||||
|
db_found += 1
|
||||||
|
print(f" {imdb_id}: {elapsed:.3f}s - Found: {movie.get('title')}")
|
||||||
|
else:
|
||||||
|
print(f" {imdb_id}: {elapsed:.3f}s - Not found")
|
||||||
|
else:
|
||||||
|
print(" Database client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Performance comparison
|
||||||
|
print("\n--- Performance Summary ---")
|
||||||
|
avg_api_time = sum(api_times) / len(api_times)
|
||||||
|
avg_db_time = sum(db_times) / len(db_times)
|
||||||
|
|
||||||
|
print(f"API Average: {avg_api_time:.3f}s ({api_found}/{len(test_imdb_ids)} found)")
|
||||||
|
print(f"Database Average: {avg_db_time:.3f}s ({db_found}/{len(test_imdb_ids)} found)")
|
||||||
|
|
||||||
|
if avg_api_time > 0 and avg_db_time > 0:
|
||||||
|
speedup = avg_api_time / avg_db_time
|
||||||
|
print(f"Speedup: {speedup:.1f}x faster with database")
|
||||||
|
|
||||||
|
def test_import_date_performance():
|
||||||
|
"""Test import date retrieval performance"""
|
||||||
|
print("\n=== Testing Import Date Performance ===")
|
||||||
|
|
||||||
|
radarr_url = os.environ.get("RADARR_URL")
|
||||||
|
radarr_api_key = os.environ.get("RADARR_API_KEY")
|
||||||
|
|
||||||
|
if not radarr_url or not radarr_api_key:
|
||||||
|
print("❌ RADARR_URL and RADARR_API_KEY required for testing")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = RadarrClient(radarr_url, radarr_api_key)
|
||||||
|
|
||||||
|
# Find a test movie
|
||||||
|
test_movie = client.movie_by_imdb("tt1596343") # Use database if available
|
||||||
|
if not test_movie:
|
||||||
|
print("❌ Test movie not found in library")
|
||||||
|
return
|
||||||
|
|
||||||
|
movie_id = test_movie.get('id')
|
||||||
|
movie_title = test_movie.get('title', 'Unknown')
|
||||||
|
print(f"Testing with: {movie_title} (ID: {movie_id})")
|
||||||
|
|
||||||
|
# Test API performance
|
||||||
|
print("\n--- API Import Date Performance ---")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Temporarily disable database client
|
||||||
|
original_db_client = client.db_client
|
||||||
|
client.db_client = None
|
||||||
|
|
||||||
|
api_date, api_source = client.get_movie_import_date(movie_id)
|
||||||
|
|
||||||
|
# Restore database client
|
||||||
|
client.db_client = original_db_client
|
||||||
|
|
||||||
|
api_time = time.time() - start_time
|
||||||
|
print(f"API Result: {api_date} ({api_source}) - {api_time:.3f}s")
|
||||||
|
|
||||||
|
# Test Database performance
|
||||||
|
if client.db_client:
|
||||||
|
print("\n--- Database Import Date Performance ---")
|
||||||
|
start_time = time.time()
|
||||||
|
db_date, db_source = client.get_movie_import_date(movie_id)
|
||||||
|
db_time = time.time() - start_time
|
||||||
|
print(f"Database Result: {db_date} ({db_source}) - {db_time:.3f}s")
|
||||||
|
|
||||||
|
if api_time > 0 and db_time > 0:
|
||||||
|
speedup = api_time / db_time
|
||||||
|
print(f"Import date speedup: {speedup:.1f}x faster with database")
|
||||||
|
else:
|
||||||
|
print("Database client not available for comparison")
|
||||||
|
|
||||||
|
def test_bulk_operations():
|
||||||
|
"""Test bulk import date operations"""
|
||||||
|
print("\n=== Testing Bulk Operations ===")
|
||||||
|
|
||||||
|
db_client = RadarrDbClient.from_env()
|
||||||
|
if not db_client:
|
||||||
|
print("❌ Database client required for bulk operations")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Test bulk import dates
|
||||||
|
test_imdb_ids = ["tt1596343", "tt0468569", "tt0137523", "tt0109830", "tt0111161"]
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
results = db_client.bulk_import_dates(test_imdb_ids)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
print(f"Bulk operation completed in {elapsed:.3f}s")
|
||||||
|
print("Results:")
|
||||||
|
for imdb_id, (date_iso, source) in results.items():
|
||||||
|
print(f" {imdb_id}: {date_iso} ({source})")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("NFOGuard Database Performance Test")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Test database connection
|
||||||
|
if not test_database_connection():
|
||||||
|
print("Exiting due to database connection failure")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Test movie lookups
|
||||||
|
test_movie_lookup_performance()
|
||||||
|
|
||||||
|
# Test import dates
|
||||||
|
test_import_date_performance()
|
||||||
|
|
||||||
|
# Test bulk operations
|
||||||
|
test_bulk_operations()
|
||||||
|
|
||||||
|
print("\n✅ All tests completed!")
|
||||||
|
print("\nTo enable database access, configure these environment variables:")
|
||||||
|
print(" RADARR_DB_TYPE=postgresql")
|
||||||
|
print(" RADARR_DB_HOST=your_postgres_host")
|
||||||
|
print(" RADARR_DB_PORT=5432")
|
||||||
|
print(" RADARR_DB_NAME=radarr-main")
|
||||||
|
print(" RADARR_DB_USER=postgres")
|
||||||
|
print(" RADARR_DB_PASSWORD=your_password")
|
||||||
|
print("\nOr for SQLite:")
|
||||||
|
print(" RADARR_DB_TYPE=sqlite")
|
||||||
|
print(" RADARR_DB_PATH=/config/radarr.db")
|
||||||
Reference in New Issue
Block a user