@jakende/media-info-cli 0.1.0

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.
@@ -0,0 +1,942 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import ctypes
6
+ import os
7
+ import select
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ import time
13
+ from pathlib import Path
14
+
15
+ if os.name == "nt":
16
+ import msvcrt
17
+
18
+ termios = None
19
+ tty = None
20
+ else:
21
+ import termios
22
+ import tty
23
+
24
+ msvcrt = None
25
+
26
+ from media_information_download.config import (
27
+ dependency_status,
28
+ get_model_name,
29
+ get_output_dir,
30
+ get_whisper_language,
31
+ )
32
+ from media_information_download.output import list_output_files
33
+ from media_information_download.pipeline import MediaPipeline, ProcessOptions
34
+
35
+
36
+ UI_OUT = sys.__stdout__
37
+ BACK = "__back__"
38
+ ESCAPE = "__escape__"
39
+ ENTER = "__enter__"
40
+ UP = "__up__"
41
+ DOWN = "__down__"
42
+ BRACKETED_PASTE_START = "[200~"
43
+ BRACKETED_PASTE_END = b"\x1b[201~"
44
+
45
+ VIEWPORT_MIN_WIDTH = 56
46
+ VIEWPORT_MAX_WIDTH = 118
47
+ VIEWPORT_MIN_HEIGHT = 14
48
+ VIEWPORT_MAX_HEIGHT = 30
49
+ VIEWPORT_LEFT_MARGIN = 2
50
+ VIEWPORT_TOP_MARGIN = 2
51
+
52
+ MODEL_OPTIONS = ["tiny", "base", "small", "medium", "large"]
53
+ LANGUAGE_OPTIONS: list[tuple[str, str | None]] = [
54
+ ("Auto detect", None),
55
+ ("German", "de"),
56
+ ("English", "en"),
57
+ ("French", "fr"),
58
+ ("Spanish", "es"),
59
+ ("Italian", "it"),
60
+ ("Portuguese", "pt"),
61
+ ("Dutch", "nl"),
62
+ ("Polish", "pl"),
63
+ ("Turkish", "tr"),
64
+ ("Swedish", "sv"),
65
+ ("Danish", "da"),
66
+ ("Norwegian", "no"),
67
+ ("Finnish", "fi"),
68
+ ("Japanese", "ja"),
69
+ ("Chinese", "zh"),
70
+ ("Korean", "ko"),
71
+ ]
72
+
73
+
74
+ class Style:
75
+ reset = "\033[0m"
76
+ dim = "\033[2m"
77
+ bold = "\033[1m"
78
+ cyan = "\033[36m"
79
+ green = "\033[32m"
80
+ yellow = "\033[33m"
81
+ red = "\033[31m"
82
+ blue = "\033[34m"
83
+ magenta = "\033[35m"
84
+
85
+
86
+ def _supports_color() -> bool:
87
+ return UI_OUT.isatty() and os.environ.get("NO_COLOR") is None
88
+
89
+
90
+ def _color(value: str, style: str) -> str:
91
+ if not _supports_color():
92
+ return value
93
+ return f"{style}{value}{Style.reset}"
94
+
95
+
96
+ def _terminal_width() -> int:
97
+ return min(96, max(64, shutil.get_terminal_size((88, 24)).columns))
98
+
99
+
100
+ def _terminal_height() -> int:
101
+ return max(16, shutil.get_terminal_size((88, 24)).lines)
102
+
103
+
104
+ def _clamp(value: int, minimum: int, maximum: int) -> int:
105
+ return max(minimum, min(value, maximum))
106
+
107
+
108
+ def _viewport_geometry(size: os.terminal_size | None = None) -> tuple[int, int, int, int]:
109
+ terminal = size or shutil.get_terminal_size((100, 30))
110
+ available_width = max(20, terminal.columns - VIEWPORT_LEFT_MARGIN)
111
+ available_height = max(10, terminal.lines - VIEWPORT_TOP_MARGIN)
112
+ width = _clamp(available_width, min(VIEWPORT_MIN_WIDTH, available_width), VIEWPORT_MAX_WIDTH)
113
+ height = _clamp(available_height, min(VIEWPORT_MIN_HEIGHT, available_height), VIEWPORT_MAX_HEIGHT)
114
+ left = max(1, min(VIEWPORT_LEFT_MARGIN, terminal.columns - width + 1))
115
+ top = max(1, min(VIEWPORT_TOP_MARGIN, terminal.lines - height + 1))
116
+ return top, left, width, height
117
+
118
+
119
+ def _move(row: int, column: int) -> str:
120
+ return f"\033[{row};{column}H"
121
+
122
+
123
+ def _visible_slice(value: str, width: int) -> str:
124
+ return value[: max(0, width)]
125
+
126
+
127
+ def _ui_print(*args, **kwargs) -> None:
128
+ kwargs.setdefault("file", UI_OUT)
129
+ print(*args, **kwargs)
130
+
131
+
132
+ def _draw_viewport(title: str) -> tuple[int, int, int, int]:
133
+ top, left, width, height = _viewport_geometry()
134
+ inner_width = width - 2
135
+ bottom = top + height - 1
136
+ right = left + width - 1
137
+ title_text = f" {title} "
138
+ if len(title_text) > inner_width:
139
+ title_text = title_text[:inner_width]
140
+
141
+ top_rule = "-" * inner_width
142
+ title_start = max(0, (inner_width - len(title_text)) // 2)
143
+ top_rule = top_rule[:title_start] + title_text + top_rule[title_start + len(title_text):]
144
+
145
+ _ui_print(_move(top, left) + _color("+" + top_rule + "+", Style.cyan), end="")
146
+ for row in range(top + 1, bottom):
147
+ _ui_print(_move(row, left) + _color("|", Style.cyan), end="")
148
+ _ui_print(" " * inner_width, end="")
149
+ _ui_print(_color("|", Style.cyan), end="")
150
+ _ui_print(_move(bottom, left) + _color("+" + "-" * inner_width + "+", Style.cyan), end="")
151
+ _ui_print(_move(top + 2, left + 2), end="", flush=True)
152
+ return top, left, width, height
153
+
154
+
155
+ def _viewport_line(row_offset: int, text: str = "", style: str | None = None) -> None:
156
+ top, left, width, _ = _viewport_geometry()
157
+ inner_width = width - 4
158
+ text = _visible_slice(text, inner_width).ljust(inner_width)
159
+ if style:
160
+ text = _color(text, style)
161
+ _ui_print(_move(top + 2 + row_offset, left + 2) + text, end="")
162
+
163
+
164
+ def _rule(label: str = "") -> str:
165
+ width = _terminal_width()
166
+ if not label:
167
+ return _color("-" * width, Style.dim)
168
+ text = f" {label} "
169
+ left = max(2, (width - len(text)) // 2)
170
+ right = max(2, width - left - len(text))
171
+ return _color("-" * left + text + "-" * right, Style.dim)
172
+
173
+
174
+ def _banner() -> None:
175
+ width = _terminal_width()
176
+ title = "MEDIA INFORMATION DOWNLOAD"
177
+ subtitle = "YouTube + RSS -> MP3 -> Whisper Markdown"
178
+ _ui_print(_color("=" * width, Style.cyan))
179
+ _ui_print(_color(title.center(width), Style.bold + Style.cyan))
180
+ _ui_print(_color(subtitle.center(width), Style.dim))
181
+ _ui_print(_color("=" * width, Style.cyan))
182
+
183
+
184
+ def _prompt(label: str) -> str:
185
+ return input(_color(f"{label} ", Style.bold + Style.blue)).strip()
186
+
187
+
188
+ def _navigation_hint(
189
+ extra: str | None = None,
190
+ escape_label: str = "Back",
191
+ include_arrows: bool = True,
192
+ ) -> str:
193
+ parts = []
194
+ if extra:
195
+ parts.append(extra)
196
+ if include_arrows:
197
+ parts.append("Up/Down: move")
198
+ parts.append("Enter: select")
199
+ else:
200
+ parts.append("Enter: continue")
201
+ parts.append("Backspace: back")
202
+ parts.append(f"Esc: {escape_label}")
203
+ return " ".join(parts)
204
+
205
+
206
+ def _footer(message: str) -> None:
207
+ if sys.stdout.isatty():
208
+ top, left, width, height = _viewport_geometry()
209
+ row = top + height - 2
210
+ column = left + 2
211
+ max_width = width - 4
212
+ else:
213
+ row = _terminal_height()
214
+ column = 1
215
+ max_width = shutil.get_terminal_size((88, 24)).columns - 1
216
+
217
+ plain_message = message[: max(1, max_width)].ljust(max_width)
218
+ clear = "" if sys.stdout.isatty() else "\033[2K"
219
+ _ui_print(f"\033[{row};{column}H{clear}{_color(plain_message, Style.dim)}", end="", flush=True)
220
+
221
+
222
+ def _read_bracketed_paste(fd: int) -> str:
223
+ data = b""
224
+ while True:
225
+ chunk = os.read(fd, 1024)
226
+ if not chunk:
227
+ break
228
+ data += chunk
229
+ end_index = data.find(BRACKETED_PASTE_END)
230
+ if end_index >= 0:
231
+ data = data[:end_index]
232
+ break
233
+ return data.decode(errors="ignore")
234
+
235
+
236
+ def _drain_available_text(fd: int) -> str:
237
+ data = b""
238
+ while select.select([sys.stdin], [], [], 0.01)[0]:
239
+ chunk = os.read(fd, 4096)
240
+ if not chunk:
241
+ break
242
+ data += chunk
243
+ return data.decode(errors="ignore")
244
+
245
+
246
+ def _read_clipboard() -> str:
247
+ command = None
248
+ if sys.platform == "darwin":
249
+ command = ["pbpaste"]
250
+ elif sys.platform == "win32":
251
+ command = ["powershell", "-NoProfile", "-Command", "Get-Clipboard -Raw"]
252
+ if command is None:
253
+ return ""
254
+
255
+ result = subprocess.run(
256
+ command,
257
+ stdout=subprocess.PIPE,
258
+ stderr=subprocess.DEVNULL,
259
+ text=True,
260
+ check=False,
261
+ )
262
+ return result.stdout if result.returncode == 0 else ""
263
+
264
+
265
+ def _sanitize_text_entry(value: str) -> str:
266
+ value = value.replace("\x1b[200~", "").replace("\x1b[201~", "")
267
+ value = value.replace("\r\n", "\n").replace("\r", "\n")
268
+ value = value.replace("\n", ",").replace("\t", ",")
269
+ return "".join(
270
+ char
271
+ for char in value
272
+ if char.isprintable() and char not in {"\x1b", "\x7f", "\b"}
273
+ )
274
+
275
+
276
+ @contextlib.contextmanager
277
+ def _suppress_external_output(enabled: bool):
278
+ if not enabled:
279
+ yield
280
+ return
281
+
282
+ with open(os.devnull, "w", encoding="utf-8") as devnull:
283
+ with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
284
+ yield
285
+
286
+
287
+ def _read_key_posix(*, text_mode: bool = False) -> str:
288
+ fd = sys.stdin.fileno()
289
+ if termios is None or tty is None:
290
+ raise RuntimeError("POSIX terminal input is not available on this platform.")
291
+ old_settings = termios.tcgetattr(fd)
292
+ try:
293
+ tty.setraw(fd)
294
+ char = os.read(fd, 1).decode(errors="ignore")
295
+ if char == "\x1b":
296
+ time.sleep(0.06)
297
+ if not select.select([sys.stdin], [], [], 0.35)[0]:
298
+ return ESCAPE
299
+
300
+ introducer = os.read(fd, 1).decode(errors="ignore")
301
+ if introducer not in {"[", "O"}:
302
+ return ESCAPE
303
+
304
+ final = ""
305
+ while select.select([sys.stdin], [], [], 0.10)[0]:
306
+ final += os.read(fd, 1).decode(errors="ignore")
307
+ if final[-1].isalpha() or final[-1] == "~":
308
+ break
309
+
310
+ sequence = introducer + final
311
+ if text_mode and sequence == BRACKETED_PASTE_START:
312
+ return _read_bracketed_paste(fd)
313
+ return _key_from_escape_sequence(sequence)
314
+ if char in {"\r", "\n"}:
315
+ return ENTER
316
+ if char in {"\x7f", "\b"}:
317
+ return BACK
318
+ if text_mode and char == "\x16":
319
+ return _read_clipboard()
320
+ if not text_mode and char.lower() == "k":
321
+ return UP
322
+ if not text_mode and char.lower() == "j":
323
+ return DOWN
324
+ if text_mode and char.isprintable():
325
+ return char + _drain_available_text(fd)
326
+ return char
327
+ finally:
328
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
329
+
330
+
331
+ def _drain_available_windows_text() -> str:
332
+ if msvcrt is None:
333
+ return ""
334
+
335
+ data = ""
336
+ while msvcrt.kbhit():
337
+ char = msvcrt.getwch()
338
+ if char in {"\x00", "\xe0"}:
339
+ if msvcrt.kbhit():
340
+ msvcrt.getwch()
341
+ continue
342
+ if char == "\x03":
343
+ raise KeyboardInterrupt
344
+ data += "\n" if char in {"\r", "\n"} else char
345
+ return data
346
+
347
+
348
+ def _read_key_windows(*, text_mode: bool = False) -> str:
349
+ if msvcrt is None:
350
+ raise RuntimeError("Windows terminal input is not available on this platform.")
351
+
352
+ char = msvcrt.getwch()
353
+ if char in {"\x00", "\xe0"}:
354
+ code = msvcrt.getwch()
355
+ if code == "H":
356
+ return UP
357
+ if code == "P":
358
+ return DOWN
359
+ return ESCAPE
360
+ if char == "\x03":
361
+ raise KeyboardInterrupt
362
+ if char == "\x1b":
363
+ return ESCAPE
364
+ if char in {"\r", "\n"}:
365
+ return ENTER
366
+ if char == "\x08":
367
+ return BACK
368
+ if text_mode and char == "\x16":
369
+ return _read_clipboard()
370
+ if not text_mode and char.lower() == "k":
371
+ return UP
372
+ if not text_mode and char.lower() == "j":
373
+ return DOWN
374
+ if text_mode and (char.isprintable() or char in {"\t"}):
375
+ return char + _drain_available_windows_text()
376
+ return char
377
+
378
+
379
+ def _read_key(*, text_mode: bool = False) -> str:
380
+ if os.name == "nt":
381
+ return _read_key_windows(text_mode=text_mode)
382
+ return _read_key_posix(text_mode=text_mode)
383
+
384
+
385
+ def _key_from_escape_sequence(sequence: str) -> str:
386
+ if sequence in {"[A", "OA", "[1A", "[1;2A", "[1;3A", "[1;5A"}:
387
+ return UP
388
+ if sequence in {"[B", "OB", "[1B", "[1;2B", "[1;3B", "[1;5B"}:
389
+ return DOWN
390
+ return ESCAPE
391
+
392
+
393
+ def _clear_screen() -> None:
394
+ _ui_print("\033[2J\033[H", end="")
395
+
396
+
397
+ def _clear_framed_screen(title: str) -> None:
398
+ _clear_screen()
399
+ _draw_viewport(title)
400
+
401
+
402
+ def _select(
403
+ title: str,
404
+ options: list[str],
405
+ *,
406
+ initial: int = 0,
407
+ allow_back: bool = True,
408
+ escape_label: str = "Back",
409
+ ) -> int | None:
410
+ if not sys.stdin.isatty():
411
+ for index, option in enumerate(options, start=1):
412
+ print(f"{index}. {option}")
413
+ raw = _prompt("Choose:")
414
+ if not raw:
415
+ return None
416
+ try:
417
+ selected = int(raw) - 1
418
+ except ValueError:
419
+ return None
420
+ return selected if 0 <= selected < len(options) else None
421
+
422
+ selected = max(0, min(initial, len(options) - 1))
423
+ while True:
424
+ _clear_framed_screen(title)
425
+ row = 0
426
+ for index, option in enumerate(options):
427
+ marker = ">" if index == selected else " "
428
+ line = f" {marker} {option}"
429
+ if index == selected:
430
+ _viewport_line(row, line, Style.bold + Style.cyan)
431
+ else:
432
+ _viewport_line(row, line)
433
+ row += 1
434
+ _footer(_navigation_hint(escape_label=escape_label))
435
+
436
+ key = _read_key()
437
+ if key == UP:
438
+ selected = (selected - 1) % len(options)
439
+ elif key == DOWN:
440
+ selected = (selected + 1) % len(options)
441
+ elif key == ENTER:
442
+ return selected
443
+ elif key == BACK and allow_back:
444
+ return None
445
+ elif key == ESCAPE:
446
+ return None
447
+ elif key.isdigit():
448
+ numeric = int(key) - 1
449
+ if 0 <= numeric < len(options):
450
+ return numeric
451
+
452
+
453
+ def _text_entry(title: str, label: str) -> str | None:
454
+ if not sys.stdin.isatty():
455
+ value = _prompt(label)
456
+ return value or None
457
+
458
+ value = ""
459
+ print("\033[?2004h", end="", flush=True)
460
+ try:
461
+ while True:
462
+ _clear_framed_screen(title)
463
+ _viewport_line(0, label, Style.bold + Style.blue)
464
+ _viewport_line(2, value)
465
+ _footer(
466
+ _navigation_hint(
467
+ "Type or paste text",
468
+ escape_label="Back",
469
+ include_arrows=False,
470
+ )
471
+ )
472
+
473
+ key = _read_key(text_mode=True)
474
+ if key == ENTER:
475
+ return value.strip() or None
476
+ if key in {BACK, ESCAPE}:
477
+ return None
478
+ if key in {UP, DOWN}:
479
+ continue
480
+ if key == "\x15":
481
+ value = ""
482
+ continue
483
+ if key == "\x03":
484
+ raise KeyboardInterrupt
485
+ if key:
486
+ value += _sanitize_text_entry(key)
487
+ finally:
488
+ print("\033[?2004l", end="", flush=True)
489
+
490
+
491
+ def _print_progress(message: str) -> None:
492
+ prefix = _color(">", Style.cyan)
493
+ if message.startswith("ERROR"):
494
+ prefix = _color("!", Style.red)
495
+ message = _color(message, Style.red)
496
+ elif "written" in message.lower() or "ready" in message.lower() or "downloaded" in message.lower():
497
+ prefix = _color("+", Style.green)
498
+ print(f" {prefix} {message}", flush=True)
499
+
500
+
501
+ class SpinnerProgress:
502
+ frames = (
503
+ "[#####-----]",
504
+ "[--#####---]",
505
+ "[----#####-]",
506
+ "[---#####--]",
507
+ )
508
+ active_prefixes = (
509
+ "Downloading:",
510
+ "Converting to MP3:",
511
+ "Transcribing:",
512
+ )
513
+ terminal_prefixes = (
514
+ "Downloaded:",
515
+ "MP3 ready:",
516
+ "Transcript written:",
517
+ "ERROR:",
518
+ )
519
+
520
+ def __init__(self) -> None:
521
+ self._enabled = sys.stdout.isatty()
522
+ self._lock = threading.Lock()
523
+ self._stop_event = threading.Event()
524
+ self._thread: threading.Thread | None = None
525
+ self._message = ""
526
+ self._frame_index = 0
527
+ self._log_messages: list[str] = []
528
+
529
+ def __call__(self, message: str) -> None:
530
+ if not self._enabled:
531
+ _print_progress(message)
532
+ return
533
+
534
+ if self._is_active_message(message):
535
+ self._start(message)
536
+ return
537
+
538
+ if self._is_terminal_message(message):
539
+ self.stop()
540
+ self._append_log(self._format_log_message(message))
541
+ return
542
+
543
+ self.stop()
544
+ self._append_log(self._format_log_message(message))
545
+
546
+ def stop(self) -> None:
547
+ thread = self._thread
548
+ if thread is None:
549
+ return
550
+
551
+ self._stop_event.set()
552
+ thread.join(timeout=1)
553
+ self._thread = None
554
+ self._stop_event.clear()
555
+ _viewport_line(0, "")
556
+
557
+ def _start(self, message: str) -> None:
558
+ with self._lock:
559
+ self._message = message
560
+ self._render_active(message)
561
+ if self._thread is not None and self._thread.is_alive():
562
+ return
563
+
564
+ self._stop_event.clear()
565
+ self._thread = threading.Thread(target=self._run, daemon=True)
566
+ self._thread.start()
567
+
568
+ def _run(self) -> None:
569
+ while not self._stop_event.is_set():
570
+ with self._lock:
571
+ message = self._message
572
+ frame = self.frames[self._frame_index % len(self.frames)]
573
+ self._frame_index += 1
574
+ self._render_active(message, frame=frame)
575
+ self._stop_event.wait(0.18)
576
+
577
+ def _render_active(self, message: str, frame: str | None = None) -> None:
578
+ frame = frame or self.frames[self._frame_index % len(self.frames)]
579
+ phase = self._active_phase(message)
580
+ _viewport_line(0, f"WORKING {frame} {phase}", Style.bold + Style.cyan)
581
+ _viewport_line(1, message, Style.cyan)
582
+
583
+ def _append_log(self, message: str) -> None:
584
+ self._log_messages.append(message)
585
+ _, _, _, height = _viewport_geometry()
586
+ max_rows = max(4, height - 8)
587
+ recent = self._log_messages[-max_rows:]
588
+ for row in range(max_rows):
589
+ value = recent[row] if row < len(recent) else ""
590
+ style = None
591
+ if value.startswith("!"):
592
+ style = Style.red
593
+ elif value.startswith("+"):
594
+ style = Style.green
595
+ elif value.startswith(">"):
596
+ style = Style.cyan
597
+ _viewport_line(row + 3, value, style)
598
+
599
+ @staticmethod
600
+ def _active_phase(message: str) -> str:
601
+ stripped = message.split("] ", maxsplit=1)[-1]
602
+ return stripped.split(":", maxsplit=1)[0].upper()
603
+
604
+ @staticmethod
605
+ def _format_log_message(message: str) -> str:
606
+ if message.startswith("ERROR:"):
607
+ return f"! {message}"
608
+ if (
609
+ message.startswith("Downloaded:")
610
+ or message.startswith("MP3 ready:")
611
+ or message.startswith("Transcript written:")
612
+ ):
613
+ return f"+ {message}"
614
+ return f"> {message}"
615
+
616
+ @classmethod
617
+ def _is_active_message(cls, message: str) -> bool:
618
+ stripped = message.split("] ", maxsplit=1)[-1]
619
+ return stripped.startswith(cls.active_prefixes)
620
+
621
+ @classmethod
622
+ def _is_terminal_message(cls, message: str) -> bool:
623
+ return message.startswith(cls.terminal_prefixes)
624
+
625
+
626
+ def _yes_no(prompt: str, default: bool = True) -> bool:
627
+ initial = 0 if default else 1
628
+ selected = _select(prompt, ["Yes", "No"], initial=initial)
629
+ return default if selected is None else selected == 0
630
+
631
+
632
+ def _choose_model(current_model: str) -> str | None:
633
+ options = MODEL_OPTIONS.copy()
634
+ if current_model not in options:
635
+ options.insert(0, current_model)
636
+ initial = options.index(current_model) if current_model in options else 0
637
+ selected = _select("Whisper Model", options, initial=initial)
638
+ return None if selected is None else options[selected]
639
+
640
+
641
+ def _choose_language(current_language: str | None) -> str | None | object:
642
+ labels = [
643
+ f"{name} ({code})" if code else name
644
+ for name, code in LANGUAGE_OPTIONS
645
+ ]
646
+ codes = [code for _, code in LANGUAGE_OPTIONS]
647
+ initial = codes.index(current_language) if current_language in codes else 0
648
+ selected = _select("Transcription Language", labels, initial=initial)
649
+ if selected is None:
650
+ return BACK
651
+ return codes[selected]
652
+
653
+
654
+ def _print_results(results) -> None:
655
+ if sys.stdout.isatty():
656
+ _clear_framed_screen("Results")
657
+ row = 0
658
+ _, _, _, height = _viewport_geometry()
659
+ max_rows = max(4, height - 5)
660
+ for result in results:
661
+ if row >= max_rows:
662
+ _viewport_line(row, "... more result details omitted", Style.dim)
663
+ break
664
+ title = result.item.title
665
+ if result.error:
666
+ _viewport_line(row, f"x {title}: {result.error}", Style.red)
667
+ row += 1
668
+ for note in result.notes:
669
+ if row >= max_rows:
670
+ break
671
+ _viewport_line(row, f" {note}", Style.yellow)
672
+ row += 1
673
+ continue
674
+
675
+ _viewport_line(row, f"+ {title}", Style.green)
676
+ row += 1
677
+ for label, path in (
678
+ ("media", result.downloaded_path),
679
+ ("mp3", result.mp3_path),
680
+ ("transcript", result.transcript_path),
681
+ ):
682
+ if path and row < max_rows:
683
+ _viewport_line(row, f" {label:<10} {path}", Style.dim)
684
+ row += 1
685
+ _footer("Enter: continue Backspace: back Esc: Back")
686
+ key = _read_key()
687
+ while key not in {ENTER, BACK, ESCAPE}:
688
+ key = _read_key()
689
+ return
690
+
691
+ print("\n" + _rule("Results"))
692
+ for result in results:
693
+ title = result.item.title
694
+ if result.error:
695
+ print(f"{_color('x', Style.red)} {title}: {_color(result.error, Style.red)}")
696
+ for note in result.notes:
697
+ print(f" {_color(note, Style.yellow)}")
698
+ continue
699
+ print(f"{_color('+', Style.green)} {_color(title, Style.bold)}")
700
+ if result.downloaded_path:
701
+ print(f" {_color('media', Style.dim)} {result.downloaded_path}")
702
+ if result.mp3_path:
703
+ print(f" {_color('mp3', Style.dim)} {result.mp3_path}")
704
+ if result.transcript_path:
705
+ print(f" {_color('transcript', Style.dim)} {result.transcript_path}")
706
+
707
+
708
+ def _process_source(source_type: str) -> None:
709
+ label = "YouTube URL(s)" if source_type == "youtube" else "RSS feed URL"
710
+ raw_input = _text_entry(label, f"{label}:")
711
+ if not raw_input:
712
+ return
713
+
714
+ transcribe = _yes_no("Run transcription after download", default=True)
715
+ model_name = get_model_name()
716
+ language = get_whisper_language()
717
+ if transcribe:
718
+ selected_model = _choose_model(model_name)
719
+ if selected_model is None:
720
+ return
721
+ model_name = selected_model
722
+
723
+ selected_language = _choose_language(language)
724
+ if selected_language == BACK:
725
+ return
726
+ language = selected_language
727
+
728
+ if sys.stdout.isatty():
729
+ _clear_framed_screen("Processing")
730
+ else:
731
+ _clear_screen()
732
+ _banner()
733
+ print(_rule("Processing"))
734
+ progress = SpinnerProgress()
735
+ try:
736
+ pipeline = MediaPipeline(progress=progress)
737
+ with _suppress_external_output(sys.stdout.isatty()):
738
+ results = pipeline.process(
739
+ ProcessOptions(
740
+ source_type=source_type,
741
+ raw_input=raw_input,
742
+ output_dir=get_output_dir(),
743
+ transcribe=transcribe,
744
+ model_name=model_name,
745
+ language=language,
746
+ )
747
+ )
748
+ finally:
749
+ progress.stop()
750
+ _print_results(results)
751
+
752
+
753
+ def _transcribe_existing() -> None:
754
+ output_dir = get_output_dir()
755
+ candidates = [
756
+ path
757
+ for path in list_output_files(output_dir)
758
+ if path.suffix.lower() in {".mp3", ".m4a", ".wav", ".flac", ".aac", ".ogg"}
759
+ ]
760
+ if not candidates:
761
+ print(_color(f"No supported audio files found in {output_dir}", Style.yellow))
762
+ return
763
+
764
+ options = ["All audio files"] + [path.name for path in candidates]
765
+ selected_index = _select("Audio Files", options)
766
+ if selected_index is None:
767
+ return
768
+ if selected_index == 0:
769
+ selected = candidates
770
+ else:
771
+ selected = [candidates[selected_index - 1]]
772
+ if not selected:
773
+ print(_color("No files selected.", Style.yellow))
774
+ return
775
+
776
+ if sys.stdout.isatty():
777
+ _clear_framed_screen("Transcription")
778
+ else:
779
+ _clear_screen()
780
+ _banner()
781
+ print(_rule("Transcription"))
782
+ progress = SpinnerProgress()
783
+ try:
784
+ pipeline = MediaPipeline(progress=progress)
785
+ with _suppress_external_output(sys.stdout.isatty()):
786
+ results = pipeline.transcribe_existing(selected, output_dir=output_dir)
787
+ finally:
788
+ progress.stop()
789
+ _print_results(results)
790
+
791
+
792
+ def _show_outputs() -> None:
793
+ output_dir = get_output_dir()
794
+ files = list_output_files(output_dir)
795
+ if not files:
796
+ if sys.stdout.isatty():
797
+ _clear_framed_screen("Output Files")
798
+ _viewport_line(0, f"No output files in {output_dir}", Style.yellow)
799
+ _footer("Backspace/Esc: back")
800
+ while _read_key() not in {BACK, ESCAPE, ENTER}:
801
+ pass
802
+ else:
803
+ print(_color(f"No output files in {output_dir}", Style.yellow))
804
+ return
805
+
806
+ if sys.stdout.isatty():
807
+ _clear_framed_screen("Output Files")
808
+ _viewport_line(0, str(output_dir), Style.dim)
809
+ _, _, _, height = _viewport_geometry()
810
+ max_files = max(1, height - 8)
811
+ for index, path in enumerate(files[:max_files], start=2):
812
+ _viewport_line(index, f"- {path.name}")
813
+ remaining = len(files) - max_files
814
+ if remaining > 0:
815
+ _viewport_line(max_files + 3, f"... {remaining} more file(s)", Style.dim)
816
+ _footer("Enter: continue Backspace: back Esc: Back")
817
+ key = _read_key()
818
+ while key not in {ENTER, BACK, ESCAPE}:
819
+ key = _read_key()
820
+ if key in {BACK, ESCAPE}:
821
+ return
822
+ else:
823
+ print("\n" + _rule("Output Files"))
824
+ print(_color(str(output_dir), Style.dim))
825
+ for path in files:
826
+ print(f"{_color('-', Style.cyan)} {path.name}")
827
+
828
+ if sys.platform == "darwin" and _yes_no("Open output folder in Finder", default=False):
829
+ subprocess.run(["open", str(output_dir)], check=False)
830
+
831
+
832
+ def run_tui() -> int:
833
+ status = dependency_status(include_transcription=False)
834
+ if status:
835
+ if sys.stdout.isatty():
836
+ _clear_framed_screen("Environment Warnings")
837
+ for index, message in enumerate(status[:8]):
838
+ _viewport_line(index, f"! {message}", Style.yellow)
839
+ _footer("Use ./run.sh for the local project venv Enter: continue")
840
+ while _read_key() not in {ENTER, ESCAPE, BACK}:
841
+ pass
842
+ else:
843
+ _banner()
844
+ print(f"{_color('Output', Style.dim)} {get_output_dir()}")
845
+ print("\n" + _rule("Environment Warnings"))
846
+ for message in status:
847
+ print(f"{_color('!', Style.yellow)} {message}")
848
+ print(_color("Use ./run.sh so Python dependencies come from the local project venv.", Style.yellow))
849
+
850
+ while True:
851
+ try:
852
+ choice = _select(
853
+ "Main Menu",
854
+ [
855
+ "Process YouTube URL",
856
+ "Process RSS feed URL",
857
+ "Transcribe existing audio from output",
858
+ "View output files",
859
+ "Quit",
860
+ ],
861
+ allow_back=False,
862
+ escape_label="Quit",
863
+ )
864
+ if choice is None or choice == 4:
865
+ print(_color("Done.", Style.green))
866
+ return 0
867
+ if choice == 0:
868
+ _process_source("youtube")
869
+ elif choice == 1:
870
+ _process_source("rss")
871
+ elif choice == 2:
872
+ _transcribe_existing()
873
+ elif choice == 3:
874
+ _show_outputs()
875
+ except KeyboardInterrupt:
876
+ print(_color("\nInterrupted.", Style.yellow))
877
+ except EOFError:
878
+ print()
879
+ return 0
880
+ except Exception as exc:
881
+ print(_color(f"ERROR: {exc}", Style.red))
882
+
883
+
884
+ def run_non_interactive(args: argparse.Namespace) -> int:
885
+ if sys.stdout.isatty():
886
+ _clear_framed_screen("Processing")
887
+ progress = SpinnerProgress()
888
+ try:
889
+ pipeline = MediaPipeline(progress=progress)
890
+ with _suppress_external_output(sys.stdout.isatty()):
891
+ results = pipeline.process(
892
+ ProcessOptions(
893
+ source_type=args.source,
894
+ raw_input=args.url,
895
+ output_dir=get_output_dir(),
896
+ transcribe=not args.no_transcribe,
897
+ model_name=args.model or get_model_name(),
898
+ language=args.language if args.language is not None else get_whisper_language(),
899
+ delay_seconds=args.delay,
900
+ )
901
+ )
902
+ finally:
903
+ progress.stop()
904
+ _print_results(results)
905
+ return 1 if any(result.error for result in results) else 0
906
+
907
+
908
+ def parse_args() -> argparse.Namespace:
909
+ parser = argparse.ArgumentParser(description="Download media from YouTube or RSS and transcribe it.")
910
+ parser.add_argument("--source", choices=("youtube", "rss"), help="Input source type.")
911
+ parser.add_argument("--url", help="YouTube URL(s) or RSS feed URL.")
912
+ parser.add_argument("--no-transcribe", action="store_true", help="Download and convert to MP3 only.")
913
+ parser.add_argument("--model", help="Whisper model name. Defaults to WHISPER_MODEL or large.")
914
+ parser.add_argument("--language", help="Optional Whisper language code. Empty/default means auto.")
915
+ parser.add_argument("--delay", type=float, default=2.0, help="Delay between batch items.")
916
+ return parser.parse_args()
917
+
918
+
919
+ def main() -> int:
920
+ _enable_virtual_terminal_processing()
921
+ args = parse_args()
922
+ if args.source or args.url:
923
+ if not args.source or not args.url:
924
+ print("--source and --url must be provided together.", file=sys.stderr)
925
+ return 2
926
+ return run_non_interactive(args)
927
+ return run_tui()
928
+
929
+
930
+ def _enable_virtual_terminal_processing() -> None:
931
+ if os.name != "nt":
932
+ return
933
+ kernel32 = ctypes.windll.kernel32
934
+ handle = kernel32.GetStdHandle(-11)
935
+ mode = ctypes.c_uint32()
936
+ if handle == ctypes.c_void_p(-1).value or not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
937
+ return
938
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
939
+
940
+
941
+ if __name__ == "__main__":
942
+ raise SystemExit(main())