Compare commits
2 Commits
9980f78cfc
...
c841aa091d
| Author | SHA1 | Date | |
|---|---|---|---|
| c841aa091d | |||
| f10915e020 |
@@ -10,9 +10,10 @@ pip 安装依赖后即可直接播放视频。
|
|||||||
- 播放 / 暂停 / 停止、上一首 / 下一首
|
- 播放 / 暂停 / 停止、上一首 / 下一首
|
||||||
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
- 前进 / 后退(步长可自定义)与进度条点击拖动定位
|
||||||
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
|
- 快进步长可在界面底部直接填写(1-600 秒),自动保存、下次启动自动加载
|
||||||
- 按句跳转:`, / .` 直接跳转上一句/下一句;勾选“按句跳转”后 `← / →` 也可
|
- 按句跳转:先点“生成句边界”。生成时会先按 SHA-256 查询线上是否已有同一
|
||||||
跳句。句边界通过线上服务生成,PC 仅上传本地提取的音频轨,服务器处理完成
|
视频的句边界;有则直接取回,没有时才本地提取音频并上传。生成完成后才能
|
||||||
后返回边界且不保存音频;结果按文件 SHA-256 缓存在本机。
|
勾选“按句跳转”,之后 `, / .` 或 `← / →` 可跳句。PC 会按文件 SHA-256
|
||||||
|
缓存结果;生成过程有进度条。
|
||||||
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
|
||||||
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
|
||||||
- 音量调节、静音,0.5x - 2.0x 变速播放
|
- 音量调节、静音,0.5x - 2.0x 变速播放
|
||||||
|
|||||||
265
media_player.py
265
media_player.py
@@ -81,15 +81,20 @@ DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
|
|||||||
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
|
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
|
||||||
|
|
||||||
|
|
||||||
def sha256_file(path: Path) -> str:
|
def sha256_file(path: Path, progress_callback=None) -> str:
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
|
total_size = max(1, path.stat().st_size)
|
||||||
|
processed = 0
|
||||||
with path.open("rb") as media_file:
|
with path.open("rb") as media_file:
|
||||||
for chunk in iter(lambda: media_file.read(1024 * 1024), b""):
|
for chunk in iter(lambda: media_file.read(1024 * 1024), b""):
|
||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
|
processed += len(chunk)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(min(1.0, processed / total_size))
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def extract_audio_to_wav(path: Path, output_path: Path) -> None:
|
def extract_audio_to_wav(path: Path, output_path: Path, progress_callback=None) -> None:
|
||||||
"""Extract one mono 16 kHz audio track before uploading it to the server."""
|
"""Extract one mono 16 kHz audio track before uploading it to the server."""
|
||||||
import av
|
import av
|
||||||
|
|
||||||
@@ -97,12 +102,17 @@ def extract_audio_to_wav(path: Path, output_path: Path) -> None:
|
|||||||
source_stream = next((s for s in source.streams if s.type == "audio"), None)
|
source_stream = next((s for s in source.streams if s.type == "audio"), None)
|
||||||
if source_stream is None:
|
if source_stream is None:
|
||||||
raise RuntimeError("文件中没有音频轨")
|
raise RuntimeError("文件中没有音频轨")
|
||||||
|
total_seconds = source.duration / 1_000_000 if source.duration and source.duration > 0 else 0.0
|
||||||
|
output_seconds = 0.0
|
||||||
resampler = av.AudioResampler(format="s16", layout="mono", rate=16_000)
|
resampler = av.AudioResampler(format="s16", layout="mono", rate=16_000)
|
||||||
with av.open(str(output_path), "w", format="wav") as target:
|
with av.open(str(output_path), "w", format="wav") as target:
|
||||||
target_stream = target.add_stream("pcm_s16le", rate=16_000, layout="mono")
|
target_stream = target.add_stream("pcm_s16le", rate=16_000, layout="mono")
|
||||||
for packet in source.demux(source_stream):
|
for packet in source.demux(source_stream):
|
||||||
for frame in packet.decode():
|
for frame in packet.decode():
|
||||||
for resampled in resampler.resample(frame):
|
for resampled in resampler.resample(frame):
|
||||||
|
output_seconds += len(resampled.samples) / resampled.sample_rate
|
||||||
|
if progress_callback and total_seconds > 0:
|
||||||
|
progress_callback(min(1.0, output_seconds / total_seconds))
|
||||||
for encoded in target_stream.encode(resampled):
|
for encoded in target_stream.encode(resampled):
|
||||||
target.mux(encoded)
|
target.mux(encoded)
|
||||||
for resampled in resampler.resample(None):
|
for resampled in resampler.resample(None):
|
||||||
@@ -873,7 +883,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.rate = 1.0
|
self.rate = 1.0
|
||||||
settings = self._load_settings()
|
settings = self._load_settings()
|
||||||
self.seek_step = settings["seek_step"]
|
self.seek_step = settings["seek_step"]
|
||||||
self.sentence_mode = tk.BooleanVar(value=settings["sentence_mode"])
|
# Sentence mode is always re-enabled manually after boundaries exist.
|
||||||
|
self.sentence_mode = tk.BooleanVar(value=False)
|
||||||
self.server_url = settings["server_url"].rstrip("/")
|
self.server_url = settings["server_url"].rstrip("/")
|
||||||
self.auth_token = settings["auth_token"]
|
self.auth_token = settings["auth_token"]
|
||||||
self.loop_mode = "list"
|
self.loop_mode = "list"
|
||||||
@@ -887,6 +898,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self._analysis_running = set()
|
self._analysis_running = set()
|
||||||
self._analysis_done = queue.Queue()
|
self._analysis_done = queue.Queue()
|
||||||
self._ui_events = queue.Queue()
|
self._ui_events = queue.Queue()
|
||||||
|
self._controls_media_path = None
|
||||||
|
self._generation_token = 0
|
||||||
self._explanation_window = None
|
self._explanation_window = None
|
||||||
self._explanation_generation = 0
|
self._explanation_generation = 0
|
||||||
|
|
||||||
@@ -1042,6 +1055,13 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
command=self._on_sentence_mode,
|
command=self._on_sentence_mode,
|
||||||
)
|
)
|
||||||
self.sentence_mode_btn.pack(side="left", padx=(16, 0))
|
self.sentence_mode_btn.pack(side="left", padx=(16, 0))
|
||||||
|
self.btn_generate_boundaries = ttk.Button(
|
||||||
|
controls,
|
||||||
|
text="生成句边界",
|
||||||
|
command=self.generate_sentence_boundaries,
|
||||||
|
takefocus=0,
|
||||||
|
)
|
||||||
|
self.btn_generate_boundaries.pack(side="left", padx=(12, 0))
|
||||||
self.btn_ai_explain = ttk.Button(
|
self.btn_ai_explain = ttk.Button(
|
||||||
controls,
|
controls,
|
||||||
text="AI讲解 (E)",
|
text="AI讲解 (E)",
|
||||||
@@ -1057,6 +1077,8 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
width=32,
|
width=32,
|
||||||
)
|
)
|
||||||
self.sentence_label.pack(side="left", padx=(10, 0))
|
self.sentence_label.pack(side="left", padx=(10, 0))
|
||||||
|
self.boundary_progress = ttk.Progressbar(bottom, mode="determinate", maximum=100)
|
||||||
|
self.boundary_progress.pack(fill="x", pady=(5, 0))
|
||||||
self.seek_step_spin.bind("<Return>", self._on_seek_step_return)
|
self.seek_step_spin.bind("<Return>", self._on_seek_step_return)
|
||||||
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
|
self.seek_step_spin.bind("<FocusOut>", lambda _e: self._commit_seek_step())
|
||||||
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True)
|
ttk.Label(controls, textvariable=self.status_var, anchor="e").pack(side="right", fill="x", expand=True)
|
||||||
@@ -1153,17 +1175,20 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
|
|
||||||
def _tick(self):
|
def _tick(self):
|
||||||
if self.engine is not None:
|
if self.engine is not None:
|
||||||
|
current_path = self._current_path()
|
||||||
|
if current_path != self._controls_media_path:
|
||||||
|
self._update_sentence_controls()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
done_path, message = self._analysis_done.get_nowait()
|
done_path, message = self._analysis_done.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
if done_path == self._current_path() and self.sentence_mode.get():
|
boundaries = self._boundaries.get(done_path)
|
||||||
boundaries = self._boundaries.get(done_path)
|
self.status_var.set(message or (
|
||||||
if boundaries:
|
f"句边界已就绪:共 {len((boundaries or {}).get('sentences', []))} 句"
|
||||||
self.status_var.set(message or f"句边界已就绪:共 {len(boundaries['sentences'])} 句")
|
))
|
||||||
else:
|
if done_path == self._current_path():
|
||||||
self.status_var.set(message or "该文件无法识别句子边界,按句跳转不可用")
|
self._update_sentence_controls()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
callback = self._ui_events.get_nowait()
|
callback = self._ui_events.get_nowait()
|
||||||
@@ -1364,8 +1389,10 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
self.title(f"{Path(path).name} — 媒体播放器")
|
self.title(f"{Path(path).name} — 媒体播放器")
|
||||||
self._update_status()
|
self._update_status()
|
||||||
self._save_playlist()
|
self._save_playlist()
|
||||||
if self.sentence_mode.get():
|
if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"):
|
||||||
self._ensure_sentence_boundaries(path)
|
self.sentence_mode.set(False)
|
||||||
|
self.status_var.set("请先生成句子边界")
|
||||||
|
self._update_sentence_controls()
|
||||||
|
|
||||||
def toggle_play(self):
|
def toggle_play(self):
|
||||||
if not self.playlist:
|
if not self.playlist:
|
||||||
@@ -1389,6 +1416,7 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
self._photo = None
|
self._photo = None
|
||||||
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
self.video_hint.place(relx=0.5, rely=0.5, anchor="center")
|
||||||
|
self._update_sentence_controls()
|
||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def next_track(self):
|
def next_track(self):
|
||||||
@@ -1444,47 +1472,135 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
return self.playlist[self.current_index]
|
return self.playlist[self.current_index]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _update_sentence_controls(self):
|
||||||
|
path = self._current_path()
|
||||||
|
self._controls_media_path = path
|
||||||
|
has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences"))
|
||||||
|
generating = bool(self._analysis_running)
|
||||||
|
if not path:
|
||||||
|
self.btn_generate_boundaries.config(state="disabled", text="生成句边界")
|
||||||
|
elif generating:
|
||||||
|
self.btn_generate_boundaries.config(state="disabled", text="生成中…")
|
||||||
|
else:
|
||||||
|
self.btn_generate_boundaries.config(
|
||||||
|
state="normal",
|
||||||
|
text="重新生成" if has_boundaries else "生成句边界",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_progress(self, value=None, message=None, indeterminate=False):
|
||||||
|
if not hasattr(self, "boundary_progress"):
|
||||||
|
return
|
||||||
|
if indeterminate:
|
||||||
|
self.boundary_progress.configure(mode="indeterminate")
|
||||||
|
self.boundary_progress.start(14)
|
||||||
|
else:
|
||||||
|
self.boundary_progress.stop()
|
||||||
|
self.boundary_progress.configure(mode="determinate")
|
||||||
|
if value is not None:
|
||||||
|
self.boundary_progress.configure(value=max(0, min(100, float(value))))
|
||||||
|
if message is not None:
|
||||||
|
self.status_var.set(message)
|
||||||
|
|
||||||
|
def _report_progress(self, value, message=None, indeterminate=False):
|
||||||
|
self._ui_events.put(
|
||||||
|
lambda: self._set_progress(value, message, indeterminate)
|
||||||
|
)
|
||||||
|
|
||||||
def _on_sentence_mode(self):
|
def _on_sentence_mode(self):
|
||||||
|
path = self._current_path()
|
||||||
|
has_boundaries = bool((self._boundaries.get(path) or {}).get("sentences"))
|
||||||
|
if self.sentence_mode.get() and not has_boundaries:
|
||||||
|
self.sentence_mode.set(False)
|
||||||
|
self.status_var.set("请先生成句子边界")
|
||||||
|
self._save_settings()
|
||||||
|
return
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
if not self.sentence_mode.get():
|
if not self.sentence_mode.get():
|
||||||
self.status_var.set("按句跳转已关闭")
|
self.status_var.set("按句跳转已关闭")
|
||||||
return
|
return
|
||||||
|
count = len(self._boundaries[path]["sentences"])
|
||||||
|
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
|
||||||
|
|
||||||
|
def generate_sentence_boundaries(self):
|
||||||
path = self._current_path()
|
path = self._current_path()
|
||||||
if path is None:
|
if path is None:
|
||||||
self.status_var.set("按句跳转已开启(请先添加并播放文件)")
|
messagebox.showinfo("生成句边界", "请先打开并选择一个媒体文件。")
|
||||||
return
|
return
|
||||||
if path not in self._boundaries:
|
if self._analysis_running:
|
||||||
self._ensure_sentence_boundaries(path)
|
messagebox.showinfo("生成句边界", "当前正在生成句边界,请稍候。")
|
||||||
self.status_var.set("正在分析句边界…")
|
|
||||||
elif self._boundaries[path]:
|
|
||||||
count = len(self._boundaries[path]["sentences"])
|
|
||||||
self.status_var.set(f"按句跳转已开启(识别到 {count} 句)")
|
|
||||||
else:
|
|
||||||
self.status_var.set("该文件无法识别句子边界,按句跳转不可用")
|
|
||||||
|
|
||||||
def _ensure_sentence_boundaries(self, path):
|
|
||||||
if path in self._boundaries or path in self._analysis_running:
|
|
||||||
return
|
return
|
||||||
if not self.server_url:
|
if not self.server_url:
|
||||||
self._boundaries[path] = {"sentences": []}
|
messagebox.showwarning("生成句边界", "请先在“服务…”中配置服务器地址。")
|
||||||
self._analysis_done.put((path, "请先在“服务…”中配置服务器地址"))
|
|
||||||
return
|
return
|
||||||
|
if (self._boundaries.get(path) or {}).get("sentences"):
|
||||||
|
if not messagebox.askyesno("生成句边界", "当前文件已有句边界,确定要重新生成吗?"):
|
||||||
|
return
|
||||||
|
self.sentence_mode.set(False)
|
||||||
|
self._save_settings()
|
||||||
self._analysis_running.add(path)
|
self._analysis_running.add(path)
|
||||||
self.status_var.set("正在从服务器生成句边界…")
|
self._generation_token += 1
|
||||||
threading.Thread(target=self._analyze_sentences, args=(path,), daemon=True).start()
|
token = self._generation_token
|
||||||
|
self._set_progress(0, "正在准备生成句边界…")
|
||||||
|
self._update_sentence_controls()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._analyze_sentences,
|
||||||
|
args=(path, token),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
def _analyze_sentences(self, path):
|
def _analyze_sentences(self, path, token):
|
||||||
error = ""
|
error = ""
|
||||||
document = None
|
document = None
|
||||||
|
source = "generated"
|
||||||
audio_path = None
|
audio_path = None
|
||||||
|
media_hash = None
|
||||||
try:
|
try:
|
||||||
media_hash = sha256_file(Path(path))
|
self._report_progress(4, "正在计算文件哈希…")
|
||||||
|
media_hash = sha256_file(
|
||||||
|
Path(path),
|
||||||
|
progress_callback=lambda ratio: self._report_progress(
|
||||||
|
4 + ratio * 12,
|
||||||
|
"正在计算文件哈希…",
|
||||||
|
),
|
||||||
|
)
|
||||||
self._media_hashes[path] = media_hash
|
self._media_hashes[path] = media_hash
|
||||||
document = self._load_cached_boundaries(media_hash)
|
|
||||||
if document is None:
|
self._report_progress(18, "正在检查线上是否已有句边界…")
|
||||||
audio_path = self._make_audio_upload(path)
|
remote_document = self._fetch_remote_boundaries(media_hash)
|
||||||
document = self._generate_remote_boundaries(audio_path)
|
if remote_document and remote_document.get("sentences"):
|
||||||
|
document = remote_document
|
||||||
|
source = "server"
|
||||||
self._save_cached_boundaries(media_hash, document)
|
self._save_cached_boundaries(media_hash, document)
|
||||||
|
else:
|
||||||
|
document = self._load_cached_boundaries(media_hash)
|
||||||
|
if document and document.get("sentences"):
|
||||||
|
source = "local"
|
||||||
|
else:
|
||||||
|
self._report_progress(25, "线上没有句边界,正在准备音频…")
|
||||||
|
audio_path = self._make_audio_upload(
|
||||||
|
path,
|
||||||
|
progress_callback=lambda ratio: self._report_progress(
|
||||||
|
25 + ratio * 20,
|
||||||
|
"正在提取音频轨…",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
document = self._generate_remote_boundaries(
|
||||||
|
audio_path,
|
||||||
|
progress_callback=lambda ratio: (
|
||||||
|
self._report_progress(
|
||||||
|
85,
|
||||||
|
"服务器正在生成句边界…",
|
||||||
|
indeterminate=True,
|
||||||
|
)
|
||||||
|
if ratio < 0
|
||||||
|
else self._report_progress(
|
||||||
|
45 + ratio * 35,
|
||||||
|
f"正在上传音频… {ratio * 100:.0f}%",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._save_cached_boundaries(media_hash, document)
|
||||||
|
source = "generated"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
error = str(exc) or exc.__class__.__name__
|
error = str(exc) or exc.__class__.__name__
|
||||||
finally:
|
finally:
|
||||||
@@ -1493,14 +1609,25 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
Path(audio_path).unlink(missing_ok=True)
|
Path(audio_path).unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._analysis_running.discard(path)
|
self._analysis_running.discard(path)
|
||||||
if document is not None and document.get("sentences"):
|
if document is not None and document.get("sentences"):
|
||||||
self._boundaries[path] = document
|
self._boundaries[path] = document
|
||||||
message = error or f"句边界已生成:共 {len(document['sentences'])} 句,已保存到本地"
|
count = len(document["sentences"])
|
||||||
self._analysis_done.put((path, message))
|
if source == "server":
|
||||||
|
message = f"已取回线上句边界:共 {count} 句,并保存到本地"
|
||||||
|
elif source == "local":
|
||||||
|
message = f"已加载本地句边界缓存:共 {count} 句"
|
||||||
|
else:
|
||||||
|
message = f"句边界已生成:共 {count} 句,已保存到本地"
|
||||||
|
self._report_progress(100, message)
|
||||||
else:
|
else:
|
||||||
self._boundaries[path] = {"sentences": []}
|
self._boundaries[path] = {"sentences": []}
|
||||||
self._analysis_done.put((path, error or "服务器未能识别句子边界"))
|
message = error or "服务器未能识别句子边界"
|
||||||
|
self._report_progress(0, message)
|
||||||
|
self._report_progress(None, None, indeterminate=False)
|
||||||
|
self._ui_events.put(lambda: self._update_sentence_controls())
|
||||||
|
self._analysis_done.put((path, message))
|
||||||
|
|
||||||
def _boundary_cache_path(self, media_hash: str) -> Path:
|
def _boundary_cache_path(self, media_hash: str) -> Path:
|
||||||
return Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.json"
|
return Path.home() / ".mediaplayer_sentence_boundaries" / f"{media_hash}.json"
|
||||||
@@ -1546,28 +1673,78 @@ class MediaPlayerApp(tk.Tk):
|
|||||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
temporary.replace(path)
|
temporary.replace(path)
|
||||||
|
|
||||||
def _make_audio_upload(self, path):
|
def _make_audio_upload(self, path, progress_callback=None):
|
||||||
source = Path(path)
|
source = Path(path)
|
||||||
if source.suffix.lower() in AUDIO_EXTS:
|
if source.suffix.lower() in AUDIO_EXTS:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(1.0)
|
||||||
return source
|
return source
|
||||||
handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False)
|
handle = tempfile.NamedTemporaryFile(prefix="mediaplayer-audio-", suffix=".wav", delete=False)
|
||||||
output = Path(handle.name)
|
output = Path(handle.name)
|
||||||
handle.close()
|
handle.close()
|
||||||
extract_audio_to_wav(source, output)
|
extract_audio_to_wav(source, output, progress_callback)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def _generate_remote_boundaries(self, audio_path) -> Dict[str, Any]:
|
def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]:
|
||||||
filename = Path(audio_path).name
|
headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"}
|
||||||
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
|
|
||||||
headers = {"User-Agent": CLIENT_NAME}
|
|
||||||
if self.auth_token:
|
if self.auth_token:
|
||||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
|
response = requests.get(
|
||||||
|
self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries",
|
||||||
|
headers=headers,
|
||||||
|
timeout=(15, 30),
|
||||||
|
)
|
||||||
|
if response.status_code == 404:
|
||||||
|
return None
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise RuntimeError("请先在“服务…”中登录")
|
||||||
|
if response.status_code not in range(200, 300):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"线上句边界查询返回 {response.status_code}:{self._server_error_detail(response)}"
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("sentences"):
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]:
|
||||||
|
filename = "audio" + Path(audio_path).suffix.lower()
|
||||||
|
content_type = "audio/wav" if filename.lower().endswith(".wav") else "application/octet-stream"
|
||||||
|
total_size = max(1, Path(audio_path).stat().st_size)
|
||||||
|
boundary = f"mediaplayer-{os.urandom(12).hex()}"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": CLIENT_NAME,
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
}
|
||||||
|
if self.auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||||
|
|
||||||
|
def multipart_chunks():
|
||||||
|
prefix = (
|
||||||
|
f"--{boundary}\r\n"
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
|
||||||
|
f"Content-Type: {content_type}\r\n\r\n"
|
||||||
|
).encode("utf-8")
|
||||||
|
yield prefix
|
||||||
|
processed = 0
|
||||||
|
with Path(audio_path).open("rb") as audio_file:
|
||||||
|
while True:
|
||||||
|
chunk = audio_file.read(256 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
processed += len(chunk)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(min(1.0, processed / total_size))
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(-1.0)
|
||||||
|
yield f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||||
|
|
||||||
with Path(audio_path).open("rb") as audio_file:
|
with Path(audio_path).open("rb") as audio_file:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
self.server_url + "/api/v1/audio/sentence-boundaries",
|
self.server_url + "/api/v1/audio/sentence-boundaries",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
files={"file": (filename, audio_file, content_type)},
|
data=multipart_chunks(),
|
||||||
data={"language": ""},
|
|
||||||
timeout=(15, 3600),
|
timeout=(15, 3600),
|
||||||
)
|
)
|
||||||
if response.status_code == 401:
|
if response.status_code == 401:
|
||||||
|
|||||||
@@ -62,12 +62,22 @@ POST /api/v1/admin/videos/{sha256}/process
|
|||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}
|
||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/split
|
||||||
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
|
PUT /api/v1/admin/videos/{sha256}/sentences/{index}/boundary
|
||||||
|
PUT /api/v1/admin/users/password
|
||||||
DELETE /api/v1/admin/videos/{sha256}
|
DELETE /api/v1/admin/videos/{sha256}
|
||||||
```
|
```
|
||||||
|
|
||||||
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
Admin endpoints use `X-Admin-Key` when `ADMIN_API_KEY` is configured. Assessment
|
||||||
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
requests use `X-Client-Key` when `CLIENT_API_KEY` is configured.
|
||||||
|
|
||||||
|
Administrators can set a local password for an SSO-linked account with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT https://your-server/api/v1/admin/users/password \
|
||||||
|
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"bobcoc","password":"new-password"}'
|
||||||
|
```
|
||||||
|
|
||||||
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
|
`POST /api/v1/audio/sentence-boundaries` accepts an audio file from an
|
||||||
authenticated client, generates boundaries in memory/temp storage only, and
|
authenticated client, generates boundaries in memory/temp storage only, and
|
||||||
returns a sentence document. It does not register or archive the uploaded
|
returns a sentence document. It does not register or archive the uploaded
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from .models import (
|
|||||||
SentenceBoundaryDocument,
|
SentenceBoundaryDocument,
|
||||||
SentenceExplanationRequest,
|
SentenceExplanationRequest,
|
||||||
SentenceExplanationResponse,
|
SentenceExplanationResponse,
|
||||||
|
PasswordResetRequest,
|
||||||
SentenceTextUpdate,
|
SentenceTextUpdate,
|
||||||
UserDubShareSummary,
|
UserDubShareSummary,
|
||||||
SsoExchangeRequest,
|
SsoExchangeRequest,
|
||||||
@@ -441,6 +442,19 @@ def create_app(
|
|||||||
raise HTTPException(status_code=404, detail="User was not found.")
|
raise HTTPException(status_code=404, detail="User was not found.")
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@application.put(
|
||||||
|
"/api/v1/admin/users/password",
|
||||||
|
status_code=204,
|
||||||
|
dependencies=[Depends(require_admin)],
|
||||||
|
)
|
||||||
|
def reset_user_password(
|
||||||
|
payload: PasswordResetRequest,
|
||||||
|
) -> Response:
|
||||||
|
updated = video_repository.set_user_password(payload.username, payload.password)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=404, detail="User was not found.")
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
@application.get("/api/v1/videos", response_model=VideoListResponse)
|
@application.get("/api/v1/videos", response_model=VideoListResponse)
|
||||||
def list_videos() -> VideoListResponse:
|
def list_videos() -> VideoListResponse:
|
||||||
return VideoListResponse(
|
return VideoListResponse(
|
||||||
|
|||||||
@@ -92,6 +92,13 @@ class SentenceTextUpdate(BaseModel):
|
|||||||
language: Optional[str] = Field(default=None, max_length=32)
|
language: Optional[str] = Field(default=None, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
class SentenceExplanationRequest(BaseModel):
|
class SentenceExplanationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -984,6 +984,22 @@ class VideoRepository:
|
|||||||
return None
|
return None
|
||||||
return self.get_user_by_id(row["id"])
|
return self.get_user_by_id(row["id"])
|
||||||
|
|
||||||
|
def set_user_password(self, username: str, password: str) -> bool:
|
||||||
|
"""Set a local password for either a normal or SSO-linked account."""
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
return False
|
||||||
|
with self._connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?
|
||||||
|
WHERE username = ? COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(hash_password(password), username),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
def create_session(self, user_id: str) -> str:
|
def create_session(self, user_id: str) -> str:
|
||||||
token = new_token()
|
token = new_token()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -1205,4 +1221,3 @@ def _is_cjk_char(char: str) -> bool:
|
|||||||
or 0xF900 <= code <= 0xFAFF
|
or 0xF900 <= code <= 0xFAFF
|
||||||
or 0x20000 <= code <= 0x2CEAF
|
or 0x20000 <= code <= 0x2CEAF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sentence_api.store import BoundaryStore
|
|||||||
VIDEO_HASH = "a" * 64
|
VIDEO_HASH = "a" * 64
|
||||||
|
|
||||||
|
|
||||||
def make_client(tmp_path):
|
def make_client(tmp_path, admin_api_key=""):
|
||||||
index_path = tmp_path / "boundaries.json"
|
index_path = tmp_path / "boundaries.json"
|
||||||
index_path.write_text(
|
index_path.write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@@ -36,6 +36,7 @@ def make_client(tmp_path):
|
|||||||
Settings.from_env(),
|
Settings.from_env(),
|
||||||
data_dir=tmp_path / "data",
|
data_dir=tmp_path / "data",
|
||||||
legacy_boundaries_file=index_path,
|
legacy_boundaries_file=index_path,
|
||||||
|
admin_api_key=admin_api_key,
|
||||||
)
|
)
|
||||||
settings.ensure_directories()
|
settings.ensure_directories()
|
||||||
client = TestClient(
|
client = TestClient(
|
||||||
@@ -116,6 +117,27 @@ def test_adhoc_audio_boundaries_require_login(tmp_path):
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_can_reset_sso_user_password(tmp_path):
|
||||||
|
client = make_client(tmp_path, admin_api_key="test-admin-key")
|
||||||
|
repository = client.app.state.video_repository
|
||||||
|
created = repository.create_sso_user(
|
||||||
|
username="bobcoc",
|
||||||
|
nickname="Bob",
|
||||||
|
sso_provider="d1kt",
|
||||||
|
)
|
||||||
|
assert created is not None
|
||||||
|
assert repository.authenticate_user("bobcoc", "anything-123") is None
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
"/api/v1/admin/users/password",
|
||||||
|
json={"username": "bobcoc", "password": "new-password-123"},
|
||||||
|
headers={"X-Admin-Key": "test-admin-key"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert repository.authenticate_user("bobcoc", "new-password-123") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_sentence_explanation_requires_login(tmp_path):
|
def test_sentence_explanation_requires_login(tmp_path):
|
||||||
index_path = tmp_path / "boundaries.json"
|
index_path = tmp_path / "boundaries.json"
|
||||||
index_path.write_text("{}", encoding="utf-8")
|
index_path.write_text("{}", encoding="utf-8")
|
||||||
|
|||||||
Reference in New Issue
Block a user