diff --git a/README.md b/README.md index dc61a70..29b39a7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ pip 安装依赖后即可直接播放视频。 缓存结果;生成过程有进度条。 - AI 讲解:播放中按 `E` 或点击“AI讲解 (E)”,由服务端 AI 讲解当前句子。 - 服务配置:点击“服务…”可填写服务器地址,或使用账号密码登录后保存令牌。 +- 调试日志:点击“调试日志”可查看/复制请求状态、耗时和错误摘要;日志保存在 + `~/.mediaplayer_logs/mediaplayer.log`,不包含登录令牌。 - 音量调节、静音,0.5x - 2.0x 变速播放 - 三种循环模式:关 / 单曲循环 / 列表循环 - 播放列表管理(打开文件、打开文件夹、删除条目、双击播放) diff --git a/media_player.py b/media_player.py index 6c58cbd..4119b9f 100644 --- a/media_player.py +++ b/media_player.py @@ -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,29 +1952,66 @@ 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}") - data = response.json() + 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 = str(detail) + 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] + "..." def _current_sentence(self) -> Optional[Dict[str, Any]]: @@ -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)