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 <noreply@anthropic.com>
This commit is contained in:
@@ -49,7 +49,9 @@ Exceptions
|
|||||||
when possible.
|
when possible.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
@@ -102,6 +104,11 @@ class ConfigManager:
|
|||||||
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
"ytdlp": {"version": None, "path": None, "last_check": 0, "path_mtime": 0},
|
||||||
"ffmpeg": {"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
|
@classmethod
|
||||||
@@ -115,13 +122,17 @@ class ConfigManager:
|
|||||||
if cls._config_file.exists():
|
if cls._config_file.exists():
|
||||||
try:
|
try:
|
||||||
with open(cls._config_file, "r", encoding="utf-8") as f:
|
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 <default>` fallbacks
|
||||||
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
|
cls._settings.update(stored)
|
||||||
logger.info("Config loaded from file.")
|
logger.info("Config loaded from file.")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
cls._settings = cls._default_config.copy()
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
logger.warning("Config file corrupt, loaded defaults.")
|
logger.warning("Config file corrupt, loaded defaults.")
|
||||||
else:
|
else:
|
||||||
cls._settings = cls._default_config.copy()
|
cls._settings = copy.deepcopy(cls._default_config)
|
||||||
cls._save()
|
cls._save()
|
||||||
logger.info("Config file not found, created default config.")
|
logger.info("Config file not found, created default config.")
|
||||||
|
|
||||||
@@ -136,8 +147,13 @@ class ConfigManager:
|
|||||||
"""
|
"""
|
||||||
with cls._lock:
|
with cls._lock:
|
||||||
try:
|
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)
|
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.")
|
logger.debug("Config saved to file.")
|
||||||
except (OSError, PermissionError) as e:
|
except (OSError, PermissionError) as e:
|
||||||
logger.exception(f"Failed to save config: {e}")
|
logger.exception(f"Failed to save config: {e}")
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class HistoryManager:
|
|||||||
if cls._connection is None:
|
if cls._connection is None:
|
||||||
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
cls._connection = sqlite3.connect(cls._db_file, check_same_thread=False)
|
||||||
cls._connection.row_factory = sqlite3.Row
|
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()
|
cursor = cls._connection.cursor()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user