10 Commits

Author SHA1 Message Date
Jaroslav Beneš 1c885e3fc5 Fix low-severity robustness issues
- show_error_dialog() aborted when QApplication construction itself was
  the failure; fall back to stderr so the real error is visible.
- Auto-update QThread cleanup dropped the last Python reference while
  run() could still be unwinding ("QThread: Destroyed while thread is
  still running"); defer destruction to deleteLater on finished.
- macOS ffmpeg install no longer curl|bash-es the Homebrew bootstrap
  script unattended; it now asks the user to install Homebrew
  themselves and fails cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:36:29 +02:00
Jaroslav Beneš 4cc48ae98f Fix medium-severity defects across download, formats and tooling
- Format table: a missing acodec was treated as "has audio", skipping
  the +bestaudio merge and producing silent videos for extractors that
  omit the field.
- Progress bar: separate video/audio stream downloads each reported
  0-100%, making the bar jump backwards; per-phase scaling now maps the
  two streams onto 0-50/50-100.
- Custom commands: parse with shlex (quoted arguments with spaces were
  shredded by str.split), keep POSIX mode off on Windows so backslash
  paths survive, hide the console window like every other call site,
  close the stdout pipe, and support cancellation of a running command.
- Settings dialog: _("settings", "error_saving", ...) passed two
  positional args to the i18n helper, raising TypeError inside the
  except handler instead of showing the intended error dialog.
- ffmpeg on Windows: Path(os.getenv("LOCALAPPDATA")) crashed with
  TypeError when the variable is unset; fall back to the standard
  AppData/Local location.
- Version cache: cached path (str) was compared against a Path, so the
  cache never hit and every version query spawned a subprocess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:35:23 +02:00
Jaroslav Beneš 288d30ad8b 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>
2026-07-25 01:31:49 +02:00
Jaroslav Beneš ebb9422591 Harden yt-dlp binary resolution and analysis subprocess handling
- get_yt_dlp_path() no longer implicitly executes a bare "yt-dlp" from
  PATH (on Windows that lookup includes the CWD, so a planted binary
  in a writable directory could be run). A system yt-dlp is used only
  behind the explicit advanced.allow_system_ytdlp config opt-in, and
  then always as a which()-resolved absolute path. Analysis and
  download refuse to exec the not-installed sentinel.
- Analysis subprocesses now run in their own session and the whole
  process group is killed on timeout, so deno grandchildren no longer
  leak; partial stderr is preserved and logged, and output decoding is
  pinned to utf-8 with replacement (Windows locale codecs crashed on
  non-UTF8 titles).
- Flat-playlist entries are filtered for None (private/deleted first
  video no longer breaks analysis).
- update_yt_dlp() normalizes the sentinel to Path, unbreaking the pip
  fallback path that crashed on str.exists().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:30:08 +02:00
Jaroslav Beneš 852804ee5c Actually suspend the download process group on pause
Pause only stopped the stdout reader loop; yt-dlp kept transferring at
full rate until the pipe buffer filled, consuming bandwidth while the
UI claimed the download was paused. Send SIGSTOP/SIGCONT to the whole
process group (yt-dlp and its ffmpeg children) on Unix. Windows has no
equivalent signal; the limitation is documented in the helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:26:44 +02:00
Jaroslav Beneš 5064b38315 Marshal channel-switch UI updates back to the GUI thread
The yt-dlp stable/nightly channel switcher mutated QLabel/QRadioButton
state directly from a raw threading.Thread, which is undefined behavior
in Qt. The worker now only runs the subprocess and emits a signal; the
connected slot applies all widget updates on the GUI thread via Qt's
queued delivery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:26:00 +02:00
Jaroslav Beneš dff14ec3e8 Enforce SHA256 verification on every binary install path
Three gaps allowed an unverified binary to reach a trusted location:

- The ffmpeg ZIP fallback logged a warning on checksum mismatch and
  installed anyway (the 7z path already aborted). Abort instead.
- The yt-dlp auto-update path downloaded and renamed the binary over
  the verified one with no checksum at all. Verify against the official
  SHA2-256SUMS like the first-install path, and use atomic os.replace.
- The yt-dlp first install streamed the download directly to the
  trusted path and only verified afterwards; a crash in between left an
  unverified executable to be run on next launch. Download to .part and
  os.replace only after verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:24:35 +02:00
Jaroslav Beneš b40deb0d5f Write ffmpeg dir to user PATH via registry instead of setx
setx truncates values at 1024 characters, and the old code fed it the
merged process PATH (system + user), permanently duplicating every
system entry into the user hive and silently dropping anything past the
limit. Read and rewrite only the HKCU Environment Path value with
winreg, preserving REG_EXPAND_SZ, and broadcast WM_SETTINGCHANGE.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:23:04 +02:00
Jaroslav Beneš 202dd9eaee Fix UnboundLocalError in manual yt-dlp binary selection
Unpacking getOpenFileName() into a variable named _ made _ local to
select_ytdlp_path(), so the i18n _() calls earlier in the function
raised UnboundLocalError before the file dialog could open, breaking
manual binary selection entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:22:26 +02:00
Jaroslav Beneš a1d1f87b65 Scope cleanup routines to files created by the current download
cleanup_partial_files() deleted every *.part and *.fNNN.* file in the
whole download directory, and cleanup_subtitle_files() deleted any new
.vtt/.srt under it recursively - including files belonging to other
applications (e.g. a browser's own .part downloads in ~/Downloads).

Track every destination path yt-dlp reports for this download and
restrict both cleanup passes to those files and their .part/.ytdl
siblings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 01:22:05 +02:00
13 changed files with 316 additions and 144 deletions
+52 -8
View File
@@ -112,18 +112,25 @@ class DownloadThread(QThread):
self.last_file_path: Optional[str] = None # Initialize full file path storage self.last_file_path: Optional[str] = None # Initialize full file path storage
self.subtitle_files: List[str] = [] # Track subtitle files that are created self.subtitle_files: List[str] = [] # Track subtitle files that are created
self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download self.initial_subtitle_files: Set[Path] = set() # Track initial subtitle files before download
self.download_files: Set[Path] = set() # Every destination path this download wrote to
self.expected_phases: int = 1 # 2 when video and audio download separately before merge
self._media_phase: int = 0 # Index of the media stream currently downloading
def cleanup_partial_files(self) -> None: def cleanup_partial_files(self) -> None:
"""Delete any partial files including .part and unmerged format-specific files""" """Delete partial files (.part/.ytdl and unmerged .fNNN. streams), but only
those belonging to destinations this download actually wrote — the download
directory may contain unrelated files from other applications."""
try: try:
pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243. pattern = re.compile(r"\.f\d+\.") # Pattern to match format codes like .f243.
for file_path in self.path.iterdir(): for dest in self.download_files:
if file_path.suffix == ".part" or pattern.search(file_path.name): candidates = [dest.with_name(dest.name + ".part"), dest.with_name(dest.name + ".ytdl")]
self._safe_delete_with_retry(file_path) if dest.suffix == ".part" or pattern.search(dest.name):
candidates.append(dest)
for file_path in candidates:
if file_path.exists():
self._safe_delete_with_retry(file_path)
except Exception as e: except Exception as e:
logger.exception(f"Error cleaning partial files: {e}") logger.exception(f"Error cleaning partial files: {e}")
# Don't emit error signal for cleanup issues to avoid crashing the thread
logger.error(f"Error cleaning partial files: {e}")
def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None: def _safe_delete_with_retry(self, file_path: Path, max_retries: int = 5, delay: float = 2.0) -> None:
"""Safely delete a file with retry mechanism for file locking issues across platforms""" """Safely delete a file with retry mechanism for file locking issues across platforms"""
@@ -219,8 +226,15 @@ class DownloadThread(QThread):
logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files") logger.debug(f"Deleted {deleted_count[0]} of {len(self.subtitle_files)} tracked subtitle files")
# --- Method 2: Delete new subtitle files not in initial set --- # --- Method 2: Delete new subtitle files not in initial set ---
# Only touch subtitles belonging to files this download wrote; the
# directory may contain subtitle files from other processes.
download_stems = {p.stem for p in self.download_files} | {Path(f).stem for f in self.subtitle_files or []}
new_subtitle_files: Set[Path] = { new_subtitle_files: Set[Path] = {
f for f in Path(self.path).rglob("*") if f.suffix in [".vtt", ".srt"] and f not in self.initial_subtitle_files f
for f in Path(self.path).rglob("*")
if f.suffix in [".vtt", ".srt"]
and f not in self.initial_subtitle_files
and any(f.name.startswith(stem) for stem in download_stems if stem)
} }
for subtitle_file in new_subtitle_files: for subtitle_file in new_subtitle_files:
deleted_count[1] += safe_delete(path=subtitle_file) deleted_count[1] += safe_delete(path=subtitle_file)
@@ -232,8 +246,12 @@ class DownloadThread(QThread):
def _build_yt_dlp_command(self) -> List[str]: def _build_yt_dlp_command(self) -> List[str]:
"""Build the yt-dlp command line with all options for direct execution.""" """Build the yt-dlp command line with all options for direct execution."""
yt_dlp_path: str = get_yt_dlp_path() yt_dlp_path: str = get_yt_dlp_path()
if str(yt_dlp_path) == "yt-dlp":
# Sentinel: no managed binary and no opted-in system binary.
# Never exec a bare command name from PATH.
raise FileNotFoundError("yt-dlp is not installed - run the yt-dlp setup first")
# Build the command line array # Build the command line array
cmd: List[str] = [yt_dlp_path] cmd: List[str] = [str(yt_dlp_path)]
logger.debug(f"Using yt-dlp from: {yt_dlp_path}") logger.debug(f"Using yt-dlp from: {yt_dlp_path}")
# Add concurrent fragments setting # Add concurrent fragments setting
@@ -277,6 +295,7 @@ class DownloadThread(QThread):
logger.debug(f"Using progressive format with bundled audio: {clean_format_id}") logger.debug(f"Using progressive format with bundled audio: {clean_format_id}")
else: else:
cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"]) cmd.extend(["-f", f"{clean_format_id}+bestaudio/best"])
self.expected_phases = 2 # separate video and audio downloads
logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best") logger.debug(f"Using video-only format merged with best audio: {clean_format_id}+bestaudio/best")
else: else:
# If no specific format ID, use resolution-based sorting (-S) # If no specific format ID, use resolution-based sorting (-S)
@@ -619,6 +638,9 @@ class DownloadThread(QThread):
filepath = dest_match.group(1).strip() filepath = dest_match.group(1).strip()
self.current_filename = Path(filepath).name self.current_filename = Path(filepath).name
self.last_file_path = filepath # Store the full path for later cleanup self.last_file_path = filepath # Store the full path for later cleanup
if Path(filepath) not in self.download_files and Path(filepath).suffix.lower() not in SUBTITLE_EXTENSIONS:
self._media_phase += 1
self.download_files.add(Path(filepath))
logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG logger.debug(f"Extracted filename: {self.current_filename}") # DEBUG
# Check if this is an audio-only download by looking in the previous lines # Check if this is an audio-only download by looking in the previous lines
@@ -711,6 +733,7 @@ class DownloadThread(QThread):
dest_path = match.group(1).strip() dest_path = match.group(1).strip()
self.current_filename = Path(dest_path).name self.current_filename = Path(dest_path).name
self.last_file_path = dest_path self.last_file_path = dest_path
self.download_files.add(Path(dest_path))
logger.debug(f"Captured destination filename: {self.current_filename}") logger.debug(f"Captured destination filename: {self.current_filename}")
elif "Downloading API JSON" in line: elif "Downloading API JSON" in line:
self.status_signal.emit(_("download.processing_playlist")) self.status_signal.emit(_("download.processing_playlist"))
@@ -737,6 +760,11 @@ class DownloadThread(QThread):
if percent_match: if percent_match:
try: try:
percent = float(percent_match.group(1)) percent = float(percent_match.group(1))
# When video and audio download as separate streams, scale each
# phase into its share of the bar instead of jumping 0-100 twice
if self.expected_phases > 1 and not self.is_playlist:
completed = max(0, min(self._media_phase - 1, self.expected_phases - 1))
percent = (completed * 100.0 + percent) / self.expected_phases
self.progress_signal.emit(percent) self.progress_signal.emit(percent)
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
@@ -771,6 +799,7 @@ class DownloadThread(QThread):
merged_filepath = merger_match.group(1).strip() merged_filepath = merger_match.group(1).strip()
self.current_filename = Path(merged_filepath).name self.current_filename = Path(merged_filepath).name
self.last_file_path = merged_filepath self.last_file_path = merged_filepath
self.download_files.add(Path(merged_filepath))
logger.debug(f"Updated to merged filename: {self.current_filename}") logger.debug(f"Updated to merged filename: {self.current_filename}")
elif "SponsorBlock" in line: elif "SponsorBlock" in line:
self.status_signal.emit(_("download.removing_sponsor_segments")) self.status_signal.emit(_("download.removing_sponsor_segments"))
@@ -824,9 +853,24 @@ class DownloadThread(QThread):
def pause(self) -> None: def pause(self) -> None:
self.paused = True self.paused = True
self._signal_process_group(signal.SIGSTOP if sys.platform != "win32" else None)
def resume(self) -> None: def resume(self) -> None:
self.paused = False self.paused = False
self._signal_process_group(signal.SIGCONT if sys.platform != "win32" else None)
def _signal_process_group(self, sig: Optional[int]) -> None:
"""Send a signal to yt-dlp's whole process group (yt-dlp + ffmpeg children).
On Windows there is no SIGSTOP/SIGCONT; pausing there only stops output
consumption, which is a known limitation.
"""
if sig is None or not self.process:
return
try:
os.killpg(os.getpgid(self.process.pid), sig)
except (ProcessLookupError, PermissionError, OSError) as e:
logger.debug(f"Could not signal process group: {e}")
def cancel(self) -> None: def cancel(self) -> None:
self.cancelled = True self.cancelled = True
+43 -27
View File
@@ -91,7 +91,7 @@ def get_ffmpeg_install_path() -> Path:
For Windows, tries to find the latest essentials build dynamically. For Windows, tries to find the latest essentials build dynamically.
""" """
if OS_NAME == "Windows": if OS_NAME == "Windows":
ffmpeg_base = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore ffmpeg_base = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
# If the directory exists, look for any ffmpeg-*-essentials_build folder # If the directory exists, look for any ffmpeg-*-essentials_build folder
if ffmpeg_base.exists(): if ffmpeg_base.exists():
@@ -205,7 +205,7 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
try: try:
# Define variables for essentials build # Define variables for essentials build
extract_dir = Path(os.getenv("LOCALAPPDATA")) / "ffmpeg" # type: ignore extract_dir = Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "ffmpeg"
# Create extraction directory if it doesn't exist # Create extraction directory if it doesn't exist
extract_dir.mkdir(exist_ok=True) extract_dir.mkdir(exist_ok=True)
@@ -282,9 +282,14 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
if progress_callback: if progress_callback:
progress_callback("🔐 Verifying download integrity...") progress_callback("🔐 Verifying download integrity...")
if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL): if not verify_sha256(temp_file, FFMPEG_ZIP_SHA256_URL):
logger.warning("SHA-256 verification failed for zip file, proceeding anyway...") logger.error("SHA-256 verification failed for zip file, aborting installation")
if progress_callback: if progress_callback:
progress_callback("⚠️ SHA-256 verification failed, proceeding anyway...") progress_callback(" SHA-256 verification failed, aborting installation")
try:
Path(temp_file).unlink(missing_ok=True)
except Exception:
pass
return False
logger.info("Extracting FFmpeg components from zip archive...") logger.info("Extracting FFmpeg components from zip archive...")
if progress_callback: if progress_callback:
@@ -337,29 +342,35 @@ def install_ffmpeg_windows(progress_callback=None) -> bool:
if progress_callback: if progress_callback:
progress_callback("🔧 Configuring system paths...") progress_callback("🔧 Configuring system paths...")
# Add to System Path # Persist to the user PATH via the registry. setx must not be used here:
user_path = os.environ.get("PATH", "") # it truncates values at 1024 characters, and os.environ["PATH"] is the
path_parts = user_path.split(os.pathsep) # merged system+user PATH, so writing it back would permanently duplicate
# every system entry into the user hive.
# Remove old FFmpeg paths and add new one
cleaned_paths = [p for p in path_parts if "ffmpeg" not in p.lower() or str(bin_dir) in p]
if str(bin_dir) not in cleaned_paths:
cleaned_paths.insert(0, str(bin_dir))
new_path = os.pathsep.join(cleaned_paths)
try: try:
subprocess.run( import winreg
["setx", "PATH", new_path],
creationflags=SUBPROCESS_CREATIONFLAGS, with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment", 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
timeout=30, try:
check=True, stored_path, value_type = winreg.QueryValueEx(key, "Path")
) except FileNotFoundError:
os.environ["PATH"] = new_path stored_path, value_type = "", winreg.REG_EXPAND_SZ
user_parts = [p for p in stored_path.split(os.pathsep) if p]
# Drop stale ffmpeg entries we previously added, then prepend the new one
user_parts = [p for p in user_parts if "ffmpeg" not in p.lower() or p == str(bin_dir)]
if str(bin_dir) not in user_parts:
user_parts.insert(0, str(bin_dir))
winreg.SetValueEx(key, "Path", 0, value_type, os.pathsep.join(user_parts))
# Broadcast the change so new shells pick it up without relogin
import ctypes
ctypes.windll.user32.SendMessageTimeoutW(0xFFFF, 0x001A, 0, "Environment", 0x0002, 5000, None)
except Exception as e: except Exception as e:
logger.warning(f"Failed to update PATH permanently: {e}") logger.warning(f"Failed to update PATH permanently: {e}")
# Still update for current session
os.environ["PATH"] = new_path # Update for the current session regardless
if str(bin_dir) not in os.environ.get("PATH", "").split(os.pathsep):
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
# Verify installation # Verify installation
if progress_callback: if progress_callback:
@@ -395,9 +406,14 @@ def install_ffmpeg_macos() -> bool:
timeout=5, timeout=5,
) )
except (subprocess.SubprocessError, FileNotFoundError): except (subprocess.SubprocessError, FileNotFoundError):
logger.info("Installing Homebrew...") # Never curl|bash a remote script without the user's say-so.
brew_install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' # Homebrew installation is the user's decision to make in a
subprocess.run(brew_install_cmd, shell=True, check=True, timeout=300) # terminal, where the script can also prompt for sudo properly.
logger.error(
"Homebrew is not installed. Install it from https://brew.sh and retry, "
"or install ffmpeg another way."
)
return False
# Install FFmpeg # Install FFmpeg
logger.info("Installing FFmpeg...") logger.info("Installing FFmpeg...")
+22 -11
View File
@@ -72,8 +72,11 @@ def should_refresh_cache(tool_name: str, current_path: Optional[str]) -> bool:
if not cache.get("version"): if not cache.get("version"):
return True return True
# Refresh if path changed # Refresh if path changed (cache stores str; callers may pass Path -
if cache.get("path") != current_path: # compare normalized strings or the cache would never hit)
cached_path = cache.get("path")
normalized_current = str(current_path) if current_path is not None else None
if cached_path != normalized_current:
return True return True
# Refresh if file was modified # Refresh if file was modified
@@ -432,8 +435,9 @@ def save_path(main_window_instance: Any, path: Union[str, Path]) -> bool:
def update_yt_dlp() -> bool: def update_yt_dlp() -> bool:
"""Check for yt-dlp updates and update if a newer version is available.""" """Check for yt-dlp updates and update if a newer version is available."""
try: try:
# Get the yt-dlp path # Get the yt-dlp path (may be the bare "yt-dlp" sentinel string when
yt_dlp_path: Path = get_yt_dlp_path() # not installed - normalize to Path so .exists()/.samefile() work)
yt_dlp_path: Path = Path(get_yt_dlp_path())
# Extra logic moved to src\utils\ytsage_constants.py # Extra logic moved to src\utils\ytsage_constants.py
@@ -463,7 +467,7 @@ def update_yt_dlp() -> bool:
# Download the latest version # Download the latest version
try: try:
response = requests.get(YTDLP_DOWNLOAD_URL, stream=True) response = requests.get(YTDLP_DOWNLOAD_URL, stream=True, timeout=60)
if response.status_code == 200: if response.status_code == 200:
# Create a temporary file # Create a temporary file
temp_file = f"{yt_dlp_path}.new" temp_file = f"{yt_dlp_path}.new"
@@ -472,21 +476,28 @@ def update_yt_dlp() -> bool:
for chunk in response.iter_content(chunk_size=8192): for chunk in response.iter_content(chunk_size=8192):
f.write(chunk) f.write(chunk)
# Verify against the official checksums before touching the
# trusted binary (same check as the first-install path)
from ytsage.core.ytsage_yt_dlp import verify_ytdlp_sha256
if not verify_ytdlp_sha256(Path(temp_file), YTDLP_DOWNLOAD_URL):
logger.error("SHA256 verification failed for yt-dlp update, keeping current binary")
Path(temp_file).unlink(missing_ok=True)
return False
# Make executable on Unix systems # Make executable on Unix systems
if OS_NAME != "Windows": if OS_NAME != "Windows":
os.chmod(temp_file, 0o755) os.chmod(temp_file, 0o755)
# Replace the old file with the new one # Replace the old file with the new one (os.replace is atomic
# and overwrites on Windows too)
try: try:
# On Windows, we need to remove the old file first os.replace(temp_file, yt_dlp_path)
if OS_NAME == "Windows" and yt_dlp_path.exists():
yt_dlp_path.unlink(missing_ok=True)
Path(temp_file).rename(yt_dlp_path)
logger.info("yt-dlp binary successfully updated") logger.info("yt-dlp binary successfully updated")
return True return True
except Exception as e: except Exception as e:
logger.exception(f"Error replacing yt-dlp binary: {e}") logger.exception(f"Error replacing yt-dlp binary: {e}")
Path(temp_file).unlink(missing_ok=True)
return False return False
else: else:
logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}") logger.info(f"Failed to download latest yt-dlp: HTTP {response.status_code}")
+28 -12
View File
@@ -112,6 +112,10 @@ class DownloadYtdlpThread(QThread):
try: try:
# Extra logic moved to src\utils\ytsage_constants.py # Extra logic moved to src\utils\ytsage_constants.py
exe_path = YTDLP_APP_BIN_PATH exe_path = YTDLP_APP_BIN_PATH
# Download to a temp name and only move to the trusted path after
# the hash checks out, so a crash mid-download can never leave an
# unverified binary where the app will execute it.
part_path = exe_path.with_name(exe_path.name + ".part")
# Download with progress reporting # Download with progress reporting
logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}") logger.info(f"Downloading yt-dlp from: {YTDLP_DOWNLOAD_URL}")
@@ -123,7 +127,7 @@ class DownloadYtdlpThread(QThread):
if total_size == 0: if total_size == 0:
self.progress_signal.emit(100) self.progress_signal.emit(100)
with open(exe_path, "wb") as f: with open(part_path, "wb") as f:
downloaded = 0 downloaded = 0
for data in response.iter_content(block_size): for data in response.iter_content(block_size):
f.write(data) f.write(data)
@@ -135,11 +139,10 @@ class DownloadYtdlpThread(QThread):
logger.info("Download complete, verifying SHA256 hash...") logger.info("Download complete, verifying SHA256 hash...")
# Verify SHA256 hash # Verify SHA256 hash
if not verify_ytdlp_sha256(exe_path, YTDLP_DOWNLOAD_URL): if not verify_ytdlp_sha256(part_path, YTDLP_DOWNLOAD_URL):
# Hash verification failed - delete the downloaded file # Hash verification failed - delete the downloaded file
logger.error("SHA256 verification failed! Removing downloaded file.") logger.error("SHA256 verification failed! Removing downloaded file.")
if Path(exe_path).exists(): part_path.unlink(missing_ok=True)
Path(exe_path).unlink()
self.finished_signal.emit( self.finished_signal.emit(
False, False,
"SHA256 verification failed. The downloaded file may be corrupted or tampered with." "SHA256 verification failed. The downloaded file may be corrupted or tampered with."
@@ -148,7 +151,9 @@ class DownloadYtdlpThread(QThread):
# Make executable on macOS and Linux # Make executable on macOS and Linux
if OS_NAME != "Windows": if OS_NAME != "Windows":
os.chmod(exe_path, 0o755) os.chmod(part_path, 0o755)
os.replace(part_path, exe_path)
logger.info("yt-dlp downloaded and verified successfully!") logger.info("yt-dlp downloaded and verified successfully!")
self.finished_signal.emit(True, str(exe_path)) self.finished_signal.emit(True, str(exe_path))
@@ -429,7 +434,7 @@ class YtdlpSetupDialog(QDialog):
""" """
) )
file_path, _ = file_dialog.getOpenFileName( file_path, _selected_filter = file_dialog.getOpenFileName(
self, _("ytdlp_setup.select_executable_title"), "", file_filter self, _("ytdlp_setup.select_executable_title"), "", file_filter
) )
@@ -607,19 +612,30 @@ def check_ytdlp_installed() -> bool:
def get_yt_dlp_path() -> Path: def get_yt_dlp_path() -> Path:
""" """
Get the yt-dlp path, either from the app's bin directory or system PATH. Get the yt-dlp path. Prefers the app-managed, SHA256-verified binary;
This replaces the function in ytsage_utils.py. a system-installed yt-dlp is only used when the user explicitly opts in
via the advanced.allow_system_ytdlp config key (resolved to an absolute
path so Windows' implicit CWD lookup can never pick up a planted binary).
Returns: Returns:
str: Path to yt-dlp binary Path to yt-dlp binary, or the bare string "yt-dlp" sentinel meaning
"not installed" (triggers the setup dialog).
""" """
# First check if we have yt-dlp in our app's bin directory or system PATH
ytdlp_path = check_ytdlp_binary() ytdlp_path = check_ytdlp_binary()
if ytdlp_path: if ytdlp_path:
logger.info(f"Using yt-dlp from: {ytdlp_path}") logger.info(f"Using yt-dlp from: {ytdlp_path}")
return ytdlp_path return ytdlp_path
# If not found anywhere, fall back to the command name as a last resort from ..utils.ytsage_config_manager import ConfigManager
logger.info("yt-dlp not found in app directory or PATH, falling back to command name")
if ConfigManager.get("advanced.allow_system_ytdlp"):
system_ytdlp = shutil.which("yt-dlp")
if system_ytdlp:
resolved = Path(system_ytdlp).resolve()
logger.info(f"Using system yt-dlp (advanced.allow_system_ytdlp): {resolved}")
return resolved
# Not installed - return the sentinel that triggers the setup dialog
logger.info("yt-dlp not found in app directory, returning setup sentinel")
return "yt-dlp" # type: ignore[return-value] return "yt-dlp" # type: ignore[return-value]
+55 -13
View File
@@ -1,7 +1,10 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
import json import json
import os
import signal
import subprocess import subprocess
import sys
from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer from PySide6.QtCore import QMetaObject, Qt, Q_ARG, QThread, Signal, QTimer
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
@@ -95,13 +98,56 @@ class AnalysisThread(QThread):
if self.geo_proxy_url: if self.geo_proxy_url:
cmd.extend(["--geo-verification-proxy", self.geo_proxy_url]) cmd.extend(["--geo-verification-proxy", self.geo_proxy_url])
@staticmethod
def _run_ytdlp(cmd: list, timeout: int) -> subprocess.CompletedProcess:
"""Run yt-dlp capturing output, killing the whole process group on timeout.
subprocess.run() only kills the direct child on TimeoutExpired, leaking
grandchildren (deno); it also loses any stderr produced before the
timeout. Use Popen with a new session so the entire group can be
reaped, and surface the partial stderr in the raised exception.
"""
popen_kwargs: Dict[str, Any] = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"text": True,
"encoding": "utf-8",
"errors": "replace",
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = SUBPROCESS_CREATIONFLAGS
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
try:
stdout, stderr = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired as exc:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
creationflags=SUBPROCESS_CREATIONFLAGS,
)
else:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
stdout, stderr = proc.communicate()
exc.stdout, exc.stderr = stdout, stderr
raise
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
def _analyze_url_with_subprocess(self, url: str) -> None: def _analyze_url_with_subprocess(self, url: str) -> None:
"""Analyze URL using yt-dlp executable.""" """Analyze URL using yt-dlp executable."""
if self._cancelled: if self._cancelled:
return return
yt_dlp_path = get_yt_dlp_path() yt_dlp_path = get_yt_dlp_path()
if not yt_dlp_path: if not yt_dlp_path or str(yt_dlp_path) == "yt-dlp":
# Sentinel: no managed binary and no opted-in system binary.
# Never exec a bare command name from PATH.
logger.error("yt-dlp executable not found. Please install yt-dlp first.") logger.error("yt-dlp executable not found. Please install yt-dlp first.")
self.analysis_error.emit(_("errors.ytdlp_not_found")) self.analysis_error.emit(_("errors.ytdlp_not_found"))
self.playlist_info_visible.emit(False) self.playlist_info_visible.emit(False)
@@ -118,12 +164,10 @@ class AnalysisThread(QThread):
logger.debug(f"Executing yt-dlp command: {cmd}") logger.debug(f"Executing yt-dlp command: {cmd}")
try: try:
result = subprocess.run( result = self._run_ytdlp(cmd, timeout=300)
cmd, capture_output=True, text=True, timeout=300, except subprocess.TimeoutExpired as e:
creationflags=SUBPROCESS_CREATIONFLAGS stderr_tail = (e.stderr or "")[-500:] if isinstance(e.stderr, str) else ""
) logger.error(f"Analysis timed out. Partial stderr: {stderr_tail}")
except subprocess.TimeoutExpired:
logger.error("Analysis timed out")
self.analysis_error.emit(_("errors.timeout")) self.analysis_error.emit(_("errors.timeout"))
return return
@@ -181,7 +225,8 @@ class AnalysisThread(QThread):
if first_info.get("_type") == "playlist": if first_info.get("_type") == "playlist":
result_data["is_playlist"] = True result_data["is_playlist"] = True
result_data["playlist_info"] = first_info result_data["playlist_info"] = first_info
playlist_entries = first_info.get("entries", []) # Private/deleted videos can appear as None entries in flat playlists
playlist_entries = [e for e in first_info.get("entries", []) if e]
result_data["playlist_entries"] = playlist_entries result_data["playlist_entries"] = playlist_entries
if not playlist_entries: if not playlist_entries:
@@ -201,15 +246,12 @@ class AnalysisThread(QThread):
self._add_auth_options(cmd_single) self._add_auth_options(cmd_single)
try: try:
result_single = subprocess.run( result_single = self._run_ytdlp(cmd_single, timeout=60)
cmd_single, capture_output=True, text=True, timeout=60,
creationflags=SUBPROCESS_CREATIONFLAGS
)
if result_single.returncode == 0: if result_single.returncode == 0:
result_data["video_info"] = json.loads(result_single.stdout) result_data["video_info"] = json.loads(result_single.stdout)
else: else:
result_data["video_info"] = first_video_entry result_data["video_info"] = first_video_entry
except subprocess.TimeoutExpired: except (subprocess.TimeoutExpired, json.JSONDecodeError):
result_data["video_info"] = first_video_entry result_data["video_info"] = first_video_entry
if self._cancelled: if self._cancelled:
@@ -3,6 +3,8 @@ Custom functionality dialogs for YTSage application.
Contains dialogs for custom commands, cookies, time ranges, and other special features. Contains dialogs for custom commands, cookies, time ranges, and other special features.
""" """
import os
import shlex
import subprocess import subprocess
import threading import threading
from pathlib import Path from pathlib import Path
@@ -32,7 +34,7 @@ from PySide6.QtWidgets import (
from ..ytsage_smooth_tab_widget import SmoothTabWidget from ..ytsage_smooth_tab_widget import SmoothTabWidget
from ...core.ytsage_yt_dlp import get_yt_dlp_path from ...core.ytsage_yt_dlp import get_yt_dlp_path
from ...core.ytsage_utils import update_auto_update_settings from ...core.ytsage_utils import update_auto_update_settings
from ...utils.ytsage_constants import YTDLP_DOCS_URL from ...utils.ytsage_constants import SUBPROCESS_CREATIONFLAGS, YTDLP_DOCS_URL
from ...utils.ytsage_config_manager import ConfigManager from ...utils.ytsage_config_manager import ConfigManager
from ...utils.ytsage_localization import LocalizationManager, _ from ...utils.ytsage_localization import LocalizationManager, _
from ...utils.ytsage_logger import logger from ...utils.ytsage_logger import logger
@@ -55,16 +57,28 @@ class CommandWorker(QObject):
self.command = command self.command = command
self.url = url self.url = url
self.path = path self.path = path
self._proc = None
self._cancelled = False
def cancel(self):
"""Terminate a running command."""
self._cancelled = True
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
except Exception:
pass
def run_command(self): def run_command(self):
"""Run the yt-dlp command and emit signals for output""" """Run the yt-dlp command and emit signals for output"""
try: try:
# Split command into arguments # Split command into arguments; posix=False on Windows so quoted
args = self.command.split() # backslash paths like "C:\Users\..." survive intact
args = shlex.split(self.command, posix=(os.name != "nt"))
# Build the full command # Build the full command
yt_dlp_path = get_yt_dlp_path() yt_dlp_path = get_yt_dlp_path()
base_cmd = [yt_dlp_path] + args base_cmd = [str(yt_dlp_path)] + args
# Add download path if specified # Add download path if specified
if self.path: if self.path:
@@ -78,19 +92,24 @@ class CommandWorker(QObject):
self.output_received.emit("=" * 50) self.output_received.emit("=" * 50)
# Run the command # Run the command
proc = subprocess.Popen( self._proc = subprocess.Popen(
base_cmd, base_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
creationflags=SUBPROCESS_CREATIONFLAGS,
) )
proc = self._proc
# Stream output # Stream output
for line in proc.stdout: # type: ignore[reportOptionalIterable] with proc.stdout: # type: ignore[union-attr]
if line.strip(): # Only show non-empty lines for line in proc.stdout: # type: ignore[reportOptionalIterable]
self.output_received.emit(line.rstrip()) if self._cancelled:
break
if line.strip(): # Only show non-empty lines
self.output_received.emit(line.rstrip())
ret = proc.wait() ret = proc.wait()
self.output_received.emit("=" * 50) self.output_received.emit("=" * 50)
@@ -985,5 +985,5 @@ class AutoUpdateSettingsDialog(QDialog):
msg_box.exec() msg_box.exec()
except Exception as e: except Exception as e:
logger.exception(f"Error saving auto-update settings: {e}") logger.exception(f"Error saving auto-update settings: {e}")
msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings", "error_saving", error=str(e))) msg_box = self._create_styled_message_box(QMessageBox.Icon.Critical, _("settings.error_title"), _("settings.error_saving", error=str(e)))
msg_box.exec() msg_box.exec()
@@ -201,6 +201,10 @@ class DenoUpdateThread(QThread):
class UpdaterTabWidget(QWidget): class UpdaterTabWidget(QWidget):
"""Widget for the Updater tab in Custom Options dialog.""" """Widget for the Updater tab in Custom Options dialog."""
# Emitted from the channel-switch worker thread; queued back to the GUI
# thread so widget updates never happen off-thread.
_channel_switch_finished = Signal(bool, str, str, str) # success, new_channel, current_channel, error
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent()) self._parent: "CustomOptionsDialog" = cast("CustomOptionsDialog", self.parent())
@@ -212,6 +216,7 @@ class UpdaterTabWidget(QWidget):
self._init_ui() self._init_ui()
self._load_auto_update_settings() self._load_auto_update_settings()
self._channel_switch_finished.connect(self._on_channel_switch_finished)
def _init_ui(self) -> None: def _init_ui(self) -> None:
"""Initialize the UI components.""" """Initialize the UI components."""
@@ -816,68 +821,54 @@ class UpdaterTabWidget(QWidget):
ConfigManager.set("ytdlp_channel", new_channel) ConfigManager.set("ytdlp_channel", new_channel)
logger.info(f"Successfully switched to {new_channel} channel") logger.info(f"Successfully switched to {new_channel} channel")
# Update UI
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
self.channel_status_label.setStyleSheet(
"color: #00cc00; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Make executable on Unix systems # Make executable on Unix systems
if OS_NAME != "Windows": if OS_NAME != "Windows":
import os import os
os.chmod(yt_dlp_path, 0o755) os.chmod(yt_dlp_path, 0o755)
self._channel_switch_finished.emit(True, new_channel, current_channel, "")
else: else:
# Failed - revert radio button
error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error" error_msg = result.stderr.strip() if result.stderr else result.stdout.strip() if result.stdout else "Unknown error"
logger.error(f"Failed to switch channel: {error_msg}") logger.error(f"Failed to switch channel: {error_msg}")
self._channel_switch_finished.emit(False, new_channel, current_channel, error_msg)
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
self.channel_status_label.setStyleSheet(
"color: #ff6666; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Revert radio selection
if current_channel == "nightly":
self.channel_nightly_radio.setChecked(True)
else:
self.channel_stable_radio.setChecked(True)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.error("Channel switch timed out") logger.error("Channel switch timed out")
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error="Timeout")) self._channel_switch_finished.emit(False, new_channel, current_channel, "Timeout")
self.channel_status_label.setStyleSheet(
"color: #ff6666; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Revert radio selection
if current_channel == "nightly":
self.channel_nightly_radio.setChecked(True)
else:
self.channel_stable_radio.setChecked(True)
except Exception as e: except Exception as e:
logger.exception(f"Error switching channel: {e}") logger.exception(f"Error switching channel: {e}")
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=str(e))) self._channel_switch_finished.emit(False, new_channel, current_channel, str(e))
self.channel_status_label.setStyleSheet(
"color: #ff6666; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Revert radio selection
if current_channel == "nightly":
self.channel_nightly_radio.setChecked(True)
else:
self.channel_stable_radio.setChecked(True)
finally:
# Re-enable radio buttons
self.channel_stable_radio.setEnabled(True)
self.channel_nightly_radio.setEnabled(True)
# Start the thread # Start the thread
thread = threading.Thread(target=switch_channel, daemon=True) thread = threading.Thread(target=switch_channel, daemon=True)
thread.start() thread.start()
@Slot(bool, str, str, str)
def _on_channel_switch_finished(self, success: bool, new_channel: str, current_channel: str, error_msg: str) -> None:
"""Apply the channel-switch outcome to the UI (always on the GUI thread)."""
if success:
self.channel_status_label.setText(_("settings.ytdlp_channel_switched", channel=new_channel))
self.channel_status_label.setStyleSheet(
"color: #00cc00; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
else:
self.channel_status_label.setText(_("settings.ytdlp_channel_switch_failed", error=error_msg))
self.channel_status_label.setStyleSheet(
"color: #ff6666; font-size: 11px; padding: 5px; "
"background-color: #2a2d36; border-radius: 4px; margin: 5px 0;"
)
# Revert radio selection
if current_channel == "nightly":
self.channel_nightly_radio.setChecked(True)
else:
self.channel_stable_radio.setChecked(True)
# Re-enable radio buttons
self.channel_stable_radio.setEnabled(True)
self.channel_nightly_radio.setEnabled(True)
def _update_channel_status(self, channel: str) -> None: def _update_channel_status(self, channel: str) -> None:
"""Update the channel status label.""" """Update the channel status label."""
self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel)) self.channel_status_label.setText(_("settings.ytdlp_current_channel", channel=channel))
+4 -1
View File
@@ -346,7 +346,10 @@ class FormatTableMixin:
checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }") checkbox.setStyleSheet("QCheckBox { margin-left: 8px; }")
checkbox.format_id = f["format_id"] checkbox.format_id = f["format_id"]
checkbox.is_audio_only = f.get("vcodec") == "none" checkbox.is_audio_only = f.get("vcodec") == "none"
checkbox.has_audio = f.get("acodec") != "none" # A missing acodec means unknown, not progressive - treating it as
# has-audio skips the +bestaudio merge and yields silent videos
acodec = f.get("acodec")
checkbox.has_audio = acodec is not None and acodec != "none"
checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb)) checkbox.clicked.connect(lambda checked, cb=checkbox: self.handle_checkbox_click(cb))
self.format_checkboxes.append(checkbox) self.format_checkboxes.append(checkbox)
+9 -5
View File
@@ -1176,12 +1176,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
# Clean up the thread reference and ensure it's properly finished # Clean up the thread reference and ensure it's properly finished
if hasattr(self, "auto_update_thread"): if hasattr(self, "auto_update_thread"):
thread = self.auto_update_thread
# Disconnect all signals to prevent further callbacks # Disconnect all signals to prevent further callbacks
self.auto_update_thread.update_finished.disconnect() thread.update_finished.disconnect()
# Make sure thread is finished # Dropping the Python reference while run() is still unwinding
if self.auto_update_thread.isRunning(): # can destroy a live QThread; let Qt delete it once finished.
self.auto_update_thread.quit() if thread.isRunning():
self.auto_update_thread.wait(1000) # Wait up to 1 second thread.finished.connect(thread.deleteLater)
thread.quit()
else:
thread.deleteLater()
# Remove the reference # Remove the reference
delattr(self, "auto_update_thread") delattr(self, "auto_update_thread")
+6
View File
@@ -7,6 +7,12 @@ from .gui.ytsage_gui_main import YTSageApp # Import the main application class
def show_error_dialog(message): def show_error_dialog(message):
# A QMessageBox needs a live QApplication; if startup failed before (or
# while) creating one, constructing the dialog would abort the process
# and swallow the real error.
if QApplication.instance() is None:
print(f"Application Error: {message}", file=sys.stderr)
return
error_dialog = QMessageBox() error_dialog = QMessageBox()
error_dialog.setIcon(QMessageBox.Icon.Critical) error_dialog.setIcon(QMessageBox.Icon.Critical)
error_dialog.setText("Application Error") error_dialog.setText("Application Error")
+20 -4
View File
@@ -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}")
+4
View File
@@ -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()