from __future__ import annotations import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone from .config import DB_PATH SCHEMA = """ CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT, email TEXT, display_name TEXT, external_auth_provider TEXT, external_subject TEXT, role TEXT DEFAULT 'user', is_active INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT ); CREATE TABLE IF NOT EXISTS user_profile_permissions ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, access_level TEXT NOT NULL DEFAULT 'ro', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, last_used_at TEXT, revoked_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_api_tokens_user_active ON api_tokens(user_id, revoked_at); CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens(token_prefix); CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id); CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id); CREATE TABLE IF NOT EXISTS user_preferences ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, theme TEXT DEFAULT 'dark', bootstrap_theme TEXT DEFAULT 'default', font_family TEXT DEFAULT 'default', active_rtorrent_id INTEGER, keyboard_json TEXT, mobile_mode INTEGER DEFAULT 0, compact_torrent_list_enabled INTEGER DEFAULT 0, footer_items_json TEXT, title_speed_enabled INTEGER DEFAULT 0, automation_toasts_enabled INTEGER DEFAULT 1, smart_queue_toasts_enabled INTEGER DEFAULT 1, interface_scale INTEGER DEFAULT 100, detail_panel_height INTEGER DEFAULT 255, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id); CREATE TABLE IF NOT EXISTS profile_preferences ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ); CREATE INDEX IF NOT EXISTS idx_profile_preferences_user_profile ON profile_preferences(user_id, profile_id); CREATE TABLE IF NOT EXISTS rtorrent_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, scgi_url TEXT NOT NULL, is_default INTEGER DEFAULT 0, timeout_seconds INTEGER DEFAULT 5, max_parallel_jobs INTEGER DEFAULT 5, light_parallel_jobs INTEGER DEFAULT 4, light_job_timeout_seconds INTEGER DEFAULT 300, heavy_job_timeout_seconds INTEGER DEFAULT 7200, pending_job_timeout_seconds INTEGER DEFAULT 900, is_remote INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE); CREATE TABLE IF NOT EXISTS jobs ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, profile_id INTEGER, action TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL, attempts INTEGER DEFAULT 0, max_attempts INTEGER DEFAULT 2, error TEXT, result_json TEXT, state_json TEXT, progress_current INTEGER DEFAULT 0, progress_total INTEGER DEFAULT 0, heartbeat_at TEXT, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_jobs_profile_status ON jobs(profile_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at); CREATE TABLE IF NOT EXISTS disk_monitor_preferences ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, stop_enabled INTEGER DEFAULT 0, stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ); CREATE TABLE IF NOT EXISTS labels ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, name TEXT NOT NULL, color TEXT DEFAULT '#64748b', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, profile_id, name) ); CREATE TABLE IF NOT EXISTS ratio_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, name TEXT NOT NULL, min_ratio REAL DEFAULT 1.0, max_ratio REAL DEFAULT 2.0, seed_time_minutes INTEGER DEFAULT 0, min_seed_time_minutes INTEGER DEFAULT 0, ignore_private INTEGER DEFAULT 1, ignore_active_upload INTEGER DEFAULT 1, active_upload_min_bytes INTEGER DEFAULT 1024, move_path TEXT, set_label TEXT, action TEXT DEFAULT 'stop', enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(user_id, profile_id, name) ); CREATE TABLE IF NOT EXISTS rss_feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, name TEXT NOT NULL, url TEXT NOT NULL, enabled INTEGER DEFAULT 1, interval_minutes INTEGER DEFAULT 30, last_error TEXT, last_checked_at TEXT, next_check_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS rss_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, name TEXT NOT NULL, pattern TEXT NOT NULL, exclude_pattern TEXT, min_size_mb INTEGER DEFAULT 0, max_size_mb INTEGER DEFAULT 0, category TEXT, quality TEXT, season INTEGER, episode INTEGER, save_path TEXT, label TEXT, start INTEGER DEFAULT 1, enabled INTEGER DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at); CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled); CREATE TABLE IF NOT EXISTS rss_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status); CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added'); CREATE TABLE IF NOT EXISTS ratio_assignments ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash) ); CREATE TABLE IF NOT EXISTS ratio_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, group_id INTEGER, group_name TEXT, torrent_hash TEXT NOT NULL, torrent_name TEXT, action TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, details_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id); CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status); CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled); CREATE TABLE IF NOT EXISTS app_backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, backup_type TEXT DEFAULT 'app', profile_id INTEGER, payload_json TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS smart_queue_settings ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, enabled INTEGER DEFAULT 0, max_active_downloads INTEGER DEFAULT 5, stalled_seconds INTEGER DEFAULT 300, min_speed_bytes INTEGER DEFAULT 1024, min_seeds INTEGER DEFAULT 1, min_peers INTEGER DEFAULT 0, ignore_seed_peer INTEGER DEFAULT 0, ignore_speed INTEGER DEFAULT 0, manage_stopped INTEGER DEFAULT 0, cooldown_minutes INTEGER DEFAULT 10, last_run_at TEXT, refill_enabled INTEGER DEFAULT 1, refill_interval_minutes INTEGER DEFAULT 0, last_refill_at TEXT, stop_batch_size INTEGER DEFAULT 50, start_grace_seconds INTEGER DEFAULT 900, protect_active_below_cap INTEGER DEFAULT 1, auto_stop_idle INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) ); CREATE TABLE IF NOT EXISTS smart_queue_stalled ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, first_stalled_at TEXT NOT NULL, updated_at TEXT NOT NULL, timer_key TEXT DEFAULT '', PRIMARY KEY(profile_id, torrent_hash) ); CREATE TABLE IF NOT EXISTS smart_queue_start_grace ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash) ); CREATE TABLE IF NOT EXISTS smart_queue_exclusions ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id, torrent_hash) ); CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at); CREATE TABLE IF NOT EXISTS smart_queue_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, event TEXT NOT NULL, paused_count INTEGER DEFAULT 0, resumed_count INTEGER DEFAULT 0, checked_count INTEGER DEFAULT 0, details_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_profile_created ON smart_queue_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at); CREATE TABLE IF NOT EXISTS smart_queue_auto_labels ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, previous_label TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash) ); CREATE TABLE IF NOT EXISTS traffic_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, profile_id INTEGER NOT NULL, down_rate INTEGER DEFAULT 0, up_rate INTEGER DEFAULT 0, total_down INTEGER DEFAULT 0, total_up INTEGER DEFAULT 0, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at); CREATE TABLE IF NOT EXISTS transfer_speed_peaks ( profile_id INTEGER PRIMARY KEY, session_started_at TEXT NOT NULL, session_down_peak INTEGER DEFAULT 0, session_up_peak INTEGER DEFAULT 0, session_down_peak_at TEXT, session_up_peak_at TEXT, all_time_down_peak INTEGER DEFAULT 0, all_time_up_peak INTEGER DEFAULT 0, all_time_down_peak_at TEXT, all_time_up_peak_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS automation_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, name TEXT NOT NULL, enabled INTEGER DEFAULT 1, conditions_json TEXT NOT NULL, effects_json TEXT NOT NULL, cooldown_minutes INTEGER DEFAULT 60, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_automation_rules_profile_enabled ON automation_rules(profile_id, enabled); CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled); CREATE TABLE IF NOT EXISTS automation_rule_state ( rule_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, condition_since_at TEXT, last_matched_at TEXT, last_applied_at TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(rule_id, profile_id, torrent_hash) ); CREATE TABLE IF NOT EXISTS automation_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, rule_id INTEGER, torrent_hash TEXT, torrent_name TEXT, rule_name TEXT, actions_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_automation_history_profile_created ON automation_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at); CREATE TABLE IF NOT EXISTS rtorrent_config_overrides ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, key TEXT NOT NULL, value TEXT, baseline_value TEXT, apply_on_start INTEGER DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id, key) ); CREATE INDEX IF NOT EXISTS idx_rtorrent_config_overrides_profile ON rtorrent_config_overrides(profile_id, apply_on_start); CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT ); CREATE TABLE IF NOT EXISTS poller_settings ( profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ); CREATE TABLE IF NOT EXISTS download_plan_settings ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) ); CREATE TABLE IF NOT EXISTS download_plan_paused ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash) ); CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at); CREATE TABLE IF NOT EXISTS torrent_stats_cache ( profile_id INTEGER PRIMARY KEY, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS tracker_summary_cache ( profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash) ); CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch); CREATE TABLE IF NOT EXISTS operation_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at); CREATE TABLE IF NOT EXISTS operation_log_settings ( user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id) ); CREATE TABLE IF NOT EXISTS tracker_favicon_cache ( domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT ); """ MIGRATIONS = [ "ALTER TABLE api_tokens ADD COLUMN last_used_at TEXT", "ALTER TABLE users ADD COLUMN email TEXT", "ALTER TABLE users ADD COLUMN display_name TEXT", "ALTER TABLE users ADD COLUMN external_auth_provider TEXT", "ALTER TABLE users ADD COLUMN external_subject TEXT", "ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'", "ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1", "ALTER TABLE users ADD COLUMN updated_at TEXT", "ALTER TABLE user_preferences ADD COLUMN mobile_mode INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN compact_torrent_list_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT", "ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0", "ALTER TABLE user_preferences ADD COLUMN interface_scale INTEGER DEFAULT 100", "ALTER TABLE user_preferences ADD COLUMN detail_panel_height INTEGER DEFAULT 255", "ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5", "ALTER TABLE rtorrent_profiles ADD COLUMN light_parallel_jobs INTEGER DEFAULT 4", "ALTER TABLE rtorrent_profiles ADD COLUMN light_job_timeout_seconds INTEGER DEFAULT 300", "ALTER TABLE rtorrent_profiles ADD COLUMN heavy_job_timeout_seconds INTEGER DEFAULT 7200", "ALTER TABLE rtorrent_profiles ADD COLUMN pending_job_timeout_seconds INTEGER DEFAULT 900", "ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN max_attempts INTEGER DEFAULT 2", "ALTER TABLE jobs ADD COLUMN result_json TEXT", "ALTER TABLE jobs ADD COLUMN state_json TEXT", "ALTER TABLE jobs ADD COLUMN progress_current INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN progress_total INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN heartbeat_at TEXT", "ALTER TABLE jobs ADD COLUMN started_at TEXT", "ALTER TABLE jobs ADD COLUMN finished_at TEXT", "CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)", "ALTER TABLE automation_rules ADD COLUMN cooldown_minutes INTEGER DEFAULT 60", "ALTER TABLE rtorrent_config_overrides ADD COLUMN apply_on_start INTEGER DEFAULT 0", "ALTER TABLE rtorrent_config_overrides ADD COLUMN baseline_value TEXT", "ALTER TABLE torrent_stats_cache ADD COLUMN updated_epoch REAL DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN manage_stopped INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN min_peers INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN ignore_seed_peer INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN ignore_speed INTEGER DEFAULT 0", "ALTER TABLE smart_queue_stalled ADD COLUMN timer_key TEXT DEFAULT ''", "CREATE TABLE IF NOT EXISTS tracker_summary_cache (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, trackers_json TEXT NOT NULL, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, PRIMARY KEY(profile_id, torrent_hash))", "CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch)", "CREATE TABLE IF NOT EXISTS tracker_favicon_cache (domain TEXT PRIMARY KEY, source_url TEXT, file_path TEXT, mime_type TEXT, updated_at TEXT NOT NULL, updated_epoch REAL DEFAULT 0, error TEXT)", "ALTER TABLE user_preferences ADD COLUMN automation_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE user_preferences ADD COLUMN smart_queue_toasts_enabled INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN cooldown_minutes INTEGER DEFAULT 10", "ALTER TABLE smart_queue_settings ADD COLUMN last_run_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN refill_enabled INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN refill_interval_minutes INTEGER DEFAULT 0", "ALTER TABLE smart_queue_settings ADD COLUMN last_refill_at TEXT", "ALTER TABLE smart_queue_settings ADD COLUMN stop_batch_size INTEGER DEFAULT 50", "ALTER TABLE smart_queue_settings ADD COLUMN start_grace_seconds INTEGER DEFAULT 900", "ALTER TABLE smart_queue_settings ADD COLUMN protect_active_below_cap INTEGER DEFAULT 1", "ALTER TABLE smart_queue_settings ADD COLUMN auto_stop_idle INTEGER DEFAULT 0", "CREATE TABLE IF NOT EXISTS smart_queue_start_grace (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, started_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", "ALTER TABLE rss_feeds ADD COLUMN interval_minutes INTEGER DEFAULT 30", "ALTER TABLE rss_feeds ADD COLUMN next_check_at TEXT", "ALTER TABLE rss_rules ADD COLUMN exclude_pattern TEXT", "ALTER TABLE rss_rules ADD COLUMN min_size_mb INTEGER DEFAULT 0", "ALTER TABLE rss_rules ADD COLUMN max_size_mb INTEGER DEFAULT 0", "ALTER TABLE rss_rules ADD COLUMN category TEXT", "ALTER TABLE rss_rules ADD COLUMN quality TEXT", "ALTER TABLE rss_rules ADD COLUMN season INTEGER", "ALTER TABLE rss_rules ADD COLUMN episode INTEGER", "ALTER TABLE ratio_groups ADD COLUMN min_seed_time_minutes INTEGER DEFAULT 0", "ALTER TABLE ratio_groups ADD COLUMN ignore_private INTEGER DEFAULT 1", "ALTER TABLE ratio_groups ADD COLUMN ignore_active_upload INTEGER DEFAULT 1", "ALTER TABLE ratio_groups ADD COLUMN active_upload_min_bytes INTEGER DEFAULT 1024", "ALTER TABLE ratio_groups ADD COLUMN move_path TEXT", "ALTER TABLE ratio_groups ADD COLUMN set_label TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_name TEXT", "ALTER TABLE automation_history ADD COLUMN rule_name TEXT", "ALTER TABLE automation_history ADD COLUMN actions_json TEXT", "ALTER TABLE automation_history ADD COLUMN torrent_hash TEXT", "CREATE TABLE IF NOT EXISTS rss_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, feed_id INTEGER, rule_id INTEGER, title TEXT, link TEXT, status TEXT NOT NULL, message TEXT, created_at TEXT NOT NULL)", "CREATE INDEX IF NOT EXISTS idx_rss_history_profile_created ON rss_history(profile_id, created_at)", "CREATE UNIQUE INDEX IF NOT EXISTS idx_rss_history_unique_success ON rss_history(profile_id, COALESCE(rule_id,0), link) WHERE status IN ('queued','added')", "CREATE TABLE IF NOT EXISTS ratio_assignments (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, group_id INTEGER, group_name TEXT, applied_at TEXT, last_status TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", "CREATE TABLE IF NOT EXISTS ratio_history (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, group_id INTEGER, group_name TEXT, torrent_hash TEXT NOT NULL, torrent_name TEXT, action TEXT NOT NULL, status TEXT NOT NULL, reason TEXT, details_json TEXT, created_at TEXT NOT NULL)", "CREATE INDEX IF NOT EXISTS idx_ratio_history_profile_created ON ratio_history(profile_id, created_at)", "CREATE TABLE IF NOT EXISTS app_backups (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, payload_json TEXT NOT NULL, created_at TEXT NOT NULL)", "CREATE TABLE IF NOT EXISTS disk_monitor_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, paths_json TEXT, mode TEXT DEFAULT 'default', selected_path TEXT, stop_enabled INTEGER DEFAULT 0, stop_threshold INTEGER DEFAULT 98, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))", "CREATE TABLE IF NOT EXISTS download_plan_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))", "CREATE TABLE IF NOT EXISTS download_plan_paused (profile_id INTEGER NOT NULL, torrent_hash TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(profile_id, torrent_hash))", "CREATE INDEX IF NOT EXISTS idx_download_plan_paused_profile ON download_plan_paused(profile_id, updated_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_created ON jobs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_feeds_user_profile_enabled_next ON rss_feeds(user_id, profile_id, enabled, next_check_at)", "CREATE INDEX IF NOT EXISTS idx_rss_rules_user_profile_enabled ON rss_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_created ON rss_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_rss_history_user_profile_status ON rss_history(user_id, profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_ratio_groups_user_profile_enabled ON ratio_groups(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_ratio_assignments_profile_status ON ratio_assignments(profile_id, last_status)", "CREATE INDEX IF NOT EXISTS idx_ratio_history_user_profile_id ON ratio_history(user_id, profile_id, id)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_exclusions_user_profile_created ON smart_queue_exclusions(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_smart_queue_history_user_profile_created ON smart_queue_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_automation_rules_user_profile_enabled ON automation_rules(user_id, profile_id, enabled)", "CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)", "CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)", "CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)", "CREATE TABLE IF NOT EXISTS profile_preferences (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL, table_columns_json TEXT, torrent_sort_json TEXT, active_filter TEXT DEFAULT 'all', peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, tracker_favicons_enabled INTEGER DEFAULT 0, reverse_dns_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id), FOREIGN KEY(user_id) REFERENCES users(id), FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))", "ALTER TABLE app_backups ADD COLUMN backup_type TEXT DEFAULT 'app'", 'ALTER TABLE app_backups ADD COLUMN profile_id INTEGER', 'CREATE TABLE IF NOT EXISTS poller_settings (profile_id INTEGER PRIMARY KEY, settings_json TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id))', "CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))", ] POST_MIGRATION_INDEXES = [ "CREATE INDEX IF NOT EXISTS idx_api_tokens_active_user ON api_tokens(revoked_at, user_id)", "CREATE INDEX IF NOT EXISTS idx_user_profile_permissions_user ON user_profile_permissions(user_id, profile_id)", "CREATE INDEX IF NOT EXISTS idx_jobs_status_updated ON jobs(status, updated_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_status_started ON jobs(status, started_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)", "CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)", ] def utcnow() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def dict_factory(cursor, row): return {col[0]: row[idx] for idx, col in enumerate(cursor.description)} @contextmanager def connect(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH, timeout=30) conn.row_factory = dict_factory conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA busy_timeout = 30000") conn.execute("PRAGMA synchronous = NORMAL") try: yield conn conn.commit() finally: conn.close() def init_db(): with connect() as conn: try: conn.execute("PRAGMA journal_mode = WAL") except sqlite3.OperationalError: pass conn.executescript(SCHEMA) for sql in MIGRATIONS: try: conn.execute(sql) except sqlite3.OperationalError: pass for sql in POST_MIGRATION_INDEXES: try: conn.execute(sql) except sqlite3.OperationalError: pass now = utcnow() conn.execute( "INSERT OR IGNORE INTO users(id, username, password_hash, role, is_active, created_at, updated_at) VALUES(1, 'default', NULL, 'admin', 1, ?, ?)", (now, now), ) conn.execute("UPDATE users SET role=COALESCE(role, 'admin'), is_active=COALESCE(is_active, 1), updated_at=COALESCE(updated_at, ?) WHERE id=1", (now,)) pref = conn.execute("SELECT id FROM user_preferences WHERE user_id=1").fetchone() if not pref: conn.execute( "INSERT INTO user_preferences(user_id, theme, created_at, updated_at) VALUES(1, 'dark', ?, ?)", (now, now), ) try: from .services.auth import ensure_admin_user ensure_admin_user() except Exception: pass def default_user_id() -> int: return 1