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