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.
This commit is contained in:
28allday 2026-04-21 20:49:29 +01:00
parent bc8dec79f0
commit f8f012e5a5
5 changed files with 150 additions and 24 deletions

View file

@ -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.

67
nocoder/config.py Normal file
View file

@ -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 {}

View file

@ -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

View file

@ -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"

View file

@ -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: