earshot-cli 0.1.1__tar.gz → 0.3.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.
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/.github/workflows/ci.yml +2 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/PKG-INFO +16 -3
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/README.md +15 -2
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/pyproject.toml +1 -1
- earshot_cli-0.3.0/src/earshot/auth.py +149 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/cli.py +49 -3
- earshot_cli-0.3.0/tests/conftest.py +29 -0
- earshot_cli-0.3.0/tests/test_auth.py +94 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_compose.py +1 -1
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_deliver_api.py +7 -1
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_extract.py +9 -3
- earshot_cli-0.3.0/tests/test_me.py +51 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/.gitignore +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/LICENSE +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/scripts/download_models.sh +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/__init__.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/capture.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/compose.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/config.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/deliver.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/extract.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/live.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/models.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/subtitle.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/transcribe.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/src/earshot/transcribe_cloud.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_config.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_deliver.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_do_deliver.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_live.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_subtitle.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/tests/test_wizard.py +0 -0
- {earshot_cli-0.1.1 → earshot_cli-0.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: earshot-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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.
|
|
63
|
+
uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.3.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
|
|
|
@@ -95,7 +104,8 @@ earshot audio recording.mp4 --title "通勤听书笔记" --deliver none
|
|
|
95
104
|
# 补投/重投 outbox 里的笔记(如环境问题当时没投出去)
|
|
96
105
|
earshot deliver ~/.earshot/outbox/20261030-xxxx.md
|
|
97
106
|
|
|
98
|
-
# 列出采集设备 / 环境自检 / 清理过期留档
|
|
107
|
+
# 查看凭据状态 / 列出采集设备 / 环境自检 / 清理过期留档
|
|
108
|
+
earshot me
|
|
99
109
|
earshot devices
|
|
100
110
|
earshot doctor
|
|
101
111
|
earshot clean --days 30
|
|
@@ -106,6 +116,9 @@ earshot clean --days 30
|
|
|
106
116
|
`--deliver webhook --webhook <url>`(投递到任意知识库,POST JSON `{origin,title,content,tags}`)、
|
|
107
117
|
`--deliver none`(只落 `~/.earshot/outbox/` 存档)。
|
|
108
118
|
|
|
119
|
+
首次使用取凭据:`earshot login`(浏览器设备流授权;凭据仍有效时直接返回,`--force` 强制重登),
|
|
120
|
+
或设 `EXOMIND_API_KEY` 环境变量(从 exomind 网页 `/ui/account` 复制)。
|
|
121
|
+
|
|
109
122
|
笔记留档:`~/.earshot/outbox/YYYYMMDD-HHMMSS-<标题>.md`。
|
|
110
123
|
|
|
111
124
|
### 文件生命周期
|
|
@@ -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.
|
|
30
|
+
uv tool install "earshot-cli @ https://github.com/helloworldtang/earshot/archive/refs/tags/v0.3.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
|
|
|
@@ -62,7 +71,8 @@ earshot audio recording.mp4 --title "通勤听书笔记" --deliver none
|
|
|
62
71
|
# 补投/重投 outbox 里的笔记(如环境问题当时没投出去)
|
|
63
72
|
earshot deliver ~/.earshot/outbox/20261030-xxxx.md
|
|
64
73
|
|
|
65
|
-
# 列出采集设备 / 环境自检 / 清理过期留档
|
|
74
|
+
# 查看凭据状态 / 列出采集设备 / 环境自检 / 清理过期留档
|
|
75
|
+
earshot me
|
|
66
76
|
earshot devices
|
|
67
77
|
earshot doctor
|
|
68
78
|
earshot clean --days 30
|
|
@@ -73,6 +83,9 @@ earshot clean --days 30
|
|
|
73
83
|
`--deliver webhook --webhook <url>`(投递到任意知识库,POST JSON `{origin,title,content,tags}`)、
|
|
74
84
|
`--deliver none`(只落 `~/.earshot/outbox/` 存档)。
|
|
75
85
|
|
|
86
|
+
首次使用取凭据:`earshot login`(浏览器设备流授权;凭据仍有效时直接返回,`--force` 强制重登),
|
|
87
|
+
或设 `EXOMIND_API_KEY` 环境变量(从 exomind 网页 `/ui/account` 复制)。
|
|
88
|
+
|
|
76
89
|
笔记留档:`~/.earshot/outbox/YYYYMMDD-HHMMSS-<标题>.md`。
|
|
77
90
|
|
|
78
91
|
### 文件生命周期
|
|
@@ -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,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
77
78
|
)
|
|
78
79
|
p_deliver.add_argument("--webhook", help="webhook 地址(--deliver webhook 时必填)")
|
|
79
80
|
|
|
81
|
+
sub.add_parser("me", help="显示凭据状态与服务连通性")
|
|
82
|
+
|
|
83
|
+
p_login = sub.add_parser("login", help="设备流登录 exomind(免装 emcli,写入 ~/.exomind/config.json)")
|
|
84
|
+
p_login.add_argument("--base-url", default=DEFAULT_EXOMIND_BASE_URL, help="exomind API 地址")
|
|
85
|
+
p_login.add_argument("--force", action="store_true", help="跳过「凭据仍有效」检查,无条件重新登录")
|
|
86
|
+
|
|
80
87
|
sub.add_parser("devices", help="列出可用采集设备")
|
|
81
88
|
sub.add_parser("doctor", help="环境自检")
|
|
82
89
|
p_clean = sub.add_parser("clean", help="清理过期留档(outbox+tmp)")
|
|
@@ -188,8 +195,9 @@ def _do_deliver(
|
|
|
188
195
|
except ValueError as e:
|
|
189
196
|
print(f" 配置文件解析失败: {e}")
|
|
190
197
|
print(f" PATH={os.environ.get('PATH', '')[:200]}")
|
|
191
|
-
print(" →
|
|
192
|
-
print("
|
|
198
|
+
print(" → 取凭据三选一:① earshot login(浏览器设备流授权) ② 设 EXOMIND_API_KEY 环境变量")
|
|
199
|
+
print(" (从 exomind-api.ai-as.cn/ui/account 复制) ③ npm i -g exomind && exomind login")
|
|
200
|
+
print(" → 或改用 --deliver webhook/none;笔记已留档,环境修好后可用 earshot deliver <md路径> 补投")
|
|
193
201
|
return EXIT_FAIL
|
|
194
202
|
mark = {"delivered": "✓", "pending": "⏳", "failed": "✗"}.get(result.status, "·")
|
|
195
203
|
print(f"{mark} {result.status}: {result.detail}")
|
|
@@ -507,6 +515,42 @@ def cmd_clean(args: argparse.Namespace, settings: Settings) -> int:
|
|
|
507
515
|
return EXIT_OK
|
|
508
516
|
|
|
509
517
|
|
|
518
|
+
def cmd_me(_args: argparse.Namespace, settings: Settings) -> int:
|
|
519
|
+
"""显示凭据状态与服务连通性(与 wxpub/emcli me 对齐)。"""
|
|
520
|
+
print(f"服务地址:{settings.exomind_base_url}")
|
|
521
|
+
if not settings.exomind_api_key:
|
|
522
|
+
print("凭据:未配置(earshot login,或设 EXOMIND_API_KEY 环境变量)")
|
|
523
|
+
return EXIT_FAIL
|
|
524
|
+
key = settings.exomind_api_key
|
|
525
|
+
masked = f"{key[:8]}…{key[-4:]}" if len(key) > 12 else key
|
|
526
|
+
print(f"凭据:{masked}")
|
|
527
|
+
if verify_key(settings.exomind_base_url, key):
|
|
528
|
+
print("凭据状态:有效")
|
|
529
|
+
return EXIT_OK
|
|
530
|
+
print("凭据状态:无效或服务不可达(重登用: earshot login --force)")
|
|
531
|
+
return EXIT_FAIL
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def cmd_login(args: argparse.Namespace, settings: Settings) -> int:
|
|
535
|
+
"""设备流登录 exomind:已有凭据探活仍有效则直接返回(与 emcli/wxpub 同款智能登录)。"""
|
|
536
|
+
base_url = args.base_url
|
|
537
|
+
if not args.force and settings.exomind_api_key:
|
|
538
|
+
existing_base = settings.exomind_base_url or base_url
|
|
539
|
+
if verify_key(existing_base, settings.exomind_api_key):
|
|
540
|
+
print(f"✓ 凭据仍有效({existing_base}),未重新登录。强制重登用: earshot login --force")
|
|
541
|
+
return EXIT_OK
|
|
542
|
+
print(f"连接 exomind:{base_url}")
|
|
543
|
+
print("设备登录:")
|
|
544
|
+
api_key = device_login(base_url)
|
|
545
|
+
if not verify_key(base_url, api_key):
|
|
546
|
+
print("✗ 拿到的凭据探活失败,未写入配置;请重试 earshot login")
|
|
547
|
+
return EXIT_FAIL
|
|
548
|
+
path = save_credentials(base_url, api_key)
|
|
549
|
+
print(f"✓ 登录成功,凭据已写入 {path}")
|
|
550
|
+
print(" 投递与云端转写(--engine server)现在可用")
|
|
551
|
+
return EXIT_OK
|
|
552
|
+
|
|
553
|
+
|
|
510
554
|
def main(argv: list[str] | None = None) -> int:
|
|
511
555
|
parser = build_parser()
|
|
512
556
|
args = parser.parse_args(argv)
|
|
@@ -516,6 +560,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
516
560
|
"audio": cmd_audio,
|
|
517
561
|
"video": cmd_video,
|
|
518
562
|
"deliver": cmd_deliver,
|
|
563
|
+
"login": cmd_login,
|
|
564
|
+
"me": cmd_me,
|
|
519
565
|
"devices": cmd_devices,
|
|
520
566
|
"doctor": cmd_doctor,
|
|
521
567
|
"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, "想理解递归"),
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
exe,
|
|
52
58
|
"-hide_banner",
|
|
53
59
|
"-loglevel",
|
|
54
60
|
"error",
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""cmd_me 测试:凭据状态展示三分支(有效/无效/未配置),探活注入不联网。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from earshot.cli import cmd_me
|
|
11
|
+
from earshot.config import Settings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _settings(api_key: str = "") -> Settings:
|
|
15
|
+
return Settings(
|
|
16
|
+
home=Path("/tmp/earshot-test"),
|
|
17
|
+
model_root=Path("/tmp/earshot-test/models"),
|
|
18
|
+
exomind_base_url="https://exomind.example",
|
|
19
|
+
exomind_api_key=api_key,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_me_valid_key(
|
|
24
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
25
|
+
capsys: pytest.CaptureFixture[str],
|
|
26
|
+
) -> None:
|
|
27
|
+
monkeypatch.setattr("earshot.cli.verify_key", lambda base, key: True)
|
|
28
|
+
code = cmd_me(argparse.Namespace(), _settings("sk_live_valid_key"))
|
|
29
|
+
out = capsys.readouterr().out
|
|
30
|
+
assert code == 0
|
|
31
|
+
assert "https://exomind.example" in out
|
|
32
|
+
assert "sk_live_…_key" in out, "凭据掩码展示"
|
|
33
|
+
assert "凭据状态:有效" in out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_me_invalid_key(
|
|
37
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
38
|
+
capsys: pytest.CaptureFixture[str],
|
|
39
|
+
) -> None:
|
|
40
|
+
monkeypatch.setattr("earshot.cli.verify_key", lambda base, key: False)
|
|
41
|
+
code = cmd_me(argparse.Namespace(), _settings("sk_live_invalid_"))
|
|
42
|
+
out = capsys.readouterr().out
|
|
43
|
+
assert code == 1
|
|
44
|
+
assert "earshot login --force" in out, "无效要指路重登"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_me_no_key(capsys: pytest.CaptureFixture[str]) -> None:
|
|
48
|
+
code = cmd_me(argparse.Namespace(), _settings(""))
|
|
49
|
+
out = capsys.readouterr().out
|
|
50
|
+
assert code == 1
|
|
51
|
+
assert "未配置" in out and "earshot login" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|