#!/usr/bin/env python3 """ PostgreSQL database management for NFOGuard Handles database operations for tracking media dates and processing history """ import json import threading from datetime import datetime from typing import Optional, Dict, List, Any from contextlib import contextmanager import psycopg2 import psycopg2.extras class NFOGuardDatabase: """PostgreSQL database manager for NFOGuard media tracking and processing history""" def __init__(self, config): """ Initialize PostgreSQL database connection Args: config: Configuration object with database settings """ if not config: raise ValueError("PostgreSQL configuration is required") 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 self.db_type = "postgresql" # NFOGuard uses PostgreSQL self._local = threading.local() self._init_database() def _get_connection(self) -> 'psycopg2.extensions.connection': """Get thread-local PostgreSQL database connection""" if not hasattr(self._local, 'connection'): 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 return self._local.connection def _get_first_value(self, row): """Get first value from row from PostgreSQL RealDictCursor""" # RealDictCursor returns dict-like objects return list(row.values())[0] if row else None @contextmanager def get_connection(self): """Context manager for PostgreSQL database connections""" conn = self._get_connection() try: yield conn # PostgreSQL uses autocommit - no manual commit needed except Exception: # PostgreSQL uses autocommit - no manual rollback needed raise def _init_database(self): """Initialize PostgreSQL database tables""" with self.get_connection() as conn: cursor = conn.cursor() self._init_postgresql_tables(cursor) # Test the connection works and verify autocommit cursor.execute("SELECT 1") print(f"✅ PostgreSQL database initialized and connection verified") print(f"🔍 Autocommit status: {conn.autocommit}") def _init_postgresql_tables(self, cursor): """Initialize database 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, title TEXT, year INTEGER, path TEXT NOT NULL, released DATE, dateadded TIMESTAMP, source VARCHAR(100), last_updated TIMESTAMP NOT NULL, has_video_file BOOLEAN DEFAULT FALSE ) """) # Add title and year columns if they don't exist (migration for existing databases) cursor.execute(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='movies' AND column_name='title') THEN ALTER TABLE movies ADD COLUMN title TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='movies' AND column_name='year') THEN ALTER TABLE movies ADD COLUMN year INTEGER; END IF; END $$; """) # Add skipped and skip_reason columns if they don't exist (migration for skip tracking) cursor.execute(""" DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='movies' AND column_name='skipped') THEN ALTER TABLE movies ADD COLUMN skipped BOOLEAN DEFAULT FALSE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='movies' AND column_name='skip_reason') THEN ALTER TABLE movies ADD COLUMN skip_reason TEXT; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='episodes' AND column_name='skipped') THEN ALTER TABLE episodes ADD COLUMN skipped BOOLEAN DEFAULT FALSE; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='episodes' AND column_name='skip_reason') THEN ALTER TABLE episodes ADD COLUMN skip_reason TEXT; END IF; END $$; """) # 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 ) """) # Missing IMDb tracking table cursor.execute(""" CREATE TABLE IF NOT EXISTS missing_imdb ( id SERIAL PRIMARY KEY, file_path TEXT NOT NULL UNIQUE, media_type VARCHAR(20) NOT NULL, folder_name TEXT, filename TEXT, discovered_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_checked TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, check_count INTEGER DEFAULT 1, notes TEXT, resolved BOOLEAN DEFAULT FALSE, resolved_at TIMESTAMP, resolved_imdb_id VARCHAR(20) ) """) # Scheduled scans table cursor.execute(""" CREATE TABLE IF NOT EXISTS scheduled_scans ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, description TEXT, cron_expression VARCHAR(100) NOT NULL, media_type VARCHAR(20) NOT NULL CHECK (media_type IN ('tv', 'movies', 'both')), scan_mode VARCHAR(20) NOT NULL CHECK (scan_mode IN ('smart', 'full', 'incomplete')), specific_paths TEXT, enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_run_at TIMESTAMP, next_run_at TIMESTAMP, run_count INTEGER DEFAULT 0, created_by VARCHAR(100), updated_by VARCHAR(100) ) """) # Schedule execution history table cursor.execute(""" CREATE TABLE IF NOT EXISTS schedule_executions ( id SERIAL PRIMARY KEY, schedule_id INTEGER NOT NULL, started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, status VARCHAR(50) NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')), media_type VARCHAR(20) NOT NULL, scan_mode VARCHAR(20) NOT NULL, items_processed INTEGER DEFAULT 0, items_skipped INTEGER DEFAULT 0, items_failed INTEGER DEFAULT 0, execution_time_seconds INTEGER, error_message TEXT, logs TEXT, triggered_by VARCHAR(100), FOREIGN KEY (schedule_id) REFERENCES scheduled_scans(id) ON DELETE CASCADE ) """) # 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)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_type ON missing_imdb(media_type)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_resolved ON missing_imdb(resolved)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_missing_imdb_path ON missing_imdb(file_path)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_enabled ON scheduled_scans(enabled)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run ON scheduled_scans(next_run_at)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_schedule ON schedule_executions(schedule_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_status ON schedule_executions(status)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_schedule_executions_started ON schedule_executions(started_at)") 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() 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)) 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() 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)) import os if os.environ.get("DEBUG", "false").lower() == "true": print(f"🔍 DEBUG: PostgreSQL upsert executed for {imdb_id} S{season:02d}E{episode:02d}, rows affected: {cursor.rowcount}") 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() 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)) def upsert_movie_dates(self, imdb_id: str, released: Optional[str], dateadded: Optional[str], source: str, has_video_file: bool = False, title: Optional[str] = None, year: Optional[int] = None): """Insert or update movie date record""" import os if os.environ.get("DEBUG", "false").lower() == "true": print(f"🔍 DATABASE UPSERT: imdb_id={imdb_id}, title={title}, dateadded={dateadded}, source={source}") with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() cursor.execute(""" INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated) VALUES (%s, %s, %s, COALESCE((SELECT path FROM movies WHERE imdb_id = %s), 'unknown'), %s, %s, %s, %s, %s) ON CONFLICT (imdb_id) DO UPDATE SET title = EXCLUDED.title, year = EXCLUDED.year, released = EXCLUDED.released, dateadded = EXCLUDED.dateadded, source = EXCLUDED.source, has_video_file = EXCLUDED.has_video_file, last_updated = EXCLUDED.last_updated """, (imdb_id, title, year, 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,)) result = cursor.fetchone() import os if os.environ.get("DEBUG", "false").lower() == "true": print(f"🔍 DATABASE VERIFY: After upsert, found dateadded={result['dateadded'] if result else 'NOT_FOUND'}, source={result['source'] if result else 'NOT_FOUND'}") def mark_movie_skipped(self, imdb_id: str, title: str, year: int, path: str, reason: str): """Mark a movie as skipped with reason""" with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() cursor.execute(""" INSERT INTO movies (imdb_id, title, year, path, skipped, skip_reason, has_video_file, last_updated) VALUES (%s, %s, %s, %s, TRUE, %s, FALSE, %s) ON CONFLICT (imdb_id) DO UPDATE SET title = EXCLUDED.title, year = EXCLUDED.year, path = EXCLUDED.path, skipped = TRUE, skip_reason = EXCLUDED.skip_reason, last_updated = EXCLUDED.last_updated """, (imdb_id, title, year, path, reason, timestamp)) def mark_episode_skipped(self, imdb_id: str, season: int, episode: int, reason: str): """Mark an episode as skipped with reason""" with self.get_connection() as conn: cursor = conn.cursor() timestamp = datetime.utcnow() cursor.execute(""" INSERT INTO episodes (imdb_id, season, episode, skipped, skip_reason, has_video_file, last_updated) VALUES (%s, %s, %s, TRUE, %s, FALSE, %s) ON CONFLICT (imdb_id, season, episode) DO UPDATE SET skipped = TRUE, skip_reason = EXCLUDED.skip_reason, last_updated = EXCLUDED.last_updated """, (imdb_id, season, episode, reason, timestamp)) def clear_movie_skipped(self, imdb_id: str): """Clear skipped flag for a movie""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE movies SET skipped = FALSE, skip_reason = NULL WHERE imdb_id = %s """, (imdb_id,)) def clear_episode_skipped(self, imdb_id: str, season: int, episode: int): """Clear skipped flag for an episode""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE episodes SET skipped = FALSE, skip_reason = NULL WHERE imdb_id = %s AND season = %s AND episode = %s """, (imdb_id, season, episode)) def get_skipped_counts(self) -> Dict: """Get counts of skipped movies and episodes""" with self.get_connection() as conn: cursor = conn.cursor() # Count skipped movies cursor.execute("SELECT COUNT(*) as count FROM movies WHERE skipped = TRUE") skipped_movies = cursor.fetchone()['count'] # Count skipped episodes cursor.execute("SELECT COUNT(*) as count FROM episodes WHERE skipped = TRUE") skipped_episodes = cursor.fetchone()['count'] return { 'movies': skipped_movies, 'episodes': skipped_episodes, 'total': skipped_movies + skipped_episodes } 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 = %s" 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 = %s AND season = %s AND episode = %s """, (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 = %s", (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 (%s, %s, %s, %s, %s) """, (imdb_id, media_type, event_type, datetime.utcnow().isoformat(), json.dumps(details) if details else None)) def migrate_movie_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool: """Migrate a movie from placeholder IMDb ID to real IMDb ID""" with self.get_connection() as conn: cursor = conn.cursor() # Check if old record exists cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (old_imdb_id,)) old_record = cursor.fetchone() if not old_record: return False old_data = dict(old_record) # Check if new IMDb ID already exists cursor.execute("SELECT * FROM movies WHERE imdb_id = %s", (new_imdb_id,)) if cursor.fetchone(): # New IMDb ID already exists, just delete the old one cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,)) return True # Create new record with correct IMDb ID cursor.execute(""" INSERT INTO movies (imdb_id, title, year, path, released, dateadded, source, has_video_file, last_updated, skipped, skip_reason) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, (new_imdb_id, old_data.get('title'), old_data.get('year'), old_data.get('path'), old_data.get('released'), old_data.get('dateadded'), old_data.get('source'), old_data.get('has_video_file'), datetime.utcnow(), False, None)) # Clear skipped status # Delete old placeholder record cursor.execute("DELETE FROM movies WHERE imdb_id = %s", (old_imdb_id,)) return True def migrate_series_imdb_id(self, old_imdb_id: str, new_imdb_id: str) -> bool: """Migrate a series and all its episodes from placeholder IMDb ID to real IMDb ID""" with self.get_connection() as conn: cursor = conn.cursor() # Check if old series exists cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (old_imdb_id,)) old_series = cursor.fetchone() if not old_series: return False old_series_data = dict(old_series) # Check if new series IMDb ID already exists cursor.execute("SELECT * FROM series WHERE imdb_id = %s", (new_imdb_id,)) if cursor.fetchone(): # New series already exists, migrate episodes and delete old series cursor.execute(""" UPDATE episodes SET imdb_id = %s WHERE imdb_id = %s """, (new_imdb_id, old_imdb_id)) cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,)) return True # Create new series record cursor.execute(""" INSERT INTO series (imdb_id, path, last_updated, metadata) VALUES (%s, %s, %s, %s) """, (new_imdb_id, old_series_data.get('path'), datetime.utcnow(), old_series_data.get('metadata'))) # Migrate all episodes to new series IMDb ID and clear skipped status cursor.execute(""" UPDATE episodes SET imdb_id = %s, skipped = FALSE, skip_reason = NULL WHERE imdb_id = %s """, (new_imdb_id, old_imdb_id)) # Delete old placeholder series cursor.execute("DELETE FROM series WHERE imdb_id = %s", (old_imdb_id,)) return True def get_stats(self) -> Dict[str, Any]: """Get database statistics""" with self.get_connection() as conn: cursor = conn.cursor() # Regular cursor for PostgreSQL # 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 for PostgreSQL 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 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": "postgresql" } def delete_episode(self, imdb_id: str, season: int, episode: int) -> bool: """ Delete a specific episode from the database Args: imdb_id: Series IMDb ID season: Season number episode: Episode number Returns: True if episode was deleted, False if not found """ with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s """, (imdb_id, season, episode)) deleted_count = cursor.rowcount conn.commit() return deleted_count > 0 def delete_series_episodes(self, imdb_id: str) -> int: """ Delete all episodes for a series from the database Args: imdb_id: Series IMDb ID Returns: Number of episodes deleted """ with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" DELETE FROM episodes WHERE imdb_id = %s """, (imdb_id,)) deleted_count = cursor.rowcount conn.commit() return deleted_count def delete_orphaned_episodes(self) -> List[Dict]: """ Find and delete episodes that don't have corresponding video files on disk This requires checking filesystem for each episode, so use carefully Returns: List of deleted episodes with their details """ from utils.file_utils import find_episodes_on_disk from pathlib import Path deleted_episodes = [] with self.get_connection() as conn: cursor = conn.cursor() # Get all series with their paths cursor.execute(""" SELECT DISTINCT imdb_id, path FROM series """) series_list = cursor.fetchall() for series in series_list: imdb_id = series['imdb_id'] series_path = Path(series['path']) if not series_path.exists(): continue # Get episodes on disk disk_episodes = find_episodes_on_disk(series_path) disk_episode_keys = set(disk_episodes.keys()) # Get episodes in database cursor.execute(""" SELECT season, episode, dateadded, source FROM episodes WHERE imdb_id = %s """, (imdb_id,)) db_episodes = cursor.fetchall() # Find orphaned episodes (in DB but not on disk) for db_episode in db_episodes: season = db_episode['season'] episode = db_episode['episode'] episode_key = (season, episode) if episode_key not in disk_episode_keys: # Episode is orphaned - delete it cursor.execute(""" DELETE FROM episodes WHERE imdb_id = %s AND season = %s AND episode = %s """, (imdb_id, season, episode)) deleted_episodes.append({ 'imdb_id': imdb_id, 'season': season, 'episode': episode, 'dateadded': db_episode['dateadded'], 'source': db_episode['source'], 'series_path': str(series_path) }) conn.commit() return deleted_episodes def delete_movie(self, imdb_id: str) -> bool: """ Delete a specific movie from the database Args: imdb_id: Movie IMDb ID Returns: True if movie was deleted, False if not found """ with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" DELETE FROM movies WHERE imdb_id = %s """, (imdb_id,)) deleted_count = cursor.rowcount conn.commit() return deleted_count > 0 def delete_series(self, imdb_id: str) -> bool: """ Delete a specific series from the database Args: imdb_id: Series IMDb ID Returns: True if series was deleted, False if not found """ with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" DELETE FROM series WHERE imdb_id = %s """, (imdb_id,)) deleted_count = cursor.rowcount conn.commit() return deleted_count > 0 def delete_orphaned_movies(self) -> List[Dict]: """ Find and delete movies that don't have corresponding video files on disk This requires checking filesystem for each movie, so use carefully Returns: List of deleted movies with their details """ from pathlib import Path deleted_movies = [] with self.get_connection() as conn: cursor = conn.cursor() # Get all movies with their paths cursor.execute(""" SELECT imdb_id, path, dateadded, source FROM movies """) movies_list = cursor.fetchall() for movie in movies_list: imdb_id = movie['imdb_id'] movie_path = Path(movie['path']) if not movie_path.exists(): # Movie directory doesn't exist - delete it cursor.execute(""" DELETE FROM movies WHERE imdb_id = %s """, (imdb_id,)) deleted_movies.append({ 'imdb_id': imdb_id, 'reason': 'directory_not_found', 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) continue # Check for video files video_exts = (".mkv", ".mp4", ".avi", ".mov", ".m4v") has_video = any(f.is_file() and f.suffix.lower() in video_exts for f in movie_path.iterdir() if f.is_file()) if not has_video: # No video files found - delete this movie cursor.execute(""" DELETE FROM movies WHERE imdb_id = %s """, (imdb_id,)) deleted_movies.append({ 'imdb_id': imdb_id, 'reason': 'no_video_files', 'path': str(movie_path), 'dateadded': movie['dateadded'], 'source': movie['source'] }) conn.commit() return deleted_movies def delete_orphaned_series(self) -> List[Dict]: """ Find and delete TV series that don't have corresponding directories on disk This requires checking filesystem for each series, so use carefully Returns: List of deleted series with their details """ from pathlib import Path deleted_series = [] with self.get_connection() as conn: cursor = conn.cursor() # Get all series with their paths cursor.execute(""" SELECT imdb_id, path, last_updated, metadata FROM series """) series_list = cursor.fetchall() for series in series_list: imdb_id = series['imdb_id'] series_path = Path(series['path']) if not series_path.exists(): # Series directory doesn't exist - delete the series and all its episodes cursor.execute(""" DELETE FROM episodes WHERE imdb_id = %s """, (imdb_id,)) episodes_deleted = cursor.rowcount cursor.execute(""" DELETE FROM series WHERE imdb_id = %s """, (imdb_id,)) deleted_series.append({ 'imdb_id': imdb_id, 'reason': 'directory_not_found', 'path': str(series_path), 'last_updated': series['last_updated'], 'episodes_deleted': episodes_deleted }) conn.commit() return deleted_series def add_missing_imdb(self, file_path: str, media_type: str, folder_name: str = None, filename: str = None, notes: str = None): """Add or update a missing IMDb entry""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO missing_imdb (file_path, media_type, folder_name, filename, notes) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (file_path) DO UPDATE SET last_checked = CURRENT_TIMESTAMP, check_count = missing_imdb.check_count + 1, media_type = EXCLUDED.media_type, folder_name = EXCLUDED.folder_name, filename = EXCLUDED.filename, notes = EXCLUDED.notes """, (file_path, media_type, folder_name, filename, notes)) conn.commit() def get_missing_imdb_items(self, media_type: str = None, resolved: bool = False) -> List[Dict]: """Get missing IMDb items, optionally filtered by type and resolution status""" with self.get_connection() as conn: cursor = conn.cursor() query = """ SELECT id, file_path, media_type, folder_name, filename, discovered_at, last_checked, check_count, notes, resolved, resolved_at, resolved_imdb_id FROM missing_imdb WHERE resolved = %s """ params = [resolved] if media_type: query += " AND media_type = %s" params.append(media_type) query += " ORDER BY last_checked DESC" cursor.execute(query, params) return cursor.fetchall() def resolve_missing_imdb(self, file_path: str, imdb_id: str): """Mark a missing IMDb item as resolved""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE missing_imdb SET resolved = TRUE, resolved_at = CURRENT_TIMESTAMP, resolved_imdb_id = %s WHERE file_path = %s """, (imdb_id, file_path)) conn.commit() return cursor.rowcount > 0 def delete_missing_imdb(self, file_path: str) -> bool: """Delete a missing IMDb entry""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM missing_imdb WHERE file_path = %s", (file_path,)) deleted_count = cursor.rowcount conn.commit() return deleted_count > 0 # Scheduled Scans Methods def create_scheduled_scan(self, name: str, description: str, cron_expression: str, media_type: str, scan_mode: str, specific_paths: str = None, enabled: bool = True, created_by: str = None) -> int: """Create a new scheduled scan""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO scheduled_scans (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, (name, description, cron_expression, media_type, scan_mode, specific_paths, enabled, created_by)) return cursor.fetchone()['id'] def get_scheduled_scans(self, enabled_only: bool = False) -> List[Dict]: """Get all scheduled scans""" with self.get_connection() as conn: cursor = conn.cursor() query = "SELECT * FROM scheduled_scans" if enabled_only: query += " WHERE enabled = TRUE" query += " ORDER BY name" cursor.execute(query) return cursor.fetchall() def get_scheduled_scan(self, scan_id: int) -> Optional[Dict]: """Get a specific scheduled scan by ID""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT * FROM scheduled_scans WHERE id = %s", (scan_id,)) return cursor.fetchone() def update_scheduled_scan(self, scan_id: int, name: str = None, description: str = None, cron_expression: str = None, media_type: str = None, scan_mode: str = None, specific_paths: str = None, enabled: bool = None, updated_by: str = None) -> bool: """Update a scheduled scan""" with self.get_connection() as conn: cursor = conn.cursor() updates = [] params = [] if name is not None: updates.append("name = %s") params.append(name) if description is not None: updates.append("description = %s") params.append(description) if cron_expression is not None: updates.append("cron_expression = %s") params.append(cron_expression) if media_type is not None: updates.append("media_type = %s") params.append(media_type) if scan_mode is not None: updates.append("scan_mode = %s") params.append(scan_mode) if specific_paths is not None: updates.append("specific_paths = %s") params.append(specific_paths) if enabled is not None: updates.append("enabled = %s") params.append(enabled) if updated_by is not None: updates.append("updated_by = %s") params.append(updated_by) updates.append("updated_at = CURRENT_TIMESTAMP") params.append(scan_id) if not updates: return False query = f"UPDATE scheduled_scans SET {', '.join(updates)} WHERE id = %s" cursor.execute(query, params) return cursor.rowcount > 0 def delete_scheduled_scan(self, scan_id: int) -> bool: """Delete a scheduled scan and its execution history""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute("DELETE FROM scheduled_scans WHERE id = %s", (scan_id,)) return cursor.rowcount > 0 def update_scan_next_run(self, scan_id: int, next_run_at: datetime) -> bool: """Update the next run time for a scheduled scan""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" UPDATE scheduled_scans SET next_run_at = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (next_run_at, scan_id)) return cursor.rowcount > 0 def update_scan_last_run(self, scan_id: int, last_run_at: datetime = None) -> bool: """Update the last run time and increment run count for a scheduled scan""" with self.get_connection() as conn: cursor = conn.cursor() if last_run_at is None: last_run_at = datetime.utcnow() cursor.execute(""" UPDATE scheduled_scans SET last_run_at = %s, run_count = run_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (last_run_at, scan_id)) return cursor.rowcount > 0 # Schedule Execution Methods def create_schedule_execution(self, schedule_id: int, media_type: str, scan_mode: str, triggered_by: str = None) -> int: """Create a new schedule execution record""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" INSERT INTO schedule_executions (schedule_id, status, media_type, scan_mode, triggered_by) VALUES (%s, 'running', %s, %s, %s) RETURNING id """, (schedule_id, media_type, scan_mode, triggered_by)) return cursor.fetchone()['id'] def update_schedule_execution(self, execution_id: int, status: str = None, items_processed: int = None, items_skipped: int = None, items_failed: int = None, error_message: str = None, logs: str = None) -> bool: """Update a schedule execution record""" with self.get_connection() as conn: cursor = conn.cursor() updates = [] params = [] if status is not None: updates.append("status = %s") params.append(status) if status in ['completed', 'failed', 'cancelled']: updates.append("completed_at = CURRENT_TIMESTAMP") updates.append("execution_time_seconds = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at))") if items_processed is not None: updates.append("items_processed = %s") params.append(items_processed) if items_skipped is not None: updates.append("items_skipped = %s") params.append(items_skipped) if items_failed is not None: updates.append("items_failed = %s") params.append(items_failed) if error_message is not None: updates.append("error_message = %s") params.append(error_message) if logs is not None: updates.append("logs = %s") params.append(logs) if not updates: return False params.append(execution_id) query = f"UPDATE schedule_executions SET {', '.join(updates)} WHERE id = %s" cursor.execute(query, params) return cursor.rowcount > 0 def get_schedule_executions(self, schedule_id: int = None, limit: int = 50) -> List[Dict]: """Get schedule execution history""" with self.get_connection() as conn: cursor = conn.cursor() query = """ SELECT se.*, ss.name as schedule_name FROM schedule_executions se JOIN scheduled_scans ss ON se.schedule_id = ss.id """ params = [] if schedule_id is not None: query += " WHERE se.schedule_id = %s" params.append(schedule_id) query += " ORDER BY se.started_at DESC LIMIT %s" params.append(limit) cursor.execute(query, params) return cursor.fetchall() def get_running_executions(self) -> List[Dict]: """Get currently running schedule executions""" with self.get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT se.*, ss.name as schedule_name FROM schedule_executions se JOIN scheduled_scans ss ON se.schedule_id = ss.id WHERE se.status = 'running' ORDER BY se.started_at DESC """) return cursor.fetchall() def close(self): """Close all database connections""" if hasattr(self._local, 'connection'): try: # For PostgreSQL, ensure all transactions are committed/rolled back try: # Force rollback any open transactions self._local.connection.rollback() except Exception: pass # Close the connection self._local.connection.close() delattr(self._local, 'connection') print("✅ Database connection closed successfully") except Exception as e: print(f"⚠️ Error closing database connection: {e}") pass # Connection may already be closed