#!/usr/bin/env python3 """ Database management for NFOGuard Handles SQLite and PostgreSQL database operations for tracking media dates and processing history """ import sqlite3 import json import threading import os from pathlib import Path from datetime import datetime from typing import Optional, Dict, List, Any, Union from contextlib import contextmanager try: import psycopg2 import psycopg2.extras POSTGRESQL_AVAILABLE = True except ImportError: psycopg2 = None POSTGRESQL_AVAILABLE = False class NFOGuardDatabase: """Manages NFOGuard database operations for both SQLite and PostgreSQL""" def __init__(self, config=None, db_path: Optional[Path] = None): """ Initialize database connection Args: config: Configuration object with database settings db_path: Path for SQLite database (backward compatibility) """ if config: self.db_type = config.db_type if self.db_type == "postgresql": if not POSTGRESQL_AVAILABLE: raise ImportError("psycopg2 is required for PostgreSQL support") self.db_host = config.db_host self.db_port = config.db_port self.db_name = config.db_name self.db_user = config.db_user self.db_password = config.db_password else: self.db_path = Path(config.db_path) self.db_path.parent.mkdir(parents=True, exist_ok=True) else: # Backward compatibility self.db_type = "sqlite" self.db_path = Path(db_path) if db_path else Path("/app/data/media_dates.db") self.db_path.parent.mkdir(parents=True, exist_ok=True) self._local = threading.local() self._init_database() def _get_placeholder(self) -> str: """Get the correct parameter placeholder for this database type""" return "%s" if self.db_type == "postgresql" else "?" def _get_connection(self) -> Union[sqlite3.Connection, 'psycopg2.extensions.connection']: """Get thread-local database connection""" if not hasattr(self._local, 'connection'): if self.db_type == "postgresql": self._local.connection = psycopg2.connect( host=self.db_host, port=self.db_port, database=self.db_name, user=self.db_user, password=self.db_password, cursor_factory=psycopg2.extras.RealDictCursor ) self._local.connection.autocommit = True else: 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 def _get_first_value(self, row): """Get first value from row, handling both dict and tuple types""" if self.db_type == "postgresql": # RealDictCursor returns dict-like objects return list(row.values())[0] if row else None else: # SQLite returns tuples return row[0] if row else None @contextmanager def get_connection(self): """Context manager for database connections""" conn = self._get_connection() try: yield conn if self.db_type == "sqlite": conn.commit() # PostgreSQL uses autocommit except Exception: if self.db_type == "sqlite": conn.rollback() # PostgreSQL uses autocommit raise def _init_database(self): """Initialize database tables with migration support""" with self.get_connection() as conn: cursor = conn.cursor() if self.db_type == "postgresql": self._init_postgresql_tables(cursor) else: self._init_sqlite_tables(cursor) # Test the connection works and verify autocommit cursor.execute("SELECT 1") if self.db_type == "postgresql": print(f"✅ Database initialized and connection verified: {self.db_type.upper()}") print(f"🔍 PostgreSQL autocommit status: {conn.autocommit}") else: print(f"✅ Database initialized and connection verified: {self.db_type.upper()}") def _init_postgresql_tables(self, cursor): """Initialize PostgreSQL tables""" # Series table cursor.execute(""" CREATE TABLE IF NOT EXISTS series ( imdb_id VARCHAR(20) PRIMARY KEY, path TEXT NOT NULL, last_updated TIMESTAMP NOT NULL, metadata JSONB ) """) # Episodes table cursor.execute(""" CREATE TABLE IF NOT EXISTS episodes ( imdb_id VARCHAR(20) NOT NULL, season INTEGER NOT NULL, episode INTEGER NOT NULL, aired DATE, dateadded TIMESTAMP, source VARCHAR(100), last_updated TIMESTAMP NOT NULL, has_video_file BOOLEAN DEFAULT FALSE, 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 VARCHAR(20) PRIMARY KEY, path TEXT NOT NULL, released DATE, dateadded TIMESTAMP, source VARCHAR(100), last_updated TIMESTAMP NOT NULL, has_video_file BOOLEAN DEFAULT FALSE ) """) # Processing history table cursor.execute(""" CREATE TABLE IF NOT EXISTS processing_history ( id SERIAL PRIMARY KEY, imdb_id VARCHAR(20) NOT NULL, media_type VARCHAR(20) NOT NULL, event_type VARCHAR(50) NOT NULL, processed_at TIMESTAMP NOT NULL, details TEXT ) """) # Create indexes for PostgreSQL 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 _init_sqlite_tables(self, cursor): """Initialize SQLite tables""" # 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, has_video_file BOOLEAN DEFAULT FALSE, 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, has_video_file BOOLEAN DEFAULT FALSE ) """) # 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 ) """) # Handle SQLite migrations for existing databases self._handle_sqlite_migrations(cursor) # Create indexes for SQLite 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 _handle_sqlite_migrations(self, cursor): """Handle SQLite database migrations for existing databases""" # 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") 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() timestamp = datetime.utcnow() if self.db_type == "postgresql": cursor.execute(""" INSERT INTO series (imdb_id, path, last_updated, metadata) VALUES (%s, %s, %s, %s) ON CONFLICT (imdb_id) DO UPDATE SET path = EXCLUDED.path, last_updated = EXCLUDED.last_updated, metadata = EXCLUDED.metadata """, (imdb_id, path, timestamp, json.dumps(metadata) if metadata else None)) else: cursor.execute(""" INSERT OR REPLACE INTO series (imdb_id, path, last_updated, metadata) VALUES (?, ?, ?, ?) """, (imdb_id, path, timestamp.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() timestamp = datetime.utcnow() if self.db_type == "postgresql": cursor.execute(""" INSERT INTO episodes (imdb_id, season, episode, aired, dateadded, source, has_video_file, last_updated) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (imdb_id, season, episode) DO UPDATE SET aired = EXCLUDED.aired, dateadded = EXCLUDED.dateadded, source = EXCLUDED.source, has_video_file = EXCLUDED.has_video_file, last_updated = EXCLUDED.last_updated """, (imdb_id, season, episode, aired, dateadded, source, has_video_file, timestamp)) print(f"🔍 DEBUG: PostgreSQL upsert executed for {imdb_id} S{season:02d}E{episode:02d}, rows affected: {cursor.rowcount}") else: 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, timestamp.isoformat())) def upsert_movie(self, imdb_id: str, path: str): """Insert or update movie record""" with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() if self.db_type == "postgresql": cursor.execute(""" INSERT INTO movies (imdb_id, path, last_updated) VALUES (%s, %s, %s) ON CONFLICT (imdb_id) DO UPDATE SET path = EXCLUDED.path, last_updated = EXCLUDED.last_updated """, (imdb_id, path, timestamp)) else: try: cursor.execute(""" INSERT OR REPLACE INTO movies (imdb_id, path, last_updated) VALUES (?, ?, ?) """, (imdb_id, path, timestamp.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, timestamp.isoformat())) else: raise def upsert_movie_dates(self, imdb_id: str, released: Optional[str], dateadded: Optional[str], source: str, has_video_file: bool = False): """Insert or update movie date record""" print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, dateadded={dateadded}, source={source}") with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() if self.db_type == "postgresql": # PostgreSQL version with UPSERT cursor.execute(""" INSERT INTO movies (imdb_id, path, released, dateadded, source, has_video_file, last_updated) VALUES (%s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s) ON CONFLICT (imdb_id) DO UPDATE SET released = EXCLUDED.released, dateadded = EXCLUDED.dateadded, source = EXCLUDED.source, has_video_file = EXCLUDED.has_video_file, last_updated = EXCLUDED.last_updated """, (imdb_id, imdb_id, released, dateadded, source, has_video_file, timestamp)) # Debug: Check what was actually saved cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = %s", (imdb_id,)) else: # SQLite version with INSERT OR REPLACE 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, timestamp.isoformat())) # Debug: Check what was actually saved cursor.execute("SELECT dateadded, source FROM movies WHERE imdb_id = ?", (imdb_id,)) result = cursor.fetchone() print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result[0] if result else 'NOT_FOUND'}, source={result[1] if result else 'NOT_FOUND'}") def get_series_episodes(self, imdb_id: str, has_video_file_only: bool = False) -> List[Dict]: """Get all episodes for a series""" with self.get_connection() as conn: cursor = conn.cursor() if self.db_type == "postgresql": query = "SELECT * FROM episodes WHERE imdb_id = %s" params = [imdb_id] if has_video_file_only: query += " AND has_video_file = TRUE" else: 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() ph = self._get_placeholder() cursor.execute(f""" SELECT * FROM episodes WHERE imdb_id = {ph} AND season = {ph} AND episode = {ph} """, (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() ph = self._get_placeholder() cursor.execute(f"SELECT * FROM movies WHERE imdb_id = {ph}", (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() placeholder = self._get_placeholder() cursor.execute(f""" INSERT INTO processing_history (imdb_id, media_type, event_type, processed_at, details) VALUES ({placeholder}, {placeholder}, {placeholder}, {placeholder}, {placeholder}) """, (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: # Use regular cursor for simple COUNT queries (not RealDictCursor) if self.db_type == "postgresql": cursor = conn.cursor() # Regular cursor for PostgreSQL else: cursor = conn.cursor() # SQLite cursor # Series stats cursor.execute("SELECT COUNT(*) FROM series") series_count = self._get_first_value(cursor.fetchone()) # Episode stats cursor.execute("SELECT COUNT(*) FROM episodes") episodes_total = self._get_first_value(cursor.fetchone()) cursor.execute("SELECT COUNT(*) FROM episodes WHERE has_video_file = TRUE") episodes_with_video = self._get_first_value(cursor.fetchone()) # Movie stats cursor.execute("SELECT COUNT(*) FROM movies") movies_total = self._get_first_value(cursor.fetchone()) cursor.execute("SELECT COUNT(*) FROM movies WHERE has_video_file = TRUE") movies_with_video = self._get_first_value(cursor.fetchone()) # Processing history cursor.execute("SELECT COUNT(*) FROM processing_history") history_count = self._get_first_value(cursor.fetchone()) # Database size calculation if self.db_type == "postgresql": # PostgreSQL: Query pg_database_size cursor.execute("SELECT pg_database_size(%s)", (self.db_name,)) db_size_bytes = self._get_first_value(cursor.fetchone()) db_size_mb = round(db_size_bytes / 1024 / 1024, 2) if db_size_bytes else 0 else: # SQLite: File size db_size_mb = round(self.db_path.stat().st_size / 1024 / 1024, 2) 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": db_size_mb, "database_type": self.db_type } def close(self): """Close all database connections""" if hasattr(self._local, 'connection'): try: self._local.connection.close() delattr(self._local, 'connection') except Exception: pass # Connection may already be closed