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/app.py
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
"""Application state, rendering, and the background download/enrich pipeline.
|
|
2
|
+
|
|
3
|
+
Layout lives in layout.py, key bindings in keys.py, slash commands in
|
|
4
|
+
commands.py; this module owns the shared state they all act on.
|
|
5
|
+
|
|
6
|
+
History entries that failed or need a hint are navigable with ↑/↓: failed
|
|
7
|
+
entries retry on Enter; low-confidence entries open an inline hint input.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import shutil
|
|
14
|
+
import threading
|
|
15
|
+
from collections import deque
|
|
16
|
+
from concurrent.futures import Executor, ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass, replace
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Literal
|
|
20
|
+
|
|
21
|
+
from prompt_toolkit.application import Application
|
|
22
|
+
from prompt_toolkit.buffer import Buffer
|
|
23
|
+
from prompt_toolkit.document import Document
|
|
24
|
+
from prompt_toolkit.filters import Condition
|
|
25
|
+
from prompt_toolkit.layout.dimension import Dimension
|
|
26
|
+
|
|
27
|
+
from .commands import HELP_ROWS, Commands
|
|
28
|
+
from .config import (
|
|
29
|
+
ACTIVITY_FILE,
|
|
30
|
+
HISTORY_FILE,
|
|
31
|
+
MAX_WORKERS,
|
|
32
|
+
SETTINGS_FILE,
|
|
33
|
+
SIGNIN_RECHECK_SECONDS,
|
|
34
|
+
STATE_DIR,
|
|
35
|
+
Settings,
|
|
36
|
+
)
|
|
37
|
+
from .downloader import Job, download_job, extract_urls, is_supported_url
|
|
38
|
+
from .enrich import Enricher, TrackTags, apply_tags
|
|
39
|
+
from .history import ActivityStore, HistoryStoreError, StoredJob
|
|
40
|
+
from .keys import build_key_bindings
|
|
41
|
+
from .layout import build_layout
|
|
42
|
+
from .settings import SettingsStore, SettingsStoreError
|
|
43
|
+
from .style import HELP_HINT, HISTORY_SHOWN, MESSAGES_SHOWN, PLACEHOLDER, STYLE, TAGLINE
|
|
44
|
+
from .widgets import Modal
|
|
45
|
+
|
|
46
|
+
EntryKind = Literal["info", "ok", "err", "hint"]
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from .sync import SyncPlan, SyncProgress, SyncResult, SyncService
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Entry:
|
|
54
|
+
style: str
|
|
55
|
+
line: str
|
|
56
|
+
kind: EntryKind = "info"
|
|
57
|
+
job: Job | None = None
|
|
58
|
+
detail: str | None = None # indented metadata rendered under `line`
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class DirPicker:
|
|
63
|
+
"""Interactive output-directory picker driven from /out (no-arg form).
|
|
64
|
+
|
|
65
|
+
The directory buffer holds only the path relative to ~/ (the "~/" is a fixed,
|
|
66
|
+
uneditable prompt prefix). `stage` is "input" while a path is being typed
|
|
67
|
+
and "confirm" while asking y/n to create a folder that doesn't exist yet.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
stage: str = "input" # input | confirm
|
|
71
|
+
typed: str = "" # relative path the create prompt is asking about
|
|
72
|
+
pending: Path | None = None # absolute path awaiting create confirmation
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class App:
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
history_file: Path = HISTORY_FILE,
|
|
80
|
+
pool: Executor | None = None,
|
|
81
|
+
activity_store: ActivityStore | None = None,
|
|
82
|
+
settings_store: SettingsStore | None = None,
|
|
83
|
+
sync_service: SyncService | None = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.enricher = Enricher()
|
|
86
|
+
self.completed: list[Job] = []
|
|
87
|
+
self.failed: list[Job] = []
|
|
88
|
+
self.history: list[Entry] = []
|
|
89
|
+
self.messages: deque[tuple[str, str]] = deque(maxlen=MESSAGES_SHOWN)
|
|
90
|
+
self.pool = pool or ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
|
91
|
+
self.sync_service = sync_service
|
|
92
|
+
self.sync_plan: SyncPlan | None = None
|
|
93
|
+
self.sync_results: dict[str, SyncResult] = {}
|
|
94
|
+
self.sync_selected: set[str] = set()
|
|
95
|
+
self.sync_cursor = 0
|
|
96
|
+
self.sync_status = ""
|
|
97
|
+
self.sync_open = False
|
|
98
|
+
self.sync_focus = False
|
|
99
|
+
self.sync_busy = False
|
|
100
|
+
self.sync_transfer_active = False
|
|
101
|
+
self.sync_cancel_requested = False
|
|
102
|
+
self.sync_pane = None
|
|
103
|
+
self.lock = threading.Lock()
|
|
104
|
+
self.activity_lock = threading.RLock()
|
|
105
|
+
self.activity_store: ActivityStore | None = activity_store
|
|
106
|
+
self.session_id = ""
|
|
107
|
+
self._run_outcomes: dict[str, str] = {}
|
|
108
|
+
self._persistence_warning_shown = False
|
|
109
|
+
self.settings_store = (
|
|
110
|
+
settings_store if settings_store is not None else SettingsStore(SETTINGS_FILE)
|
|
111
|
+
)
|
|
112
|
+
self.settings = self._load_settings()
|
|
113
|
+
self.active: dict[int, str] = {}
|
|
114
|
+
self._active_paths: dict[int, Path | None] = {}
|
|
115
|
+
self._queued_jobs: list[Job] = []
|
|
116
|
+
self.queued = 0
|
|
117
|
+
self.sel: int | None = None
|
|
118
|
+
self.modal: Modal | None = None
|
|
119
|
+
self.dir_picker: DirPicker | None = None
|
|
120
|
+
self.help_open = False
|
|
121
|
+
self.input_notice: tuple[str, str] | None = None
|
|
122
|
+
self._input_notice_id = 0
|
|
123
|
+
self._seq = 0
|
|
124
|
+
self._last_ctrl_c = 0.0
|
|
125
|
+
self._prepare_input_history(history_file)
|
|
126
|
+
self._restore_activity()
|
|
127
|
+
self.commands = Commands(self)
|
|
128
|
+
layout = build_layout(self, history_file)
|
|
129
|
+
self.app = Application(
|
|
130
|
+
layout=layout,
|
|
131
|
+
key_bindings=build_key_bindings(self),
|
|
132
|
+
style=STYLE,
|
|
133
|
+
full_screen=True,
|
|
134
|
+
mouse_support=Condition(lambda: self.help_open or self.sync_open),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _prepare_input_history(path: Path) -> None:
|
|
139
|
+
"""Create the prompt-recall file with private local permissions."""
|
|
140
|
+
path = Path(path).expanduser()
|
|
141
|
+
parent_was_created = not path.parent.exists()
|
|
142
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
143
|
+
if parent_was_created or (
|
|
144
|
+
path.parent == STATE_DIR and not path.parent.is_symlink()
|
|
145
|
+
):
|
|
146
|
+
path.parent.chmod(0o700)
|
|
147
|
+
path.touch(mode=0o600, exist_ok=True)
|
|
148
|
+
path.chmod(0o600)
|
|
149
|
+
|
|
150
|
+
def _restore_activity(self) -> None:
|
|
151
|
+
"""Restore durable job outcomes before the first UI render."""
|
|
152
|
+
if self.activity_store is None:
|
|
153
|
+
try:
|
|
154
|
+
self.activity_store = ActivityStore(ACTIVITY_FILE)
|
|
155
|
+
except HistoryStoreError as exc:
|
|
156
|
+
self._warn_persistence(exc)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
self.session_id = self.activity_store.start_session()
|
|
161
|
+
records = self.activity_store.load_jobs()
|
|
162
|
+
except HistoryStoreError as exc:
|
|
163
|
+
self._warn_persistence(exc)
|
|
164
|
+
self.activity_store = None
|
|
165
|
+
self.session_id = ""
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
for record in records:
|
|
169
|
+
self._restore_record(record)
|
|
170
|
+
|
|
171
|
+
def _restore_record(self, record: StoredJob) -> None:
|
|
172
|
+
job = record.job
|
|
173
|
+
if job.status == "done":
|
|
174
|
+
self.completed.append(job)
|
|
175
|
+
self.history.append(self._done_entry(job, record.detail))
|
|
176
|
+
if (
|
|
177
|
+
record.needs_hint
|
|
178
|
+
and not record.hint_in_progress
|
|
179
|
+
and job.path
|
|
180
|
+
and job.path.is_file()
|
|
181
|
+
):
|
|
182
|
+
self.history.append(
|
|
183
|
+
self._hint_entry(job, again=record.hint_attempts > 0)
|
|
184
|
+
)
|
|
185
|
+
elif job.status == "error":
|
|
186
|
+
self.failed.append(job)
|
|
187
|
+
self.history.append(self._failure_entry(job))
|
|
188
|
+
|
|
189
|
+
def _warn_persistence(self, error: Exception) -> None:
|
|
190
|
+
with self.lock:
|
|
191
|
+
if self._persistence_warning_shown:
|
|
192
|
+
return
|
|
193
|
+
self._persistence_warning_shown = True
|
|
194
|
+
self.messages.append(
|
|
195
|
+
(
|
|
196
|
+
"class:warn",
|
|
197
|
+
f"⚠ activity history unavailable for this run: {str(error)[:100]}",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
self.refresh()
|
|
201
|
+
|
|
202
|
+
def _persist(self, method: str, *args, **kwargs):
|
|
203
|
+
store = self.activity_store
|
|
204
|
+
if store is None:
|
|
205
|
+
return None
|
|
206
|
+
try:
|
|
207
|
+
return getattr(store, method)(*args, **kwargs)
|
|
208
|
+
except HistoryStoreError as exc:
|
|
209
|
+
# Avoid repeatedly stalling the UI on the same unavailable store.
|
|
210
|
+
# This run continues in memory; the next launch will try again.
|
|
211
|
+
self.activity_store = None
|
|
212
|
+
self._warn_persistence(exc)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def _load_settings(self) -> Settings:
|
|
216
|
+
"""Restore saved settings, falling back to defaults if the file is bad."""
|
|
217
|
+
try:
|
|
218
|
+
return self.settings_store.load()
|
|
219
|
+
except SettingsStoreError as exc:
|
|
220
|
+
self.msg("class:warn", f"⚠ saved settings ignored: {str(exc)[:100]}")
|
|
221
|
+
return Settings()
|
|
222
|
+
|
|
223
|
+
def _persist_settings(self) -> None:
|
|
224
|
+
"""Write the current settings; a failure warns but is not fatal."""
|
|
225
|
+
with self.lock:
|
|
226
|
+
settings = self.settings
|
|
227
|
+
try:
|
|
228
|
+
self.settings_store.save(settings)
|
|
229
|
+
except SettingsStoreError as exc:
|
|
230
|
+
self.msg("class:warn", f"⚠ couldn't save settings: {str(exc)[:100]}")
|
|
231
|
+
|
|
232
|
+
# ── rendering ───────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _cols() -> int:
|
|
236
|
+
from prompt_toolkit.application.current import get_app
|
|
237
|
+
|
|
238
|
+
return get_app().output.get_size().columns
|
|
239
|
+
|
|
240
|
+
def _render_header(self):
|
|
241
|
+
provider_line, provider_style = self._provider_connection_line()
|
|
242
|
+
return [
|
|
243
|
+
("", "\n"),
|
|
244
|
+
("class:header.bar", "▌ "),
|
|
245
|
+
("class:header.title", "Transmute\n"),
|
|
246
|
+
("class:header.bar", "▌ "),
|
|
247
|
+
("class:header.desc", TAGLINE + "\n"),
|
|
248
|
+
(provider_style, " " + provider_line + "\n"),
|
|
249
|
+
]
|
|
250
|
+
|
|
251
|
+
def _provider_connection_line(self) -> tuple[str, str]:
|
|
252
|
+
states, backend, has_api_key = self.enricher.connection_snapshot()
|
|
253
|
+
if states.get("codex") == "signed_in":
|
|
254
|
+
return "Codex is connected.", "class:ok"
|
|
255
|
+
if states.get("claude") == "signed_in":
|
|
256
|
+
return "Claude is connected.", "class:ok"
|
|
257
|
+
if has_api_key:
|
|
258
|
+
label = "OpenAI API" if backend == "openai_api" else "Anthropic API"
|
|
259
|
+
return f"{label} is connected.", "class:ok"
|
|
260
|
+
|
|
261
|
+
provider = next(
|
|
262
|
+
(name for name in ("codex", "claude") if states.get(name) != "missing"),
|
|
263
|
+
None,
|
|
264
|
+
)
|
|
265
|
+
command = f"/login {provider}" if provider else "/key"
|
|
266
|
+
return (
|
|
267
|
+
f"No provider is connected. Get started with {command}.",
|
|
268
|
+
"class:err",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _render_help(self):
|
|
272
|
+
"""Render the scrollable command reference above the live prompt."""
|
|
273
|
+
out = [("class:subsection", "\n Commands\n")]
|
|
274
|
+
for cmd, desc in HELP_ROWS:
|
|
275
|
+
out.append(("class:dim", f" {cmd:<34}{desc}\n"))
|
|
276
|
+
with self.lock:
|
|
277
|
+
msgs = list(self.messages)
|
|
278
|
+
if msgs:
|
|
279
|
+
out.append(("class:subsection", "\n Recent feedback\n"))
|
|
280
|
+
out.extend((style, f" {line[:72]}\n") for style, line in msgs[-4:])
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
def _render_sync(self) -> list[tuple[str, str]]:
|
|
284
|
+
"""Format a locked snapshot of the read-only plan and transfer results."""
|
|
285
|
+
with self.lock:
|
|
286
|
+
plan = self.sync_plan
|
|
287
|
+
results = dict(self.sync_results)
|
|
288
|
+
selected = set(self.sync_selected)
|
|
289
|
+
cursor = self.sync_cursor
|
|
290
|
+
status = self.sync_status
|
|
291
|
+
busy = self.sync_busy
|
|
292
|
+
copying = self.sync_transfer_active
|
|
293
|
+
out = [("class:subsection", " Spotify sync preview\n")]
|
|
294
|
+
if status:
|
|
295
|
+
out.append(("class:accent", f" {status[:100]}\n"))
|
|
296
|
+
if plan is None:
|
|
297
|
+
message = (
|
|
298
|
+
"Scanning output folder and Spotify…"
|
|
299
|
+
if busy
|
|
300
|
+
else "No preview available. Reconnect the iPhone and run /sync."
|
|
301
|
+
)
|
|
302
|
+
out.append(("class:dim", f" {message}\n"))
|
|
303
|
+
return out
|
|
304
|
+
out.append(("class:dim", f" {plan.device_name} · {plan.out_dir}\n"))
|
|
305
|
+
items = list(plan.items)
|
|
306
|
+
for index, item in enumerate(items):
|
|
307
|
+
result = results.get(item.id)
|
|
308
|
+
selected_mark = "[x]" if item.id in selected else "[ ]"
|
|
309
|
+
if item.direction == "matched":
|
|
310
|
+
label, style = "Already synced", "class:dim"
|
|
311
|
+
elif item.confidence == "low":
|
|
312
|
+
direction = "To iPhone" if item.direction == "to_phone" else "To computer"
|
|
313
|
+
label, style = f"{direction} ?", "class:dim"
|
|
314
|
+
else:
|
|
315
|
+
label = "To iPhone" if item.direction == "to_phone" else "To computer"
|
|
316
|
+
style = "class:dim" if result and result.status == "done" else "class:accent"
|
|
317
|
+
if result:
|
|
318
|
+
result_label = {"done": "Copied", "error": "Failed", "cancelled": "Cancelled"}.get(result.status, label)
|
|
319
|
+
if item.confidence == "low":
|
|
320
|
+
label = f"{result_label} {direction} ?"
|
|
321
|
+
style = "class:dim"
|
|
322
|
+
elif item.direction == "matched":
|
|
323
|
+
style = "class:dim"
|
|
324
|
+
else:
|
|
325
|
+
label = f"{result_label} {label}"
|
|
326
|
+
style = "class:ok" if result.status == "done" else "class:err"
|
|
327
|
+
marker = "❯" if index == cursor else " "
|
|
328
|
+
name = item.track.relative_path.rsplit("/", 1)[-1]
|
|
329
|
+
out.append((style, f"{marker} {selected_mark} {label}: {name[:60]}\n"))
|
|
330
|
+
details = []
|
|
331
|
+
if item.confidence == "low":
|
|
332
|
+
details.append("? Possible duplicate/conflict")
|
|
333
|
+
if item.reason:
|
|
334
|
+
details.append(item.reason[:56])
|
|
335
|
+
if result and result.status != "done":
|
|
336
|
+
details.append(f"{result.status}: {result.message[:80]}")
|
|
337
|
+
if details:
|
|
338
|
+
detail_style = "class:dim" if item.confidence == "low" else "class:err"
|
|
339
|
+
out.append((detail_style, f" {' · '.join(details)}\n"))
|
|
340
|
+
continue_index = len(items)
|
|
341
|
+
marker = "❯" if cursor == continue_index else " "
|
|
342
|
+
enabled = bool(selected) and not busy
|
|
343
|
+
suffix = " (sync running)" if busy else ("" if enabled else " (select tracks first)")
|
|
344
|
+
out.append(("class:accent" if enabled else "class:dim", f"{marker} Continue{suffix}\n"))
|
|
345
|
+
escape_hint = "Esc stops after this file" if copying else "Esc closes"
|
|
346
|
+
out.append(("class:dim", f" ↑/↓ move · Enter select/continue · a high confidence · A all · Tab input · {escape_hint}\n"))
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
def _sync_excluded_paths(self) -> tuple[Path, ...]:
|
|
350
|
+
with self.lock:
|
|
351
|
+
paths = [path for path in self._active_paths.values() if path is not None]
|
|
352
|
+
paths.extend(job.path for job in self._queued_jobs if job.path is not None)
|
|
353
|
+
return tuple(set(paths))
|
|
354
|
+
|
|
355
|
+
def start_sync_preview(self) -> None:
|
|
356
|
+
with self.lock:
|
|
357
|
+
self.help_open = False
|
|
358
|
+
self.dir_picker = None
|
|
359
|
+
if self.sync_busy:
|
|
360
|
+
self.sync_open = True
|
|
361
|
+
self.sync_focus = True
|
|
362
|
+
reopen = True
|
|
363
|
+
else:
|
|
364
|
+
reopen = False
|
|
365
|
+
self.sync_open = True
|
|
366
|
+
self.sync_busy = True
|
|
367
|
+
self.sync_plan = None
|
|
368
|
+
self.sync_results = {}
|
|
369
|
+
self.sync_selected = set()
|
|
370
|
+
self.sync_status = "Scanning (read only)…"
|
|
371
|
+
self.sync_focus = True
|
|
372
|
+
self.sync_pane.vertical_scroll = 0
|
|
373
|
+
self.app.layout.focus(self.sync_control)
|
|
374
|
+
if reopen:
|
|
375
|
+
self.refresh()
|
|
376
|
+
return
|
|
377
|
+
try:
|
|
378
|
+
self.pool.submit(self._sync_preview_worker, self.settings_snapshot().out_dir, self._sync_excluded_paths())
|
|
379
|
+
except Exception as error: # noqa: BLE001 - normalize worker submission failures.
|
|
380
|
+
self._publish_sync_error(error)
|
|
381
|
+
self.refresh()
|
|
382
|
+
|
|
383
|
+
def _get_sync_service(self) -> SyncService:
|
|
384
|
+
if self.sync_service is None:
|
|
385
|
+
from .sync import SyncService
|
|
386
|
+
|
|
387
|
+
self.sync_service = SyncService()
|
|
388
|
+
return self.sync_service
|
|
389
|
+
|
|
390
|
+
def _sync_preview_worker(self, out_dir: Path, excluded: tuple[Path, ...]) -> None:
|
|
391
|
+
try:
|
|
392
|
+
plan = self._get_sync_service().preview(out_dir, excluded_paths=excluded, progress=self._sync_progress)
|
|
393
|
+
except Exception as error: # noqa: BLE001 - terminate sync UI state for filesystem and USB failures.
|
|
394
|
+
self._publish_sync_error(error)
|
|
395
|
+
return
|
|
396
|
+
with self.lock:
|
|
397
|
+
self.sync_plan = plan
|
|
398
|
+
self.sync_busy = False
|
|
399
|
+
self.sync_status = f"Preview ready · {len(plan.items)} tracks · no files changed"
|
|
400
|
+
self.sync_cursor = 0
|
|
401
|
+
self.refresh()
|
|
402
|
+
|
|
403
|
+
def _sync_progress(self, progress: SyncProgress) -> None:
|
|
404
|
+
filename = getattr(progress, "filename", "")
|
|
405
|
+
phase = getattr(progress, "phase", "Working")
|
|
406
|
+
with self.lock:
|
|
407
|
+
self.sync_status = f"{phase} {filename}".strip()[:120]
|
|
408
|
+
self.refresh()
|
|
409
|
+
|
|
410
|
+
def _publish_sync_error(self, error: Exception) -> None:
|
|
411
|
+
with self.lock:
|
|
412
|
+
self.sync_busy = False
|
|
413
|
+
self.sync_transfer_active = False
|
|
414
|
+
self.sync_status = f"Sync unavailable: {self._error_line(error)}"
|
|
415
|
+
self.refresh()
|
|
416
|
+
|
|
417
|
+
def sync_move(self, delta: int) -> None:
|
|
418
|
+
with self.lock:
|
|
419
|
+
count = len(self.sync_plan.items) if self.sync_plan else 0
|
|
420
|
+
self.sync_cursor = min(max(self.sync_cursor + delta, 0), count)
|
|
421
|
+
self._scroll_sync_to_cursor()
|
|
422
|
+
self.refresh()
|
|
423
|
+
|
|
424
|
+
def _scroll_sync_to_cursor(self) -> None:
|
|
425
|
+
if self.sync_pane is None:
|
|
426
|
+
return
|
|
427
|
+
with self.lock:
|
|
428
|
+
plan = self.sync_plan
|
|
429
|
+
cursor = self.sync_cursor
|
|
430
|
+
results = dict(self.sync_results)
|
|
431
|
+
preceding_rows = 0
|
|
432
|
+
if plan is not None:
|
|
433
|
+
preceding_rows = sum(
|
|
434
|
+
1 + bool(
|
|
435
|
+
item.confidence == "low"
|
|
436
|
+
or item.reason
|
|
437
|
+
or results.get(item.id) is not None
|
|
438
|
+
and results[item.id].status != "done"
|
|
439
|
+
)
|
|
440
|
+
for item in plan.items[:cursor]
|
|
441
|
+
)
|
|
442
|
+
size = self.app.output.get_size()
|
|
443
|
+
content_height = self.sync_pane.content.preferred_height(max(size.columns - 1, 1), self.sync_pane.max_available_height).preferred
|
|
444
|
+
viewport = max(size.rows - 9, 1)
|
|
445
|
+
self.sync_pane.vertical_scroll = min(max(preceding_rows - viewport + 2, 0), max(content_height - viewport, 0))
|
|
446
|
+
|
|
447
|
+
def sync_scroll(self, delta: int) -> None:
|
|
448
|
+
if self.sync_pane is None:
|
|
449
|
+
return
|
|
450
|
+
self.sync_pane.vertical_scroll = max(self.sync_pane.vertical_scroll + delta, 0)
|
|
451
|
+
self.refresh()
|
|
452
|
+
|
|
453
|
+
def sync_toggle(self) -> None:
|
|
454
|
+
with self.lock:
|
|
455
|
+
if not self.sync_plan or self.sync_cursor >= len(self.sync_plan.items):
|
|
456
|
+
start = bool(self.sync_selected) and not self.sync_busy
|
|
457
|
+
else:
|
|
458
|
+
item = self.sync_plan.items[self.sync_cursor]
|
|
459
|
+
if item.direction == "matched" or self.sync_results.get(item.id, None) and self.sync_results[item.id].status == "done":
|
|
460
|
+
return
|
|
461
|
+
if item.id in self.sync_selected:
|
|
462
|
+
self.sync_selected.remove(item.id)
|
|
463
|
+
else:
|
|
464
|
+
self.sync_selected.add(item.id)
|
|
465
|
+
start = False
|
|
466
|
+
if start:
|
|
467
|
+
self.start_sync_transfer()
|
|
468
|
+
self.refresh()
|
|
469
|
+
|
|
470
|
+
def sync_select(self, high_only: bool) -> None:
|
|
471
|
+
with self.lock:
|
|
472
|
+
if not self.sync_plan:
|
|
473
|
+
return
|
|
474
|
+
self.sync_selected = {
|
|
475
|
+
item.id for item in self.sync_plan.items
|
|
476
|
+
if item.direction != "matched"
|
|
477
|
+
and (not high_only or item.confidence == "high")
|
|
478
|
+
and not (self.sync_results.get(item.id) and self.sync_results[item.id].status == "done")
|
|
479
|
+
}
|
|
480
|
+
self.refresh()
|
|
481
|
+
|
|
482
|
+
def start_sync_transfer(self) -> None:
|
|
483
|
+
with self.lock:
|
|
484
|
+
if self.sync_busy or self.sync_plan is None or not self.sync_selected:
|
|
485
|
+
return
|
|
486
|
+
selected = set(self.sync_selected)
|
|
487
|
+
plan = self.sync_plan
|
|
488
|
+
self.sync_busy = True
|
|
489
|
+
self.sync_transfer_active = True
|
|
490
|
+
self.sync_cancel_requested = False
|
|
491
|
+
self.sync_status = "Copying selected tracks…"
|
|
492
|
+
try:
|
|
493
|
+
self.pool.submit(self._sync_transfer_worker, plan, selected)
|
|
494
|
+
except RuntimeError as error:
|
|
495
|
+
self._publish_sync_error(error)
|
|
496
|
+
self.refresh()
|
|
497
|
+
|
|
498
|
+
def _sync_transfer_worker(self, plan: SyncPlan, selected: set[str]) -> None:
|
|
499
|
+
try:
|
|
500
|
+
results = self._get_sync_service().transfer(
|
|
501
|
+
plan, selected, progress=self._sync_progress, cancelled=self._sync_cancelled
|
|
502
|
+
)
|
|
503
|
+
except Exception as error: # noqa: BLE001 - normalize provider and filesystem failures.
|
|
504
|
+
self._publish_sync_error(error)
|
|
505
|
+
return
|
|
506
|
+
with self.lock:
|
|
507
|
+
self.sync_results.update({result.item_id: result for result in results})
|
|
508
|
+
self.sync_selected.difference_update(
|
|
509
|
+
result.item_id for result in results if result.status == "done"
|
|
510
|
+
)
|
|
511
|
+
self.sync_busy = False
|
|
512
|
+
self.sync_transfer_active = False
|
|
513
|
+
self.sync_status = "Transfer complete" if not self.sync_cancel_requested else "Stopped after current file"
|
|
514
|
+
self.sync_cancel_requested = False
|
|
515
|
+
self.refresh()
|
|
516
|
+
|
|
517
|
+
def _sync_cancelled(self) -> bool:
|
|
518
|
+
with self.lock:
|
|
519
|
+
return self.sync_cancel_requested
|
|
520
|
+
|
|
521
|
+
def sync_back(self) -> None:
|
|
522
|
+
closed = False
|
|
523
|
+
with self.lock:
|
|
524
|
+
if self.sync_busy and self.sync_transfer_active:
|
|
525
|
+
self.sync_cancel_requested = True
|
|
526
|
+
self.sync_status = "Stopping after current file…"
|
|
527
|
+
else:
|
|
528
|
+
self.sync_open = False
|
|
529
|
+
self.sync_focus = False
|
|
530
|
+
closed = True
|
|
531
|
+
if closed:
|
|
532
|
+
self.app.layout.focus(self.input_buffer)
|
|
533
|
+
self.refresh()
|
|
534
|
+
|
|
535
|
+
def toggle_sync_focus(self) -> None:
|
|
536
|
+
focus_report = not self.app.layout.has_focus(self.sync_control)
|
|
537
|
+
with self.lock:
|
|
538
|
+
self.sync_focus = focus_report
|
|
539
|
+
self.app.layout.focus(self.sync_control if focus_report else self.input_buffer)
|
|
540
|
+
self.refresh()
|
|
541
|
+
|
|
542
|
+
def retry_sync(self) -> None:
|
|
543
|
+
no_preview = False
|
|
544
|
+
with self.lock:
|
|
545
|
+
if not self.sync_plan:
|
|
546
|
+
no_preview = True
|
|
547
|
+
elif self.sync_busy:
|
|
548
|
+
return
|
|
549
|
+
else:
|
|
550
|
+
failed = {key for key, result in self.sync_results.items() if result.status in ("error", "cancelled")}
|
|
551
|
+
if not failed:
|
|
552
|
+
return
|
|
553
|
+
self.help_open = False
|
|
554
|
+
self.dir_picker = None
|
|
555
|
+
self.sync_selected = failed
|
|
556
|
+
self.sync_cursor = 0
|
|
557
|
+
self.sync_open = True
|
|
558
|
+
self.sync_focus = True
|
|
559
|
+
if no_preview:
|
|
560
|
+
self.msg("class:dim", "no Spotify sync preview to retry")
|
|
561
|
+
return
|
|
562
|
+
self.app.layout.focus(self.sync_control)
|
|
563
|
+
self.refresh()
|
|
564
|
+
|
|
565
|
+
def _build(self):
|
|
566
|
+
"""Body fragments, split where the inline hint input goes (below the
|
|
567
|
+
selected low-confidence entry). Returns (above, below)."""
|
|
568
|
+
if self.help_open:
|
|
569
|
+
return self._render_help(), []
|
|
570
|
+
with self.lock:
|
|
571
|
+
active = list(self.active.values())
|
|
572
|
+
queued = self.queued
|
|
573
|
+
hist = list(self.history)
|
|
574
|
+
msgs = list(self.messages)
|
|
575
|
+
sel = self.sel
|
|
576
|
+
cols = self._cols()
|
|
577
|
+
width = max(cols - 8, 20)
|
|
578
|
+
rule = ("class:rule", " " + "─" * max(cols - 4, 10) + "\n")
|
|
579
|
+
|
|
580
|
+
above: list[tuple[str, str]] = []
|
|
581
|
+
below: list[tuple[str, str]] = []
|
|
582
|
+
cur = above
|
|
583
|
+
|
|
584
|
+
cur.append(("class:subsection", "\n Processing\n"))
|
|
585
|
+
if not active and not queued:
|
|
586
|
+
cur.append(("class:dim", " idle\n"))
|
|
587
|
+
for status in active:
|
|
588
|
+
cur.append(("class:accent", f" ⠿ {status[:width]}\n"))
|
|
589
|
+
if queued:
|
|
590
|
+
cur.append(("class:dim", f" ⧗ {queued} queued\n"))
|
|
591
|
+
|
|
592
|
+
cur.append(rule)
|
|
593
|
+
cur.append(("class:subsection", " History\n"))
|
|
594
|
+
if not hist:
|
|
595
|
+
cur.append(("class:dim", " nothing yet\n"))
|
|
596
|
+
start = max(0, len(hist) - HISTORY_SHOWN)
|
|
597
|
+
if sel is not None and sel < start:
|
|
598
|
+
start = sel
|
|
599
|
+
if start > 0:
|
|
600
|
+
cur.append(("class:dim", f" … {start} earlier\n"))
|
|
601
|
+
for i in range(start, len(hist)):
|
|
602
|
+
e = hist[i]
|
|
603
|
+
style = f"{e.style} class:selected" if i == sel else e.style
|
|
604
|
+
cur.append((style, f" {e.line[:width]}\n"))
|
|
605
|
+
if e.detail:
|
|
606
|
+
cur.append(("class:dim", f" {e.detail[:width]}\n"))
|
|
607
|
+
if i == sel and e.kind == "hint":
|
|
608
|
+
cur = below # inline hint input renders between above/below
|
|
609
|
+
|
|
610
|
+
if msgs:
|
|
611
|
+
cur.append(rule)
|
|
612
|
+
cur.append(("class:subsection", " Logs\n"))
|
|
613
|
+
for style, line in msgs:
|
|
614
|
+
cur.append((style, f" {line[:width]}\n"))
|
|
615
|
+
return above, below
|
|
616
|
+
|
|
617
|
+
def _render_above(self):
|
|
618
|
+
return self._build()[0]
|
|
619
|
+
|
|
620
|
+
def _render_below(self):
|
|
621
|
+
return self._build()[1]
|
|
622
|
+
|
|
623
|
+
def _above_height(self):
|
|
624
|
+
height = sum(text.count("\n") for _, text in self._build()[0])
|
|
625
|
+
return Dimension(min=0, preferred=height, max=height)
|
|
626
|
+
|
|
627
|
+
def _toolbar(self):
|
|
628
|
+
with self.lock:
|
|
629
|
+
active = len(self.active)
|
|
630
|
+
queued = self.queued
|
|
631
|
+
settings = self.settings
|
|
632
|
+
parts = [f"out {settings.out_dir}", f"q {settings.quality}k"]
|
|
633
|
+
if active:
|
|
634
|
+
parts.append(f"{active} active")
|
|
635
|
+
if queued:
|
|
636
|
+
parts.append(f"{queued} queued")
|
|
637
|
+
return [("class:toolbar", " " + " · ".join(parts) + " ")]
|
|
638
|
+
|
|
639
|
+
def _status_line(self):
|
|
640
|
+
"""The bottom row: the contextual input hint, then the status toolbar
|
|
641
|
+
(labeled output directory and bitrate) on the same line. The gap is
|
|
642
|
+
unstyled so the toolbar's background starts at its own text."""
|
|
643
|
+
return [*self._input_hint(), ("", " "), *self._toolbar()]
|
|
644
|
+
|
|
645
|
+
def _input_prefix(self):
|
|
646
|
+
"""Main input prefix; the directory editor renders its own prefix."""
|
|
647
|
+
if self.modal:
|
|
648
|
+
return [("class:prompt", self.modal.prefix)]
|
|
649
|
+
return [("class:prompt", "❯ ")]
|
|
650
|
+
|
|
651
|
+
def _input_placeholder(self) -> str:
|
|
652
|
+
if self.modal:
|
|
653
|
+
return self.modal.placeholder
|
|
654
|
+
return PLACEHOLDER
|
|
655
|
+
|
|
656
|
+
def _dir_prefix(self):
|
|
657
|
+
return [("class:accent", "Output folder ~/ ")]
|
|
658
|
+
|
|
659
|
+
def _dir_placeholder(self) -> str:
|
|
660
|
+
if self.dir_picker and self.dir_picker.stage == "confirm":
|
|
661
|
+
return f"~/{self.dir_picker.typed} doesn't exist — create it? (y/n)"
|
|
662
|
+
return "type a folder under home — tab to complete, enter to set"
|
|
663
|
+
|
|
664
|
+
def _input_hint(self):
|
|
665
|
+
if self.sync_open:
|
|
666
|
+
if self.app.layout.current_control is self.sync_control:
|
|
667
|
+
with self.lock:
|
|
668
|
+
copying = self.sync_transfer_active
|
|
669
|
+
escape = "Esc stops after current file" if copying else "Esc closes"
|
|
670
|
+
return [("class:input.hint", f" ↑/↓ browse · Enter select/continue · a high confidence · A all · Tab input · {escape}")]
|
|
671
|
+
return [("class:input.hint", " type URLs or commands · Tab sync report · Esc closes when ready")]
|
|
672
|
+
if self.dir_picker is not None:
|
|
673
|
+
if self.dir_buffer_focused:
|
|
674
|
+
if self.dir_picker.stage == "confirm":
|
|
675
|
+
tip = "y/n confirms · shift-tab to URL input · esc cancels"
|
|
676
|
+
else:
|
|
677
|
+
tip = "enter applies · shift-tab to URL input · esc cancels"
|
|
678
|
+
else:
|
|
679
|
+
tip = "type URLs or commands · shift-tab to folder · esc cancels"
|
|
680
|
+
return [("class:input.hint", f" {tip}")]
|
|
681
|
+
if self.help_open:
|
|
682
|
+
return [
|
|
683
|
+
(
|
|
684
|
+
"class:input.hint",
|
|
685
|
+
" scroll over help · type URLs or commands · esc closes",
|
|
686
|
+
)
|
|
687
|
+
]
|
|
688
|
+
with self.lock:
|
|
689
|
+
notice = self.input_notice
|
|
690
|
+
actionable = any(e.kind in ("err", "hint") for e in self.history)
|
|
691
|
+
modal = self.modal
|
|
692
|
+
if notice:
|
|
693
|
+
style, text = notice
|
|
694
|
+
elif modal:
|
|
695
|
+
style, text = "class:input.hint", modal.hint
|
|
696
|
+
elif actionable:
|
|
697
|
+
style, text = (
|
|
698
|
+
"class:input.hint",
|
|
699
|
+
"↑/↓ to select · enter retries · esc deselects",
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
style, text = "class:input.hint", HELP_HINT
|
|
703
|
+
return [(style, f" {text}")]
|
|
704
|
+
|
|
705
|
+
# ── selection ───────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
def _actionable(self) -> list[int]:
|
|
708
|
+
with self.lock:
|
|
709
|
+
return [i for i, e in enumerate(self.history) if e.kind in ("err", "hint")]
|
|
710
|
+
|
|
711
|
+
def _sel_kind(self) -> str | None:
|
|
712
|
+
with self.lock:
|
|
713
|
+
if self.sel is None or self.sel >= len(self.history):
|
|
714
|
+
return None
|
|
715
|
+
return self.history[self.sel].kind
|
|
716
|
+
|
|
717
|
+
def has_selection(self) -> bool:
|
|
718
|
+
with self.lock:
|
|
719
|
+
return self.sel is not None
|
|
720
|
+
|
|
721
|
+
def _move_sel(self, delta: int) -> None:
|
|
722
|
+
with self.lock:
|
|
723
|
+
acts = [
|
|
724
|
+
i for i, entry in enumerate(self.history) if entry.kind in ("err", "hint")
|
|
725
|
+
]
|
|
726
|
+
if not acts:
|
|
727
|
+
self.sel = None
|
|
728
|
+
elif self.sel is None:
|
|
729
|
+
if delta < 0:
|
|
730
|
+
self.sel = acts[-1]
|
|
731
|
+
else:
|
|
732
|
+
pos = acts.index(self.sel) if self.sel in acts else len(acts) - 1
|
|
733
|
+
pos += delta
|
|
734
|
+
self.sel = None if pos >= len(acts) else acts[max(pos, 0)]
|
|
735
|
+
self._update_focus()
|
|
736
|
+
self.refresh()
|
|
737
|
+
|
|
738
|
+
def _update_focus(self) -> None:
|
|
739
|
+
if not hasattr(self, "app"):
|
|
740
|
+
return
|
|
741
|
+
if self._sel_kind() == "hint":
|
|
742
|
+
self.app.layout.focus(self.hint_buffer)
|
|
743
|
+
elif self.dir_picker is not None and self.dir_buffer_focused:
|
|
744
|
+
self.app.layout.focus(self.dir_buffer)
|
|
745
|
+
else:
|
|
746
|
+
self.app.layout.focus(self.input_buffer)
|
|
747
|
+
|
|
748
|
+
@property
|
|
749
|
+
def dir_buffer_focused(self) -> bool:
|
|
750
|
+
return (
|
|
751
|
+
getattr(self, "app", None) is not None
|
|
752
|
+
and self.app.layout.current_buffer is self.dir_buffer
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
def toggle_dir_focus(self) -> None:
|
|
756
|
+
if self.dir_picker is None or self.modal:
|
|
757
|
+
return
|
|
758
|
+
if self.app.layout.current_buffer is self.dir_buffer:
|
|
759
|
+
self.app.layout.focus(self.input_buffer)
|
|
760
|
+
else:
|
|
761
|
+
self.app.layout.focus(self.dir_buffer)
|
|
762
|
+
self.refresh()
|
|
763
|
+
|
|
764
|
+
def scroll_help(self, delta: int) -> None:
|
|
765
|
+
if not self.help_open:
|
|
766
|
+
return
|
|
767
|
+
size = self.app.output.get_size()
|
|
768
|
+
content_width = max(size.columns - 1, 1) # reserve the pane scrollbar
|
|
769
|
+
content_height = self.help_pane.content.preferred_height(
|
|
770
|
+
content_width, self.help_pane.max_available_height
|
|
771
|
+
).preferred
|
|
772
|
+
# Reserve the header, rules, main input, and status rows.
|
|
773
|
+
fixed_rows = 8 + (1 if self.dir_picker is not None else 0)
|
|
774
|
+
viewport_height = max(size.rows - fixed_rows, 1)
|
|
775
|
+
max_scroll = max(content_height - viewport_height, 0)
|
|
776
|
+
self.help_pane.vertical_scroll = min(
|
|
777
|
+
max(self.help_pane.vertical_scroll + delta, 0), max_scroll
|
|
778
|
+
)
|
|
779
|
+
self.refresh()
|
|
780
|
+
|
|
781
|
+
# ── state helpers (safe from any thread) ────────────────────────────
|
|
782
|
+
|
|
783
|
+
def refresh(self) -> None:
|
|
784
|
+
if hasattr(self, "app"):
|
|
785
|
+
self.app.invalidate()
|
|
786
|
+
|
|
787
|
+
def msg(self, style: str, line: str) -> None:
|
|
788
|
+
with self.lock:
|
|
789
|
+
self.messages.append((style, line))
|
|
790
|
+
self.refresh()
|
|
791
|
+
|
|
792
|
+
def _recheck_signin(self) -> str | None:
|
|
793
|
+
backend = self.enricher.refresh_subscription()
|
|
794
|
+
if backend is None:
|
|
795
|
+
return None
|
|
796
|
+
name = "Claude" if backend == "claude" else "Codex"
|
|
797
|
+
self.msg("class:ok", f"signed in to {name} — enrichment enabled")
|
|
798
|
+
return backend
|
|
799
|
+
|
|
800
|
+
def _startup_enrichment_notice(self) -> None:
|
|
801
|
+
if self.enricher.backend != "none":
|
|
802
|
+
return
|
|
803
|
+
for provider in ("claude", "codex"):
|
|
804
|
+
if self.enricher.cli_states.get(provider) == "signed_out":
|
|
805
|
+
label = "Claude" if provider == "claude" else "Codex"
|
|
806
|
+
self.msg(
|
|
807
|
+
"class:warn",
|
|
808
|
+
f"⚠ {label} is installed but not signed in — run /login {provider} "
|
|
809
|
+
f"(checking every {SIGNIN_RECHECK_SECONDS}s)",
|
|
810
|
+
)
|
|
811
|
+
return
|
|
812
|
+
|
|
813
|
+
def _start_signin_recheck(self) -> None:
|
|
814
|
+
async def recheck_loop() -> None:
|
|
815
|
+
while self.enricher.needs_subscription_recheck:
|
|
816
|
+
await asyncio.sleep(SIGNIN_RECHECK_SECONDS)
|
|
817
|
+
try:
|
|
818
|
+
future = self.pool.submit(self._recheck_signin)
|
|
819
|
+
backend = await asyncio.wrap_future(future)
|
|
820
|
+
except Exception: # noqa: BLE001
|
|
821
|
+
return
|
|
822
|
+
if backend is not None:
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
self.app.create_background_task(recheck_loop())
|
|
826
|
+
|
|
827
|
+
def settings_snapshot(self) -> Settings:
|
|
828
|
+
with self.lock:
|
|
829
|
+
return self.settings
|
|
830
|
+
|
|
831
|
+
def set_out_dir(self, out_dir: Path) -> None:
|
|
832
|
+
with self.lock:
|
|
833
|
+
self.settings = replace(self.settings, out_dir=out_dir)
|
|
834
|
+
self._persist_settings()
|
|
835
|
+
self.refresh()
|
|
836
|
+
|
|
837
|
+
def set_quality(self, quality: str) -> None:
|
|
838
|
+
with self.lock:
|
|
839
|
+
self.settings = replace(self.settings, quality=quality)
|
|
840
|
+
self._persist_settings()
|
|
841
|
+
self.refresh()
|
|
842
|
+
|
|
843
|
+
def clear_selection(self) -> None:
|
|
844
|
+
with self.lock:
|
|
845
|
+
self.sel = None
|
|
846
|
+
self.hint_buffer.reset()
|
|
847
|
+
self._update_focus()
|
|
848
|
+
self.refresh()
|
|
849
|
+
|
|
850
|
+
def show_input_notice(
|
|
851
|
+
self, style: str, line: str, *, duration: float | None = None
|
|
852
|
+
) -> None:
|
|
853
|
+
with self.lock:
|
|
854
|
+
self._input_notice_id += 1
|
|
855
|
+
notice_id = self._input_notice_id
|
|
856
|
+
self.input_notice = (style, line)
|
|
857
|
+
self.refresh()
|
|
858
|
+
if duration is not None:
|
|
859
|
+
timer = threading.Timer(duration, self._clear_input_notice, (notice_id,))
|
|
860
|
+
timer.daemon = True
|
|
861
|
+
timer.start()
|
|
862
|
+
|
|
863
|
+
def clear_input_notice(self) -> None:
|
|
864
|
+
with self.lock:
|
|
865
|
+
self._input_notice_id += 1
|
|
866
|
+
self.input_notice = None
|
|
867
|
+
self.refresh()
|
|
868
|
+
|
|
869
|
+
def _clear_input_notice(self, notice_id: int) -> None:
|
|
870
|
+
with self.lock:
|
|
871
|
+
if notice_id != self._input_notice_id:
|
|
872
|
+
return
|
|
873
|
+
self.input_notice = None
|
|
874
|
+
self.refresh()
|
|
875
|
+
|
|
876
|
+
def add_entry(self, entry: Entry) -> None:
|
|
877
|
+
with self.lock:
|
|
878
|
+
self.history.append(entry)
|
|
879
|
+
self.refresh()
|
|
880
|
+
|
|
881
|
+
def _remove_entry(self, entry: Entry) -> None:
|
|
882
|
+
with self.lock:
|
|
883
|
+
try:
|
|
884
|
+
idx = self.history.index(entry)
|
|
885
|
+
except ValueError:
|
|
886
|
+
return
|
|
887
|
+
del self.history[idx]
|
|
888
|
+
if self.sel is not None and self.sel >= idx:
|
|
889
|
+
self.sel = self.sel - 1 if self.sel > idx else None
|
|
890
|
+
self.refresh()
|
|
891
|
+
|
|
892
|
+
# ── output-directory picker ─────────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
def open_dir_picker(self) -> None:
|
|
895
|
+
with self.lock:
|
|
896
|
+
self.dir_picker = DirPicker()
|
|
897
|
+
self.sel = None
|
|
898
|
+
self.sync_open = False
|
|
899
|
+
self.sync_focus = False
|
|
900
|
+
self.dir_buffer.reset()
|
|
901
|
+
self.app.layout.focus(self.input_buffer)
|
|
902
|
+
self._update_focus()
|
|
903
|
+
self.refresh()
|
|
904
|
+
|
|
905
|
+
def close_dir_picker(self) -> None:
|
|
906
|
+
with self.lock:
|
|
907
|
+
self.dir_picker = None
|
|
908
|
+
self.dir_buffer.reset()
|
|
909
|
+
self.app.layout.focus(self.input_buffer)
|
|
910
|
+
self.refresh()
|
|
911
|
+
|
|
912
|
+
def _apply_out_dir(self, path: Path) -> None:
|
|
913
|
+
self.set_out_dir(path)
|
|
914
|
+
self.msg("class:dim", f"output directory: {path}")
|
|
915
|
+
|
|
916
|
+
def _accept_dir(self, buff: Buffer) -> None:
|
|
917
|
+
"""Enter in the path stage: use the folder if it exists, else offer to
|
|
918
|
+
create it."""
|
|
919
|
+
picker = self.dir_picker
|
|
920
|
+
if picker is None or picker.stage != "input":
|
|
921
|
+
return
|
|
922
|
+
rel = buff.text.strip().lstrip("/")
|
|
923
|
+
target = Path.home() / rel if rel else Path.home()
|
|
924
|
+
if target.is_dir():
|
|
925
|
+
self._apply_out_dir(target)
|
|
926
|
+
self.close_dir_picker()
|
|
927
|
+
return
|
|
928
|
+
picker.stage = "confirm"
|
|
929
|
+
picker.typed = rel
|
|
930
|
+
picker.pending = target
|
|
931
|
+
buff.reset() # cleared while we ask y/n
|
|
932
|
+
self.refresh()
|
|
933
|
+
|
|
934
|
+
def _dir_confirm_create(self) -> None:
|
|
935
|
+
"""y in the confirm stage: make the folder, adopt it, and exit."""
|
|
936
|
+
picker = self.dir_picker
|
|
937
|
+
if picker is None or picker.pending is None:
|
|
938
|
+
return
|
|
939
|
+
target = picker.pending
|
|
940
|
+
try:
|
|
941
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
942
|
+
except OSError as e:
|
|
943
|
+
self.msg("class:err", f"can't create {target}: {e}")
|
|
944
|
+
picker.stage = "input"
|
|
945
|
+
picker.pending = None
|
|
946
|
+
self.dir_buffer.set_document(
|
|
947
|
+
Document(picker.typed, len(picker.typed)), bypass_readonly=True
|
|
948
|
+
)
|
|
949
|
+
self.refresh()
|
|
950
|
+
return
|
|
951
|
+
self.msg("class:ok", f"created {target}")
|
|
952
|
+
self._apply_out_dir(target)
|
|
953
|
+
self.close_dir_picker()
|
|
954
|
+
|
|
955
|
+
def _dir_decline_create(self) -> None:
|
|
956
|
+
"""n in the confirm stage: return to the path input, restoring the text."""
|
|
957
|
+
picker = self.dir_picker
|
|
958
|
+
if picker is None:
|
|
959
|
+
return
|
|
960
|
+
rel = picker.typed
|
|
961
|
+
picker.stage = "input"
|
|
962
|
+
picker.pending = None
|
|
963
|
+
self.dir_buffer.set_document(
|
|
964
|
+
Document(rel, len(rel)), bypass_readonly=True
|
|
965
|
+
)
|
|
966
|
+
self.refresh()
|
|
967
|
+
|
|
968
|
+
def open_help(self) -> None:
|
|
969
|
+
with self.lock:
|
|
970
|
+
self.help_open = True
|
|
971
|
+
self.sel = None
|
|
972
|
+
self.sync_open = False
|
|
973
|
+
self.sync_focus = False
|
|
974
|
+
self.app.layout.focus(self.input_buffer)
|
|
975
|
+
self._update_focus()
|
|
976
|
+
self.refresh()
|
|
977
|
+
|
|
978
|
+
def close_help(self) -> None:
|
|
979
|
+
self.help_open = False
|
|
980
|
+
self.refresh()
|
|
981
|
+
|
|
982
|
+
def open_modal(self, modal: Modal) -> None:
|
|
983
|
+
with self.lock:
|
|
984
|
+
self.modal = modal
|
|
985
|
+
self.sync_open = False
|
|
986
|
+
self.sync_focus = False
|
|
987
|
+
self.input_buffer.set_document(
|
|
988
|
+
Document(modal.initial, len(modal.initial)), bypass_readonly=True
|
|
989
|
+
)
|
|
990
|
+
self.refresh()
|
|
991
|
+
|
|
992
|
+
def close_modal(self) -> None:
|
|
993
|
+
with self.lock:
|
|
994
|
+
self.modal = None
|
|
995
|
+
self.input_buffer.reset()
|
|
996
|
+
self.refresh()
|
|
997
|
+
|
|
998
|
+
# ── background pipeline ─────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
@staticmethod
|
|
1001
|
+
def _display_name(job: Job) -> str:
|
|
1002
|
+
return job.path.name if job.path else (job.title or job.url)
|
|
1003
|
+
|
|
1004
|
+
def _done_entry(self, job: Job, detail: str | None) -> Entry:
|
|
1005
|
+
return Entry(
|
|
1006
|
+
"class:ok",
|
|
1007
|
+
f"✔ {self._display_name(job)}",
|
|
1008
|
+
"ok",
|
|
1009
|
+
job,
|
|
1010
|
+
detail=detail,
|
|
1011
|
+
)
|
|
1012
|
+
|
|
1013
|
+
@staticmethod
|
|
1014
|
+
def _failure_entry(job: Job) -> Entry:
|
|
1015
|
+
# Only retryable failures become selectable. Retrying an unsupported
|
|
1016
|
+
# site or deleted video cannot succeed without something else changing.
|
|
1017
|
+
if job.retryable:
|
|
1018
|
+
line = (
|
|
1019
|
+
f"✘ {job.url[:44]} {job.error or 'unknown error'}"
|
|
1020
|
+
" — ↑ + enter to retry"
|
|
1021
|
+
)
|
|
1022
|
+
kind = "err"
|
|
1023
|
+
else:
|
|
1024
|
+
line = f"✘ {job.url[:44]} {job.error or 'unknown error'}"
|
|
1025
|
+
kind = "info"
|
|
1026
|
+
return Entry("class:err", line, kind, job)
|
|
1027
|
+
|
|
1028
|
+
@staticmethod
|
|
1029
|
+
def _hint_entry(job: Job, again: bool = False) -> Entry:
|
|
1030
|
+
tail = (
|
|
1031
|
+
"still unconfirmed, ↑ to add another hint"
|
|
1032
|
+
if again
|
|
1033
|
+
else "artist unconfirmed, ↑ to add a hint"
|
|
1034
|
+
)
|
|
1035
|
+
return Entry(
|
|
1036
|
+
"class:warn",
|
|
1037
|
+
f"⚠ “{(job.title or '')[:40]}” — {tail}",
|
|
1038
|
+
"hint",
|
|
1039
|
+
job,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
def _set_active(self, seq: int, text: str, *, path: Path | None = None) -> None:
|
|
1043
|
+
with self.lock:
|
|
1044
|
+
self.active[seq] = text
|
|
1045
|
+
self._active_paths[seq] = path
|
|
1046
|
+
self.refresh()
|
|
1047
|
+
|
|
1048
|
+
def _note_done(self, job: Job, tags: TrackTags | None) -> None:
|
|
1049
|
+
detail = self._detail_line(tags) if tags else None
|
|
1050
|
+
self.add_entry(self._done_entry(job, detail))
|
|
1051
|
+
|
|
1052
|
+
def _note_low_confidence(self, job: Job, again: bool = False) -> None:
|
|
1053
|
+
self.add_entry(self._hint_entry(job, again))
|
|
1054
|
+
|
|
1055
|
+
def _note_failed(self, job: Job) -> None:
|
|
1056
|
+
with self.lock:
|
|
1057
|
+
if not any(existing is job for existing in self.failed):
|
|
1058
|
+
self.failed.append(job)
|
|
1059
|
+
if job.retryable:
|
|
1060
|
+
line = (
|
|
1061
|
+
f"✘ {job.url[:44]} {job.error or 'unknown error'}"
|
|
1062
|
+
" — ↑ + enter to retry"
|
|
1063
|
+
)
|
|
1064
|
+
kind: EntryKind = "err"
|
|
1065
|
+
else:
|
|
1066
|
+
line = f"✘ {job.url[:44]} {job.error or 'unknown error'}"
|
|
1067
|
+
kind = "info"
|
|
1068
|
+
self.add_entry(
|
|
1069
|
+
Entry(
|
|
1070
|
+
"class:err",
|
|
1071
|
+
line,
|
|
1072
|
+
kind,
|
|
1073
|
+
job,
|
|
1074
|
+
)
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
def _record_completed(self, job: Job) -> None:
|
|
1078
|
+
with self.lock:
|
|
1079
|
+
if not any(existing is job for existing in self.completed):
|
|
1080
|
+
self.completed.append(job)
|
|
1081
|
+
|
|
1082
|
+
def submit_urls(self, urls: list[str]) -> None:
|
|
1083
|
+
self._submit_jobs([Job(url=url) for url in urls])
|
|
1084
|
+
|
|
1085
|
+
@staticmethod
|
|
1086
|
+
def _reset_for_retry(job: Job) -> None:
|
|
1087
|
+
job.status = "queued"
|
|
1088
|
+
job.error = None
|
|
1089
|
+
job.error_detail = None
|
|
1090
|
+
job.retryable = True
|
|
1091
|
+
|
|
1092
|
+
def _submit_jobs(self, jobs: list[Job]) -> list[Job]:
|
|
1093
|
+
if not jobs:
|
|
1094
|
+
return []
|
|
1095
|
+
for job in jobs:
|
|
1096
|
+
self._reset_for_retry(job)
|
|
1097
|
+
with self.activity_lock:
|
|
1098
|
+
claimed_ids = self._persist("queue_jobs", jobs, self.session_id)
|
|
1099
|
+
claimed_jobs = (
|
|
1100
|
+
jobs
|
|
1101
|
+
if claimed_ids is None
|
|
1102
|
+
else [job for job in jobs if job.history_id in claimed_ids]
|
|
1103
|
+
)
|
|
1104
|
+
skipped = len(jobs) - len(claimed_jobs)
|
|
1105
|
+
with self.lock:
|
|
1106
|
+
self.queued += len(claimed_jobs)
|
|
1107
|
+
self._queued_jobs.extend(claimed_jobs)
|
|
1108
|
+
for job in claimed_jobs:
|
|
1109
|
+
self._run_outcomes[job.history_id] = "queued"
|
|
1110
|
+
if skipped:
|
|
1111
|
+
self.msg(
|
|
1112
|
+
"class:dim",
|
|
1113
|
+
f"{skipped} job{'s are' if skipped != 1 else ' is'} "
|
|
1114
|
+
"already active in another Transmute session",
|
|
1115
|
+
)
|
|
1116
|
+
with self.lock:
|
|
1117
|
+
settings = self.settings
|
|
1118
|
+
self.refresh()
|
|
1119
|
+
for job in claimed_jobs:
|
|
1120
|
+
self.pool.submit(self._process, job, settings)
|
|
1121
|
+
return claimed_jobs
|
|
1122
|
+
|
|
1123
|
+
def _record_failed_outcome(self, job: Job) -> None:
|
|
1124
|
+
with self.activity_lock:
|
|
1125
|
+
self._persist("save_failure", job)
|
|
1126
|
+
with self.lock:
|
|
1127
|
+
self._run_outcomes[job.history_id] = "error"
|
|
1128
|
+
self._note_failed(job)
|
|
1129
|
+
|
|
1130
|
+
def _record_successful_outcome(
|
|
1131
|
+
self,
|
|
1132
|
+
job: Job,
|
|
1133
|
+
tags: TrackTags | None,
|
|
1134
|
+
) -> None:
|
|
1135
|
+
detail = self._detail_line(tags) if tags else None
|
|
1136
|
+
needs_hint = tags is not None and tags.confidence == "low"
|
|
1137
|
+
with self.activity_lock:
|
|
1138
|
+
self._persist("save_success", job, detail, needs_hint)
|
|
1139
|
+
with self.lock:
|
|
1140
|
+
self._run_outcomes[job.history_id] = "done"
|
|
1141
|
+
self._record_completed(job)
|
|
1142
|
+
self._note_done(job, tags)
|
|
1143
|
+
if needs_hint:
|
|
1144
|
+
self._note_low_confidence(job)
|
|
1145
|
+
|
|
1146
|
+
def _process(self, job: Job, settings: Settings) -> None:
|
|
1147
|
+
with self.lock:
|
|
1148
|
+
self._seq += 1
|
|
1149
|
+
seq = self._seq
|
|
1150
|
+
self.queued -= 1
|
|
1151
|
+
self._queued_jobs = [queued for queued in self._queued_jobs if queued is not job]
|
|
1152
|
+
self.active[seq] = f"starting {job.url[:50]}"
|
|
1153
|
+
self._active_paths[seq] = job.path
|
|
1154
|
+
self.refresh()
|
|
1155
|
+
|
|
1156
|
+
def on_progress(j: Job, frac: float | None) -> None:
|
|
1157
|
+
name = (j.title or j.url)[:44]
|
|
1158
|
+
phase = "converting " if frac is None else f"↓ {frac:>4.0%} "
|
|
1159
|
+
self._set_active(seq, phase + name, path=j.path)
|
|
1160
|
+
|
|
1161
|
+
try:
|
|
1162
|
+
download_job(job, settings, on_progress)
|
|
1163
|
+
with self.lock:
|
|
1164
|
+
self._active_paths[seq] = job.path
|
|
1165
|
+
if job.status != "done":
|
|
1166
|
+
self._record_failed_outcome(job)
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
tags = None
|
|
1170
|
+
if self.enricher.enabled:
|
|
1171
|
+
self._set_active(seq, f"enriching {(job.title or '')[:44]}", path=job.path)
|
|
1172
|
+
tags = self._enrich(job)
|
|
1173
|
+
with self.lock:
|
|
1174
|
+
self._active_paths[seq] = job.path
|
|
1175
|
+
|
|
1176
|
+
self._record_successful_outcome(job, tags)
|
|
1177
|
+
except Exception as error: # noqa: BLE001
|
|
1178
|
+
message = self._error_line(error)
|
|
1179
|
+
if job.status == "done":
|
|
1180
|
+
self.msg("class:warn", f"⚠ post-processing skipped: {message}")
|
|
1181
|
+
self._record_successful_outcome(job, None)
|
|
1182
|
+
else:
|
|
1183
|
+
job.status = "error"
|
|
1184
|
+
job.error = message
|
|
1185
|
+
self._record_failed_outcome(job)
|
|
1186
|
+
finally:
|
|
1187
|
+
with self.lock:
|
|
1188
|
+
self.active.pop(seq, None)
|
|
1189
|
+
self._active_paths.pop(seq, None)
|
|
1190
|
+
self.refresh()
|
|
1191
|
+
|
|
1192
|
+
def _enrich(
|
|
1193
|
+
self,
|
|
1194
|
+
job: Job,
|
|
1195
|
+
hint: str | None = None,
|
|
1196
|
+
*,
|
|
1197
|
+
failure_label: str = "enrichment skipped",
|
|
1198
|
+
) -> TrackTags | None:
|
|
1199
|
+
try:
|
|
1200
|
+
tags = self.enricher.lookup(
|
|
1201
|
+
title=job.title or "",
|
|
1202
|
+
uploader=job.uploader,
|
|
1203
|
+
duration=job.duration,
|
|
1204
|
+
url=job.url,
|
|
1205
|
+
description=job.description,
|
|
1206
|
+
tags=job.tags,
|
|
1207
|
+
hint=hint,
|
|
1208
|
+
)
|
|
1209
|
+
except Exception as error: # noqa: BLE001
|
|
1210
|
+
self.msg(
|
|
1211
|
+
"class:warn",
|
|
1212
|
+
f"⚠ {failure_label}: {self._error_line(error)}",
|
|
1213
|
+
)
|
|
1214
|
+
return None
|
|
1215
|
+
if not tags or not job.path:
|
|
1216
|
+
error = self.enricher.last_error
|
|
1217
|
+
if not job.path and not error:
|
|
1218
|
+
error = "download output path unavailable"
|
|
1219
|
+
if error:
|
|
1220
|
+
self.msg("class:warn", f"⚠ {failure_label}: {error}")
|
|
1221
|
+
return None
|
|
1222
|
+
try:
|
|
1223
|
+
job.path = apply_tags(job.path, tags)
|
|
1224
|
+
except Exception as error: # noqa: BLE001
|
|
1225
|
+
self.msg(
|
|
1226
|
+
"class:warn",
|
|
1227
|
+
f"⚠ tagging skipped: {self._error_line(error)}",
|
|
1228
|
+
)
|
|
1229
|
+
return None
|
|
1230
|
+
job.title = tags.title or job.title
|
|
1231
|
+
return tags
|
|
1232
|
+
|
|
1233
|
+
def _rehint(
|
|
1234
|
+
self,
|
|
1235
|
+
job: Job,
|
|
1236
|
+
hint: str,
|
|
1237
|
+
placeholder: Entry,
|
|
1238
|
+
claim_token: str | None,
|
|
1239
|
+
) -> None:
|
|
1240
|
+
tags = self._enrich(job, hint=hint, failure_label="retry failed")
|
|
1241
|
+
with self.activity_lock:
|
|
1242
|
+
self._remove_entry(placeholder)
|
|
1243
|
+
if tags is not None:
|
|
1244
|
+
needs_hint = tags is not None and tags.confidence == "low"
|
|
1245
|
+
persisted = (
|
|
1246
|
+
self._persist(
|
|
1247
|
+
"save_hint_success",
|
|
1248
|
+
job,
|
|
1249
|
+
self._detail_line(tags),
|
|
1250
|
+
needs_hint,
|
|
1251
|
+
claim_token,
|
|
1252
|
+
)
|
|
1253
|
+
if claim_token
|
|
1254
|
+
else None
|
|
1255
|
+
)
|
|
1256
|
+
if persisted is False:
|
|
1257
|
+
self.msg(
|
|
1258
|
+
"class:dim",
|
|
1259
|
+
"hint result was superseded by another Transmute session",
|
|
1260
|
+
)
|
|
1261
|
+
return
|
|
1262
|
+
with self.lock:
|
|
1263
|
+
self.history = [
|
|
1264
|
+
entry
|
|
1265
|
+
for entry in self.history
|
|
1266
|
+
if not (entry.job is job and entry.kind == "ok")
|
|
1267
|
+
]
|
|
1268
|
+
self._record_completed(job)
|
|
1269
|
+
self._note_done(job, tags)
|
|
1270
|
+
if needs_hint:
|
|
1271
|
+
self._note_low_confidence(job, again=True)
|
|
1272
|
+
else:
|
|
1273
|
+
released = (
|
|
1274
|
+
self._persist("release_hint", job.history_id, claim_token)
|
|
1275
|
+
if claim_token
|
|
1276
|
+
else None
|
|
1277
|
+
)
|
|
1278
|
+
if released is False:
|
|
1279
|
+
return
|
|
1280
|
+
with self.lock:
|
|
1281
|
+
has_done_entry = any(
|
|
1282
|
+
entry.job is job and entry.kind == "ok"
|
|
1283
|
+
for entry in self.history
|
|
1284
|
+
)
|
|
1285
|
+
self._record_completed(job)
|
|
1286
|
+
if not has_done_entry:
|
|
1287
|
+
self._note_done(job, None)
|
|
1288
|
+
self._note_low_confidence(job, again=True)
|
|
1289
|
+
|
|
1290
|
+
def _retry_selected(self) -> None:
|
|
1291
|
+
with self.activity_lock:
|
|
1292
|
+
with self.lock:
|
|
1293
|
+
if self.sel is None or self.sel >= len(self.history):
|
|
1294
|
+
return
|
|
1295
|
+
entry = self.history[self.sel]
|
|
1296
|
+
if entry.kind != "err" or entry.job is None:
|
|
1297
|
+
return
|
|
1298
|
+
# Queue durably before hiding the old failure, so a process crash
|
|
1299
|
+
# cannot lose the only retryable record.
|
|
1300
|
+
self._submit_jobs([entry.job])
|
|
1301
|
+
with self.lock:
|
|
1302
|
+
try:
|
|
1303
|
+
self.history.remove(entry)
|
|
1304
|
+
except ValueError:
|
|
1305
|
+
pass
|
|
1306
|
+
self.failed = [j for j in self.failed if j is not entry.job]
|
|
1307
|
+
self.sel = None
|
|
1308
|
+
self._update_focus()
|
|
1309
|
+
|
|
1310
|
+
@staticmethod
|
|
1311
|
+
def _detail_line(tags: TrackTags) -> str | None:
|
|
1312
|
+
"""The `•`-separated metadata rendered under a completed entry."""
|
|
1313
|
+
parts = [tags.artist]
|
|
1314
|
+
if tags.album_artist and tags.album_artist != tags.artist:
|
|
1315
|
+
parts.append(tags.album_artist)
|
|
1316
|
+
parts += [tags.album, tags.year, tags.genre]
|
|
1317
|
+
if tags.kind == "derivative" and tags.based_on:
|
|
1318
|
+
parts.append(f"derivative of {tags.based_on}")
|
|
1319
|
+
joined = " • ".join(p for p in parts if p)
|
|
1320
|
+
return joined or None
|
|
1321
|
+
|
|
1322
|
+
@staticmethod
|
|
1323
|
+
def _error_line(error: Exception) -> str:
|
|
1324
|
+
lines = [line.strip() for line in str(error).splitlines() if line.strip()]
|
|
1325
|
+
return (lines[0] if lines else error.__class__.__name__)[:200]
|
|
1326
|
+
|
|
1327
|
+
# ── input handling ──────────────────────────────────────────────────
|
|
1328
|
+
|
|
1329
|
+
def _accept(self, buff: Buffer) -> bool:
|
|
1330
|
+
if buff is self.dir_buffer:
|
|
1331
|
+
self._accept_dir(buff)
|
|
1332
|
+
return False
|
|
1333
|
+
if self.modal:
|
|
1334
|
+
modal = self.modal
|
|
1335
|
+
text = buff.text.strip()
|
|
1336
|
+
self.close_modal()
|
|
1337
|
+
if text and text != modal.initial.strip():
|
|
1338
|
+
modal.on_submit(text)
|
|
1339
|
+
return False
|
|
1340
|
+
|
|
1341
|
+
text = buff.text.strip()
|
|
1342
|
+
if not text:
|
|
1343
|
+
return False
|
|
1344
|
+
if text.startswith("/"):
|
|
1345
|
+
self.commands.dispatch(text)
|
|
1346
|
+
if text.split(maxsplit=1)[0].lower() == "/key":
|
|
1347
|
+
buff.text = "/key" # never append a pasted key to command history
|
|
1348
|
+
return False
|
|
1349
|
+
|
|
1350
|
+
urls = extract_urls(text)
|
|
1351
|
+
if not urls:
|
|
1352
|
+
self.msg(
|
|
1353
|
+
"class:dim", "that doesn't look like a link — paste a URL or type /help"
|
|
1354
|
+
)
|
|
1355
|
+
return False
|
|
1356
|
+
# Gate on source: only hosts yt-dlp can extract audio from. To accept a
|
|
1357
|
+
# new media source, extend SUPPORTED_HOSTS in downloader.py — not here.
|
|
1358
|
+
supported = [u for u in urls if is_supported_url(u)]
|
|
1359
|
+
if not supported:
|
|
1360
|
+
self.msg(
|
|
1361
|
+
"class:err", "only YouTube and SoundCloud links are supported"
|
|
1362
|
+
)
|
|
1363
|
+
return False
|
|
1364
|
+
rejected = len(urls) - len(supported)
|
|
1365
|
+
if rejected:
|
|
1366
|
+
self.msg(
|
|
1367
|
+
"class:warn",
|
|
1368
|
+
f"ignored {rejected} unsupported "
|
|
1369
|
+
f"link{'s' if rejected != 1 else ''} — "
|
|
1370
|
+
"only YouTube and SoundCloud are supported",
|
|
1371
|
+
)
|
|
1372
|
+
self.submit_urls(supported)
|
|
1373
|
+
return False # False → clear the input line (and append to history)
|
|
1374
|
+
|
|
1375
|
+
def _accept_hint(self, buff: Buffer) -> bool:
|
|
1376
|
+
text = buff.text.strip()
|
|
1377
|
+
if not text:
|
|
1378
|
+
return False
|
|
1379
|
+
with self.activity_lock:
|
|
1380
|
+
with self.lock:
|
|
1381
|
+
if self.sel is None or self.sel >= len(self.history):
|
|
1382
|
+
return False
|
|
1383
|
+
selected = self.sel
|
|
1384
|
+
entry = self.history[selected]
|
|
1385
|
+
if entry.kind != "hint" or entry.job is None:
|
|
1386
|
+
return False
|
|
1387
|
+
job = entry.job
|
|
1388
|
+
|
|
1389
|
+
claim = self._persist("claim_hint", job, self.session_id)
|
|
1390
|
+
if claim is False:
|
|
1391
|
+
self.msg(
|
|
1392
|
+
"class:dim",
|
|
1393
|
+
"that hint is already being checked in another Transmute session",
|
|
1394
|
+
)
|
|
1395
|
+
return False
|
|
1396
|
+
claim_token = claim if isinstance(claim, str) else None
|
|
1397
|
+
placeholder = Entry(
|
|
1398
|
+
"class:dim",
|
|
1399
|
+
f"⧗ re-checking “{(job.title or '')[:40]}” with your hint…",
|
|
1400
|
+
"info",
|
|
1401
|
+
job,
|
|
1402
|
+
)
|
|
1403
|
+
with self.lock:
|
|
1404
|
+
if (
|
|
1405
|
+
selected >= len(self.history)
|
|
1406
|
+
or self.history[selected] is not entry
|
|
1407
|
+
or self.sel != selected
|
|
1408
|
+
):
|
|
1409
|
+
replaced = False
|
|
1410
|
+
else:
|
|
1411
|
+
self.history[selected] = placeholder
|
|
1412
|
+
self.sel = None
|
|
1413
|
+
replaced = True
|
|
1414
|
+
if not replaced:
|
|
1415
|
+
if claim_token:
|
|
1416
|
+
self._persist("release_hint", job.history_id, claim_token)
|
|
1417
|
+
return False
|
|
1418
|
+
self._update_focus()
|
|
1419
|
+
try:
|
|
1420
|
+
self.pool.submit(
|
|
1421
|
+
self._rehint,
|
|
1422
|
+
job,
|
|
1423
|
+
text,
|
|
1424
|
+
placeholder,
|
|
1425
|
+
claim_token,
|
|
1426
|
+
)
|
|
1427
|
+
except RuntimeError as error:
|
|
1428
|
+
with self.activity_lock:
|
|
1429
|
+
if claim_token:
|
|
1430
|
+
self._persist("release_hint", job.history_id, claim_token)
|
|
1431
|
+
with self.lock:
|
|
1432
|
+
try:
|
|
1433
|
+
index = self.history.index(placeholder)
|
|
1434
|
+
except ValueError:
|
|
1435
|
+
pass
|
|
1436
|
+
else:
|
|
1437
|
+
self.history[index] = entry
|
|
1438
|
+
self.msg("class:err", f"could not schedule hint lookup: {error}")
|
|
1439
|
+
self.refresh()
|
|
1440
|
+
return False
|
|
1441
|
+
|
|
1442
|
+
def run(self) -> None:
|
|
1443
|
+
if not shutil.which("ffmpeg"):
|
|
1444
|
+
self.msg(
|
|
1445
|
+
"class:warn",
|
|
1446
|
+
"⚠ ffmpeg not found — install it with: brew install ffmpeg",
|
|
1447
|
+
)
|
|
1448
|
+
self._startup_enrichment_notice()
|
|
1449
|
+
drained = False
|
|
1450
|
+
try:
|
|
1451
|
+
try:
|
|
1452
|
+
self.app.run(pre_run=self._start_signin_recheck)
|
|
1453
|
+
except (EOFError, KeyboardInterrupt):
|
|
1454
|
+
pass # non-tty input ends with EOF; treat as a normal quit
|
|
1455
|
+
|
|
1456
|
+
# Back in the normal terminal: drain any in-flight work.
|
|
1457
|
+
with self.lock:
|
|
1458
|
+
busy = len(self.active) + self.queued
|
|
1459
|
+
if busy:
|
|
1460
|
+
print(
|
|
1461
|
+
f"finishing {busy} job{'s' if busy != 1 else ''}… "
|
|
1462
|
+
"(ctrl-c to abandon)"
|
|
1463
|
+
)
|
|
1464
|
+
try:
|
|
1465
|
+
self.pool.shutdown(wait=True)
|
|
1466
|
+
except KeyboardInterrupt:
|
|
1467
|
+
pass
|
|
1468
|
+
else:
|
|
1469
|
+
drained = True
|
|
1470
|
+
finally:
|
|
1471
|
+
# A Ctrl-C during executor draining does not actually stop Python's
|
|
1472
|
+
# worker threads. Keep this session live until the process exits so
|
|
1473
|
+
# another REPL cannot claim and mutate the same file concurrently.
|
|
1474
|
+
if self.session_id and drained:
|
|
1475
|
+
self._persist("finish_session", self.session_id)
|
|
1476
|
+
|
|
1477
|
+
with self.lock:
|
|
1478
|
+
done = sum(state == "done" for state in self._run_outcomes.values())
|
|
1479
|
+
failed = sum(state == "error" for state in self._run_outcomes.values())
|
|
1480
|
+
out_dir = self.settings.out_dir
|
|
1481
|
+
print(f"{done} converted · {failed} failed · files in {out_dir}")
|