""" Backup helper functions for creating, managing, and restoring backups. """ import os import shutil import json import logging import sqlite3 import zipfile from datetime import datetime import tempfile from flask import current_app # Set up logging logger = logging.getLogger(__name__) def create_backup(backup_name=None, include_mp3s=True, include_config=True): """ Create a full system backup including database, MP3s, and configuration. Args: backup_name: Optional name for the backup (defaults to timestamp) include_mp3s: Whether to include MP3 files in the backup include_config: Whether to include configuration files Returns: dict: Backup information including path and status """ try: # Generate backup name if not provided if not backup_name: timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') backup_name = f"backup_{timestamp}" # Ensure backup directory exists backup_dir = os.path.join('/data', 'backups') os.makedirs(backup_dir, exist_ok=True) # Create backup zip file path backup_path = os.path.join(backup_dir, f"{backup_name}.zip") # Create a temporary directory for collecting files with tempfile.TemporaryDirectory() as temp_dir: # Step 1: Backup the database db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') if os.path.exists(db_path): # Create a copy of the database (to avoid locking issues) temp_db = os.path.join(temp_dir, 'song_data.db') # Connect to source database and back it up conn = sqlite3.connect(db_path) backup_conn = sqlite3.connect(temp_db) conn.backup(backup_conn) conn.close() backup_conn.close() logger.info(f"Database backed up to {temp_db}") else: logger.error(f"Database not found at {db_path}") return { "status": "error", "message": f"Database not found at {db_path}", "path": None } # Step 2: Copy MP3 files if requested if include_mp3s: mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3') if os.path.exists(mp3_dir): mp3_backup_dir = os.path.join(temp_dir, 'mp3') os.makedirs(mp3_backup_dir, exist_ok=True) # Copy all MP3 files for mp3_file in os.listdir(mp3_dir): if mp3_file.endswith('.mp3'): source_path = os.path.join(mp3_dir, mp3_file) dest_path = os.path.join(mp3_backup_dir, mp3_file) shutil.copy2(source_path, dest_path) logger.info(f"MP3 files backed up to {mp3_backup_dir}") else: logger.warning(f"MP3 directory not found at {mp3_dir}") # Step 3: Add configuration files if requested if include_config: config_dir = os.path.join(temp_dir, 'config') os.makedirs(config_dir, exist_ok=True) # Copy .env file if it exists env_path = os.path.join(os.path.dirname(current_app.root_path), '.env') if os.path.exists(env_path): shutil.copy2(env_path, os.path.join(config_dir, '.env')) logger.info(f".env file backed up") # Extract system settings from database and save as JSON try: from musicround.models import SystemSetting settings = SystemSetting.all_settings() # Save settings to JSON file settings_path = os.path.join(config_dir, 'system_settings.json') with open(settings_path, 'w') as f: json.dump(settings, f, indent=2) logger.info(f"System settings backed up to {settings_path}") except Exception as e: logger.error(f"Error backing up system settings: {str(e)}") # Step 4: Add backup metadata file with version info and timestamp from musicround.version import VERSION_INFO metadata = { "backup_name": backup_name, "timestamp": datetime.now().isoformat(), "version": VERSION_INFO['version'], "release_name": VERSION_INFO['release_name'], "includes_mp3s": include_mp3s, "includes_config": include_config } metadata_path = os.path.join(temp_dir, 'backup_metadata.json') with open(metadata_path, 'w') as f: json.dump(metadata, f, indent=2) # Step 5: Create a ZIP archive of all backed up content with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED) as zipf: # Add all files from temp directory to ZIP for root, _, files in os.walk(temp_dir): for file in files: file_path = os.path.join(root, file) # Add file to ZIP with a relative path arcname = os.path.relpath(file_path, temp_dir) zipf.write(file_path, arcname) # Get backup file size backup_size = os.path.getsize(backup_path) return { "status": "success", "message": "Backup created successfully", "path": backup_path, "name": backup_name, "size": backup_size, "timestamp": datetime.now().isoformat() } except Exception as e: logger.error(f"Error during backup creation: {str(e)}") return { "status": "error", "message": f"Backup failed: {str(e)}", "path": None } def list_backups(): """ List all available backups with their metadata. Returns: list: List of backup information dictionaries """ backup_dir = os.path.join('/data', 'backups') if not os.path.exists(backup_dir): os.makedirs(backup_dir, exist_ok=True) return [] backups = [] for filename in os.listdir(backup_dir): if filename.endswith('.zip'): backup_path = os.path.join(backup_dir, filename) try: # Extract metadata from ZIP file with zipfile.ZipFile(backup_path, 'r') as zipf: if 'backup_metadata.json' in zipf.namelist(): with zipf.open('backup_metadata.json') as f: metadata = json.load(f) # Add file information to metadata file_info = os.stat(backup_path) metadata['file_size'] = file_info.st_size metadata['file_name'] = filename metadata['file_path'] = backup_path metadata['file_date'] = datetime.fromtimestamp(file_info.st_mtime).isoformat() backups.append(metadata) else: # No metadata file, create basic info file_info = os.stat(backup_path) backups.append({ 'backup_name': os.path.splitext(filename)[0], 'file_name': filename, 'file_path': backup_path, 'file_size': file_info.st_size, 'file_date': datetime.fromtimestamp(file_info.st_mtime).isoformat(), 'timestamp': datetime.fromtimestamp(file_info.st_mtime).isoformat(), 'version': 'Unknown', 'release_name': 'Unknown' }) except Exception as e: logger.error(f"Error reading backup metadata from {filename}: {str(e)}") # Sort backups by timestamp (newest first) backups.sort(key=lambda x: x.get('timestamp', ''), reverse=True) return backups def delete_backup(backup_filename): """ Delete a backup file. Args: backup_filename: Name of the backup file to delete Returns: dict: Operation status information """ backup_dir = os.path.join('/data', 'backups') backup_path = os.path.join(backup_dir, backup_filename) if not os.path.exists(backup_path): return { "status": "error", "message": f"Backup file {backup_filename} not found" } try: os.remove(backup_path) return { "status": "success", "message": f"Backup {backup_filename} deleted successfully" } except Exception as e: logger.error(f"Error deleting backup {backup_filename}: {str(e)}") return { "status": "error", "message": f"Error deleting backup: {str(e)}" } def restore_backup(backup_filename): """ Restore system from a backup file. Args: backup_filename: Name of the backup file to restore Returns: dict: Operation status information """ backup_dir = os.path.join('/data', 'backups') backup_path = os.path.join(backup_dir, backup_filename) if not os.path.exists(backup_path): return { "status": "error", "message": f"Backup file {backup_filename} not found" } try: # Create a temporary directory for extracting backup with tempfile.TemporaryDirectory() as temp_dir: # Extract the backup ZIP with zipfile.ZipFile(backup_path, 'r') as zipf: zipf.extractall(temp_dir) # Get backup metadata metadata_path = os.path.join(temp_dir, 'backup_metadata.json') if os.path.exists(metadata_path): with open(metadata_path, 'r') as f: metadata = json.load(f) else: metadata = { "includes_mp3s": True, "includes_config": True } # Restore database db_backup_path = os.path.join(temp_dir, 'song_data.db') db_path = current_app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '') if os.path.exists(db_backup_path): # Create a backup of the current database before overwriting timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') db_current_backup = f"{db_path}.{timestamp}.bak" if os.path.exists(db_path): shutil.copy2(db_path, db_current_backup) logger.info(f"Created backup of current database at {db_current_backup}") # Restore the database from backup shutil.copy2(db_backup_path, db_path) logger.info(f"Restored database from backup") else: logger.error("Database file not found in backup") return { "status": "error", "message": "Database file not found in backup" } # Restore MP3 files if included in backup if metadata.get("includes_mp3s", True): mp3_backup_dir = os.path.join(temp_dir, 'mp3') if os.path.exists(mp3_backup_dir): mp3_dir = os.path.join(os.path.dirname(current_app.root_path), 'mp3') # Create backup of current MP3 files if os.path.exists(mp3_dir): mp3_backup = f"{mp3_dir}.{timestamp}.bak" shutil.copytree(mp3_dir, mp3_backup) logger.info(f"Created backup of current MP3 files at {mp3_backup}") # Remove current MP3 directory and replace with backup if os.path.exists(mp3_dir): shutil.rmtree(mp3_dir) # Create MP3 directory if it doesn't exist os.makedirs(mp3_dir, exist_ok=True) # Copy MP3 files from backup for mp3_file in os.listdir(mp3_backup_dir): if mp3_file.endswith('.mp3'): source_path = os.path.join(mp3_backup_dir, mp3_file) dest_path = os.path.join(mp3_dir, mp3_file) shutil.copy2(source_path, dest_path) logger.info(f"Restored MP3 files from backup") # Restore config files if included in backup if metadata.get("includes_config", True): config_backup_dir = os.path.join(temp_dir, 'config') if os.path.exists(config_backup_dir): # Restore .env file if present in backup env_backup_path = os.path.join(config_backup_dir, '.env') if os.path.exists(env_backup_path): env_path = os.path.join(os.path.dirname(current_app.root_path), '.env') # Backup current .env if os.path.exists(env_path): env_backup = f"{env_path}.{timestamp}.bak" shutil.copy2(env_path, env_backup) logger.info(f"Created backup of current .env file at {env_backup}") # Restore .env from backup shutil.copy2(env_backup_path, env_path) logger.info(f"Restored .env file from backup") # Restore system settings from JSON if present settings_backup_path = os.path.join(config_backup_dir, 'system_settings.json') if os.path.exists(settings_backup_path): try: with open(settings_backup_path, 'r') as f: settings = json.load(f) # Import within function to avoid circular imports from musicround.models import SystemSetting, db # Restore each setting for key, value in settings.items(): SystemSetting.set(key, value) logger.info("Restored system settings from backup") except Exception as e: logger.error(f"Error restoring system settings: {str(e)}") return { "status": "success", "message": "Backup restored successfully", "backup_name": backup_filename } except Exception as e: logger.error(f"Error restoring backup {backup_filename}: {str(e)}") return { "status": "error", "message": f"Error restoring backup: {str(e)}" } def verify_backup(backup_filename): """ Verify the integrity of a backup file. Args: backup_filename: Name of the backup file to verify Returns: dict: Verification result """ backup_dir = os.path.join('/data', 'backups') backup_path = os.path.join(backup_dir, backup_filename) if not os.path.exists(backup_path): return { "status": "error", "message": f"Backup file {backup_filename} not found", "is_valid": False } try: # Check if the file is a valid ZIP if not zipfile.is_zipfile(backup_path): return { "status": "error", "message": f"Backup file is not a valid ZIP archive", "is_valid": False } # Try to open the ZIP and extract metadata with zipfile.ZipFile(backup_path, 'r') as zipf: # Test the integrity of all files in the ZIP test_result = zipf.testzip() if test_result is not None: return { "status": "error", "message": f"Backup file contains corrupted files, first bad file: {test_result}", "is_valid": False } # Check for essential files if 'song_data.db' not in zipf.namelist(): return { "status": "error", "message": "Backup file does not contain a database", "is_valid": False } # Extract metadata if available if 'backup_metadata.json' in zipf.namelist(): with zipf.open('backup_metadata.json') as f: metadata = json.load(f) else: metadata = {"version": "Unknown"} # If we got here, the backup is valid return { "status": "success", "message": "Backup file is valid", "is_valid": True, "version": metadata.get("version", "Unknown"), "timestamp": metadata.get("timestamp", "Unknown") } except Exception as e: logger.error(f"Error verifying backup {backup_filename}: {str(e)}") return { "status": "error", "message": f"Error verifying backup: {str(e)}", "is_valid": False } def schedule_backup(schedule_time=None, frequency='daily', retention_days=30): """ Schedule automatic backups. Args: schedule_time: Time to run the backup (HH:MM format) frequency: Frequency of backups ('hourly', 'daily', 'weekly') retention_days: Number of days of backups to keep (0 = keep all) Returns: dict: Operation status information """ # This would typically integrate with a scheduler like cron # For now, we'll just store the settings in SystemSetting try: from musicround.models import SystemSetting # Get current time if not provided if schedule_time is None: schedule_time = datetime.now().strftime('%H:%M') # Store backup schedule settings SystemSetting.set('backup_schedule_time', schedule_time) SystemSetting.set('backup_schedule_frequency', frequency) SystemSetting.set('backup_schedule_enabled', 'true') SystemSetting.set('backup_retention_days', str(retention_days)) # If retention policy is set, apply it immediately if retention_days > 0: apply_retention_policy(retention_days) return { "status": "success", "message": f"Backup scheduled for {schedule_time} ({frequency}), keeping {retention_days} days of backups", "schedule_time": schedule_time, "frequency": frequency, "retention_days": retention_days } except Exception as e: logger.error(f"Error scheduling backup: {str(e)}") return { "status": "error", "message": f"Error scheduling backup: {str(e)}" } def get_backup_summary(): """ Get a summary of backup system status. Returns: dict: Summary information including counts, schedule info, etc. """ from musicround.models import SystemSetting # Get all backups backups = list_backups() # Extract info from settings schedule_enabled = SystemSetting.get('backup_schedule_enabled', 'false') == 'true' schedule_time = SystemSetting.get('backup_schedule_time', '03:00') schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily') retention_days = int(SystemSetting.get('backup_retention_days', '30')) # Calculate next backup time based on schedule from datetime import datetime, time, timedelta now = datetime.now() next_backup = None if schedule_enabled: try: # Parse schedule time hour, minute = map(int, schedule_time.split(':')) schedule_time_obj = time(hour, minute) # Calculate next occurrence next_backup_date = now.date() next_backup_datetime = datetime.combine(next_backup_date, schedule_time_obj) # If today's scheduled time has passed, move to next occurrence based on frequency if next_backup_datetime < now: if schedule_frequency == 'hourly': next_backup_datetime = now + timedelta(hours=1) elif schedule_frequency == 'daily': next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=1), schedule_time_obj) elif schedule_frequency == 'weekly': next_backup_datetime = datetime.combine(next_backup_date + timedelta(days=7), schedule_time_obj) next_backup = next_backup_datetime.strftime('%Y-%m-%d %H:%M') except: next_backup = "Error calculating next backup time" # Get latest backup info latest_backup = backups[0] if backups else None return { "backup_count": len(backups), "latest_backup": latest_backup, "schedule_enabled": schedule_enabled, "schedule_time": schedule_time, "schedule_frequency": schedule_frequency, "next_backup": next_backup, "backup_location": "/data/backups", "retention_days": retention_days } def generate_backup_config_suggestion(retention_days=30): """ Generate a configuration suggestion for setting up automated backups. This does NOT modify any files, it only returns a suggestion. Args: retention_days: Number of days to keep backups Returns: dict: Configuration suggestion and instructions """ # Generate the backup schedule configuration suggestion from musicround.models import SystemSetting # Get backup schedule information schedule_time = SystemSetting.get('backup_schedule_time', '03:00') schedule_frequency = SystemSetting.get('backup_schedule_frequency', 'daily') # Map schedule frequency to cron expressions for documentation frequency_map = { 'hourly': '@hourly', 'daily': '@daily', 'weekly': '@weekly' } schedule_cron = frequency_map.get(schedule_frequency, '@daily') # Generate docker-compose config example docker_compose_suggestion = f"""labels: ofelia.enabled: "true" ofelia.job-exec.backup.schedule: "{schedule_cron}" ofelia.job-exec.backup.command: "python /app/run.py backup create --auto" ofelia.job-exec.backup.no-overlap: "true" # Retention policy - automatically delete backups older than {retention_days} days ofelia.job-exec.retention.schedule: "@weekly" ofelia.job-exec.retention.command: "python /app/run.py backup retention --days {retention_days}" ofelia.job-exec.retention.no-overlap: "true" """ # Generate ofelia.ini config example (for standalone setups) ofelia_ini_suggestion = f"""[global] save-folder = /var/log/ofelia [job-exec "backup"] schedule = {schedule_cron} command = python /app/run.py backup create --auto user = root no-overlap = true [job-exec "retention"] schedule = @weekly command = python /app/run.py backup retention --days {retention_days} user = root no-overlap = true """ # Generate instructions for manual setup instructions = f"""To set up automated backups, add the configuration to your Docker Compose file OR use the ofelia.ini file. Option 1: Add these labels to your main service in docker-compose.yml: {docker_compose_suggestion} Option 2: Add these sections to ofelia.ini: {ofelia_ini_suggestion} After making changes, restart your containers to apply the configuration: docker-compose down docker-compose up -d """ return { "status": "success", "schedule": { "frequency": schedule_frequency, "time": schedule_time, "retention_days": retention_days }, "docker_compose_suggestion": docker_compose_suggestion, "ofelia_ini_suggestion": ofelia_ini_suggestion, "instructions": instructions, "message": "Generated backup configuration suggestion" } def apply_retention_policy(retention_days=30): """ Apply the backup retention policy by deleting old backups. Args: retention_days: Number of days of backups to keep (0 = keep all) Returns: dict: Operation status information """ if retention_days <= 0: return { "status": "success", "message": "Retention policy disabled, all backups kept", "deleted_count": 0, "deleted_backups": [] } try: from datetime import datetime, timedelta import os # Get the cutoff date cutoff_date = datetime.now() - timedelta(days=retention_days) # Get list of all backups backups = list_backups() # Filter to find backups older than the cutoff date deleted_backups = [] for backup in backups: # Get backup timestamp backup_time = None if 'timestamp' in backup: try: backup_time = datetime.fromisoformat(backup['timestamp']) except (ValueError, TypeError): # Try the file date as fallback if 'file_date' in backup: try: backup_time = datetime.fromisoformat(backup['file_date']) except (ValueError, TypeError): # Can't determine date, skip this backup continue elif 'file_date' in backup: try: backup_time = datetime.fromisoformat(backup['file_date']) except (ValueError, TypeError): # Can't determine date, skip this backup continue # If we couldn't determine when this backup was created, skip it if not backup_time: continue # Check if this backup is older than the cutoff date if backup_time < cutoff_date: backup_path = backup.get('file_path') if backup_path and os.path.exists(backup_path): try: os.remove(backup_path) deleted_backups.append({ 'name': backup.get('backup_name') or os.path.basename(backup_path), 'date': backup_time.isoformat() }) except Exception as e: logger.error(f"Error deleting old backup {backup_path}: {str(e)}") return { "status": "success", "message": f"Retention policy applied: deleted {len(deleted_backups)} backups older than {retention_days} days", "deleted_count": len(deleted_backups), "deleted_backups": deleted_backups } except Exception as e: logger.error(f"Error applying retention policy: {str(e)}") return { "status": "error", "message": f"Error applying retention policy: {str(e)}", "deleted_count": 0, "deleted_backups": [] }