earshot-cli 0.1.0__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.
earshot/cli.py ADDED
@@ -0,0 +1,545 @@
1
+ """earshot CLI:listen / audio / devices / doctor。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from earshot import __version__
16
+ from earshot.capture import blackhole_missing_hint, list_capture_devices, pick_device, record_to_wav
17
+ from earshot.compose import NoteMeta, add_punctuation, derive_title, group_paragraphs, outbox_path, render_markdown
18
+ from earshot.config import Settings, load_settings
19
+ from earshot.deliver import DeliveryResult, deliver_exomind, deliver_exomind_api, deliver_webhook
20
+ from earshot.extract import ExtractError, ffmpeg_available, to_wav16k, wav_duration
21
+ from earshot.live import LiveSession
22
+ from earshot.models import ensure_models
23
+ from earshot.subtitle import SubtitleError, fetch_video_note
24
+ from earshot.transcribe import LocalTranscriber, Segment, TranscribeError
25
+ from earshot.transcribe_cloud import CloudTranscribeError, transcribe_cloud
26
+
27
+ EXIT_OK = 0
28
+ EXIT_FAIL = 1
29
+ EXIT_NO_CONTENT = 2
30
+
31
+ _LEVEL_BLOCKS = "▁▂▃▄▅▆▇█"
32
+
33
+
34
+ def _add_common(p: argparse.ArgumentParser) -> None:
35
+ p.add_argument("--title", help="笔记标题(默认取首句截断)")
36
+ p.add_argument("--tag", action="append", default=None, help="投递标签,可多次(默认 旁听)")
37
+ p.add_argument("--note", default="", help="来源备注,如「B站·xxx第3期」,写进脚注")
38
+ p.add_argument("--engine", choices=["local", "server"], default="local", help="转写引擎(默认 local 端侧)")
39
+ p.add_argument(
40
+ "--deliver",
41
+ choices=["exomind", "webhook", "none"],
42
+ default="exomind",
43
+ help="投递方式(默认 exomind CLI)",
44
+ )
45
+ p.add_argument("--webhook", help="webhook 地址(--deliver webhook 时必填)")
46
+ p.add_argument("--out", type=Path, help="outbox 目录(默认 ~/.earshot/outbox)")
47
+ p.add_argument("-y", "--yes", action="store_true", help="跳过内容确认,直接投递")
48
+
49
+
50
+ def build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(prog="earshot", description="旁听剪藏:听到的都能进知识库")
52
+ parser.add_argument("--version", action="version", version=f"earshot {__version__}")
53
+ sub = parser.add_subparsers(dest="command") # 不带子命令时进入交互向导
54
+
55
+ p_listen = sub.add_parser("listen", help="录系统声音(Ctrl-C=完成),转写并投递")
56
+ _add_common(p_listen)
57
+ p_listen.add_argument("--device", help="采集设备名子串(默认:默认扬声器的环回)")
58
+
59
+ p_audio = sub.add_parser("audio", help="转写已有音视频文件并投递")
60
+ _add_common(p_audio)
61
+ p_audio.add_argument("file", type=Path, nargs="?", help="音视频文件路径(缺省时交互询问)")
62
+
63
+ p_video = sub.add_parser("video", help="B站等视频链接直拉字幕入库(无噪音路线)")
64
+ _add_common(p_video)
65
+ p_video.add_argument("url", nargs="?", help="视频链接(B站等 yt-dlp 支持的平台;缺省时交互询问)")
66
+ p_video.add_argument(
67
+ "--cookies-from-browser",
68
+ help="浏览器名(chrome/safari/firefox/edge),拉 AI 字幕(ai-zh)通常需要登录态",
69
+ )
70
+
71
+ p_deliver = sub.add_parser("deliver", help="补投/重投 outbox 里的笔记")
72
+ p_deliver.add_argument("file", type=Path, help="笔记 md 路径")
73
+ p_deliver.add_argument("--title", help="标题(默认取首行 # 标题)")
74
+ p_deliver.add_argument("--tag", action="append", default=None, help="标签,可多次(默认 旁听)")
75
+ p_deliver.add_argument(
76
+ "--deliver", choices=["exomind", "webhook", "none"], default="exomind", help="投递方式(默认 exomind)"
77
+ )
78
+ p_deliver.add_argument("--webhook", help="webhook 地址(--deliver webhook 时必填)")
79
+
80
+ sub.add_parser("devices", help="列出可用采集设备")
81
+ sub.add_parser("doctor", help="环境自检")
82
+ p_clean = sub.add_parser("clean", help="清理过期留档(outbox+tmp)")
83
+ p_clean.add_argument("--days", type=int, default=30, help="删除超过 N 天的文件(默认 30)")
84
+ return parser
85
+
86
+
87
+ def _transcribe(wav16k: Path, engine: str, settings: Settings) -> list[Segment]:
88
+ """按引擎转写,返回 Segment 列表(server 引擎按行造伪段)。"""
89
+ if engine == "server":
90
+ print("→ 云端转写中(经 exomind /transcribe,dashscope)…")
91
+ text = transcribe_cloud(wav16k, settings)
92
+ return [Segment(0.0, 0.0, line.strip()) for line in text.splitlines() if line.strip()]
93
+ print("→ 本地转写中(VAD+SenseVoice)…")
94
+ ensure_models(settings.model_root) # 缺失则首次运行自动下载(hf-mirror)
95
+ transcriber = LocalTranscriber(settings.model_root)
96
+
97
+ def _progress(frac: float) -> None:
98
+ print(f"\r→ 本地转写中… {frac * 100:3.0f}%", end="", flush=True)
99
+
100
+ segments = transcriber.transcribe(wav16k, on_progress=_progress)
101
+ print()
102
+ return segments
103
+
104
+
105
+ def _run_pipeline(
106
+ segments: list[Segment],
107
+ meta: NoteMeta,
108
+ args: argparse.Namespace,
109
+ settings: Settings,
110
+ chapter_breaks: list[float] | None = None,
111
+ ) -> int:
112
+ """组装 md → 落 outbox → 投递,返回退出码。"""
113
+ if not any(s.text.strip() for s in segments):
114
+ print("未识别到语音内容(检查系统输出/设备,或音频本身无人声)")
115
+ return EXIT_NO_CONTENT
116
+
117
+ segments = add_punctuation(segments) # 无标点转写(B站AI字幕)按时间轴补齐;已有标点自动跳过
118
+ title = derive_title(segments, args.title)
119
+ tags = args.tag if args.tag else ["旁听"]
120
+ paragraphs = ["".join(s.text for s in group) for group in group_paragraphs(segments, chapter_breaks=chapter_breaks)]
121
+ md = render_markdown(meta, paragraphs, title)
122
+
123
+ outbox = args.out if args.out else settings.outbox
124
+ outbox.mkdir(parents=True, exist_ok=True)
125
+ md_path = outbox_path(outbox, meta.started_at, title)
126
+ md_path.write_text(md, encoding="utf-8")
127
+ print(f"已保存笔记: {md_path}")
128
+
129
+ # 投递前先看内容:识别差可当场放弃(笔记仍留档)
130
+ print("\n=== 识别内容 ===")
131
+ for para in paragraphs:
132
+ print(para + "\n")
133
+ print("=== 内容结束 ===")
134
+ if sys.stdin.isatty() and not args.yes:
135
+ try:
136
+ answer = input("[回车] 投递 | [q] 放弃投递(笔记保留): ").strip().lower()
137
+ except (KeyboardInterrupt, EOFError):
138
+ print("\n已中断;笔记保留在 outbox")
139
+ return EXIT_OK
140
+ if answer == "q":
141
+ print("已放弃投递;笔记保留在 outbox,可稍后编辑后手动 ingest")
142
+ return EXIT_OK
143
+
144
+ if args.deliver == "none":
145
+ print("跳过投递(--deliver none)")
146
+ return EXIT_OK
147
+ return _do_deliver(title, md, md_path, tags, args.deliver, args.webhook, settings, meta.source_url)
148
+
149
+
150
+ def _do_deliver(
151
+ title: str,
152
+ md: str,
153
+ md_path: Path,
154
+ tags: list[str],
155
+ deliver_kind: str,
156
+ webhook: str | None,
157
+ settings: Settings,
158
+ source_url: str = "",
159
+ ) -> int:
160
+ """执行投递并汇报,返回退出码(webhook / exomind API / exomind CLI)。"""
161
+ if deliver_kind == "none":
162
+ print("跳过投递(--deliver none)")
163
+ return EXIT_OK
164
+ result: DeliveryResult | None = None
165
+ try:
166
+ if deliver_kind == "webhook":
167
+ if not webhook:
168
+ print("✗ --deliver webhook 需要 --webhook <url>")
169
+ return EXIT_FAIL
170
+ result = deliver_webhook(webhook, title, md, tags)
171
+ elif settings.exomind_api_key:
172
+ # 直连 API,免装 CLI;video 路线带 source_url 作出处留痕
173
+ result = deliver_exomind_api(md_path, title, tags, settings, source_url=source_url or None)
174
+ elif shutil.which("exomind"):
175
+ result = deliver_exomind(md_path, title, tags) # 无凭据但有 CLI,回退
176
+ except KeyboardInterrupt:
177
+ print("\n⏳ 已中断:内容可能已提交(服务端会继续处理);笔记保留在 outbox")
178
+ return EXIT_OK
179
+ if result is None:
180
+ cfg_path = Path.home() / ".exomind" / "config.json"
181
+ print("✗ failed: 无 exomind 凭据也无 exomind CLI,自诊断:")
182
+ print(f" HOME={Path.home()}")
183
+ print(f" 配置文件 {cfg_path} 存在={cfg_path.is_file()}")
184
+ if cfg_path.is_file():
185
+ try:
186
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
187
+ print(f" api_key 字段长度={len(str(cfg.get('api_key') or ''))}")
188
+ except ValueError as e:
189
+ print(f" 配置文件解析失败: {e}")
190
+ print(f" PATH={os.environ.get('PATH', '')[:200]}")
191
+ print(" → exomind login 生成 ~/.exomind/config.json,或 npm i -g exomind,或改用 --deliver webhook/none")
192
+ print(" → 笔记已留档,环境修好后可用 earshot deliver <md路径> 补投")
193
+ return EXIT_FAIL
194
+ mark = {"delivered": "✓", "pending": "⏳", "failed": "✗"}.get(result.status, "·")
195
+ print(f"{mark} {result.status}: {result.detail}")
196
+ return EXIT_OK if result.status in ("delivered", "pending") else EXIT_FAIL
197
+
198
+
199
+ def cmd_listen(args: argparse.Namespace, settings: Settings) -> int:
200
+ try:
201
+ mic, mode_label = pick_device(args.device)
202
+ except LookupError as e:
203
+ print(f"✗ {e}")
204
+ return EXIT_FAIL
205
+ print(f"采集: {mode_label}")
206
+
207
+ tmp_dir = settings.home / "tmp"
208
+ tmp_dir.mkdir(parents=True, exist_ok=True)
209
+ started_at = datetime.now()
210
+ raw_wav = tmp_dir / f"listen-{started_at.strftime('%Y%m%d-%H%M%S')}.wav"
211
+
212
+ # 实时字幕:本地引擎边录边转,段闭合即上屏
213
+ live: LiveSession | None = None
214
+ on_audio = None
215
+ if args.engine == "local":
216
+ try:
217
+ ensure_models(settings.model_root)
218
+ transcriber = LocalTranscriber(settings.model_root)
219
+ except (RuntimeError, TranscribeError) as e:
220
+ print(f"✗ {e}")
221
+ return EXIT_FAIL
222
+
223
+ def _show(text: str) -> None:
224
+ print(f"\r\033[K ↪ {text}", flush=True) # 清掉电平行再出字幕
225
+
226
+ live = LiveSession(transcriber, on_text=_show)
227
+ live.start()
228
+
229
+ import soxr
230
+
231
+ resampler = soxr.ResampleStream(48000, 16000, num_channels=1)
232
+
233
+ def _push_16k(mono: np.ndarray) -> None:
234
+ assert live is not None
235
+ live.push(np.asarray(resampler.resample_chunk(mono), dtype=np.float32))
236
+
237
+ on_audio = _push_16k
238
+
239
+ print("录音中… 听完按 Ctrl-C 结束(实时字幕随说话滚动;无字幕/电平不动=检查麦克风权限)")
240
+ import time as _time
241
+
242
+ t0 = _time.monotonic()
243
+
244
+ def _meter(rms: float) -> None:
245
+ elapsed = _time.monotonic() - t0
246
+ m, s = divmod(int(elapsed), 60)
247
+ level = int(min(1.0, max(rms, 0.0) * 8.0) * len(_LEVEL_BLOCKS))
248
+ bar = _LEVEL_BLOCKS[level - 1] * max(level, 1) if level else "·"
249
+ print(f"\r⏺ {m:02d}:{s:02d} {bar:<8}", end="", flush=True)
250
+
251
+ duration_s = record_to_wav(mic, raw_wav, on_level=_meter, on_audio=on_audio)
252
+ print()
253
+ if duration_s < 1.0:
254
+ print(f"录音太短({duration_s:.1f}s),忽略")
255
+ return EXIT_NO_CONTENT
256
+
257
+ if live is not None:
258
+ print("→ 收尾转写中(最后未闭段)…")
259
+ try:
260
+ segments = live.finalize()
261
+ except TranscribeError as e:
262
+ print(f"✗ {e}")
263
+ return EXIT_FAIL
264
+ else:
265
+ wav16k = raw_wav.with_suffix(".16k.wav")
266
+ try:
267
+ to_wav16k(raw_wav, wav16k)
268
+ except ExtractError as e:
269
+ print(f"✗ {e}")
270
+ return EXIT_FAIL
271
+ try:
272
+ segments = _transcribe(wav16k, args.engine, settings)
273
+ except (TranscribeError, CloudTranscribeError) as e:
274
+ print(f"✗ {e}")
275
+ return EXIT_FAIL
276
+
277
+ meta = NoteMeta(started_at=started_at, duration_s=duration_s, device=mic.name, source="listen", note=args.note)
278
+ rc = _run_pipeline(segments, meta, args, settings)
279
+ if rc == EXIT_OK: # 成功后清掉临时录音(失败保留供排查)
280
+ raw_wav.unlink(missing_ok=True)
281
+ raw_wav.with_suffix(".16k.wav").unlink(missing_ok=True)
282
+ return rc
283
+
284
+
285
+ def cmd_audio(args: argparse.Namespace, settings: Settings) -> int:
286
+ src = args.file
287
+ if src is None:
288
+ if not _interactive():
289
+ print("✗ 缺少文件路径:earshot audio <file>(或交互模式直接 earshot)")
290
+ return EXIT_FAIL
291
+ raw = _prompt("音视频文件路径(录屏 mp4 / 语音 m4a / 播客 mp3)")
292
+ if not raw:
293
+ print("✗ 未提供文件路径")
294
+ return EXIT_FAIL
295
+ src = Path(raw).expanduser()
296
+ if not src.is_file():
297
+ print(f"✗ 文件不存在: {src}")
298
+ return EXIT_FAIL
299
+ tmp_dir = settings.home / "tmp"
300
+ tmp_dir.mkdir(parents=True, exist_ok=True)
301
+ wav16k = tmp_dir / f"{src.stem}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.16k.wav"
302
+ try:
303
+ to_wav16k(src, wav16k)
304
+ duration_s = wav_duration(wav16k)
305
+ except ExtractError as e:
306
+ print(f"✗ {e}")
307
+ return EXIT_FAIL
308
+ try:
309
+ segments = _transcribe(wav16k, args.engine, settings)
310
+ except (TranscribeError, CloudTranscribeError) as e:
311
+ print(f"✗ {e}")
312
+ return EXIT_FAIL
313
+ meta = NoteMeta(
314
+ started_at=datetime.fromtimestamp(src.stat().st_mtime),
315
+ duration_s=duration_s,
316
+ device="-",
317
+ source="audio",
318
+ note=args.note,
319
+ )
320
+ rc = _run_pipeline(segments, meta, args, settings)
321
+ if rc == EXIT_OK: # 成功后清掉临时 wav(失败保留供排查)
322
+ wav16k.unlink(missing_ok=True)
323
+ return rc
324
+
325
+
326
+ def _prompt(label: str, default: str = "", default_display: str | None = None, prefill: bool = False) -> str:
327
+ """交互询问一个字段:回车取默认值。
328
+
329
+ prefill=True 且默认值非空时,手工预填:提示+默认值先打印,光标停在值末尾,
330
+ 回车即用(所打即所用)。不走 readline startup_hook —— macOS libedit 上
331
+ insert_text/redisplay 不重绘,预填文本不可见(2026-10-31 pty 实测两写法均不渲染)。
332
+
333
+ Raises:
334
+ KeyboardInterrupt / EOFError: 交由 main 统一优雅收场。
335
+ """
336
+ if prefill and default and len(default) <= 120:
337
+ print(f"{label}(回车=用): {default}", end="", flush=True)
338
+ try:
339
+ raw = input()
340
+ except KeyboardInterrupt:
341
+ raise
342
+ return raw.strip() or default
343
+ shown = default_display if default_display is not None else default
344
+ suffix = f"(回车={shown})" if shown else ""
345
+ raw = input(f"{label}{suffix}: ").strip()
346
+ return raw or default
347
+
348
+
349
+ def _clipboard_text() -> str:
350
+ """跨平台读剪贴板文本,不可用时返回空串。"""
351
+ candidates = (
352
+ ["pbpaste"], # macOS
353
+ ["xclip", "-selection", "clipboard", "-o"], # Linux(X11)
354
+ ["xsel", "--clipboard", "--output"],
355
+ ["powershell", "-NoProfile", "-Command", "Get-Clipboard"], # Windows
356
+ )
357
+ import subprocess
358
+
359
+ for cmd in candidates:
360
+ if not shutil.which(cmd[0]):
361
+ continue
362
+ try:
363
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=5) # noqa: S603
364
+ except Exception:
365
+ continue
366
+ if proc.returncode == 0:
367
+ return proc.stdout.strip()
368
+ return ""
369
+
370
+
371
+ def _interactive() -> bool:
372
+ return bool(sys.stdin.isatty())
373
+
374
+
375
+ def _short_url(url: str, max_len: int = 60) -> str:
376
+ """URL 展示形态:去追踪参数,超长截断 —— 让用户看清回车将用哪条链接。"""
377
+ base = url.split("?", 1)[0]
378
+ if len(base) > max_len:
379
+ base = base[:max_len] + "…"
380
+ return base
381
+
382
+
383
+ def cmd_video(args: argparse.Namespace, settings: Settings) -> int:
384
+ url = args.url
385
+ if not url:
386
+ if not _interactive():
387
+ print("✗ 缺少视频链接:earshot video <url>(或交互模式直接 earshot)")
388
+ return EXIT_FAIL
389
+ clip = _clipboard_text()
390
+ if clip.lower().startswith("http"):
391
+ # 预填剪贴板链接(去追踪参数):所见即所用,回车即取、可退格改
392
+ url = _prompt("视频链接", default=_short_url(clip), prefill=True)
393
+ else:
394
+ url = _prompt("视频链接")
395
+ if not url:
396
+ print("✗ 未提供链接")
397
+ return EXIT_FAIL
398
+ cookies = args.cookies_from_browser
399
+ if cookies is None and _interactive():
400
+ ans = _prompt("字幕登录态浏览器(拉 AI 字幕用;回车=chrome,输 n 匿名)", default="chrome")
401
+ cookies = None if ans.lower() == "n" else ans
402
+ try:
403
+ meta, segments = fetch_video_note(url, cookies)
404
+ except SubtitleError as e:
405
+ print(f"✗ {e}")
406
+ return EXIT_FAIL
407
+ print(f"→ {meta.title}(UP:{meta.uploader},字幕:{meta.subtitle_lang},{len(segments)} 段)")
408
+ if not args.title:
409
+ args.title = meta.title # 视频有正标题,优先于首句截断
410
+ if not args.note:
411
+ args.note = url # 脚注默认放原文链接,实体页可回跳
412
+ note_meta = NoteMeta(
413
+ started_at=datetime.now(),
414
+ duration_s=meta.duration_s,
415
+ device=meta.uploader,
416
+ source="video",
417
+ note=args.note,
418
+ source_url=meta.url,
419
+ )
420
+ return _run_pipeline(segments, note_meta, args, settings, chapter_breaks=meta.chapter_starts)
421
+
422
+
423
+ def cmd_deliver(args: argparse.Namespace, settings: Settings) -> int:
424
+ md_path = args.file
425
+ if not md_path.is_file():
426
+ print(f"✗ 文件不存在: {md_path}")
427
+ return EXIT_FAIL
428
+ md = md_path.read_text(encoding="utf-8")
429
+ title = args.title
430
+ if not title:
431
+ first = next((ln[2:].strip() for ln in md.splitlines() if ln.startswith("# ")), "")
432
+ title = first or md_path.stem
433
+ tags = args.tag if args.tag else ["旁听"]
434
+ if args.deliver == "none":
435
+ print("跳过投递(--deliver none)")
436
+ return EXIT_OK
437
+ return _do_deliver(title, md, md_path, tags, args.deliver, args.webhook, settings)
438
+
439
+
440
+ def cmd_devices(_args: argparse.Namespace, _settings: Settings) -> int:
441
+ devices = list_capture_devices()
442
+ if not devices:
443
+ print("未发现任何输入设备")
444
+ return EXIT_FAIL
445
+ print("输入设备(★=环回):")
446
+ for name, is_loopback in devices:
447
+ mark = "★" if is_loopback else " "
448
+ print(f" {mark} {name}")
449
+ hint = blackhole_missing_hint()
450
+ if hint:
451
+ print(f"\n提示: {hint}")
452
+ return EXIT_OK
453
+
454
+
455
+ def cmd_doctor(_args: argparse.Namespace, settings: Settings) -> int:
456
+ ok = True
457
+
458
+ def check(label: str, fine: bool, fix: str = "") -> None:
459
+ nonlocal ok
460
+ ok = ok and fine
461
+ print(f"{'✓' if fine else '✗'} {label}" + ("" if fine else f" → {fix}"))
462
+
463
+ check("ffmpeg(系统或内置)", ffmpeg_available(), "重装 earshot 依赖(imageio-ffmpeg 应自带)")
464
+ try:
465
+ devices = list_capture_devices()
466
+ loopbacks = [n for n, lb in devices if lb]
467
+ mac_hint = blackhole_missing_hint()
468
+ if loopbacks:
469
+ print(f"✓ 采集设备({len(loopbacks)} 个环回)")
470
+ elif mac_hint is not None:
471
+ # 麦录兜底存在,不算失败:降级为提示
472
+ print(f"○ 采集设备:无环回,listen 将退回麦克风收音。{mac_hint}")
473
+ else:
474
+ check("采集设备", False, "无环回设备,检查系统音频")
475
+ except Exception as e: # soundcard 底层异常族不稳定,收口
476
+ check("采集设备", False, f"soundcard 枚举失败: {e}")
477
+ from earshot.models import missing_models
478
+
479
+ missing = missing_models(settings.model_root)
480
+ if missing:
481
+ print(f"○ 模型:{len(missing)} 个缺失,首次转写时自动下载(~230MB,hf-mirror)")
482
+ else:
483
+ print("✓ 模型(SenseVoice int8 + silero VAD)")
484
+ check(
485
+ "exomind 投递(API 凭据或 CLI)",
486
+ bool(settings.exomind_api_key) or shutil.which("exomind") is not None,
487
+ "exomind login 生成 ~/.exomind/config.json,或 npm i -g exomind,或 --deliver webhook/none",
488
+ )
489
+ print("\n全部通过 ✓" if ok else "\n存在未通过项 ✗")
490
+ return EXIT_OK if ok else EXIT_FAIL
491
+
492
+
493
+ def cmd_clean(args: argparse.Namespace, settings: Settings) -> int:
494
+ """清理超过 N 天的 outbox 笔记与 tmp 录音。"""
495
+ import time as _time
496
+
497
+ cutoff = _time.time() - args.days * 86400
498
+ removed = 0
499
+ for folder in (settings.outbox, settings.home / "tmp"):
500
+ if not folder.is_dir():
501
+ continue
502
+ for f in folder.iterdir():
503
+ if f.is_file() and f.stat().st_mtime < cutoff:
504
+ f.unlink()
505
+ removed += 1
506
+ print(f"已清理 {removed} 个超过 {args.days} 天的文件(outbox + tmp)")
507
+ return EXIT_OK
508
+
509
+
510
+ def main(argv: list[str] | None = None) -> int:
511
+ parser = build_parser()
512
+ args = parser.parse_args(argv)
513
+ settings = load_settings()
514
+ handlers = {
515
+ "listen": cmd_listen,
516
+ "audio": cmd_audio,
517
+ "video": cmd_video,
518
+ "deliver": cmd_deliver,
519
+ "devices": cmd_devices,
520
+ "doctor": cmd_doctor,
521
+ "clean": cmd_clean,
522
+ }
523
+ if args.command is None:
524
+ if not _interactive():
525
+ parser.print_help()
526
+ return EXIT_FAIL
527
+ choice = _prompt("earshot 模式 [1]听录 [2]视频字幕 [3]本地文件(回车=1;输入 q 退出)", default="1")
528
+ if choice == "q":
529
+ return EXIT_OK
530
+ mapping = {"1": "listen", "2": "video", "3": "audio"}
531
+ sub = mapping.get(choice)
532
+ if sub is None:
533
+ print(f"✗ 无效选择: {choice}")
534
+ return EXIT_FAIL
535
+ args = parser.parse_args([sub]) # 取该子命令的默认值
536
+ try:
537
+ return int(handlers[args.command](args, settings))
538
+ except (KeyboardInterrupt, EOFError):
539
+ # Ctrl-C 语义 =「点完成」或「不等了」,不该以 traceback 收场
540
+ print("\n(已中断)")
541
+ return 130
542
+
543
+
544
+ if __name__ == "__main__":
545
+ sys.exit(main())