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:
parent
bc8dec79f0
commit
f8f012e5a5
5 changed files with 150 additions and 24 deletions
16
README.md
16
README.md
|
|
@ -68,11 +68,23 @@ Removes the installed app tree, launcher, desktop entry, all six icon sizes, the
|
||||||
|
|
||||||
## Configuration
|
## 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
|
## 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.
|
- "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).
|
- 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.
|
- No live theme-change pickup — theme swaps apply on next launch, not immediately.
|
||||||
|
|
|
||||||
67
nocoder/config.py
Normal file
67
nocoder/config.py
Normal 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 {}
|
||||||
|
|
@ -11,18 +11,15 @@ config is missing, the first encode will lazily re-probe and cache.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
CONFIG_PATH = (
|
from .config import load_config, update_config
|
||||||
Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config"))
|
|
||||||
/ "nocoder"
|
# Re-exported for backward compatibility — users may have referenced this
|
||||||
/ "config.json"
|
# 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
|
# 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,
|
# 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:
|
def save_hwaccel(hw: Optional[str]) -> None:
|
||||||
"""Persist the selected hwaccel. ``None`` means CPU decode."""
|
"""Persist the selected hwaccel. ``None`` means CPU decode."""
|
||||||
try:
|
update_config({"hwaccel": hw or "none"})
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class _Sentinel:
|
class _Sentinel:
|
||||||
|
|
@ -77,14 +70,11 @@ class _Sentinel:
|
||||||
|
|
||||||
|
|
||||||
def _read_configured_hwaccel():
|
def _read_configured_hwaccel():
|
||||||
"""Return the stored hwaccel, None (CPU), or MISSING (no config yet)."""
|
"""Return the stored hwaccel, None (CPU), or MISSING (no entry yet)."""
|
||||||
if not CONFIG_PATH.exists():
|
data = load_config()
|
||||||
|
if "hwaccel" not in data:
|
||||||
return _Sentinel.MISSING
|
return _Sentinel.MISSING
|
||||||
try:
|
hw = data["hwaccel"]
|
||||||
data = json.loads(CONFIG_PATH.read_text())
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return _Sentinel.MISSING
|
|
||||||
hw = data.get("hwaccel")
|
|
||||||
if hw in (None, "", "none"):
|
if hw in (None, "", "none"):
|
||||||
return None
|
return None
|
||||||
return hw if hw in _CANDIDATES else None
|
return hw if hw in _CANDIDATES else None
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("GObject", "2.0")
|
gi.require_version("GObject", "2.0")
|
||||||
from gi.repository import GObject, Gtk, Pango
|
from gi.repository import GObject, Gtk, Pango
|
||||||
|
|
||||||
|
from .config import load_config
|
||||||
from .data import PROFILES, PROFILES_BY_ID
|
from .data import PROFILES, PROFILES_BY_ID
|
||||||
from .encoder import format_preview_command
|
from .encoder import format_preview_command
|
||||||
|
|
||||||
|
|
@ -69,6 +70,57 @@ class Settings:
|
||||||
self.audio_bits, self.auto_reveal,
|
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):
|
class SettingsPane(Gtk.Box):
|
||||||
__gtype_name__ = "NoCoderSettingsPane"
|
__gtype_name__ = "NoCoderSettingsPane"
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ from .encoder import (
|
||||||
)
|
)
|
||||||
from .footer import Footer
|
from .footer import Footer
|
||||||
from .queue_pane import FileEntry, QueuePane
|
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_WIDTH = 1280
|
||||||
WINDOW_HEIGHT = 880
|
WINDOW_HEIGHT = 880
|
||||||
|
|
@ -65,7 +66,7 @@ class MainWindow(Adw.ApplicationWindow):
|
||||||
self._selected_id: Optional[str] = None
|
self._selected_id: Optional[str] = None
|
||||||
self._state: str = "empty"
|
self._state: str = "empty"
|
||||||
self._encoder_kind = detect_prores_encoder()
|
self._encoder_kind = detect_prores_encoder()
|
||||||
self._settings = Settings()
|
self._settings = load_persisted_settings()
|
||||||
self._ensure_out_dir()
|
self._ensure_out_dir()
|
||||||
self._encode_thread: Optional[threading.Thread] = None
|
self._encode_thread: Optional[threading.Thread] = None
|
||||||
self._cancel_event: Optional[threading.Event] = None
|
self._cancel_event: Optional[threading.Event] = None
|
||||||
|
|
@ -279,6 +280,10 @@ class MainWindow(Adw.ApplicationWindow):
|
||||||
overall=self._overall_progress(),
|
overall=self._overall_progress(),
|
||||||
current_idx=self._current_idx,
|
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:
|
def _ensure_out_dir(self) -> None:
|
||||||
if not self._settings.out_dir:
|
if not self._settings.out_dir:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue