earshot-cli 0.1.0__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.
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/.github/workflows/ci.yml +2 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/PKG-INFO +16 -4
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/README.md +15 -3
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/pyproject.toml +2 -1
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/__init__.py +1 -1
- earshot_cli-0.2.0/src/earshot/auth.py +149 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/cli.py +30 -3
- earshot_cli-0.2.0/tests/conftest.py +29 -0
- earshot_cli-0.2.0/tests/test_auth.py +94 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_compose.py +1 -1
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_deliver_api.py +7 -1
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_extract.py +9 -3
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/uv.lock +1 -1
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/.gitignore +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/LICENSE +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/scripts/download_models.sh +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/capture.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/compose.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/config.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/deliver.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/extract.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/live.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/models.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/subtitle.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/transcribe.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/src/earshot/transcribe_cloud.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_config.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_deliver.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_do_deliver.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_live.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_subtitle.py +0 -0
- {earshot_cli-0.1.0 → earshot_cli-0.2.0}/tests/test_wizard.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: earshot-cli
|
|
3
|
-
Version: 0.
|
|
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
|
|
@@ -45,19 +45,28 @@ Description-Content-Type: text/markdown
|
|
|
45
45
|
## 安装(开箱即用)
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
|
-
uv tool install earshot-cli # 或 pipx install earshot-cli
|
|
49
|
-
earshot doctor #
|
|
48
|
+
uv tool install earshot-cli # 或 pipx install earshot-cli
|
|
49
|
+
earshot doctor # 环境自检;earshot 与 earshot-cli 两个命令名等价可用
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
升级:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv tool upgrade earshot-cli
|
|
56
|
+
# 刚发布的版本若报 "Nothing to upgrade":PyPI 索引传播延迟(约 1 分钟),
|
|
57
|
+
# 稍后再试,或立即生效:uv tool install --force earshot-cli
|
|
50
58
|
```
|
|
51
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.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
|
### 文件生命周期
|
|
@@ -12,19 +12,28 @@
|
|
|
12
12
|
## 安装(开箱即用)
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
uv tool install earshot-cli # 或 pipx install earshot-cli
|
|
16
|
-
earshot doctor #
|
|
15
|
+
uv tool install earshot-cli # 或 pipx install earshot-cli
|
|
16
|
+
earshot doctor # 环境自检;earshot 与 earshot-cli 两个命令名等价可用
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
升级:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv tool upgrade earshot-cli
|
|
23
|
+
# 刚发布的版本若报 "Nothing to upgrade":PyPI 索引传播延迟(约 1 分钟),
|
|
24
|
+
# 稍后再试,或立即生效:uv tool install --force earshot-cli
|
|
17
25
|
```
|
|
18
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.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.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "旁听剪藏:跨平台桌面端系统声录制 → 本地转写 → 投递知识库(默认 ExoMind,可插拔)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -43,6 +43,7 @@ Issues = "https://github.com/helloworldtang/earshot/issues"
|
|
|
43
43
|
|
|
44
44
|
[project.scripts]
|
|
45
45
|
earshot = "earshot.cli:main"
|
|
46
|
+
earshot-cli = "earshot.cli:main" # 别名:包名直觉党与命令名党的双入口
|
|
46
47
|
|
|
47
48
|
[dependency-groups]
|
|
48
49
|
dev = [
|
|
@@ -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(" →
|
|
192
|
-
print("
|
|
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, "想理解递归"),
|
|
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",
|
|
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
|