transmute-cli 0.2.3a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
transmute/iphone.py ADDED
@@ -0,0 +1,247 @@
1
+ """Synchronous, bounded AFC access to Spotify's vended Documents directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager, suppress
9
+ from typing import BinaryIO
10
+
11
+ _BUNDLE_ID = "com.spotify.client"
12
+ _DOCUMENTS_ROOT = "/Documents"
13
+ _CHUNK_SIZE = 1024 * 1024
14
+ _DEADLINE_SECONDS = 20.0
15
+ _MAX_ENTRIES = 20_000
16
+ _MAX_DEPTH = 32
17
+ _MAX_PATH_LENGTH = 1024
18
+ _TEMP_COMPONENT = re.compile(r"\.transmute-sync-[0-9a-f]{32}\.partial\Z")
19
+
20
+
21
+ def _sync_error(message: str, cause: BaseException | None = None) -> Exception:
22
+ from .sync import SyncError
23
+
24
+ error = SyncError(message)
25
+ if cause is not None:
26
+ error.__cause__ = cause
27
+ return error
28
+
29
+
30
+ def _safe_relative(path: str) -> str:
31
+ if (
32
+ not isinstance(path, str)
33
+ or not path
34
+ or len(path) > _MAX_PATH_LENGTH
35
+ or path.startswith("/")
36
+ or "\\" in path
37
+ or "\x00" in path
38
+ or any(part in ("", ".", "..") for part in path.split("/"))
39
+ ):
40
+ raise _sync_error("Phone file path is unsafe; preview the phone again.")
41
+ return path
42
+
43
+
44
+ def _remote_path(path: str) -> str:
45
+ return f"{_DOCUMENTS_ROOT}/{_safe_relative(path)}"
46
+
47
+
48
+ def _is_hidden_or_partial(path: str) -> bool:
49
+ parts = path.split("/")
50
+ return any(part.startswith(".") for part in parts) or any(
51
+ part.lower().endswith((".part", ".partial", ".tmp", ".temp")) for part in parts
52
+ )
53
+
54
+
55
+ def _is_owned_temp(path: str) -> bool:
56
+ return "/" not in path and bool(_TEMP_COMPONENT.fullmatch(path))
57
+
58
+
59
+ class _PhoneStore:
60
+ def __init__(self, loop: asyncio.AbstractEventLoop, lockdown: object, service: object, device_id: str, device_name: str) -> None:
61
+ self.device_id = device_id
62
+ self.device_name = device_name
63
+ self._loop = loop
64
+ self._lockdown = lockdown
65
+ self._service = service
66
+
67
+ def _run(self, awaitable: object):
68
+ try:
69
+ return self._loop.run_until_complete(asyncio.wait_for(awaitable, timeout=_DEADLINE_SECONDS))
70
+ except asyncio.TimeoutError as exc:
71
+ raise _sync_error("iPhone USB operation timed out; unlock the phone and retry.", exc) from exc
72
+ except Exception as exc:
73
+ from pymobiledevice3.exceptions import AfcFileNotFoundError
74
+
75
+ from .sync import SyncError
76
+
77
+ if isinstance(exc, (AfcFileNotFoundError, FileNotFoundError, SyncError, FileExistsError)):
78
+ raise
79
+ raise _sync_error("iPhone USB operation failed; unlock and trust the phone, then retry.", exc) from exc
80
+
81
+ def _check_exists(self, path: str) -> bool:
82
+ from pymobiledevice3.exceptions import AfcFileNotFoundError
83
+
84
+ try:
85
+ self._run(self._service.stat(_remote_path(path)))
86
+ return True
87
+ except (AfcFileNotFoundError, FileNotFoundError):
88
+ return False
89
+
90
+ def list_files(self) -> list[str]:
91
+ found: list[str] = []
92
+ visited = 0
93
+
94
+ def walk(relative_directory: str, remote_directory: str, depth: int) -> None:
95
+ nonlocal visited
96
+ if depth > _MAX_DEPTH:
97
+ raise _sync_error("Spotify Documents is nested too deeply to scan safely.")
98
+ names = self._run(self._service.listdir(remote_directory))
99
+ for name in names:
100
+ if not isinstance(name, str) or name in ("", ".", "..") or "/" in name or "\\" in name or "\x00" in name:
101
+ raise _sync_error("Phone returned an unsafe directory entry; scan stopped.")
102
+ relative = f"{relative_directory}/{name}" if relative_directory else name
103
+ relative = _safe_relative(relative)
104
+ visited += 1
105
+ if visited > _MAX_ENTRIES:
106
+ raise _sync_error("Spotify Documents contains too many entries to scan safely.")
107
+ if _is_hidden_or_partial(relative):
108
+ continue
109
+ absolute = f"{remote_directory.rstrip('/')}/{name}"
110
+ try:
111
+ metadata = self._run(self._service.stat(absolute))
112
+ except FileNotFoundError:
113
+ continue
114
+ kind = metadata.get("st_ifmt")
115
+ if kind == "S_IFDIR":
116
+ walk(relative, absolute, depth + 1)
117
+ elif kind == "S_IFREG" and relative.lower().endswith(".mp3"):
118
+ found.append(relative)
119
+
120
+ walk("", _DOCUMENTS_ROOT, 0)
121
+ return found
122
+
123
+ def exists(self, relative_path: str) -> bool:
124
+ return self._check_exists(relative_path)
125
+
126
+ def read_to(self, relative_path: str, sink: BinaryIO) -> None:
127
+ handle = self._run(self._service.fopen(_remote_path(relative_path), "r"))
128
+ try:
129
+ while True:
130
+ chunk = self._run(self._service.fread(handle, _CHUNK_SIZE))
131
+ if not chunk:
132
+ break
133
+ sink.write(chunk)
134
+ finally:
135
+ self._run(self._service.fclose(handle))
136
+
137
+ def write_from(self, relative_path: str, source: BinaryIO) -> None:
138
+ relative_path = _safe_relative(relative_path)
139
+ if not _is_owned_temp(relative_path):
140
+ raise _sync_error("Phone writes are limited to Transmute-owned temporary files.")
141
+ handle = self._run(self._service.fopen(_remote_path(relative_path), "w"))
142
+ try:
143
+ while True:
144
+ chunk = source.read(_CHUNK_SIZE)
145
+ if not chunk:
146
+ break
147
+ self._run(self._service.fwrite(handle, chunk))
148
+ finally:
149
+ self._run(self._service.fclose(handle))
150
+
151
+ def publish(self, temporary_path: str, destination_path: str) -> None:
152
+ temporary_path = _safe_relative(temporary_path)
153
+ destination_path = _safe_relative(destination_path)
154
+ if not _is_owned_temp(temporary_path):
155
+ raise _sync_error("Phone publish source is not a Transmute temporary file.")
156
+ if self._check_exists(destination_path):
157
+ raise FileExistsError(destination_path)
158
+ try:
159
+ from pymobiledevice3.services.afc import AfcLinkType
160
+
161
+ self._run(
162
+ self._service.link(
163
+ _remote_path(temporary_path),
164
+ _remote_path(destination_path),
165
+ type_=AfcLinkType.HARDLINK,
166
+ )
167
+ )
168
+ except FileExistsError:
169
+ raise
170
+ except Exception as exc:
171
+ from pymobiledevice3.services.afc import AfcError
172
+
173
+ from .sync import SyncError
174
+
175
+ if isinstance(exc, SyncError) and getattr(exc.__cause__, "status", None) == AfcError.OBJECT_EXISTS:
176
+ raise FileExistsError(destination_path) from exc
177
+ raise _sync_error("This iPhone cannot publish files atomically; no file was replaced.", exc) from exc
178
+ self.remove(temporary_path)
179
+
180
+ def remove(self, relative_path: str) -> None:
181
+ relative_path = _safe_relative(relative_path)
182
+ if not _is_owned_temp(relative_path):
183
+ raise _sync_error("Phone cleanup is limited to Transmute-owned temporary files.")
184
+ self._run(self._service.rm_single(_remote_path(relative_path)))
185
+
186
+
187
+ @contextmanager
188
+ def open_spotify(device_id: str | None = None) -> Iterator[_PhoneStore]:
189
+ """Open exactly one trusted USB iPhone and vend Spotify Documents only."""
190
+ try:
191
+ from pymobiledevice3.lockdown import create_using_usbmux
192
+ from pymobiledevice3.services.house_arrest import HouseArrestService
193
+ from pymobiledevice3.usbmux import list_devices
194
+ except ImportError as exc:
195
+ raise _sync_error("pymobiledevice3 is missing; reinstall Transmute to restore its iPhone sync support.", exc) from exc
196
+
197
+ loop = asyncio.new_event_loop()
198
+ lockdown = None
199
+ service = None
200
+ try:
201
+ try:
202
+ devices = loop.run_until_complete(asyncio.wait_for(list_devices(), timeout=_DEADLINE_SECONDS))
203
+ except asyncio.TimeoutError as exc:
204
+ raise _sync_error("iPhone USB discovery timed out; connect and unlock the phone, then retry.", exc) from exc
205
+ except Exception as exc:
206
+ raise _sync_error("Could not discover iPhones over USB; reconnect the phone and retry.", exc) from exc
207
+ devices = [device for device in devices if device.is_usb]
208
+ if device_id is not None:
209
+ devices = [device for device in devices if device.serial == device_id]
210
+ if not devices:
211
+ raise _sync_error("The planned iPhone is not connected over USB; reconnect it and preview again.")
212
+ if len(devices) != 1:
213
+ raise _sync_error("The planned iPhone was found more than once; reconnect it and preview again.")
214
+ elif not devices:
215
+ raise _sync_error("No iPhone is connected by USB; connect and unlock your phone, then retry.")
216
+ elif len(devices) != 1:
217
+ raise _sync_error("More than one iPhone is connected; disconnect the other phones and retry.")
218
+ device = devices[0]
219
+ try:
220
+ lockdown = loop.run_until_complete(
221
+ asyncio.wait_for(
222
+ create_using_usbmux(device.serial, autopair=False, connection_type="USB"),
223
+ timeout=_DEADLINE_SECONDS,
224
+ )
225
+ )
226
+ service = loop.run_until_complete(
227
+ asyncio.wait_for(
228
+ HouseArrestService.create(lockdown, bundle_id=_BUNDLE_ID, documents_only=True),
229
+ timeout=_DEADLINE_SECONDS,
230
+ )
231
+ )
232
+ except asyncio.TimeoutError as exc:
233
+ raise _sync_error("Opening Spotify over USB timed out; unlock and reconnect the phone, then retry.", exc) from exc
234
+ except Exception as exc:
235
+ message = "Unlock and trust this iPhone, install Spotify, then reconnect and retry."
236
+ raise _sync_error(message, exc) from exc
237
+ name = str(lockdown.all_values.get("DeviceName") or "iPhone")[:100]
238
+ store = _PhoneStore(loop, lockdown, service, device.serial, name)
239
+ yield store
240
+ finally:
241
+ if service is not None:
242
+ with suppress(Exception):
243
+ loop.run_until_complete(asyncio.wait_for(service.close(), timeout=_DEADLINE_SECONDS))
244
+ if lockdown is not None:
245
+ with suppress(Exception):
246
+ loop.run_until_complete(asyncio.wait_for(lockdown.close(), timeout=_DEADLINE_SECONDS))
247
+ loop.close()
transmute/keys.py ADDED
@@ -0,0 +1,152 @@
1
+ """Key bindings. Takes the App for state access; owns no state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from prompt_toolkit.filters import Condition, has_completions, has_focus
8
+ from prompt_toolkit.key_binding import KeyBindings
9
+
10
+
11
+ def _exit_shortcut() -> str:
12
+ return "CTRL+C"
13
+
14
+
15
+ def build_key_bindings(app) -> KeyBindings:
16
+ kb = KeyBindings()
17
+ # Main input actions remain live while help and the folder editor are shown.
18
+ no_overlay = Condition(
19
+ lambda: app.modal is None and app.dir_picker is None and not app.help_open and not app.sync_open
20
+ )
21
+ has_actionable = Condition(lambda: bool(app._actionable())) & no_overlay
22
+ has_selection = Condition(app.has_selection)
23
+ sel_is_err = (
24
+ Condition(lambda: app._sel_kind() == "err" and not app.input_buffer.text)
25
+ & no_overlay
26
+ )
27
+ picker_confirm = Condition(
28
+ lambda: app.dir_picker is not None and app.dir_picker.stage == "confirm"
29
+ )
30
+ dir_focused = has_focus(app.dir_buffer)
31
+ sync_focused = Condition(
32
+ lambda: app.sync_open
33
+ and app.app.layout.current_control is app.sync_control
34
+ )
35
+
36
+ @kb.add("up", filter=has_actionable)
37
+ def _(event):
38
+ app._move_sel(-1)
39
+
40
+ @kb.add("up", filter=sync_focused)
41
+ def _(event):
42
+ app.sync_move(-1)
43
+
44
+ @kb.add("down", filter=sync_focused)
45
+ def _(event):
46
+ app.sync_move(1)
47
+
48
+ @kb.add("enter", filter=sync_focused, eager=True)
49
+ def _(event):
50
+ app.sync_toggle()
51
+
52
+ @kb.add("a", filter=sync_focused, eager=True)
53
+ def _(event):
54
+ app.sync_select(True)
55
+
56
+ @kb.add("A", filter=sync_focused, eager=True)
57
+ def _(event):
58
+ app.sync_select(False)
59
+
60
+ @kb.add("down", filter=has_actionable & has_selection)
61
+ def _(event):
62
+ app._move_sel(1)
63
+
64
+ @kb.add("y", filter=picker_confirm & dir_focused)
65
+ def _(event):
66
+ app._dir_confirm_create()
67
+
68
+ @kb.add("n", filter=picker_confirm & dir_focused)
69
+ def _(event):
70
+ app._dir_decline_create()
71
+
72
+ @kb.add("escape", eager=True)
73
+ def _(event):
74
+ if app.sync_open:
75
+ app.sync_back()
76
+ return
77
+ if app.dir_picker is not None:
78
+ app.close_dir_picker()
79
+ return
80
+ if app.help_open:
81
+ app.close_help()
82
+ return
83
+ if app.modal:
84
+ app.close_modal()
85
+ return
86
+ app.clear_selection()
87
+
88
+ @kb.add("enter", filter=sel_is_err & has_focus(app.input_buffer))
89
+ def _(event):
90
+ app._retry_selected()
91
+
92
+ @kb.add("c-c")
93
+ def _(event):
94
+ now = time.monotonic()
95
+ if app.dir_picker is not None:
96
+ app.close_dir_picker()
97
+ return
98
+ if app.sync_open:
99
+ app.sync_back()
100
+ return
101
+ if app.help_open:
102
+ app.close_help()
103
+ return
104
+ if app.has_selection():
105
+ app.clear_selection()
106
+ app.clear_input_notice()
107
+ app._last_ctrl_c = now
108
+ return
109
+ if app.input_buffer.text:
110
+ app.input_buffer.reset()
111
+ app.clear_input_notice()
112
+ app._last_ctrl_c = now
113
+ return
114
+ if now - app._last_ctrl_c <= 2.0:
115
+ event.app.exit()
116
+ else:
117
+ app._last_ctrl_c = now
118
+ with app.lock:
119
+ busy = len(app.active) + app.queued
120
+ note = (
121
+ f" ({busy} job{'s' if busy != 1 else ''} still running)" if busy else ""
122
+ )
123
+ app.show_input_notice(
124
+ "class:input.warn",
125
+ f"press {_exit_shortcut()} again to exit{note}",
126
+ duration=2.0,
127
+ )
128
+
129
+ @kb.add("c-d")
130
+ def _(event):
131
+ event.app.exit()
132
+
133
+ @kb.add(
134
+ "tab",
135
+ filter=Condition(lambda: app.dir_picker is not None) & ~has_completions,
136
+ )
137
+ def _(event):
138
+ app.toggle_dir_focus()
139
+
140
+ @kb.add("s-tab", filter=Condition(lambda: app.dir_picker is not None))
141
+ def _(event):
142
+ app.toggle_dir_focus()
143
+
144
+ @kb.add("tab", filter=Condition(lambda: app.sync_open) & ~has_completions)
145
+ def _(event):
146
+ app.toggle_sync_focus()
147
+
148
+ @kb.add("s-tab", filter=Condition(lambda: app.sync_open))
149
+ def _(event):
150
+ app.toggle_sync_focus()
151
+
152
+ return kb
transmute/layout.py ADDED
@@ -0,0 +1,125 @@
1
+ """Window arrangement. Takes the App for state/render callables; owns no state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from prompt_toolkit.buffer import Buffer
8
+ from prompt_toolkit.filters import Condition
9
+ from prompt_toolkit.history import FileHistory
10
+ from prompt_toolkit.layout import (
11
+ ConditionalContainer,
12
+ Float,
13
+ FloatContainer,
14
+ HSplit,
15
+ Layout,
16
+ ScrollablePane,
17
+ Window,
18
+ )
19
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
20
+ from prompt_toolkit.layout.dimension import Dimension
21
+ from prompt_toolkit.layout.menus import CompletionsMenu
22
+
23
+ from .style import HINT_PLACEHOLDER
24
+ from .widgets import HomeDirCompleter, PlaceholderProcessor, ScrollableHelpControl
25
+
26
+
27
+ def build_layout(app, history_file: Path) -> Layout:
28
+ """Create buffers + windows on `app` and return the assembled Layout."""
29
+
30
+ app.input_buffer = Buffer(
31
+ history=FileHistory(str(history_file)),
32
+ multiline=False,
33
+ accept_handler=app._accept,
34
+ enable_history_search=True,
35
+ )
36
+ app.dir_buffer = Buffer(
37
+ multiline=False,
38
+ accept_handler=app._accept,
39
+ completer=HomeDirCompleter(
40
+ lambda: app.dir_picker is not None and app.dir_picker.stage == "input"
41
+ ),
42
+ complete_while_typing=True,
43
+ )
44
+ app.hint_buffer = Buffer(multiline=False, accept_handler=app._accept_hint)
45
+
46
+ input_window = Window(
47
+ BufferControl(
48
+ app.input_buffer,
49
+ input_processors=[PlaceholderProcessor(app._input_placeholder)],
50
+ ),
51
+ height=1,
52
+ get_line_prefix=lambda _line_no, _wrap_count: app._input_prefix(),
53
+ )
54
+ hint_window = Window(
55
+ BufferControl(
56
+ app.hint_buffer,
57
+ input_processors=[PlaceholderProcessor(HINT_PLACEHOLDER)],
58
+ ),
59
+ height=1,
60
+ get_line_prefix=lambda _line_no, _wrap_count: [("class:prompt", " ↳ ")],
61
+ )
62
+ dir_window = Window(
63
+ BufferControl(
64
+ app.dir_buffer,
65
+ input_processors=[PlaceholderProcessor(app._dir_placeholder)],
66
+ ),
67
+ height=1,
68
+ get_line_prefix=lambda _line_no, _wrap_count: app._dir_prefix(),
69
+ )
70
+ app.help_pane = ScrollablePane(
71
+ Window(
72
+ ScrollableHelpControl(app._render_help, app.scroll_help), wrap_lines=True
73
+ )
74
+ )
75
+ app.sync_control = ScrollableHelpControl(app._render_sync, app.sync_scroll, focusable=True)
76
+ app.sync_pane = ScrollablePane(
77
+ Window(app.sync_control, wrap_lines=False), keep_cursor_visible=False
78
+ )
79
+ normal_content = Condition(lambda: not app.help_open and not app.sync_open)
80
+
81
+ root = HSplit(
82
+ [
83
+ Window(FormattedTextControl(app._render_header), height=4),
84
+ Window(height=1, char="─", style="class:rule"),
85
+ ConditionalContainer(
86
+ Window(
87
+ FormattedTextControl(app._render_above),
88
+ height=app._above_height,
89
+ wrap_lines=False,
90
+ ),
91
+ filter=normal_content,
92
+ ),
93
+ ConditionalContainer(
94
+ hint_window,
95
+ filter=normal_content & Condition(lambda: app._sel_kind() == "hint"),
96
+ ),
97
+ ConditionalContainer(
98
+ Window(
99
+ FormattedTextControl(app._render_below),
100
+ height=Dimension(weight=1),
101
+ wrap_lines=False,
102
+ ),
103
+ filter=normal_content,
104
+ ),
105
+ ConditionalContainer(app.help_pane, filter=Condition(lambda: app.help_open)),
106
+ ConditionalContainer(app.sync_pane, filter=Condition(lambda: app.sync_open)),
107
+ Window(height=1, char="─", style="class:rule"),
108
+ ConditionalContainer(
109
+ dir_window, filter=Condition(lambda: app.dir_picker is not None)
110
+ ),
111
+ input_window,
112
+ Window(FormattedTextControl(app._status_line), height=1),
113
+ ]
114
+ )
115
+ root = FloatContainer(
116
+ root,
117
+ floats=[
118
+ Float(
119
+ xcursor=True,
120
+ ycursor=True,
121
+ content=CompletionsMenu(max_height=8, scroll_offset=1),
122
+ )
123
+ ],
124
+ )
125
+ return Layout(root, focused_element=input_window)
transmute/main.py ADDED
@@ -0,0 +1,38 @@
1
+ """Entry point: thin wiring only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from .app import App
10
+
11
+
12
+ def _build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="transmute",
15
+ description="Convert YouTube or SoundCloud links into rich MP3s.",
16
+ epilog=(
17
+ "Run with no arguments to open the interface, then paste links. "
18
+ "Type /help inside it for the command reference."
19
+ ),
20
+ )
21
+ parser.add_argument(
22
+ "--version",
23
+ action="version",
24
+ version=f"transmute {__version__}",
25
+ )
26
+ return parser
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> None:
30
+ """Parse argv, then hand control to the interface.
31
+
32
+ Installed as a console script, so `--version` and `--help` must answer and
33
+ exit before the full-screen interface takes over the terminal.
34
+ """
35
+ _build_parser().parse_args(argv)
36
+ sys.stdout.write("\x1b]0;Transmute\x07") # terminal tab title
37
+ sys.stdout.flush()
38
+ App().run()
transmute/settings.py ADDED
@@ -0,0 +1,114 @@
1
+ """Durable, prompt-toolkit-independent user settings.
2
+
3
+ Settings resolve in one order: a runtime command in the current session
4
+ overrides the value read from this file, which overrides the built-in defaults
5
+ in `Settings`. There is no environment layer for output directory or quality
6
+ yet; a new source belongs in this resolution order rather than in scattered
7
+ `os.environ` reads.
8
+
9
+ The file is versioned JSON written atomically (temporary file plus
10
+ `os.replace`) so an interrupted write cannot corrupt the only saved copy.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+ from .config import QUALITIES, SETTINGS_VERSION, Settings
21
+
22
+
23
+ class SettingsStoreError(RuntimeError):
24
+ """Saved settings could not be read or written safely."""
25
+
26
+
27
+ class SettingsStore:
28
+ """Reads and writes the persisted `Settings` record as versioned JSON."""
29
+
30
+ def __init__(self, path: Path | str) -> None:
31
+ self.path = Path(path).expanduser()
32
+
33
+ def load(self) -> Settings:
34
+ """Return saved settings, or defaults when nothing is stored yet.
35
+
36
+ A missing file is the normal first-run case and yields defaults. A
37
+ present-but-unreadable or schema-incompatible file raises
38
+ `SettingsStoreError` so the caller can warn and fall back to defaults
39
+ without overwriting the unrecognized file.
40
+ """
41
+ try:
42
+ raw = self.path.read_text(encoding="utf-8")
43
+ except FileNotFoundError:
44
+ return Settings()
45
+ except OSError as exc:
46
+ raise SettingsStoreError(
47
+ f"Could not read settings at {self.path}: {exc}"
48
+ ) from exc
49
+
50
+ try:
51
+ data = json.loads(raw)
52
+ except json.JSONDecodeError as exc:
53
+ raise SettingsStoreError(
54
+ f"Settings at {self.path} contain malformed JSON: {exc}"
55
+ ) from exc
56
+ if not isinstance(data, dict):
57
+ raise SettingsStoreError(f"Settings at {self.path} are not a JSON object")
58
+
59
+ version = data.get("version")
60
+ if version != SETTINGS_VERSION:
61
+ raise SettingsStoreError(
62
+ f"Settings at {self.path} use unsupported schema version "
63
+ f"{version!r}; expected {SETTINGS_VERSION}"
64
+ )
65
+
66
+ return Settings(
67
+ out_dir=_resolve_out_dir(data.get("out_dir")),
68
+ quality=_resolve_quality(data.get("quality")),
69
+ )
70
+
71
+ def save(self, settings: Settings) -> None:
72
+ """Persist settings atomically with owner-only permissions."""
73
+ payload = {
74
+ "version": SETTINGS_VERSION,
75
+ "out_dir": str(settings.out_dir),
76
+ "quality": settings.quality,
77
+ }
78
+ text = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
79
+ try:
80
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
81
+ descriptor, tmp_name = tempfile.mkstemp(
82
+ dir=self.path.parent, prefix=".settings-", suffix=".tmp"
83
+ )
84
+ tmp_path = Path(tmp_name)
85
+ try:
86
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
87
+ handle.write(text)
88
+ handle.flush()
89
+ os.fsync(handle.fileno())
90
+ tmp_path.chmod(0o600)
91
+ os.replace(tmp_path, self.path)
92
+ except OSError:
93
+ tmp_path.unlink(missing_ok=True)
94
+ raise
95
+ except OSError as exc:
96
+ raise SettingsStoreError(
97
+ f"Could not save settings at {self.path}: {exc}"
98
+ ) from exc
99
+
100
+
101
+ def _resolve_out_dir(value: object) -> Path:
102
+ if value is None:
103
+ return Settings().out_dir
104
+ if not isinstance(value, str) or not value:
105
+ raise SettingsStoreError(f"invalid output directory: {value!r}")
106
+ return Path(value).expanduser()
107
+
108
+
109
+ def _resolve_quality(value: object) -> str:
110
+ if value is None:
111
+ return Settings().quality
112
+ if value not in QUALITIES:
113
+ raise SettingsStoreError(f"unsupported quality: {value!r}")
114
+ return value