From 288d30ad8b00b3760a1640c595ec0c4c2df9efa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 25 Jul 2026 01:31:49 +0200 Subject: [PATCH] Make config persistence robust and history DB concurrency-safe - Config saves are atomic (temp file + fsync + os.replace); a crash or power loss mid-write no longer truncates the file, which previously caused a silent reset to defaults on next launch. - Stored config is merged over a deep copy of the defaults: keys added in newer versions resolve properly instead of returning None, and the nested cached_versions dict is no longer shared with (and mutated on) the class-level default dict. - History SQLite connection enables WAL and a 5s busy timeout so the download thread can record entries while the history dialog reads without "database is locked" errors. Co-Authored-By: Claude Fable 5 --- ytsage/utils/ytsage_config_manager.py | 24 ++++++++++++++++++++---- ytsage/utils/ytsage_history_manager.py | 4 ++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index 05e61d2..af12b12 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -49,7 +49,9 @@ Exceptions when possible. """ +import copy import json +import os import threading from pathlib import Path from typing import Any, Dict, Optional @@ -102,6 +104,11 @@ class ConfigManager: "ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, "ffmpeg": {"version": None, "path": None, "last_check": 0, "path_mtime": 0}, }, + "advanced": { + # Allow falling back to a system-installed yt-dlp from PATH when + # the app-managed, SHA256-verified binary is absent + "allow_system_ytdlp": False, + }, } @classmethod @@ -115,13 +122,17 @@ class ConfigManager: if cls._config_file.exists(): try: with open(cls._config_file, "r", encoding="utf-8") as f: - cls._settings = json.load(f) + stored = json.load(f) + # Merge on top of defaults so keys added in newer versions + # exist without call sites needing `or ` fallbacks + cls._settings = copy.deepcopy(cls._default_config) + cls._settings.update(stored) logger.info("Config loaded from file.") except json.JSONDecodeError: - cls._settings = cls._default_config.copy() + cls._settings = copy.deepcopy(cls._default_config) logger.warning("Config file corrupt, loaded defaults.") else: - cls._settings = cls._default_config.copy() + cls._settings = copy.deepcopy(cls._default_config) cls._save() logger.info("Config file not found, created default config.") @@ -136,8 +147,13 @@ class ConfigManager: """ with cls._lock: try: - with open(cls._config_file, "w", encoding="utf-8") as f: + # Atomic write: a crash mid-save must not truncate the config + tmp_file = cls._config_file.with_suffix(".json.tmp") + with open(tmp_file, "w", encoding="utf-8") as f: json.dump(cls._settings, f, indent=4) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_file, cls._config_file) logger.debug("Config saved to file.") except (OSError, PermissionError) as e: logger.exception(f"Failed to save config: {e}") diff --git a/ytsage/utils/ytsage_history_manager.py b/ytsage/utils/ytsage_history_manager.py index d373581..74c07db 100644 --- a/ytsage/utils/ytsage_history_manager.py +++ b/ytsage/utils/ytsage_history_manager.py @@ -72,6 +72,10 @@ class HistoryManager: if cls._connection is None: cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False) cls._connection.row_factory = sqlite3.Row + # WAL lets the download thread write while the history + # dialog reads; busy_timeout avoids "database is locked" + cls._connection.execute("PRAGMA journal_mode=WAL") + cls._connection.execute("PRAGMA busy_timeout=5000") cursor = cls._connection.cursor()