diff --git a/scripts/recover_database.py b/scripts/recover_database.py new file mode 100644 index 00000000..e0628cd7 --- /dev/null +++ b/scripts/recover_database.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Huntarr Database Recovery Utility + +This script helps recover from database corruption issues by: +1. Backing up the corrupted database +2. Creating a fresh database +3. Attempting to recover any salvageable data + +Usage: +- For Docker: docker exec huntarr python /app/scripts/recover_database.py +- For local: python scripts/recover_database.py +""" + +import sys +import os +import sqlite3 +import json +import time +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +def get_database_path(): + """Get the database path based on environment""" + # Check if running in Docker + config_dir = Path("/config") + if config_dir.exists() and config_dir.is_dir(): + return config_dir / "huntarr.db" + + # Check for Windows config directory + windows_config = os.environ.get("HUNTARR_CONFIG_DIR") + if windows_config: + return Path(windows_config) / "huntarr.db" + + # Check for Windows AppData + import platform + if platform.system() == "Windows": + appdata = os.environ.get("APPDATA", os.path.expanduser("~")) + return Path(appdata) / "Huntarr" / "huntarr.db" + + # Local development + project_root = Path(__file__).parent.parent + return project_root / "data" / "huntarr.db" + +def backup_corrupted_database(db_path): + """Create backup of corrupted database""" + if not db_path.exists(): + print(f"Database file does not exist: {db_path}") + return None + + timestamp = int(time.time()) + backup_path = db_path.parent / f"huntarr_corrupted_backup_{timestamp}.db" + + try: + # Copy the corrupted file + import shutil + shutil.copy2(db_path, backup_path) + print(f"✓ Corrupted database backed up to: {backup_path}") + return backup_path + except Exception as e: + print(f"✗ Failed to backup corrupted database: {e}") + return None + +def attempt_data_recovery(backup_path): + """Attempt to recover data from corrupted database""" + if not backup_path or not backup_path.exists(): + return {} + + print("⚡ Attempting to recover data from corrupted database...") + recovered_data = {} + + try: + # Try to connect and recover whatever we can + conn = sqlite3.connect(backup_path) + conn.row_factory = sqlite3.Row + + # Try to recover users table + try: + cursor = conn.execute("SELECT * FROM users") + users = [dict(row) for row in cursor.fetchall()] + if users: + recovered_data['users'] = users + print(f"✓ Recovered {len(users)} user(s)") + except Exception as e: + print(f" Could not recover users: {e}") + + # Try to recover general settings + try: + cursor = conn.execute("SELECT * FROM general_settings") + settings = [dict(row) for row in cursor.fetchall()] + if settings: + recovered_data['general_settings'] = settings + print(f"✓ Recovered {len(settings)} general setting(s)") + except Exception as e: + print(f" Could not recover general settings: {e}") + + # Try to recover app configs + try: + cursor = conn.execute("SELECT * FROM app_configs") + configs = [dict(row) for row in cursor.fetchall()] + if configs: + recovered_data['app_configs'] = configs + print(f"✓ Recovered {len(configs)} app configuration(s)") + except Exception as e: + print(f" Could not recover app configs: {e}") + + conn.close() + + except Exception as e: + print(f"✗ Could not open corrupted database for recovery: {e}") + + return recovered_data + +def create_fresh_database(db_path): + """Create a fresh database with proper structure""" + print("🔧 Creating fresh database...") + + try: + # Remove corrupted file + if db_path.exists(): + db_path.unlink() + + # Import and create fresh database + from primary.utils.database import HuntarrDatabase + + # This will create a fresh database with all tables + db = HuntarrDatabase() + print(f"✓ Fresh database created at: {db_path}") + return db + + except Exception as e: + print(f"✗ Failed to create fresh database: {e}") + return None + +def restore_recovered_data(db, recovered_data): + """Restore recovered data to fresh database""" + if not recovered_data: + print("⚠ No data to restore") + return + + print("📋 Restoring recovered data...") + + # Restore users + if 'users' in recovered_data: + try: + for user in recovered_data['users']: + # Remove auto-generated fields + user_data = {k: v for k, v in user.items() + if k not in ['id', 'created_at', 'updated_at']} + + db.create_user( + username=user_data.get('username', ''), + password=user_data.get('password', ''), + two_fa_enabled=user_data.get('two_fa_enabled', False), + two_fa_secret=user_data.get('two_fa_secret'), + plex_token=user_data.get('plex_token'), + plex_user_data=json.loads(user_data.get('plex_user_data', '{}')) if user_data.get('plex_user_data') else None + ) + print(f"✓ Restored {len(recovered_data['users'])} user(s)") + except Exception as e: + print(f"✗ Failed to restore users: {e}") + + # Restore general settings + if 'general_settings' in recovered_data: + try: + settings_dict = {} + for setting in recovered_data['general_settings']: + key = setting['setting_key'] + value = setting['setting_value'] + setting_type = setting.get('setting_type', 'string') + + # Convert value based on type + if setting_type == 'boolean': + settings_dict[key] = value.lower() == 'true' + elif setting_type == 'integer': + settings_dict[key] = int(value) + elif setting_type == 'float': + settings_dict[key] = float(value) + elif setting_type == 'json': + try: + settings_dict[key] = json.loads(value) + except: + settings_dict[key] = value + else: + settings_dict[key] = value + + db.save_general_settings(settings_dict) + print(f"✓ Restored {len(settings_dict)} general setting(s)") + except Exception as e: + print(f"✗ Failed to restore general settings: {e}") + + # Restore app configs + if 'app_configs' in recovered_data: + try: + for config in recovered_data['app_configs']: + app_type = config['app_type'] + config_data = json.loads(config['config_data']) + db.save_app_config(app_type, config_data) + print(f"✓ Restored {len(recovered_data['app_configs'])} app configuration(s)") + except Exception as e: + print(f"✗ Failed to restore app configs: {e}") + +def main(): + print("🔥 Huntarr Database Recovery Utility") + print("=" * 50) + + # Get database path + db_path = get_database_path() + print(f"Database location: {db_path}") + + if not db_path.exists(): + print("✗ Database file does not exist. Nothing to recover.") + return + + # Check if database is actually corrupted + try: + conn = sqlite3.connect(db_path) + conn.execute("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").fetchone() + conn.close() + print("✓ Database appears to be intact. No recovery needed.") + return + except sqlite3.DatabaseError as e: + if "file is not a database" in str(e) or "database disk image is malformed" in str(e): + print(f"⚠ Database corruption detected: {e}") + else: + print(f"✗ Database error: {e}") + return + + # Backup corrupted database + backup_path = backup_corrupted_database(db_path) + + # Attempt data recovery + recovered_data = attempt_data_recovery(backup_path) + + # Create fresh database + db = create_fresh_database(db_path) + if not db: + print("✗ Failed to create fresh database. Recovery aborted.") + return + + # Restore recovered data + restore_recovered_data(db, recovered_data) + + print("\n🎉 Database recovery completed!") + print("=" * 50) + print("Summary:") + print(f"- Corrupted database backed up to: {backup_path}") + print(f"- Fresh database created at: {db_path}") + + if recovered_data: + print("- Data recovered:") + for table, data in recovered_data.items(): + print(f" • {table}: {len(data)} record(s)") + else: + print("- No data could be recovered from corrupted database") + + print("\nYou can now restart Huntarr.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/primary/utils/database.py b/src/primary/utils/database.py index 9ea80329..37829001 100644 --- a/src/primary/utils/database.py +++ b/src/primary/utils/database.py @@ -66,9 +66,22 @@ class HuntarrDatabase: def get_connection(self): """Get a configured SQLite connection with Synology NAS compatibility""" - conn = sqlite3.connect(self.db_path) - self._configure_connection(conn) - return conn + try: + conn = sqlite3.connect(self.db_path) + self._configure_connection(conn) + # Test the connection by running a simple query + conn.execute("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1").fetchone() + return conn + except (sqlite3.DatabaseError, sqlite3.OperationalError) as e: + if "file is not a database" in str(e) or "database disk image is malformed" in str(e): + logger.error(f"Database corruption detected: {e}") + self._handle_database_corruption() + # Try connecting again after recovery + conn = sqlite3.connect(self.db_path) + self._configure_connection(conn) + return conn + else: + raise def _get_database_path(self) -> Path: """Get database path - use /config for Docker, Windows AppData, or local data directory""" @@ -100,6 +113,48 @@ class HuntarrDatabase: # Ensure directory exists data_dir.mkdir(parents=True, exist_ok=True) return data_dir / "huntarr.db" + + def _handle_database_corruption(self): + """Handle database corruption by creating backup and starting fresh""" + import time + + logger.error(f"Handling database corruption for: {self.db_path}") + + try: + # Create backup of corrupted database if it exists + if self.db_path.exists(): + backup_path = self.db_path.parent / f"huntarr_corrupted_backup_{int(time.time())}.db" + self.db_path.rename(backup_path) + logger.warning(f"Corrupted database backed up to: {backup_path}") + logger.warning("Starting with fresh database - all previous data has been backed up but will be lost") + + # Ensure the corrupted file is completely removed + if self.db_path.exists(): + self.db_path.unlink() + + except Exception as backup_error: + logger.error(f"Error during database corruption recovery: {backup_error}") + # Force remove the corrupted file + try: + if self.db_path.exists(): + self.db_path.unlink() + except: + pass + + def _check_database_integrity(self) -> bool: + """Check if database integrity is intact""" + try: + with self.get_connection() as conn: + # Run SQLite integrity check + result = conn.execute("PRAGMA integrity_check").fetchone() + if result and result[0] == "ok": + return True + else: + logger.error(f"Database integrity check failed: {result}") + return False + except Exception as e: + logger.error(f"Database integrity check failed with error: {e}") + return False def ensure_database_exists(self): """Create database and all tables if they don't exist""" @@ -129,6 +184,20 @@ class HuntarrDatabase: logger.error(f"Error setting up database directory: {e}") raise + # Create all tables with corruption recovery + try: + self._create_all_tables() + except (sqlite3.DatabaseError, sqlite3.OperationalError) as e: + if "file is not a database" in str(e) or "database disk image is malformed" in str(e): + logger.error(f"Database corruption detected during table creation: {e}") + self._handle_database_corruption() + # Try creating tables again after recovery + self._create_all_tables() + else: + raise + + def _create_all_tables(self): + """Create all database tables""" with self.get_connection() as conn: # Create app_configs table for all app settings