earshot-cli 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (33) hide show
  1. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/.github/workflows/ci.yml +2 -0
  2. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/PKG-INFO +14 -2
  3. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/README.md +13 -1
  4. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/pyproject.toml +1 -1
  5. earshot_cli-0.2.0/src/earshot/auth.py +149 -0
  6. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/cli.py +30 -3
  7. earshot_cli-0.2.0/tests/conftest.py +29 -0
  8. earshot_cli-0.2.0/tests/test_auth.py +94 -0
  9. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_compose.py +1 -1
  10. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_deliver_api.py +7 -1
  11. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_extract.py +9 -3
  12. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  13. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/.gitignore +0 -0
  14. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/LICENSE +0 -0
  15. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/scripts/download_models.sh +0 -0
  16. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/__init__.py +0 -0
  17. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/capture.py +0 -0
  18. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/compose.py +0 -0
  19. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/config.py +0 -0
  20. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/deliver.py +0 -0
  21. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/extract.py +0 -0
  22. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/live.py +0 -0
  23. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/models.py +0 -0
  24. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/subtitle.py +0 -0
  25. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/transcribe.py +0 -0
  26. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/src/earshot/transcribe_cloud.py +0 -0
  27. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_config.py +0 -0
  28. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_deliver.py +0 -0
  29. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_do_deliver.py +0 -0
  30. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_live.py +0 -0
  31. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_subtitle.py +0 -0
  32. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/tests/test_wizard.py +0 -0
  33. {earshot_cli-0.1.1 → earshot_cli-0.2.0}/uv.lock +0 -0
@@ -13,6 +13,8 @@ jobs:
13
13
  - uses: astral-sh/setup-uv@v5
14
14
  with:
15
15
  python-version: "3.12"
16
+ - name: Install system audio deps
17
+ run: sudo apt-get update && sudo apt-get install -y libpulse-dev
16
18
  - name: Install
17
19
  run: uv sync
18
20
  - name: Lint
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: earshot-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: 旁听剪藏:跨平台桌面端系统声录制 → 本地转写 → 投递知识库(默认 ExoMind,可插拔)
5
5
  Project-URL: Homepage, https://github.com/helloworldtang/earshot
6
6
  Project-URL: Repository, https://github.com/helloworldtang/earshot
@@ -49,15 +49,24 @@ uv tool install earshot-cli # 或 pipx install earshot-cli
49
49
  earshot doctor # 环境自检;earshot 与 earshot-cli 两个命令名等价可用
50
50
  ```
51
51
 
52
+ 升级:
53
+
54
+ ```bash
55
+ uv tool upgrade earshot-cli
56
+ # 刚发布的版本若报 "Nothing to upgrade":PyPI 索引传播延迟(约 1 分钟),
57
+ # 稍后再试,或立即生效:uv tool install --force earshot-cli
58
+ ```
59
+
52
60
  源码安装请**锁版本 tag**,不要引滚动的 `main`(消费方 `uv sync`/`pip install` 会隐性升级到未审阅代码;升级应当是显式改版本号的决策):
53
61
 
54
62
  ```bash
55
- uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.1.0.tar.gz"
63
+ uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.2.0.tar.gz"
56
64
  ```
57
65
 
58
66
  - **零系统依赖**:ffmpeg 已随 Python wheel 内置([imageio-ffmpeg](https://pypi.org/project/imageio-ffmpeg),MoviePy 同款),无需 brew/apt。
59
67
  - **模型首次运行自动下载**:SenseVoice int8 + silero VAD(~230MB,默认走 hf-mirror,`HF_ENDPOINT` 可覆盖),装完不再下。
60
68
  - **投递免装 CLI**:默认直连 ExoMind HTTP API(复用 `~/.exomind/config.json` 凭据);没有凭据但有 exomind CLI 时自动回退;也支持任意 webhook。
69
+ - **独立登录免装 emcli**:`earshot login` 设备流浏览器授权,直接写入 `~/.exomind/config.json`(与 emcli 共用同一凭据文件);已装 emcli 且登录过则无需再登。
61
70
 
62
71
  ### 平台采集矩阵
63
72
 
@@ -106,6 +115,9 @@ earshot clean --days 30
106
115
  `--deliver webhook --webhook <url>`(投递到任意知识库,POST JSON `{origin,title,content,tags}`)、
107
116
  `--deliver none`(只落 `~/.earshot/outbox/` 存档)。
108
117
 
118
+ 首次使用取凭据:`earshot login`(浏览器设备流授权;凭据仍有效时直接返回,`--force` 强制重登),
119
+ 或设 `EXOMIND_API_KEY` 环境变量(从 exomind 网页 `/ui/account` 复制)。
120
+
109
121
  笔记留档:`~/.earshot/outbox/YYYYMMDD-HHMMSS-<标题>.md`。
110
122
 
111
123
  ### 文件生命周期
@@ -16,15 +16,24 @@ uv tool install earshot-cli # 或 pipx install earshot-cli
16
16
  earshot doctor # 环境自检;earshot 与 earshot-cli 两个命令名等价可用
17
17
  ```
