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/__init__.py +1 -0
- transmute/__main__.py +4 -0
- transmute/app.py +1481 -0
- transmute/commands.py +321 -0
- transmute/config.py +21 -0
- transmute/downloader.py +190 -0
- transmute/enrich.py +865 -0
- transmute/history.py +858 -0
- transmute/iphone.py +247 -0
- transmute/keys.py +152 -0
- transmute/layout.py +125 -0
- transmute/main.py +38 -0
- transmute/settings.py +114 -0
- transmute/style.py +32 -0
- transmute/sync.py +532 -0
- transmute/widgets.py +86 -0
- transmute_cli-0.2.3a0.dist-info/METADATA +335 -0
- transmute_cli-0.2.3a0.dist-info/RECORD +21 -0
- transmute_cli-0.2.3a0.dist-info/WHEEL +4 -0
- transmute_cli-0.2.3a0.dist-info/entry_points.txt +2 -0
- transmute_cli-0.2.3a0.dist-info/licenses/LICENSE +674 -0
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
|