@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.
- package/README.md +178 -0
- package/bin/media-information-download.js +102 -0
- package/media_information_download/__init__.py +0 -0
- package/media_information_download/audio.py +33 -0
- package/media_information_download/config.py +93 -0
- package/media_information_download/downloaders/__init__.py +0 -0
- package/media_information_download/downloaders/http.py +56 -0
- package/media_information_download/downloaders/youtube.py +89 -0
- package/media_information_download/models.py +29 -0
- package/media_information_download/output.py +86 -0
- package/media_information_download/pipeline.py +164 -0
- package/media_information_download/sources/__init__.py +0 -0
- package/media_information_download/sources/rss.py +132 -0
- package/media_information_download/sources/youtube.py +41 -0
- package/media_information_download/transcription.py +109 -0
- package/media_information_download/tui.py +942 -0
- package/media_tui.py +8 -0
- package/package.json +36 -0
- package/pyproject.toml +26 -0
- package/requirements-transcribe.txt +3 -0
- package/requirements.txt +1 -0
- package/youtube_download.py +63 -0
- package/youtube_download_transcribe.py +67 -0
|
@@ -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())
|