18
18
 
19
+ 升级:
20
+
21
+ ```bash
22
+ uv tool upgrade earshot-cli
23
+ # 刚发布的版本若报 "Nothing to upgrade":PyPI 索引传播延迟(约 1 分钟),
24
+ # 稍后再试,或立即生效:uv tool install --force earshot-cli
25
+ ```
26
+
19
27
  源码安装请**锁版本 tag**,不要引滚动的 `main`(消费方 `uv sync`/`pip install` 会隐性升级到未审阅代码;升级应当是显式改版本号的决策):
20
28
 
21
29
  ```bash
22
- uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.1.0.tar.gz"
30
+ uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.2.0.tar.gz"
23
31
  ```
24
32
 
25
33
  - **零系统依赖**:ffmpeg 已随 Python wheel 内置([imageio-ffmpeg](https://pypi.org/project/imageio-ffmpeg),MoviePy 同款),无需 brew/apt。
26
34
  - **模型首次运行自动下载**:SenseVoice int8 + silero VAD(~230MB,默认走 hf-mirror,`HF_ENDPOINT` 可覆盖),装完不再下。
27
35
  - **投递免装 CLI**:默认直连 ExoMind HTTP API(复用 `~/.exomind/config.json` 凭据);没有凭据但有 exomind CLI 时自动回退;也支持任意 webhook。
36
+ - **独立登录免装 emcli**:`earshot login` 设备流浏览器授权,直接写入 `~/.exomind/config.json`(与 emcli 共用同一凭据文件);已装 emcli 且登录过则无需再登。
28
37
 
29
38
  ### 平台采集矩阵
30
39
 
@@ -73,6 +82,9 @@ earshot clean --days 30
73
82
  `--deliver webhook --webhook <url>`(投递到任意知识库,POST JSON `{origin,title,content,tags}`)、
74
83
  `--deliver none`(只落 `~/.earshot/outbox/` 存档)。
75
84
 
85
+ 首次使用取凭据:`earshot login`(浏览器设备流授权;凭据仍有效时直接返回,`--force` 强制重登),
86
+ 或设 `EXOMIND_API_KEY` 环境变量(从 exomind 网页 `/ui/account` 复制)。
87
+
76
88
  笔记留档:`~/.earshot/outbox/YYYYMMDD-HHMMSS-<标题>.md`。
77
89
 
78
90
  ### 文件生命周期
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "earshot-cli"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "旁听剪藏:跨平台桌面端系统声录制 → 本地转写 → 投递知识库(默认 ExoMind,可插拔)"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,149 @@
1
+ """设备流登录:复用 exomind 服务端 /auth/device/* 端点,写入 ~/.exomind/config.json。
2
+
3
+ earshot 单独安装(未装 emcli)时的取凭据出口;与 emcli 共写同一配置文件,单一凭据源不变。
4
+ 智能登录:已有凭据探活仍有效则直接返回,不弹浏览器(--force 跳过)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import socket
12
+ import time
13
+ import webbrowser
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ from earshot.config import EXOMIND_CONFIG_FILENAME
21
+
22
+ Poster = Callable[[str, dict[str, Any]], dict[str, Any]]
23
+ Getter = Callable[[str, dict[str, str]], int]
24
+ Opener = Callable[[str], bool]
25
+ Sleeper = Callable[[float], None]
26
+
27
+
28
+ def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
29
+ resp = httpx.post(url, json=payload, timeout=30.0)
30
+ resp.raise_for_status()
31
+ return dict(resp.json())
32
+
33
+
34
+ def _get_status(url: str, headers: dict[str, str]) -> int:
35
+ return int(httpx.get(url, headers=headers, timeout=30.0).status_code)
36
+
37
+
38
+ def _open_browser(url: str) -> bool:
39
+ return webbrowser.open(url)
40
+
41
+
42
+ def verify_key(base_url: str, api_key: str, getter: Getter | None = None) -> bool:
43
+ """探活已有凭据:打一次受保护端点 /keywords,200 即仍有效。
44
+
45
+ Args:
46
+ base_url: exomind API 地址。
47
+ api_key: 待验证的凭据。
48
+ getter: ``(url, headers) -> HTTP 状态码``,测试注入;缺省走 httpx。
49
+
50
+ Returns:
51
+ 是否仍有效;401/403 与网络错误均返回 False(交给设备流给出更准确的错误)。
52
+ """
53
+ get_status = getter or _get_status
54
+ try:
55
+ return get_status(f"{base_url.rstrip('/')}/keywords", {"Authorization": f"Bearer {api_key}"}) == 200
56
+ except httpx.HTTPError:
57
+ return False
58
+
59
+
60
+ def device_login(
61
+ base_url: str,
62
+ poster: Poster | None = None,
63
+ opener: Opener | None = None,
64
+ sleeper: Sleeper | None = None,
65
+ ) -> str:
66
+ """设备流登录:发起 → 打开浏览器授权页 → 轮询拿 api_key。
67
+
68
+ Args:
69
+ base_url: exomind API 地址。
70
+ poster: ``(url, payload) -> 响应 dict``,测试注入;缺省走 httpx。
71
+ opener: 打开授权页的回调(返回是否成功),测试注入;缺省 webbrowser。
72
+ sleeper: 轮询间隔等待,测试注入;缺省 time.sleep。
73
+
74
+ Returns:
75
+ 服务端签发的 api_key(长期有效,写入 ~/.exomind/config.json 后投递/云转写均可用)。
76
+
77
+ Raises:
78
+ RuntimeError: 授权被拒、会话过期或等待超时。
79
+ httpx.HTTPError: 网络或服务端错误(5xx 等)。
80
+ """
81
+ post = poster or _post_json
82
+ open_page = opener or _open_browser
83
+ sleep = sleeper or time.sleep
84
+
85
+ info = post(
86
+ f"{base_url.rstrip('/')}/auth/device/code",
87
+ {"name": f"earshot@{socket.gethostname()[:60]}"},
88
+ )
89
+ device_code = str(info["device_code"])
90
+ user_code = str(info.get("user_code", ""))
91
+ interval = max(1, int(info.get("interval", 3)))
92
+ expires_in = max(30, int(info.get("expires_in", 600)))
93
+ url = str(info.get("verification_url") or "")
94
+ if not url.startswith("http"):
95
+ url = f"{base_url.rstrip('/')}/ui/device?code={user_code}"
96
+
97
+ print(f" 1. 打开 {url}")
98
+ print(f" 2. 确认设备码 {user_code},点击「授权登录」")
99
+ open_page(url)
100
+
101
+ deadline = time.monotonic() + expires_in
102
+ while time.monotonic() < deadline:
103
+ sleep(interval)
104
+ try:
105
+ result = post(f"{base_url.rstrip('/')}/auth/device/token", {"device_code": device_code})
106
+ except httpx.HTTPStatusError as exc:
107
+ if exc.response.status_code != 400:
108
+ raise
109
+ detail = ""
110
+ with contextlib.suppress(ValueError):
111
+ detail = str((exc.response.json() or {}).get("error", ""))
112
+ if detail == "authorization_pending":
113
+ continue
114
+ raise RuntimeError("设备登录会话已过期,请重新执行 earshot login") from exc
115
+ api_key = str(result.get("api_key") or "")
116
+ if api_key:
117
+ return api_key
118
+ raise RuntimeError(f"等待授权超时({expires_in}s),请重新执行 earshot login")
119
+
120
+
121
+ def save_credentials(
122
+ base_url: str,
123
+ api_key: str,
124
+ config_path: Path | None = None,
125
+ ) -> Path:
126
+ """写入凭据到 ~/.exomind/config.json(0600),保留既有其他字段。
127
+
128
+ Args:
129
+ base_url: exomind API 地址。
130
+ api_key: 登录拿到的凭据。
131
+ config_path: 配置文件路径,测试注入;缺省 ``~/.exomind/config.json``。
132
+
133
+ Returns:
134
+ 实际写入的配置文件路径。
135
+ """
136
+ path = config_path if config_path is not None else Path.home() / EXOMIND_CONFIG_FILENAME
137
+ data: dict[str, str] = {}
138
+ try:
139
+ raw = json.loads(path.read_text(encoding="utf-8"))
140
+ if isinstance(raw, dict):
141
+ data = {k: v for k, v in raw.items() if isinstance(v, str)}
142
+ except (OSError, ValueError):
143
+ pass
144
+ data["base_url"] = base_url.rstrip("/")
145
+ data["api_key"] = api_key
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
148
+ path.chmod(0o600)
149
+ return path
@@ -13,9 +13,10 @@ from pathlib import Path
13
13
  import numpy as np
14
14
 
15
15
  from earshot import __version__
16
+ from earshot.auth import device_login, save_credentials, verify_key
16
17
  from earshot.capture import blackhole_missing_hint, list_capture_devices, pick_device, record_to_wav
17
18
  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.config import DEFAULT_EXOMIND_BASE_URL, Settings, load_settings
19
20
  from earshot.deliver import DeliveryResult, deliver_exomind, deliver_exomind_api, deliver_webhook
20
21
  from earshot.extract import ExtractError, ffmpeg_available, to_wav16k, wav_duration
21
22
  from earshot.live import LiveSession
@@ -77,6 +78,10 @@ def build_parser() -> argparse.ArgumentParser:
77
78
  )
78
79
  p_deliver.add_argument("--webhook", help="webhook 地址(--deliver webhook 时必填)")
79
80
 
81
+ p_login = sub.add_parser("login", help="设备流登录 exomind(免装 emcli,写入 ~/.exomind/config.json)")
82
+ p_login.add_argument("--base-url", default=DEFAULT_EXOMIND_BASE_URL, help="exomind API 地址")
83
+ p_login.add_argument("--force", action="store_true", help="跳过「凭据仍有效」检查,无条件重新登录")
84
+
80
85
  sub.add_parser("devices", help="列出可用采集设备")
81
86
  sub.add_parser("doctor", help="环境自检")
82
87
  p_clean = sub.add_parser("clean", help="清理过期留档(outbox+tmp)")
@@ -188,8 +193,9 @@ def _do_deliver(
188
193
  except ValueError as e:
189
194
  print(f" 配置文件解析失败: {e}")
190
195
  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路径> 补投")
196
+ print(" → 取凭据三选一:① earshot login(浏览器设备流授权) ② 设 EXOMIND_API_KEY 环境变量")
197
+ print(" (从 exomind-api.ai-as.cn/ui/account 复制) ③ npm i -g exomind && exomind login")
198
+ print(" → 或改用 --deliver webhook/none;笔记已留档,环境修好后可用 earshot deliver <md路径> 补投")
193
199
  return EXIT_FAIL
194
200
  mark = {"delivered": "✓", "pending": "⏳", "failed": "✗"}.get(result.status, "·")
195
201
  print(f"{mark} {result.status}: {result.detail}")
@@ -507,6 +513,26 @@ def cmd_clean(args: argparse.Namespace, settings: Settings) -> int:
507
513
  return EXIT_OK
508
514
 
509
515
 
516
+ def cmd_login(args: argparse.Namespace, settings: Settings) -> int:
517
+ """设备流登录 exomind:已有凭据探活仍有效则直接返回(与 emcli/wxpub 同款智能登录)。"""
518
+ base_url = args.base_url
519
+ if not args.force and settings.exomind_api_key:
520
+ existing_base = settings.exomind_base_url or base_url
521
+ if verify_key(existing_base, settings.exomind_api_key):
522
+ print(f"✓ 凭据仍有效({existing_base}),未重新登录。强制重登用: earshot login --force")
523
+ return EXIT_OK
524
+ print(f"连接 exomind:{base_url}")
525
+ print("设备登录:")
526
+ api_key = device_login(base_url)
527
+ if not verify_key(base_url, api_key):
528
+ print("✗ 拿到的凭据探活失败,未写入配置;请重试 earshot login")
529
+ return EXIT_FAIL
530
+ path = save_credentials(base_url, api_key)
531
+ print(f"✓ 登录成功,凭据已写入 {path}")
532
+ print(" 投递与云端转写(--engine server)现在可用")
533
+ return EXIT_OK
534
+
535
+
510
536
  def main(argv: list[str] | None = None) -> int:
511
537
  parser = build_parser()
512
538
  args = parser.parse_args(argv)
@@ -516,6 +542,7 @@ def main(argv: list[str] | None = None) -> int:
516
542
  "audio": cmd_audio,
517
543
  "video": cmd_video,
518
544
  "deliver": cmd_deliver,
545
+ "login": cmd_login,
519
546
  "devices": cmd_devices,
520
547
  "doctor": cmd_doctor,
521
548
  "clean": cmd_clean,
@@ -0,0 +1,29 @@
1
+ """测试公共桩:headless CI(Linux 无音频栈)上 stub 掉 soundcard。
2
+
3
+ soundcard 在无 PulseAudio 守护的环境 import 即挂(OSError: libpulse 缺失 /
4
+ AssertionError: 无守护),而 capture.py 仅在函数内使用它——stub 成「调用即报错」
5
+ 的空模块。采集类测试本就不在 CI 跑,投递/向导/登录等纯逻辑测试不被 import 连坐。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import types
12
+
13
+ try:
14
+ import soundcard # noqa: F401
15
+ except Exception: # OSError / AssertionError 均视为不可用
16
+ stub = types.ModuleType("soundcard")
17
+
18
+ def _unavailable(*_args: object, **_kwargs: object) -> object:
19
+ raise RuntimeError("soundcard 不可用(headless 环境 stub)")
20
+
21
+ for _name in (
22
+ "all_microphones",
23
+ "default_speaker",
24
+ "default_microphone",
25
+ "get_microphone",
26
+ "get_speaker",
27
+ ):
28
+ setattr(stub, _name, _unavailable)
29
+ sys.modules["soundcard"] = stub
@@ -0,0 +1,94 @@
1
+ """设备流登录测试:全 mock 网络与浏览器,无真实请求。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from earshot.auth import device_login, save_credentials, verify_key
12
+
13
+
14
+ def test_verify_key_three_branches() -> None:
15
+ """探活:200→True;401→False;网络错误→False(交给设备流)。"""
16
+ assert verify_key("https://x.test/", "sk_ok", getter=lambda url, headers: 200) is True
17
+ assert verify_key("https://x.test", "sk_bad", getter=lambda url, headers: 401) is False
18
+
19
+ def _raise(url: str, headers: dict[str, str]) -> int:
20
+ raise httpx.ConnectError("network down")
21
+
22
+ assert verify_key("https://x.test", "sk_x", getter=_raise) is False
23
+
24
+
25
+ def test_device_login_pending_then_key() -> None:
26
+ """发起→pending 一次→拿到 key;verification_url 相对路径拼成绝对;浏览器被打开。"""
27
+ calls: list[str] = []
28
+ opened: list[str] = []
29
+
30
+ def poster(url: str, payload: dict[str, object]) -> dict[str, object]:
31
+ calls.append(url)
32
+ if url.endswith("/auth/device/code"):
33
+ return {
34
+ "device_code": "dc-1",
35
+ "user_code": "AB12-CD34",
36
+ "expires_in": 60,
37
+ "interval": 1,
38
+ "verification_url": "/ui/device",
39
+ }
40
+ if url.endswith("/auth/device/token"):
41
+ if len(calls) == 2:
42
+ raise httpx.HTTPStatusError(
43
+ "pending",
44
+ request=httpx.Request("POST", url),
45
+ response=httpx.Response(400, json={"error": "authorization_pending"}),
46
+ )
47
+ return {"api_key": "sk_dev_1"}
48
+ raise AssertionError(f"意外请求: {url}")
49
+
50
+ def opener(url: str) -> bool:
51
+ opened.append(url)
52
+ return True
53
+
54
+ key = device_login(
55
+ "https://x.test",
56
+ poster=poster,
57
+ opener=opener,
58
+ sleeper=lambda s: None,
59
+ )
60
+ assert key == "sk_dev_1"
61
+ assert opened and "code=AB12-CD34" in opened[0], "相对 verification_url 应拼成带设备码的绝对地址"
62
+
63
+
64
+ def test_device_login_expired_session() -> None:
65
+ """400 非 pending(expires)→ 明确报错会话过期。"""
66
+
67
+ def poster(url: str, payload: dict[str, object]) -> dict[str, object]:
68
+ if url.endswith("/auth/device/code"):
69
+ return {"device_code": "dc-x", "user_code": "AAAA-BBBB", "expires_in": 60, "interval": 1}
70
+ raise httpx.HTTPStatusError(
71
+ "expired", request=httpx.Request("POST", url), response=httpx.Response(400, json={"error": "expired_token"})
72
+ )
73
+
74
+ with pytest.raises(RuntimeError, match="过期"):
75
+ device_login("https://x.test", poster=poster, opener=lambda url: True, sleeper=lambda s: None)
76
+
77
+
78
+ def test_save_credentials_merges_and_chmods(tmp_path: Path) -> None:
79
+ """写入保留既有其他字段;权限 0600;损坏旧文件可覆盖。"""
80
+ cfg = tmp_path / "config.json"
81
+ cfg.write_text(json.dumps({"base_url": "https://old.example", "extra": "keep"}), encoding="utf-8")
82
+
83
+ path = save_credentials("https://x.test/", "sk_new", config_path=cfg)
84
+ assert path == cfg
85
+ data = json.loads(cfg.read_text(encoding="utf-8"))
86
+ assert data["base_url"] == "https://x.test", "去尾斜杠且更新"
87
+ assert data["api_key"] == "sk_new"
88
+ assert data["extra"] == "keep", "既有其他字段保留"
89
+ assert cfg.stat().st_mode & 0o777 == 0o600
90
+
91
+ cfg.write_text("not-json", encoding="utf-8")
92
+ save_credentials("https://x.test", "sk_again", config_path=cfg)
93
+ data = json.loads(cfg.read_text(encoding="utf-8"))
94
+ assert data["api_key"] == "sk_again", "损坏旧文件可覆盖"
@@ -54,7 +54,7 @@ def test_derive_title_no_content() -> None:
54
54
 
55
55
  def test_add_punctuation_by_timing() -> None:
56
56
  segs = [
57
- _seg(0.0, 2.0, "想理解递归"), # 与下一段间隔 0.2s → 逗号
57
+ _seg(0.0, 2.0, "想理解递归"), # 与下一段间隔 0.2s → 逗号
58
58
  _seg(2.2, 4.0, "你得先理解递归"), # 与下一段间隔 1.5s → 句号
59
59
  _seg(5.5, 7.0, "这句话像个玩笑"), # 末段 → 句号
60
60
  ]
@@ -135,7 +135,13 @@ def test_deliver_api_passes_source_url(tmp_path: Path) -> None:
135
135
  return {"status": "done", "entities": 1}
136
136
 
137
137
  result = deliver_exomind_api(
138
- md, "t", [], _settings(), poster=poster, getter=getter, poll_interval_s=0,
138
+ md,
139
+ "t",
140
+ [],
141
+ _settings(),
142
+ poster=poster,
143
+ getter=getter,
144
+ poll_interval_s=0,
139
145
  source_url="https://www.bilibili.com/video/BV16qpF64E6V/",
140
146
  )
141
147
  assert result.status == "delivered"
@@ -6,15 +6,19 @@ from pathlib import Path
6
6
 
7
7
  import pytest
8
8
 
9
- from earshot.extract import ffmpeg_available, to_wav16k, wav_duration
9
+ from earshot.extract import ffmpeg_available, ffmpeg_exe, to_wav16k, wav_duration
10
10
 
11
11
  pytestmark = pytest.mark.skipif(not ffmpeg_available(), reason="本机无 ffmpeg")
12
12
 
13
13
 
14
14
  def _make_sine(path: Path, seconds: float = 1.0, rate: int = 44100, channels: int = 2) -> None:
15
+ # 用 ffmpeg_exe() 而非裸 "ffmpeg":CI 无系统 ffmpeg 时 fallback 到 imageio-ffmpeg
16
+ # wheel 内置二进制(ffmpeg_available()=True 使 skipif 不触发,裸调反而找不到)
17
+ exe = ffmpeg_exe()
18
+ assert exe is not None
15
19
  subprocess.run(
16
20
  [
17
- "ffmpeg",
21
+ exe,
18
22
  "-hide_banner",
19
23
  "-loglevel",
20
24
  "error",
@@ -46,9 +50,11 @@ def test_to_wav16k_downmixes_and_resamples(tmp_path: Path) -> None:
46
50
 
47
51
  def test_to_wav16k_accepts_video_container(tmp_path: Path) -> None:
48
52
  src = tmp_path / "in.mp4"
53
+ exe = ffmpeg_exe()
54
+ assert exe is not None
49
55
  subprocess.run(
50
56
  [
51
- "ffmpeg",
57
+ exe,
52
58
  "-hide_banner",
53
59
  "-loglevel",
54
60
  "error",
File without changes
File without changes
File without changes