#!/usr/bin/env python3 """ Settings Manager for Media Downloader Handles settings storage in database with JSON file compatibility """ import json import sqlite3 from pathlib import Path from datetime import datetime from typing import Dict, List, Optional, Any, Union, Tuple from contextlib import contextmanager import threading from modules.universal_logger import get_logger logger = get_logger('SettingsManager') class SettingsManager: """Manage application settings in database (thread-safe)""" def __init__(self, db_path: str): """ Initialize settings manager Args: db_path: Path to SQLite database """ self.db_path = db_path self._write_lock = threading.RLock() # Reentrant lock for write operations self._create_tables() @contextmanager def _get_connection(self, for_write: bool = False): """Get database connection (thread-safe)""" conn = sqlite3.connect(self.db_path, timeout=30.0, check_same_thread=False) conn.row_factory = sqlite3.Row try: if for_write: with self._write_lock: yield conn else: yield conn finally: conn.close() def _create_tables(self): """Create settings table if it doesn't exist""" with self._get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, value_type TEXT NOT NULL, category TEXT, description TEXT, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_by TEXT DEFAULT 'system' ) ''') # Create index for category lookups cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_settings_category ON settings(category) ''') conn.commit() logger.info("Settings tables initialized") def get(self, key: str, default: Any = None) -> Any: """ Get a setting value Args: key: Setting key (supports dot notation, e.g., 'instagram.enabled') default: Default value if not found Returns: Setting value or default """ with self._get_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT value, value_type FROM settings WHERE key = ?', (key,)) row = cursor.fetchone() if not row: return default value, value_type = row['value'], row['value_type'] return self._deserialize_value(value, value_type) def set(self, key: str, value: Any, category: str = None, description: str = None, updated_by: str = 'system'): """ Set a setting value Args: key: Setting key value: Setting value (will be serialized to JSON if needed) category: Optional category description: Optional description updated_by: Who updated the setting """ value_str, value_type = self._serialize_value(value) with self._get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute(''' INSERT OR REPLACE INTO settings (key, value, value_type, category, description, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?) ''', (key, value_str, value_type, category, description, datetime.now().isoformat(), updated_by)) conn.commit() logger.debug(f"Setting updated: {key} = {value_str[:100]}") def get_category(self, category: str) -> Dict[str, Any]: """ Get all settings in a category Args: category: Category name Returns: Dictionary of settings """ with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT key, value, value_type FROM settings WHERE category = ? ''', (category,)) result = {} for row in cursor.fetchall(): key = row['key'] value = self._deserialize_value(row['value'], row['value_type']) result[key] = value return result def get_all(self) -> Dict[str, Any]: """ Get all settings as a nested dictionary Returns: Nested dictionary of all settings """ with self._get_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT key, value, value_type FROM settings') result = {} for row in cursor.fetchall(): key = row['key'] value = self._deserialize_value(row['value'], row['value_type']) # Support nested keys like 'instagram.enabled' self._set_nested(result, key, value) return result def delete(self, key: str): """Delete a setting""" with self._get_connection(for_write=True) as conn: cursor = conn.cursor() cursor.execute('DELETE FROM settings WHERE key = ?', (key,)) conn.commit() logger.debug(f"Setting deleted: {key}") def migrate_from_json(self, json_path: str): """ Migrate settings from JSON file to database Args: json_path: Path to settings.json file """ json_file = Path(json_path) if not json_file.exists(): logger.warning(f"JSON file not found: {json_path}") return with open(json_file, 'r') as f: settings = json.load(f) # Flatten and store settings self._migrate_dict(settings, prefix='', category='root') logger.info(f"Settings migrated from {json_path}") def _migrate_dict(self, data: Dict, prefix: str = '', category: str = None): """Recursively migrate nested dictionary""" for key, value in data.items(): full_key = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): # Store the entire dict as a value self.set(full_key, value, category=category or key) else: # Store primitive value self.set(full_key, value, category=category or prefix.split('.')[0]) def export_to_json(self, json_path: str): """ Export settings to JSON file Args: json_path: Path to save settings.json """ settings = self.get_all() with open(json_path, 'w') as f: json.dump(settings, f, indent=2) logger.info(f"Settings exported to {json_path}") def _serialize_value(self, value: Any) -> Tuple[str, str]: """ Serialize value to string and determine type Returns: Tuple of (value_string, value_type) """ if isinstance(value, bool): return (json.dumps(value), 'boolean') elif isinstance(value, int): return (json.dumps(value), 'number') elif isinstance(value, float): return (json.dumps(value), 'number') elif isinstance(value, str): return (value, 'string') elif isinstance(value, (dict, list)): return (json.dumps(value), 'object' if isinstance(value, dict) else 'array') else: return (json.dumps(value), 'object') def _deserialize_value(self, value_str: str, value_type: str) -> Any: """Deserialize value from string""" if value_type == 'string': return value_str else: return json.loads(value_str) def _set_nested(self, data: Dict, key: str, value: Any): """Set value in nested dictionary using dot notation""" parts = key.split('.') current = data for part in parts[:-1]: if part not in current: current[part] = {} current = current[part] current[parts[-1]] = value