fixed again

This commit is contained in:
2026-09-10 16:16:39 +08:00
parent 65b6617a5f
commit 2b663d50ad
2 changed files with 449 additions and 19 deletions

View File

@@ -16,6 +16,8 @@ pip 安装依赖后即可直接播放视频。
缓存结果;生成过程有进度条。
- AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。
- 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。
- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在
`~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。
- 音量调节、静音,0.5x - 2.0x 变速播放
- 三种循环模式:关 / 单曲循环 / 列表循环
- 播放列表管理(打开文件、打开文件夹、删除条目、双击播放)

View File

@@ -31,6 +31,8 @@
import collections
import hashlib
import json
import logging
from logging.handlers import RotatingFileHandler
import os
import queue
import sys
@@ -79,6 +81,37 @@ SEEK_STEP = 10
SEEK_STEP_BIG = 60
DEFAULT_SERVER_URL = "https://videoservice.d1kt.cn"
CLIENT_NAME = "MediaPlayer/1.0 (Desktop)"
DEBUG_LOG_DIR = Path.home() / ".mediaplayer_logs"
DEBUG_LOG_PATH = DEBUG_LOG_DIR / "mediaplayer.log"
_debug_logger = logging.getLogger("mediaplayer")
def setup_debug_logging() -> Path:
"""Write concise diagnostics to a small local rotating log."""
try:
DEBUG_LOG_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(
DEBUG_LOG_PATH,
maxBytes=1024 * 1024,
backupCount=2,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
_debug_logger.addHandler(handler)
_debug_logger.setLevel(logging.INFO)
_debug_logger.propagate = False
except OSError:
pass
return DEBUG_LOG_PATH
def debug_log(event: str, **fields: Any) -> None:
"""Record a diagnostic event without any authentication material."""
try:
_debug_logger.info("%s %s", event, json.dumps(fields, ensure_ascii=False, sort_keys=True))
except Exception:
# Logging must never interfere with playback or request handling.
pass
def sha256_file(path: Path, progress_callback=None) -> str:
@@ -110,7 +143,8 @@ def extract_audio_to_wav(path: Path, output_path: Path, progress_callback=None)
for packet in source.demux(source_stream):
for frame in packet.decode():
for resampled in resampler.resample(frame):
output_seconds += len(resampled.samples) / resampled.sample_rate
# PyAV exposes Frame.samples as the frame's sample count.
output_seconds += 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):
@@ -876,6 +910,7 @@ class MediaPlayerApp(tk.Tk):
self.title("媒体播放器")
self.geometry("960x600")
self.minsize(720, 460)
self.debug_log_path = setup_debug_logging()
self.playlist = []
self.current_index = -1
@@ -887,6 +922,8 @@ class MediaPlayerApp(tk.Tk):
self.sentence_mode = tk.BooleanVar(value=False)
self.server_url = settings["server_url"].rstrip("/")
self.auth_token = settings["auth_token"]
self.auth_username = settings["auth_username"]
self.auth_nickname = settings["auth_nickname"]
self.loop_mode = "list"
self._seeking = False
self._switching_until = 0.0
@@ -896,12 +933,14 @@ class MediaPlayerApp(tk.Tk):
self._boundaries = {}
self._media_hashes = {}
self._analysis_running = set()
self._local_cache_loading = set()
self._analysis_done = queue.Queue()
self._ui_events = queue.Queue()
self._controls_media_path = None
self._generation_token = 0
self._explanation_window = None
self._explanation_text = None
self._explanation_debug_var = None
self._explanation_generation = 0
self.engine = self._create_engine()
@@ -916,11 +955,14 @@ class MediaPlayerApp(tk.Tk):
self._build_ui()
self._bind_shortcuts()
debug_log("app_started", server_url=self.server_url, token_present=bool(self.auth_token))
if isinstance(self.engine, PygameEngine):
self.status_var.set("pygame 音频模式:未检测到 VLC,视频功能不可用")
elif isinstance(self.engine, AvEngine):
self.status_var.set("纯 Python 解码内核:无需安装 VLC,可直接播放音视频")
self._load_playlist()
self._load_local_boundaries_async(self._current_path())
self._validate_saved_session()
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.after(100, self._maybe_embed)
self.after(200, self._tick)
@@ -953,6 +995,7 @@ class MediaPlayerApp(tk.Tk):
ttk.Button(toolbar, text="打开文件…", command=self.open_files, takefocus=0).pack(side="left", padx=(0, 4))
ttk.Button(toolbar, text="打开文件夹…", command=self.open_folder, takefocus=0).pack(side="left", padx=(0, 8))
ttk.Button(toolbar, text="服务…", command=self.configure_server, takefocus=0).pack(side="left")
ttk.Button(toolbar, text="调试日志", command=self.show_debug_log, takefocus=0).pack(side="left", padx=(8, 0))
self.btn_prev = ttk.Button(toolbar, text="上一首", command=self.prev_track, takefocus=0)
self.btn_prev.pack(side="left", padx=2)
@@ -1302,6 +1345,8 @@ class MediaPlayerApp(tk.Tk):
"sentence_mode": bool(data.get("sentence_mode", False)),
"server_url": server_url,
"auth_token": str(data.get("auth_token") or "").strip(),
"auth_username": str(data.get("auth_username") or "").strip(),
"auth_nickname": str(data.get("auth_nickname") or "").strip(),
}
def _save_settings(self):
@@ -1313,6 +1358,8 @@ class MediaPlayerApp(tk.Tk):
"sentence_mode": bool(self.sentence_mode.get()),
"server_url": self.server_url,
"auth_token": self.auth_token,
"auth_username": self.auth_username,
"auth_nickname": self.auth_nickname,
},
ensure_ascii=False,
indent=2,
@@ -1393,6 +1440,7 @@ class MediaPlayerApp(tk.Tk):
if self.sentence_mode.get() and not (self._boundaries.get(path) or {}).get("sentences"):
self.sentence_mode.set(False)
self.status_var.set("请先生成句子边界")
self._load_local_boundaries_async(path)
self._update_sentence_controls()
def toggle_play(self):
@@ -1473,6 +1521,130 @@ class MediaPlayerApp(tk.Tk):
return self.playlist[self.current_index]
return None
def _auth_display(self):
return self.auth_nickname or self.auth_username or ""
def _validate_saved_session(self):
if not self.auth_token:
self._ui_events.put(lambda: self._set_saved_session_result(False, "未登录;请在“服务…”中登录"))
return
threading.Thread(target=self._validate_saved_session_worker, daemon=True).start()
def _validate_saved_session_worker(self):
started = time.monotonic()
try:
response = requests.get(
self.server_url + "/api/v1/auth/me",
headers={
"Authorization": f"Bearer {self.auth_token}",
"User-Agent": CLIENT_NAME,
"Accept": "application/json",
},
timeout=(10, 15),
)
elapsed_ms = int((time.monotonic() - started) * 1000)
debug_log(
"saved_session_validation",
status_code=response.status_code,
elapsed_ms=elapsed_ms,
)
if response.status_code not in range(200, 300):
self._ui_events.put(
lambda: self._set_saved_session_result(
False,
"保存的登录已过期,请在“服务…”中重新登录",
clear_token=response.status_code == 401,
)
)
return
user = response.json()
display = user.get("nickname") or user.get("username") or ""
self._ui_events.put(
lambda: self._set_saved_session_result(
True,
f"已自动登录:{display}",
username=user.get("username"),
nickname=user.get("nickname"),
)
)
except Exception as exc:
debug_log(
"saved_session_validation_failed",
error_class=exc.__class__.__name__,
error=str(exc),
)
self._ui_events.put(
lambda: self._set_saved_session_result(False, "自动登录校验失败;已保留本地令牌")
)
def _set_saved_session_result(
self,
ok,
message,
clear_token=False,
username=None,
nickname=None,
):
if clear_token:
self.auth_token = ""
self.auth_username = ""
self.auth_nickname = ""
self._save_settings()
if ok:
if username is not None:
self.auth_username = str(username or "")
if nickname is not None:
self.auth_nickname = str(nickname or "")
self._save_settings()
self._update_status()
self.status_var.set(message)
def _load_local_boundaries_async(self, path):
if not path or path in self._boundaries or path in self._local_cache_loading:
return
self._local_cache_loading.add(path)
self.status_var.set("正在检查本地句边界缓存…")
threading.Thread(
target=self._load_local_boundaries_worker,
args=(path,),
daemon=True,
).start()
def _load_local_boundaries_worker(self, path):
try:
media_hash = sha256_file(Path(path))
document = self._load_cached_boundaries(media_hash)
if document and document.get("sentences"):
self._media_hashes[path] = media_hash
self._boundaries[path] = document
count = len(document["sentences"])
message = f"已加载本地句边界缓存:共 {count} 句"
debug_log(
"sentence_cache_loaded",
media_hash=media_hash,
sentence_count=count,
)
else:
message = "本地没有句边界缓存;可点击“生成句边界”"
debug_log("sentence_cache_miss", media_hash=media_hash)
self._ui_events.put(lambda: self._finish_local_boundary_load(path, message))
except Exception as exc:
message = str(exc) or exc.__class__.__name__
debug_log(
"sentence_cache_load_failed",
error_class=exc.__class__.__name__,
error=message,
)
self._ui_events.put(lambda: self._finish_local_boundary_load(path, message))
def _finish_local_boundary_load(self, path, message):
self._local_cache_loading.discard(path)
if path in self._analysis_running or path in self._boundaries:
return
if path == self._current_path():
self.status_var.set(message)
self._update_sentence_controls()
def _update_sentence_controls(self):
path = self._current_path()
self._controls_media_path = path
@@ -1578,6 +1750,7 @@ class MediaPlayerApp(tk.Tk):
source = "local"
else:
self._report_progress(25, "线上没有句边界,正在准备音频…")
debug_log("audio_extraction_started", media_hash=media_hash)
audio_path = self._make_audio_upload(
path,
progress_callback=lambda ratio: self._report_progress(
@@ -1585,6 +1758,11 @@ class MediaPlayerApp(tk.Tk):
"正在提取音频轨…",
),
)
debug_log(
"audio_extraction_ready",
media_hash=media_hash,
audio_bytes=Path(audio_path).stat().st_size,
)
document = self._generate_remote_boundaries(
audio_path,
progress_callback=lambda ratio: (
@@ -1604,6 +1782,9 @@ class MediaPlayerApp(tk.Tk):
source = "generated"
except Exception as exc:
error = str(exc) or exc.__class__.__name__
_debug_logger.exception(
"sentence_boundaries_exception media_hash=%s", media_hash
)
finally:
if audio_path is not None:
try:
@@ -1622,10 +1803,22 @@ class MediaPlayerApp(tk.Tk):
else:
message = f"句边界已生成:共 {count} 句,已保存到本地"
self._report_progress(100, message)
debug_log(
"sentence_boundaries_ready",
source=source,
sentence_count=count,
media_hash=media_hash,
)
else:
self._boundaries[path] = {"sentences": []}
message = error or "服务器未能识别句子边界"
self._report_progress(0, message)
debug_log(
"sentence_boundaries_failed",
source=source,
media_hash=media_hash,
error=message,
)
self._report_progress(None, None, indeterminate=False)
self._ui_events.put(lambda: self._update_sentence_controls())
self._analysis_done.put((path, message))
@@ -1673,6 +1866,12 @@ class MediaPlayerApp(tk.Tk):
}
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
debug_log(
"sentence_cache_saved",
media_hash=media_hash,
sentence_count=len(payload["sentences"]),
cache_file=str(path),
)
def _make_audio_upload(self, path, progress_callback=None):
source = Path(path)
@@ -1687,14 +1886,22 @@ class MediaPlayerApp(tk.Tk):
return output
def _fetch_remote_boundaries(self, media_hash: str) -> Optional[Dict[str, Any]]:
started = time.monotonic()
request_url = self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries"
headers = {"User-Agent": CLIENT_NAME, "Accept": "application/json"}
if self.auth_token:
headers["Authorization"] = f"Bearer {self.auth_token}"
response = requests.get(
self.server_url + f"/api/v1/videos/{media_hash}/sentence-boundaries",
request_url,
headers=headers,
timeout=(15, 30),
)
debug_log(
"remote_boundaries_lookup",
status_code=response.status_code,
elapsed_ms=int((time.monotonic() - started) * 1000),
media_hash=media_hash,
)
if response.status_code == 404:
return None
if response.status_code == 401:
@@ -1709,6 +1916,8 @@ class MediaPlayerApp(tk.Tk):
return data
def _generate_remote_boundaries(self, audio_path, progress_callback=None) -> Dict[str, Any]:
started = time.monotonic()
request_url = self.server_url + "/api/v1/audio/sentence-boundaries"
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)
@@ -1743,28 +1952,65 @@ class MediaPlayerApp(tk.Tk):
with Path(audio_path).open("rb") as audio_file:
response = requests.post(
self.server_url + "/api/v1/audio/sentence-boundaries",
request_url,
headers=headers,
data=multipart_chunks(),
timeout=(15, 3600),
)
debug_log(
"audio_boundaries_response",
status_code=response.status_code,
elapsed_ms=int((time.monotonic() - started) * 1000),
upload_bytes=total_size,
)
if response.status_code == 401:
raise RuntimeError("请先在“服务…”中登录")
if response.status_code not in range(200, 300):
response_body = response.text or ""
debug_log(
"audio_boundaries_failed",
status_code=response.status_code,
body_snippet=response_body[:1000].replace("\n", "\\n"),
)
detail = self._server_error_detail(response)
raise RuntimeError(f"句边界服务返回 {response.status_code}:{detail}")
try:
data = response.json()
except Exception as exc:
debug_log(
"audio_boundaries_bad_json",
status_code=response.status_code,
body_snippet=(response.text or "")[:1000].replace("\n", "\\n"),
error=str(exc),
)
raise RuntimeError("句边界服务返回了无法解析的 JSON 数据") from exc
if not data.get("sentences"):
debug_log(
"audio_boundaries_empty",
status_code=response.status_code,
response_body=(response.text or "")[:1000],
)
raise RuntimeError("服务器未能识别句子边界")
return data
@staticmethod
def _server_error_detail(response) -> str:
"""Return a short, human-readable API error for any FastAPI shape."""
try:
data = response.json()
detail = data.get("detail", response.text)
if isinstance(data, dict):
detail = data.get("detail", data)
else:
detail = data
except Exception:
detail = response.text
detail = response.text or response.reason or "未知错误"
if isinstance(detail, (dict, list)):
try:
detail = json.dumps(detail, ensure_ascii=False)
except Exception:
detail = str(detail)
else:
detail = str(detail)
return detail if len(detail) <= 300 else detail[:297] + "..."
@@ -1930,6 +2176,62 @@ class MediaPlayerApp(tk.Tk):
self._save_playlist()
self.status_var.set("播放列表已清空")
def show_debug_log(self):
window = tk.Toplevel(self)
window.title("调试日志")
window.geometry("860x540")
window.transient(self)
frame = ttk.Frame(window, padding=10)
frame.pack(fill="both", expand=True)
frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1)
info = ttk.Frame(frame)
info.grid(row=0, column=0, sticky="ew", pady=(0, 8))
ttk.Label(info, text=f"日志文件:{self.debug_log_path}", wraplength=700, justify="left").pack(anchor="w")
ttk.Label(
info,
text="日志不包含登录令牌;发送问题反馈时可点击“复制日志”。",
wraplength=700,
justify="left",
foreground="#666666",
).pack(anchor="w")
text = tk.Text(frame, wrap="none", state="disabled", relief="flat", background="#111111", foreground="#eeeeee")
text.grid(row=1, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(frame, orient="vertical", command=text.yview)
scrollbar.grid(row=1, column=1, sticky="ns")
text.configure(yscrollcommand=scrollbar.set)
buttons = ttk.Frame(frame)
buttons.grid(row=2, column=0, columnspan=2, sticky="e", pady=(8, 0))
def refresh():
try:
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")[-256 * 1024:]
except FileNotFoundError:
data = "日志文件尚未创建。"
except OSError as exc:
data = f"读取日志失败:{exc}"
text.configure(state="normal")
text.delete("1.0", "end")
text.insert("1.0", data)
text.see("end")
text.configure(state="disabled")
def copy_log():
try:
data = self.debug_log_path.read_text(encoding="utf-8", errors="replace")[-256 * 1024:]
self.clipboard_clear()
self.clipboard_append(data)
self.status_var.set("调试日志已复制")
except OSError as exc:
messagebox.showerror("复制日志", str(exc), parent=window)
ttk.Button(buttons, text="刷新", command=refresh, takefocus=0).pack(side="left", padx=(0, 8))
ttk.Button(buttons, text="复制日志", command=copy_log, takefocus=0).pack(side="left")
refresh()
def configure_server(self):
window = tk.Toplevel(self)
window.title("服务配置")
@@ -1945,7 +2247,7 @@ class MediaPlayerApp(tk.Tk):
ttk.Entry(frame, textvariable=server_var, width=40).grid(row=0, column=1, sticky="ew")
ttk.Label(frame, text="账号").grid(row=1, column=0, sticky="w", pady=5)
username_var = tk.StringVar()
username_var = tk.StringVar(value=self.auth_username)
ttk.Entry(frame, textvariable=username_var).grid(row=1, column=1, sticky="ew")
ttk.Label(frame, text="密码").grid(row=2, column=0, sticky="w", pady=5)
@@ -1956,7 +2258,14 @@ class MediaPlayerApp(tk.Tk):
token_var = tk.StringVar(value=self.auth_token)
ttk.Entry(frame, textvariable=token_var, show="*").grid(row=3, column=1, sticky="ew")
message = tk.StringVar(value="已有令牌可直接保存;没有令牌时输入账号密码登录。")
display = self._auth_display()
message = tk.StringVar(
value=(
f"已保存登录状态:{display}。下次启动会自动使用令牌;过期时重新登录即可。"
if self.auth_token
else "输入账号密码登录,或粘贴服务端返回的令牌。"
)
)
ttk.Label(frame, textvariable=message, wraplength=360, foreground="#666666").grid(
row=4, column=0, columnspan=2, sticky="ew", pady=(12, 8)
)
@@ -1970,8 +2279,12 @@ class MediaPlayerApp(tk.Tk):
return
self.server_url = url
self.auth_token = token_var.get().strip()
if not self.auth_token:
self.auth_username = ""
self.auth_nickname = ""
self._save_settings()
window.destroy()
self._update_status()
self.status_var.set("服务配置已保存")
def login_and_save():
@@ -1985,11 +2298,16 @@ class MediaPlayerApp(tk.Tk):
)
if response.status_code not in range(200, 300):
raise RuntimeError(self._server_error_detail(response))
token_var.set(response.json()["token"])
message.set("登录成功,令牌已保存。")
body = response.json()
token_var.set(body["token"])
user = body.get("user") or {}
self.server_url = url
self.auth_token = token_var.get().strip()
self.auth_username = str(user.get("username") or username_var.get().strip())
self.auth_nickname = str(user.get("nickname") or "")
self._save_settings()
message.set(f"登录成功,已保存 {self._auth_display()} 的令牌。")
self._update_status()
window.after(500, window.destroy)
except Exception as exc:
messagebox.showerror("登录失败", str(exc), parent=window)
@@ -2025,24 +2343,63 @@ class MediaPlayerApp(tk.Tk):
frame.pack(fill="both", expand=True)
frame.columnconfigure(0, weight=1)
frame.rowconfigure(1, weight=1)
ttk.Label(frame, text=sentence_text, wraplength=440, justify="left", font=("Helvetica", 12, "bold")).grid(
ttk.Label(
frame,
text=sentence_text,
wraplength=440,
justify="left",
foreground="#111111",
font=("Helvetica", 12, "bold"),
).grid(
row=0, column=0, sticky="ew", pady=(0, 10)
)
text = tk.Text(frame, wrap="word", state="disabled", relief="flat", background="#f6f6f6")
text = tk.Text(
frame,
wrap="word",
relief="flat",
background="#f6f6f6",
foreground="#111111",
insertbackground="#111111",
selectbackground="#3f8efc",
selectforeground="#ffffff",
padx=10,
pady=10,
)
text.grid(row=1, column=0, sticky="nsew")
text.insert("1.0", "AI 正在讲解这句话…")
text.configure(state="disabled")
self._explanation_text = text
self._explanation_debug_var = tk.StringVar(
value=f"请求 #{generation};连接 15 秒 / 响应最长 180 秒;日志见“调试日志”。"
)
ttk.Label(
frame,
textvariable=self._explanation_debug_var,
wraplength=440,
justify="left",
foreground="#666666",
).grid(row=2, column=0, sticky="ew")
ttk.Button(
frame,
text="关闭",
command=lambda: self._close_explanation(generation),
takefocus=0,
).grid(row=2, column=0, sticky="e", pady=(10, 0))
).grid(row=3, column=0, sticky="e", pady=(10, 0))
self._explanation_window = window
window.protocol("WM_DELETE_WINDOW", lambda: self._close_explanation(generation))
path = self._current_path() or ""
course_title = Path(path).stem
debug_log(
"ai_explain_started",
request_id=generation,
server_url=self.server_url,
sentence_index=sentence.get("index"),
sentence_text_length=len(sentence_text),
course_title_length=len(course_title),
language=sentence.get("language"),
token_present=bool(self.auth_token),
)
threading.Thread(
target=self._explain_sentence_worker,
args=(sentence, course_title, generation),
@@ -2050,6 +2407,8 @@ class MediaPlayerApp(tk.Tk):
).start()
def _explain_sentence_worker(self, sentence, course_title, generation):
started = time.monotonic()
request_url = self.server_url + "/api/v1/sentences/explain"
try:
headers = {"Content-Type": "application/json", "User-Agent": CLIENT_NAME}
if self.auth_token:
@@ -2061,38 +2420,102 @@ class MediaPlayerApp(tk.Tk):
"language": sentence.get("language"),
}
response = requests.post(
self.server_url + "/api/v1/sentences/explain",
request_url,
headers=headers,
json=payload,
timeout=(15, 180),
)
elapsed_ms = int((time.monotonic() - started) * 1000)
response_body = response.text or ""
debug_log(
"ai_explain_response",
request_id=generation,
status_code=response.status_code,
elapsed_ms=elapsed_ms,
content_type=response.headers.get("content-type"),
response_length=len(response_body.encode("utf-8")),
)
if response.status_code == 401:
raise RuntimeError("登录已过期,请在“服务…”中重新登录")
if response.status_code not in range(200, 300):
raise RuntimeError(f"AI 讲解服务返回 {response.status_code}:{self._server_error_detail(response)}")
explanation = response.json()["explanation"]
self._ui_events.put(lambda: self._show_explanation_result(explanation, generation))
try:
response_json = response.json()
explanation = response_json["explanation"]
except Exception as exc:
snippet = response_body[:500].replace("\n", "\\n")
debug_log(
"ai_explain_bad_body",
request_id=generation,
error=str(exc),
body_snippet=snippet,
)
raise RuntimeError(f"AI 服务返回了无法解析的数据:{snippet}") from exc
if not isinstance(explanation, str) or not explanation.strip():
debug_log("ai_explain_empty_body", request_id=generation, response_body=response_body[:500])
raise RuntimeError("AI 服务返回的讲解内容为空")
debug_log(
"ai_explain_success",
request_id=generation,
explanation_length=len(explanation),
elapsed_ms=int((time.monotonic() - started) * 1000),
)
debug_text = f"请求 #{generation}:HTTP {response.status_code},耗时 {elapsed_ms / 1000:.1f} 秒。"
self._ui_events.put(lambda: self._show_explanation_result(explanation, generation, debug_text=debug_text))
except Exception as exc:
message = str(exc) or exc.__class__.__name__
self._ui_events.put(lambda: self._show_explanation_result("讲解失败:" + message, generation, error=True))
elapsed_ms = int((time.monotonic() - started) * 1000)
debug_log(
"ai_explain_failed",
request_id=generation,
elapsed_ms=elapsed_ms,
error_class=exc.__class__.__name__,
error=message,
)
debug_text = f"请求 #{generation}失败:{exc.__class__.__name__},耗时 {elapsed_ms / 1000:.1f} 秒。"
self._ui_events.put(
lambda: self._show_explanation_result(
"讲解失败:" + message,
generation,
error=True,
debug_text=debug_text,
)
)
def _show_explanation_result(self, explanation, generation, error=False):
def _show_explanation_result(self, explanation, generation, error=False, debug_text=None):
if generation != self._explanation_generation:
debug_log(
"ai_explain_ui_skipped",
request_id=generation,
current_generation=self._explanation_generation,
reason="generation_mismatch",
)
return
window = self._explanation_window
if window is None:
debug_log("ai_explain_ui_skipped", request_id=generation, reason="window_missing")
return
text = self._explanation_text
if text is None:
debug_log("ai_explain_ui_skipped", request_id=generation, reason="text_widget_missing")
return
try:
text.configure(state="normal")
text.delete("1.0", "end")
text.configure(foreground="#111111")
text.insert("1.0", explanation)
text.configure(state="disabled")
if error:
window.title("AI 讲解失败")
self.status_var.set(explanation)
if debug_text and self._explanation_debug_var is not None:
self._explanation_debug_var.set(debug_text + " 详情见“调试日志”。")
debug_log(
"ai_explain_ui_applied",
request_id=generation,
error=error,
content_length=len(explanation),
)
except tk.TclError:
pass
@@ -2114,6 +2537,11 @@ class MediaPlayerApp(tk.Tk):
parts.append("pygame 音频模式(未检测到 VLC,视频不可用)")
elif isinstance(self.engine, AvEngine):
parts.append("纯 Python 解码内核(无需 VLC)")
if self.auth_token:
display = self._auth_display()
parts.append(f"已登录:{display}" if display else "已登录")
else:
parts.append("未登录")
if 0 <= self.current_index < len(self.playlist):
name = Path(self.playlist[self.current_index]).name
total = len(self.playlist)