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/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}")