From 03904a7532fae176f6d7358a2d5988e1ef447418 Mon Sep 17 00:00:00 2001 From: TN019 Date: Wed, 5 Aug 2026 11:13:08 +1000 Subject: [PATCH] =?UTF-8?q?Background=20translation=20queue:=20enqueue=20s?= =?UTF-8?q?ingle=20or=20batch-selected=20history=20translations,=20watch?= =?UTF-8?q?=20progress=20from=20the=20history=20page=20(strip=20+=20per-ca?= =?UTF-8?q?rd=20badges),=20get=20toasts=20on=20completion=20=E2=80=94=20cl?= =?UTF-8?q?osing=20the=20viewer=20dialog=20no=20longer=20loses=20the=20job?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/scripto/gui/viewmodel.py | 103 +++++++++++++++++- src/scripto/gui_qt/history_page.py | 161 ++++++++++++++++++++++------- src/scripto/gui_qt/main_window.py | 2 + src/scripto/i18n/en.py | 9 ++ src/scripto/i18n/zh.py | 9 ++ tests/test_gui_qt.py | 22 ++++ tests/test_viewmodel.py | 52 ++++++++++ 7 files changed, 314 insertions(+), 44 deletions(-) diff --git a/src/scripto/gui/viewmodel.py b/src/scripto/gui/viewmodel.py index 07b7a8e..801a57a 100644 --- a/src/scripto/gui/viewmodel.py +++ b/src/scripto/gui/viewmodel.py @@ -8,9 +8,10 @@ from __future__ import annotations +import queue as queue_mod import threading import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Callable @@ -70,6 +71,23 @@ def deleted(self) -> bool: return not self.existing +@dataclass +class TranslationJob: + """One queued history translation; mutated by the worker, read by the UI.""" + source: str + name: str + srt_path: str + target: str + status: str = "queued" # queued | running | done | failed + done: int = 0 # translated blocks so far + total: int = 0 + error: str = "" + + @property + def fraction(self) -> float: + return self.done / self.total if self.total else 0.0 + + @dataclass class DrainResult: changed_rows: list[int] = field(default_factory=list) @@ -104,6 +122,12 @@ def __init__( self._durations: list[float] = [] self._active_since: float | None = None + # History-translation queue: one worker, jobs survive any dialog. + self.translation_jobs: list[TranslationJob] = [] + self._translation_q: "queue_mod.Queue[TranslationJob]" = queue_mod.Queue() + self._translation_worker: threading.Thread | None = None + self._translation_lock = threading.Lock() + # ------------------------------------------------------------------ # # Input / scanning (call from a background thread; never the UI thread) # ------------------------------------------------------------------ # @@ -377,6 +401,15 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]: the view runs this on a worker thread); records a history entry.""" if not group.translate_from: return [] + return self._run_translation( + srt_path=group.translate_from, source=group.source, + target=target, progress=None, + ) + + def _run_translation( + self, *, srt_path: str, source: str, target: str, + progress: Callable[[int, int], None] | None, + ) -> list[Path]: config = self.get_config() client = OllamaClient(config["ollama_url"]) stage = OllamaTranslateStage( @@ -389,14 +422,17 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]: ) try: produced = stage.translate( - Path(group.translate_from), Path(group.source), - stop_check=None, progress=None, + Path(srt_path), Path(source), + stop_check=None, progress=progress, ) finally: - stage.release() + # Keep the model loaded while more jobs wait; keep_alive handles + # eviction once the queue actually drains. + if self._translation_q.empty(): + stage.release() if produced: self.history.append(HistoryEntry( - source=group.source, + source=source, outputs=[ {"lang": target, "format": p.suffix.lstrip("."), "path": str(p)} for p in produced @@ -407,6 +443,63 @@ def translate_history(self, group: "HistoryGroup", target: str) -> list[Path]: )) return produced + # ------------------------------------------------------------------ # + # Translation queue: batch/single history translations with status + # ------------------------------------------------------------------ # + + def queue_translation(self, group: "HistoryGroup", target: str) -> bool: + """Enqueue a history translation; False when duplicate or invalid.""" + if not group.translate_from or target in group.existing: + return False + with self._translation_lock: + for job in self.translation_jobs: + if (job.source == group.source and job.target == target + and job.status in ("queued", "running")): + return False + job = TranslationJob( + source=group.source, name=group.name, + srt_path=group.translate_from, target=target, + ) + self.translation_jobs.append(job) + self._translation_q.put(job) + self._ensure_translation_worker() + return True + + def translation_snapshot(self) -> list[TranslationJob]: + """Point-in-time copies for the UI (jobs mutate on the worker).""" + with self._translation_lock: + return [replace(job) for job in self.translation_jobs] + + def _ensure_translation_worker(self) -> None: + if self._translation_worker is not None and self._translation_worker.is_alive(): + return + self._translation_worker = threading.Thread( + target=self._translation_loop, name="scripto-translate-q", daemon=True + ) + self._translation_worker.start() + + def _translation_loop(self) -> None: + while True: + job = self._translation_q.get() + job.status = "running" + + def progress(done: int, total: int, job: TranslationJob = job) -> None: + job.done, job.total = done, total + + try: + produced = self._run_translation( + srt_path=job.srt_path, source=job.source, + target=job.target, progress=progress, + ) + if produced: + job.status = "done" + else: + job.status = "failed" + job.error = "no output" + except Exception as exc: # surfaced per-job in the UI + job.status = "failed" + job.error = str(exc) + def history_clean_missing(self) -> int: stale = {e.id for e, exists in self.history_rows() if not exists} return self.history.remove(stale) if stale else 0 diff --git a/src/scripto/gui_qt/history_page.py b/src/scripto/gui_qt/history_page.py index b43a550..07d45a4 100644 --- a/src/scripto/gui_qt/history_page.py +++ b/src/scripto/gui_qt/history_page.py @@ -12,7 +12,9 @@ QDialog, QHBoxLayout, QLabel, + QMenu, QPlainTextEdit, + QProgressBar, QPushButton, QScrollArea, QTextBrowser, @@ -20,6 +22,8 @@ QWidget, ) +from ..core.languages import known_languages + _MS_RE = re.compile(r"[,.]\d{3}") from .widgets import ElidedLabel, card, clear_layout, reveal_in_file_manager, subtext @@ -45,13 +49,42 @@ def _build(self) -> None: self.delete_selected_btn.clicked.connect(self._delete_selected) self.delete_selected_btn.hide() + self.translate_selected_btn = QPushButton() + self.translate_selected_btn.setProperty("variant", "primary") + translate_menu = QMenu(self.translate_selected_btn) + for spec in known_languages(): + translate_menu.addAction( + self.window_ref.lang_label(spec.code), + lambda code=spec.code: self._translate_selected(code), + ) + self.translate_selected_btn.setMenu(translate_menu) + self.translate_selected_btn.hide() + top = QHBoxLayout() top.setSpacing(8) top.addWidget(refresh_btn) top.addWidget(clean_btn) + top.addWidget(self.translate_selected_btn) top.addWidget(self.delete_selected_btn) top.addStretch(1) + # Translation-queue strip: visible whenever jobs are queued/running, + # regardless of any dialog being open. + self.tq_label = subtext() + self.tq_bar = QProgressBar() + self.tq_bar.setRange(0, 1000) + self.tq_bar.setTextVisible(False) + self.tq_bar.setFixedWidth(160) + self.tq_strip = QWidget() + strip = QHBoxLayout(self.tq_strip) + strip.setContentsMargins(0, 0, 0, 0) + strip.setSpacing(8) + strip.addWidget(self.tq_bar) + strip.addWidget(self.tq_label, 1) + self.tq_strip.hide() + self._badges: dict[str, QLabel] = {} + self._seen_terminal = 0 + self.list_box = QVBoxLayout() self.list_box.setSpacing(6) self.list_box.addStretch(1) @@ -65,6 +98,7 @@ def _build(self) -> None: root.setContentsMargins(16, 12, 16, 12) root.setSpacing(10) root.addLayout(top) + root.addWidget(self.tq_strip) root.addWidget(scroll, 1) # ------------------------------------------------------------------ # @@ -73,6 +107,7 @@ def refresh(self) -> None: t = self.t clear_layout(self.list_box, keep_tail=1) self._selected.clear() + self._badges.clear() self._sync_delete_button() groups = self.vm.history_groups() @@ -108,6 +143,14 @@ def refresh(self) -> None: text_col.addWidget(sub) row.addLayout(text_col, 1) + badge = subtext() + badge.setStyleSheet( + f"color: {self.window_ref.palette_tokens.accent}; font-size: 11px;" + ) + badge.hide() + row.addWidget(badge) + self._badges[group.source] = badge + if group.deleted: deleted = subtext(t("gui.history_deleted")) deleted.setStyleSheet( @@ -156,10 +199,83 @@ def _toggle_selected(self, source: str, on: bool) -> None: def _sync_delete_button(self) -> None: count = len(self._selected) self.delete_selected_btn.setVisible(count > 0) + self.translate_selected_btn.setVisible(count > 0) if count: self.delete_selected_btn.setText( self.t("gui.history_delete_selected", n=count) ) + self.translate_selected_btn.setText( + self.t("gui.translate_selected", n=count) + ) + + def _translate_selected(self, target: str) -> None: + queued = 0 + for group in self.vm.history_groups(): + if group.source in self._selected: + if self.vm.queue_translation(group, target): + queued += 1 + if queued: + self.window_ref.toast(self.t("gui.tq_enqueued")) + else: + self.window_ref.toast(self.t("gui.tq_nothing"), ok=False) + + # ------------------------------------------------------------------ # + # Queue status (driven by MainWindow's 4 Hz ticker) + # ------------------------------------------------------------------ # + + def tick_translations(self) -> None: + jobs = self.vm.translation_snapshot() + if not jobs and not self.tq_strip.isVisible(): + return + + running = [j for j in jobs if j.status == "running"] + queued = [j for j in jobs if j.status == "queued"] + if running or queued: + active = running[0] if running else queued[0] + text = self.t( + "gui.tq_running", name=active.name, + lang=self.window_ref.lang_label(active.target), + pct=int(active.fraction * 100), + ) + if queued: + text += self.t("gui.tq_queued_n", n=len(queued)) + self.tq_label.setText(text) + self.tq_bar.setValue(int(active.fraction * 1000)) + self.tq_strip.show() + else: + self.tq_strip.hide() + + by_source: dict[str, str] = {} + for job in running + queued: + if job.source not in by_source: + key = ("gui.tq_badge_running" if job.status == "running" + else "gui.tq_badge_queued") + by_source[job.source] = self.t( + key, lang=self.window_ref.lang_label(job.target), + pct=int(job.fraction * 100), + ) + for source, badge in self._badges.items(): + text = by_source.get(source, "") + badge.setText(text) + badge.setVisible(bool(text)) + + # Newly finished jobs: toast once each, refresh the list once. + terminal = [j for j in jobs if j.status in ("done", "failed")] + if len(terminal) > self._seen_terminal: + for job in terminal[self._seen_terminal:]: + lang = self.window_ref.lang_label(job.target) + if job.status == "done": + self.window_ref.toast( + self.t("gui.tq_done", name=job.name, lang=lang) + ) + else: + self.window_ref.toast( + self.t("gui.tq_failed", name=job.name, lang=lang, + reason=job.error), + ok=False, + ) + self._seen_terminal = len(terminal) + self.refresh() def _delete_selected(self) -> None: self._delete_sources(set(self._selected)) @@ -376,42 +492,9 @@ def _render(self, content: str, path: str) -> None: self.body.setHtml("".join(parts) or escape(content)) def _translate(self, lang: str) -> None: - if self.busy: - return - self.busy = True - self.status_label.setText( - self.t("gui.history_translating", - lang=self.page.window_ref.lang_label(lang)) - ) - self._rebuild_buttons() - window = self.page.window_ref - - def job() -> None: - status_text = "" - try: - produced = self.vm.translate_history(self.group, lang) - if produced: - status_text = self.t("gui.history_translate_done") - self.group.existing[lang] = str(produced[0]) - if lang in self.group.missing: - self.group.missing.remove(lang) - self.lang = lang - else: - status_text = self.t("gui.models_failed", reason="no output") - except Exception as exc: - status_text = self.t("gui.models_failed", reason=exc) - - def apply() -> None: - self.busy = False - self.status_label.setText(status_text) - if self.lang in self.group.existing: - path = self.group.existing[self.lang] - try: - self._render(self.vm.read_preview(path), path) - except Exception: - pass - self._rebuild_buttons() - - window.run_in_main(apply) - - window.run_thread(job) + # Enqueued, not run here: the job survives closing this dialog, and + # its progress lives on the history page (strip + card badge). + if self.vm.queue_translation(self.group, lang): + self.status_label.setText(self.t("gui.tq_enqueued")) + else: + self.status_label.setText(self.t("gui.tq_nothing")) diff --git a/src/scripto/gui_qt/main_window.py b/src/scripto/gui_qt/main_window.py index 3f41d87..0cb3a9b 100644 --- a/src/scripto/gui_qt/main_window.py +++ b/src/scripto/gui_qt/main_window.py @@ -259,6 +259,8 @@ def _tick(self) -> None: self._last_log_len = len(result.log_lines) self.run_page.refresh_log(result.log_lines) + self.history_page.tick_translations() + # ------------------------------------------------------------------ # # Lifecycle # ------------------------------------------------------------------ # diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index d4bdc8d..7ae9b39 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -170,6 +170,15 @@ "gui.sub_none": "No subtitle", "gui.player_missing_video": "The source video no longer exists on disk.", "gui.history_translate_to": "Translate to {lang}", + "gui.translate_selected": "Translate selected ({n})", + "gui.tq_enqueued": "Added to the translation queue.", + "gui.tq_nothing": "Nothing to translate — targets already exist or are queued.", + "gui.tq_running": "Translating {name} → {lang} {pct}%", + "gui.tq_queued_n": " · {n} queued", + "gui.tq_badge_running": "translating → {lang} {pct}%", + "gui.tq_badge_queued": "queued → {lang}", + "gui.tq_done": "{name} → {lang} finished.", + "gui.tq_failed": "{name} → {lang} failed: {reason}", "gui.history_translating": "Translating to {lang}…", "gui.history_translate_done": "Translation finished.", } diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index 015bf07..cdfb7f1 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -170,6 +170,15 @@ "gui.sub_none": "无字幕", "gui.player_missing_video": "源视频已不在磁盘上。", "gui.history_translate_to": "翻译成{lang}", + "gui.translate_selected": "翻译选中({n})", + "gui.tq_enqueued": "已加入翻译队列。", + "gui.tq_nothing": "没有可翻译的——目标语言已存在或已在队列中。", + "gui.tq_running": "正在翻译 {name} → {lang} {pct}%", + "gui.tq_queued_n": " · 队列中 {n} 个", + "gui.tq_badge_running": "翻译中 → {lang} {pct}%", + "gui.tq_badge_queued": "排队中 → {lang}", + "gui.tq_done": "{name} → {lang} 翻译完成。", + "gui.tq_failed": "{name} → {lang} 失败:{reason}", "gui.history_translating": "正在翻译成{lang}…", "gui.history_translate_done": "翻译完成。", } diff --git a/tests/test_gui_qt.py b/tests/test_gui_qt.py index 86d9d62..358dad8 100644 --- a/tests/test_gui_qt.py +++ b/tests/test_gui_qt.py @@ -209,3 +209,25 @@ def test_history_viewer_edits_the_file_in_place(tmp_path, qapp): assert "corrected line" in saved assert "corrected line" in dialog.body.toPlainText() # re-rendered assert not dialog.editor.isVisibleTo(dialog) + + +def test_history_page_shows_translation_queue_status(tmp_path, qapp): + from scripto.gui.viewmodel import TranslationJob + + window = make_window(tmp_path, qapp) + src = _seed_history(tmp_path, window.vm, "talk") + page = window.history_page + page.refresh() + + job = TranslationJob(source=src, name="talk.mp4", srt_path="x", + target="zh", status="running", done=21, total=40) + window.vm.translation_jobs.append(job) + page.tick_translations() + assert page.tq_strip.isVisibleTo(page) # survives any dialog + assert "52%" in page.tq_label.text() + assert page._badges[src].isVisibleTo(page) # per-card badge + + job.status = "done" + page.tick_translations() + assert page._seen_terminal == 1 # toast fired exactly once + assert not page.tq_strip.isVisibleTo(page) diff --git a/tests/test_viewmodel.py b/tests/test_viewmodel.py index ed18cc7..572cfa2 100644 --- a/tests/test_viewmodel.py +++ b/tests/test_viewmodel.py @@ -256,3 +256,55 @@ def test_first_run_detection(tmp_path): def test_gui_module_imports(): import scripto.gui_qt.main_window # noqa: F401 (catches Qt API drift at import time) + + +def test_translation_queue_processes_records_and_dedupes(tmp_path, monkeypatch): + vm = make_vm(tmp_path) + en_srt = tmp_path / "talk.en.srt" + en_srt.write_text("1\n00:00:00,000 --> 00:00:01,000\nhi\n", encoding="utf-8") + vm.history.append(HistoryEntry( + source=str(tmp_path / "talk.mp4"), + outputs=[{"lang": "en", "format": "srt", "path": str(en_srt)}], + model="tiny", engine="mlx", status="done", + )) + gate = threading.Event() + + class FakeStage: + label = "ollama/fake" + + def __init__(self, _client, **kwargs): + self.kwargs = kwargs + + def translate(self, srt_path, source, stop_check=None, progress=None): + gate.wait(timeout=5) + if progress is not None: + progress(40, 40) + out = source.with_name(f"{source.stem}.{self.kwargs['target']}.srt") + out.write_text("1\n00:00:00,000 --> 00:00:01,000\nx\n", encoding="utf-8") + return [out] + + def release(self): + pass + + import scripto.gui.viewmodel as vmod + + monkeypatch.setattr(vmod, "OllamaTranslateStage", FakeStage) + group = vm.history_groups()[0] + assert vm.queue_translation(group, "zh") + assert not vm.queue_translation(group, "zh") # duplicate while queued + assert vm.queue_translation(group, "ja") # second job queues fine + gate.set() + + deadline = time.time() + 5 + while time.time() < deadline: + jobs = vm.translation_snapshot() + if len(jobs) == 2 and all(j.status in ("done", "failed") for j in jobs): + break + time.sleep(0.02) + jobs = vm.translation_snapshot() + assert [j.status for j in jobs] == ["done", "done"] + assert (jobs[0].done, jobs[0].total) == (40, 40) # progress reached the job + + regrouped = vm.history_groups()[0] + assert {"en", "zh", "ja"} <= set(regrouped.existing) + assert not vm.queue_translation(regrouped, "zh") # target already exists