From f8f012e5a517f29210b307064719ed2804a8b975 Mon Sep 17 00:00:00 2001 From: 28allday Date: Tue, 21 Apr 2026 20:49:29 +0100 Subject: [PATCH] Persist user settings across launches Profile, naming, output folder, audio bit depth and auto-reveal toggle now round-trip via ~/.config/nocoder/config.json instead of resetting to defaults on every launch. Alpha is deliberately NOT persisted because the toggle is conditional on the chosen profile and would create more confusion than value when restored from a stale session. * New nocoder/config.py owns the JSON file with read-modify-write merging so multiple writers (hwaccel.py + UI prefs) don't clobber each other. Atomic write via tempfile + os.replace. * hwaccel.py refactored to use the shared load_config / update_config helpers; CONFIG_PATH still re-exported for backward compat. * settings_pane.Settings gains to_persistable() that returns the subset to round-trip; new load_persisted_settings() validates each field against its allowed range and falls back to defaults. * window.py loads settings on startup and persists them every time the settings-changed signal fires (which already covers the folder picker via set_output_folder). Verified: round-trip works, validation rejects bogus values cleanly, hwaccel survives the merge. --- README.md | 16 ++++++++-- nocoder/config.py | 67 ++++++++++++++++++++++++++++++++++++++++ nocoder/hwaccel.py | 30 ++++++------------ nocoder/settings_pane.py | 52 +++++++++++++++++++++++++++++++ nocoder/window.py | 9 ++++-- 5 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 nocoder/config.py diff --git a/README.md b/README.md index 87a294f..8335fcc 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,23 @@ Removes the installed app tree, launcher, desktop entry, all six icon sizes, the ## Configuration -`~/.config/nocoder/config.json` — currently just `{"hwaccel": "cuda" | "qsv" | "vaapi" | "none"}`. Edit by hand to override the auto-probed choice. +`~/.config/nocoder/config.json` is read at launch and merged on every settings change. All keys are optional: + +```json +{ + "hwaccel": "cuda" | "qsv" | "vaapi" | "none", + "profile": "proxy" | "lt" | "standard" | "hq" | "4444" | "4444xq", + "naming": "keep" | "suffix", + "out_dir": "/absolute/path/to/output/folder", + "audio_bits": 16 | 24, + "auto_reveal": true | false +} +``` + +You can edit by hand to override the auto-probed `hwaccel` or to seed defaults; everything else just reflects what you've picked in the UI most recently. ## Known gaps -- No persistence for last-used output folder / profile (resets to defaults each launch). - "Reveal in Files" opens the output folder but doesn't *select* the specific file. - Per-row remove button isn't keyboard-accessible (mouse-hover only, by design — keeps tab order clean). - No live theme-change pickup — theme swaps apply on next launch, not immediately. diff --git a/nocoder/config.py b/nocoder/config.py new file mode 100644 index 0000000..e66289f --- /dev/null +++ b/nocoder/config.py @@ -0,0 +1,67 @@ +"""Shared user-config file at ``$XDG_CONFIG_HOME/nocoder/config.json``. + +Multiple parts of the app persist values here (hwaccel pick from +``hwaccel.py``; UI prefs from the settings pane). To avoid one writer +clobbering another's keys, every write goes through ``update_config()`` which +reads the current contents, merges in the updates, and writes back atomically. + +Schema (all keys optional): + { + "hwaccel": "cuda" | "qsv" | "vaapi" | "none", + "out_dir": "/home/.../Footage/prores", + "profile": "hq", + "naming": "suffix" | "keep", + "audio_bits": 16 | 24, + "auto_reveal": false + } +""" +from __future__ import annotations + +import json +import os +import threading +from pathlib import Path +from typing import Any + +CONFIG_PATH = ( + Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) + / "nocoder" + / "config.json" +) + +# Serialise reads + writes from any thread (settings panel runs on the GTK +# main loop; hwaccel.get_hwaccel can run from an encode worker on the first +# probe). All access goes through one of the public functions below. +_lock = threading.Lock() + + +def load_config() -> dict: + """Return the persisted config as a dict, or {} if missing/corrupt.""" + with _lock: + return _read_locked() + + +def update_config(updates: dict) -> None: + """Read-modify-write: merge `updates` into the persisted config.""" + if not updates: + return + with _lock: + data = _read_locked() + data.update(updates) + try: + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = CONFIG_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n") + tmp.replace(CONFIG_PATH) # atomic on POSIX + except OSError: + pass + + +def _read_locked() -> dict: + if not CONFIG_PATH.exists(): + return {} + try: + data = json.loads(CONFIG_PATH.read_text()) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} diff --git a/nocoder/hwaccel.py b/nocoder/hwaccel.py index b90d08b..b4fce63 100644 --- a/nocoder/hwaccel.py +++ b/nocoder/hwaccel.py @@ -11,18 +11,15 @@ config is missing, the first encode will lazily re-probe and cache. """ from __future__ import annotations -import json -import os import subprocess import threading -from pathlib import Path from typing import Optional -CONFIG_PATH = ( - Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) - / "nocoder" - / "config.json" -) +from .config import load_config, update_config + +# Re-exported for backward compatibility — users may have referenced this +# constant. Kept as a thin alias to the shared config module. +from .config import CONFIG_PATH # noqa: F401 # Ordered by vendor preference: NVIDIA > Intel > AMD/generic. ffmpeg silently # falls back to CPU decode when the source codec can't be GPU-decoded (MJPEG, @@ -65,11 +62,7 @@ def probe_best_hwaccel() -> Optional[str]: def save_hwaccel(hw: Optional[str]) -> None: """Persist the selected hwaccel. ``None`` means CPU decode.""" - try: - CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - CONFIG_PATH.write_text(json.dumps({"hwaccel": hw or "none"}, indent=2) + "\n") - except OSError: - pass + update_config({"hwaccel": hw or "none"}) class _Sentinel: @@ -77,14 +70,11 @@ class _Sentinel: def _read_configured_hwaccel(): - """Return the stored hwaccel, None (CPU), or MISSING (no config yet).""" - if not CONFIG_PATH.exists(): + """Return the stored hwaccel, None (CPU), or MISSING (no entry yet).""" + data = load_config() + if "hwaccel" not in data: return _Sentinel.MISSING - try: - data = json.loads(CONFIG_PATH.read_text()) - except (OSError, json.JSONDecodeError): - return _Sentinel.MISSING - hw = data.get("hwaccel") + hw = data["hwaccel"] if hw in (None, "", "none"): return None return hw if hw in _CANDIDATES else None diff --git a/nocoder/settings_pane.py b/nocoder/settings_pane.py index 676c9e7..db72877 100644 --- a/nocoder/settings_pane.py +++ b/nocoder/settings_pane.py @@ -15,6 +15,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("GObject", "2.0") from gi.repository import GObject, Gtk, Pango +from .config import load_config from .data import PROFILES, PROFILES_BY_ID from .encoder import format_preview_command @@ -69,6 +70,57 @@ class Settings: self.audio_bits, self.auto_reveal, ) + def to_persistable(self) -> dict: + """Subset of fields that survives across launches. + + Excludes `alpha` because the alpha toggle is conditional on the + chosen profile (it auto-clears when a non-4444 profile is selected), + so persisting it across launches creates more confusion than value. + """ + return { + "profile": self.profile, + "naming": self.naming, + "out_dir": self.out_dir, + "audio_bits": self.audio_bits, + "auto_reveal": self.auto_reveal, + } + + +def load_persisted_settings() -> Settings: + """Construct a Settings populated from `~/.config/nocoder/config.json`. + + Each field is validated against its allowed range; an out-of-range or + missing entry falls back to the Settings constructor default. + """ + data = load_config() + + profile = data.get("profile") + if profile not in PROFILES_BY_ID: + profile = "hq" + + naming = data.get("naming") + if naming not in ("keep", "suffix"): + naming = "suffix" + + audio_bits = data.get("audio_bits") + if audio_bits not in (16, 24): + audio_bits = 16 + + out_dir = data.get("out_dir") + if not isinstance(out_dir, str) or not out_dir.strip(): + out_dir = "" + + auto_reveal = bool(data.get("auto_reveal", False)) + + return Settings( + profile=profile, + alpha=False, + naming=naming, + out_dir=out_dir, + audio_bits=audio_bits, + auto_reveal=auto_reveal, + ) + class SettingsPane(Gtk.Box): __gtype_name__ = "NoCoderSettingsPane" diff --git a/nocoder/window.py b/nocoder/window.py index bdfa618..1bfda9f 100644 --- a/nocoder/window.py +++ b/nocoder/window.py @@ -39,7 +39,8 @@ from .encoder import ( ) from .footer import Footer from .queue_pane import FileEntry, QueuePane -from .settings_pane import Settings, SettingsPane +from .config import update_config +from .settings_pane import Settings, SettingsPane, load_persisted_settings WINDOW_WIDTH = 1280 WINDOW_HEIGHT = 880 @@ -65,7 +66,7 @@ class MainWindow(Adw.ApplicationWindow): self._selected_id: Optional[str] = None self._state: str = "empty" self._encoder_kind = detect_prores_encoder() - self._settings = Settings() + self._settings = load_persisted_settings() self._ensure_out_dir() self._encode_thread: Optional[threading.Thread] = None self._cancel_event: Optional[threading.Event] = None @@ -279,6 +280,10 @@ class MainWindow(Adw.ApplicationWindow): overall=self._overall_progress(), current_idx=self._current_idx, ) + # Persist the new state across launches. Cheap (single small JSON + # file write) and idempotent — if nothing actually changed we still + # round-trip the same dict, no harm. + update_config(self._settings.to_persistable()) def _ensure_out_dir(self) -> None: if not self._settings.out_dir: