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: