Files
meetrec/meetrec_gui.py
fegger 76cf992373 Desktop GUI: Library tab with live sync to the server
- new Record/Library tabs (like the mobile app); the recording UI is
  unchanged inside the Record tab
- Library tab: recording list (date, duration, language, device), detail
  view with summary (scrollable, re-triggerable), agenda checklist with
  coverage marks, and the full timestamped transcript; Library URL field
  (defaults to MEETREC_LIBRARY_URL)
- sync: auto-refresh every 10 s — recordings made on the phone appear on
  the desktop while the tab is open; pending summaries update to done
  automatically and re-fetch
- shared library client in meetrec.py: library_list/library_get_file/
  library_trigger_summary
- validated headlessly (offscreen) against the live server: 6 recordings
  from phone and desktop listed, 68-min meeting detail with 1333-char
  summary and ~100k-char transcript
2026-09-08 21:37:17 +02:00

841 lines
31 KiB
Python

#!/usr/bin/env python3
"""
meetrec_gui.py — Qt (PySide6) GUI for meetrec.
Records a meeting from any PipeWire/ALSA input, shows a live rolling
transcript while recording, and on Stop runs the final full transcription.
Reuses all recording/transcription logic from meetrec.py (the CLI).
Setup (Arch):
sudo pacman -S python pipewire pipewire-pulse python-pyside6
# or: pip install pyside6
# (plus the usual deps: pip install numpy sounddevice faster-whisper)
Run:
python meetrec_gui.py
"""
import json
import queue
import sys
import threading
from types import SimpleNamespace
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
QProgressBar, QPushButton, QSpinBox, QSplitter, QTabWidget, QTextEdit,
QVBoxLayout, QWidget,
)
import sounddevice as sd
import meetrec
import os
from meetrec import (
Recorder,
final_transcribe,
fmt_hms,
live_worker,
resolve_source,
)
# --------------------------------------------------------------------------
# Worker threads
# --------------------------------------------------------------------------
class ModelLoader(QThread):
"""Loads (and on first run downloads) the Whisper model off the GUI thread."""
loaded = Signal(object)
failed = Signal(str)
def __init__(self, model_name, engine="faster-whisper", device="auto",
compute_type="int8", server_url=None, parent=None):
super().__init__(parent)
self.model_name = model_name
self.engine = engine
self.device = device
self.compute_type = compute_type
self.server_url = server_url
def run(self):
try:
self.model = meetrec.load_engine(
self.model_name, engine=self.engine,
device=self.device, compute_type=self.compute_type,
server_url=self.server_url)
self.loaded.emit(self.model)
except Exception as e:
self.failed.emit(str(e))
class FinalWorker(QThread):
"""Runs the final full-pass transcription without blocking the GUI."""
info = Signal(str)
segment = Signal(float, float, str)
done = Signal(str) # output stem
failed = Signal(str)
def __init__(self, model, wav_path, out_stem, language,
diarize_url=None, agenda=None, library_url=None, parent=None):
super().__init__(parent)
self.model = model
self.wav_path = wav_path
self.out_stem = out_stem
self.language = language
self.diarize_url = diarize_url
self.agenda = agenda
self.library_url = library_url
def run(self):
try:
final_transcribe(
self.model, self.wav_path, self.out_stem, self.language,
on_info=self.info.emit,
on_segment=self.segment.emit,
on_done=lambda: self.done.emit(self.out_stem),
diarize_url=self.diarize_url,
agenda=self.agenda,
library_url=self.library_url,
)
except Exception as e:
self.failed.emit(str(e))
# --------------------------------------------------------------------------
# Main window
# --------------------------------------------------------------------------
class MeetRecWindow(QWidget):
MODELS = ["tiny", "base", "small", "medium", "large-v3"]
LANGS = ["autodetect", "en", "de", "fr", "es", "it", "nl", "pl",
"pt", "ru", "zh", "ja", "ko"]
DEVICES = ["auto", "cpu", "cuda"]
COMPUTE_TYPES = ["int8", "int8_float16", "float16", "float32"]
ENGINES = ["faster-whisper", "whisper-cpp", "whisper-server"]
def __init__(self):
super().__init__()
self.setWindowTitle("MeetRec — record & transcribe meetings")
self.setMinimumSize(700, 600)
self.model = None
self.model_key = None # (engine, model, device, compute) loaded
self.rec = None
self.state = "idle" # idle | loading | record | finalizing
self.decay = 0.0
self.stop_evt = threading.Event()
self.line_q: "queue.Queue" = queue.Queue()
self._final_header = False
self._args = None
self._build_ui()
self.ui_timer = QTimer(self)
self.ui_timer.setInterval(120)
self.ui_timer.timeout.connect(self._tick)
self.ui_timer.start()
# ------------------------------ UI layout -----------------------------
def _build_ui(self):
self.tabs = QTabWidget()
record_tab = QWidget()
root = QVBoxLayout(record_tab)
# row 1: microphone source
r1 = QHBoxLayout()
r1.addWidget(QLabel("Microphone:"))
self.src = QComboBox()
self.src.setEditable(True) # free text = name substring, like the CLI
self.src.addItem("default")
self.src.setMinimumWidth(280)
self._fill_sources()
refresh = QPushButton("\u21bb")
refresh.setFixedWidth(36)
refresh.setToolTip("Rescan input devices")
refresh.clicked.connect(self._fill_sources)
r1.addWidget(self.src, 1)
r1.addWidget(refresh)
root.addLayout(r1)
# row 2: model + language
r2 = QHBoxLayout()
self.model_lbl = QLabel("Model:")
r2.addWidget(self.model_lbl)
self.model_cb = QComboBox()
self.model_cb.addItems(self.MODELS)
self.model_cb.setCurrentText("small")
r2.addWidget(self.model_cb)
r2.addStretch(1)
r2.addWidget(QLabel("Language:"))
self.lang = QComboBox()
self.lang.setEditable(True)
self.lang.addItems(self.LANGS)
self.lang.setCurrentText("autodetect")
self.lang.setMinimumWidth(110)
r2.addWidget(self.lang)
root.addLayout(r2)
# row 2b: engine + compute device + precision
r2b = QHBoxLayout()
r2b.addWidget(QLabel("Engine:"))
self.engine_cb = QComboBox()
self.engine_cb.addItems(self.ENGINES)
self.engine_cb.setCurrentText("faster-whisper")
self.engine_cb.activated.connect(self._engine_changed)
self.engine_cb.setToolTip("faster-whisper: NVIDIA CUDA or CPU.\n"
"whisper-cpp: GPU via Vulkan (AMD, Intel, "
"NVIDIA) or CPU — preferred on AMD.")
r2b.addWidget(self.engine_cb)
self.device_lbl = QLabel("Device:")
r2b.addWidget(self.device_lbl)
self.device_cb = QComboBox()
self.device_cb.addItems(self.DEVICES)
self.device_cb.setCurrentText("auto")
r2b.addWidget(self.device_cb)
self.compute_lbl = QLabel("Compute:")
r2b.addWidget(self.compute_lbl)
self.compute_cb = QComboBox()
self.compute_cb.addItems(self.COMPUTE_TYPES)
self.compute_cb.setCurrentText("int8")
r2b.addWidget(self.compute_cb)
r2b.addStretch(1)
root.addLayout(r2b)
# row 2c: whisper-server URL
r2c = QHBoxLayout()
self.server_lbl = QLabel("Server:")
r2c.addWidget(self.server_lbl)
self.server_url = QLineEdit("http://100.103.83.12:8085")
self.server_url.setToolTip(
"whisper.cpp server (see server/whisper-server/) — "
"the model lives on the server")
r2c.addWidget(self.server_url, 1)
root.addLayout(r2c)
# row 2d: library + diarization servers
r2d = QHBoxLayout()
self.library_url = QLineEdit(os.environ.get("MEETREC_LIBRARY_URL", ""))
self.library_url.setPlaceholderText("Library URL (meetrec-server)")
self.diarize_url = QLineEdit(os.environ.get("MEETREC_DIARIZE_URL", ""))
self.diarize_url.setPlaceholderText("Diarize URL (Sprecher 1/2)")
r2d.addWidget(self.library_url, 1)
r2d.addWidget(self.diarize_url, 1)
root.addLayout(r2d)
# agenda items (one per line), used for the server-side coverage check
self.agenda_edit = QPlainTextEdit()
self.agenda_edit.setPlaceholderText("Agenda (one item per line)")
self.agenda_edit.setFixedHeight(64)
root.addWidget(self.agenda_edit)
# row 3: live options + output name
r3 = QHBoxLayout()
r3.addWidget(QLabel("Live:"))
self.live_cb = QCheckBox("transcribe while recording every")
self.live_cb.setChecked(True)
r3.addWidget(self.live_cb)
self.live_spin = QSpinBox()
self.live_spin.setRange(3, 60)
self.live_spin.setValue(8)
self.live_spin.setSuffix(" s")
r3.addWidget(self.live_spin)
r3.addStretch(1)
r3.addWidget(QLabel("Output:"))
self.out = QLineEdit("meeting")
self.out.setPlaceholderText("file stem, e.g. meeting")
self.out.setMinimumWidth(160)
r3.addWidget(self.out)
root.addLayout(r3)
# row 4: record / stop
r4 = QHBoxLayout()
self.record_btn = QPushButton("\u23fa Record")
self.record_btn.setMinimumHeight(44)
self.record_btn.setStyleSheet(
"QPushButton { background: #1a7f37; color: white; font-weight: bold; }"
"QPushButton:disabled { background: #888; }")
self.record_btn.clicked.connect(self.on_record)
self.stop_btn = QPushButton("\u23f9 Stop & transcribe")
self.stop_btn.setMinimumHeight(44)
self.stop_btn.setEnabled(False)
self.stop_btn.setStyleSheet(
"QPushButton { background: #c0392b; color: white; font-weight: bold; }"
"QPushButton:disabled { background: #888; }")
self.stop_btn.clicked.connect(self.on_stop)
r4.addWidget(self.record_btn, 1)
r4.addWidget(self.stop_btn, 1)
root.addLayout(r4)
# row 5: level meter + elapsed
r5 = QHBoxLayout()
self.level = QProgressBar()
self.level.setRange(0, 100)
self.level.setTextVisible(False)
self.level.setFixedHeight(10)
self.level.setStyleSheet(
"QProgressBar::chunk { background: #2ea043; }")
self.elapsed = QLabel("00:00:00")
self.elapsed.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.elapsed.setMinimumWidth(80)
self.elapsed.setStyleSheet("font-size: 15px; font-weight: bold;")
r5.addWidget(self.level, 1)
r5.addWidget(self.elapsed)
root.addLayout(r5)
# transcript
self.transcript = QTextEdit()
self.transcript.setReadOnly(True)
self.transcript.setPlaceholderText(
"Live transcript appears here while recording.\n"
"After stopping, the final (higher-quality) transcript is shown "
"and saved as .txt / .srt / .json next to the .wav.")
root.addWidget(self.transcript, 1)
# status bar
bar = QHBoxLayout()
self.status = QLabel("")
bar.addWidget(self.status, 1)
clear = QPushButton("Clear transcript")
clear.clicked.connect(lambda: self.transcript.clear())
bar.addWidget(clear)
root.addLayout(bar)
self.tabs.addTab(record_tab, "Record")
self.library_tab = LibraryTab()
self.tabs.addTab(self.library_tab, "Library")
self.tabs.currentChanged.connect(
lambda i: self.library_tab.refresh() if i == 1 else None)
outer = QVBoxLayout(self)
outer.addWidget(self.tabs)
# ------------------------------ helpers -------------------------------
def _fill_sources(self):
keep = self.src.currentText()
self.src.blockSignals(True)
self.src.clear()
self.src.addItem("default")
seen = set()
try:
for d in sd.query_devices():
if d["max_input_channels"] > 0 and d["name"] not in seen:
seen.add(d["name"])
self.src.addItem(d["name"])
except Exception:
pass
self.src.blockSignals(False)
idx = self.src.findText(keep)
if idx >= 0:
self.src.setCurrentIndex(idx)
else:
self.src.setCurrentText(keep)
def _engine_changed(self, _idx):
# What applies per engine:
# model: faster-whisper + whisper-cpp (server owns the
# model otherwise)
# device/compute: faster-whisper only (CTranslate2)
# server url: whisper-server only
engine = self.engine_cb.currentText()
has_model = engine != "whisper-server"
has_dc = engine == "faster-whisper"
self.model_lbl.setText("Model:" if has_model else "Model (n/a):")
self.device_lbl.setText("Device:" if has_dc else "Device (n/a):")
self.compute_lbl.setText("Compute:" if has_dc else "Compute (n/a):")
self.model_cb.setEnabled(has_model)
self.device_cb.setEnabled(has_dc)
self.compute_cb.setEnabled(has_dc)
self.server_url.setEnabled(engine == "whisper-server")
def _set_inputs_enabled(self, enabled: bool):
for w in (self.src, self.model_cb, self.engine_cb, self.device_cb,
self.compute_cb, self.server_url, self.lang, self.live_cb,
self.live_spin, self.out):
w.setEnabled(enabled)
if enabled:
self._engine_changed(-1) # whisper-cpp: keep device/compute off
def _to_idle(self):
self.state = "idle"
self._set_inputs_enabled(True)
self.record_btn.setEnabled(True)
self.stop_btn.setEnabled(False)
def _append(self, text: str):
self.transcript.moveCursor(QTextEdit.End)
self.transcript.insertPlainText(text)
sb = self.transcript.verticalScrollBar()
sb.setValue(sb.maximum())
def _push_line(self, a: float, text: str):
"""Thread-safe: called from the live worker thread."""
self.line_q.put((a, text))
# ------------------------------ state machine --------------------------
def on_record(self):
if self.state != "idle":
return
lang = self.lang.currentText().strip()
live = self.live_spin.value() if self.live_cb.isChecked() else 0
self._args = SimpleNamespace(
source=self.src.currentText().strip() or "default",
model=self.model_cb.currentText(),
output=self.out.text().strip() or "meeting",
live=live,
language=None if lang in ("", "autodetect") else lang,
library_url=self.library_url.text().strip() or None,
diarize_url=self.diarize_url.text().strip() or None,
agenda=[ln.strip() for ln in self.agenda_edit.toPlainText()
.splitlines() if ln.strip()],
)
self._set_inputs_enabled(False)
self.transcript.clear()
self._final_header = False
self.decay = 0.0
self.level.setValue(0)
self.elapsed.setText("00:00:00")
key = (self.engine_cb.currentText(), self._args.model,
self.device_cb.currentText(), self.compute_cb.currentText(),
self.server_url.text().strip())
if self.model is None or self.model_key != key:
self.state = "loading"
if key[0] == "whisper-server":
self.status.setText(f"Using whisper server {key[4]} ...")
else:
self.status.setText(
f"Loading Whisper model {self._args.model!r} via {key[0]} "
"(first run downloads it, this can take a minute)...")
self._pending_key = key
self.loader = ModelLoader(
self._args.model,
engine=key[0],
device=self.device_cb.currentText(),
compute_type=self.compute_cb.currentText(),
server_url=key[4] or None,
parent=self)
self.loader.loaded.connect(self._model_ready)
self.loader.failed.connect(self._model_failed)
self.loader.start()
else:
self._start_recording()
def _model_ready(self, model):
self.model = model
self.model_key = self._pending_key
self._start_recording()
def _model_failed(self, err):
self._to_idle()
self.status.setText("Failed to load model")
QMessageBox.critical(self, "MeetRec", f"Could not load Whisper model:\n\n{err}")
def _start_recording(self):
try:
device = resolve_source(self._args.source)
except SystemExit:
self._to_idle()
QMessageBox.critical(
self, "MeetRec",
f"No input device matching {self._args.source!r}.\n"
"Pick one from the dropdown or fix the name.")
return
try:
self.rec = Recorder(device, self._args.output + ".wav")
self.rec.start()
except Exception as e:
self._to_idle()
QMessageBox.critical(self, "MeetRec",
f"Could not open the microphone:\n\n{e}")
return
self.stop_evt.clear()
self.state = "record"
self.record_btn.setEnabled(False)
self.stop_btn.setEnabled(True)
try:
idx = device if device is not None else sd.default.device[0]
name = sd.query_devices(idx)["name"]
except Exception:
name = self._args.source
self.status.setText(
f"Recording from: {name} @ {self.rec.rate} Hz → "
f"{self._args.output}.wav")
if self._args.live:
self.live_thread = threading.Thread(
target=live_worker,
args=(self.model, self.rec, self._args,
self._args.output + ".live.srt", self.stop_evt,
self._push_line),
daemon=True)
self.live_thread.start()
def on_stop(self):
if self.state != "record":
return
self.stop_evt.set()
self.status.setText("Stopped — running final transcription...")
if self.rec:
self.rec.stop() # flushes the WAV writer (a few ms)
self.state = "finalizing"
self.stop_btn.setEnabled(False)
self.worker = FinalWorker(
self.model,
self._args.output + ".wav",
self._args.output,
self._args.language,
diarize_url=self._args.diarize_url,
agenda=self._args.agenda,
library_url=self._args.library_url,
parent=self)
self.worker.info.connect(lambda m: self.status.setText(m))
self.worker.segment.connect(self._final_segment)
self.worker.done.connect(self._final_done)
self.worker.failed.connect(self._final_failed)
self.worker.start()
def _final_segment(self, a: float, b: float, text: str):
if not self._final_header:
self._append("\n— final transcript —\n")
self._final_header = True
self._append(f"[{fmt_hms(a)}] {text}\n")
def _final_done(self, stem: str):
self._to_idle()
self.status.setText(
f"Done. Saved: {stem}.wav {stem}.txt {stem}.srt {stem}.json")
def _final_failed(self, err):
self._to_idle()
self.status.setText("Final transcription failed")
QMessageBox.critical(self, "MeetRec",
f"Final transcription failed:\n\n{err}\n\n"
"The .wav recording is still saved.")
# ------------------------------ UI tick --------------------------------
def _tick(self):
# drain live lines produced by the worker thread
try:
while True:
a, text = self.line_q.get_nowait()
self._append(f"[{fmt_hms(a)}] {text}\n")
except queue.Empty:
pass
if self.state == "record" and self.rec is not None:
self.decay = max(self.rec.peak, self.decay * 0.70)
self.level.setValue(int(min(1.0, self.decay) * 100))
self.elapsed.setText(fmt_hms(self.rec.duration()))
# ------------------------------ close ----------------------------------
def closeEvent(self, ev):
if self.state in ("loading", "record", "finalizing"):
ret = QMessageBox.question(
self, "MeetRec",
"Stop and exit now?\n\n"
"The .wav file is kept, but the final transcription "
"will be aborted (if it hasn't finished).",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if ret != QMessageBox.Yes:
ev.ignore()
return
self.stop_evt.set()
if self.rec:
self.rec.stop()
if self.state == "finalizing" and self.worker:
self.worker.wait(5000)
elif self.state == "loading" and self.loader:
self.loader.wait(5000)
ev.accept()
class LibraryWorker(QThread):
"""One-shot library operation (list / detail / summary trigger)."""
done = Signal(str, object)
failed = Signal(str, str)
def __init__(self, op, url, rid=None, force=False, parent=None):
super().__init__(parent)
self.op = op
self.url = url
self.rid = rid
self.force = force
def run(self):
try:
if self.op == "list":
self.done.emit("list", meetrec.library_list(self.url))
elif self.op == "detail":
it = next(x for x in meetrec.library_list(self.url)
if x["id"] == self.rid)
json_text = None
if "meeting.json" in it.get("files", []):
json_text = meetrec.library_get_file(
self.url, self.rid, "meeting.json")
summary_text = None
if it.get("summary") == "done":
summary_text = meetrec.library_get_file(
self.url, self.rid, "summary.md")
elif isinstance(it.get("summary"), str) and \
it["summary"].startswith("error"):
summary_text = it["summary"]
self.done.emit("detail", (it, json_text, summary_text))
elif self.op == "summary":
meetrec.library_trigger_summary(self.url, self.rid,
force=self.force)
self.done.emit("summary", self.rid)
except Exception as e:
self.failed.emit(self.op, str(e))
class LibraryTab(QWidget):
"""Server library browser: recordings with summary, agenda coverage
and transcript. Auto-refreshes every 10 s, so recordings made on the
phone show up here while the tab is open (the desktop 'sync')."""
def __init__(self):
super().__init__()
self.url = os.environ.get("MEETREC_LIBRARY_URL", "")
self.items = []
self.current_id = None
self._worker = None
self._summary_shown = None # summary status last rendered for current
self._build_ui()
self.poll = QTimer(self)
self.poll.setInterval(10_000)
self.poll.timeout.connect(self.refresh)
self.poll.start()
self.refresh()
def _build_ui(self):
root = QVBoxLayout(self)
top = QHBoxLayout()
refresh = QPushButton("Refresh")
refresh.clicked.connect(self.refresh)
self.auto_cb = QCheckBox("auto (10 s)")
self.auto_cb.setChecked(True)
self.auto_cb.toggled.connect(self._auto_toggled)
self.lib_status = QLabel("")
top.addWidget(refresh)
top.addWidget(self.auto_cb)
top.addStretch(1)
top.addWidget(self.lib_status)
root.addLayout(top)
self.url_edit = QLineEdit(self.url)
self.url_edit.setPlaceholderText("Library URL (meetrec-server)")
self.url_edit.editingFinished.connect(self._url_changed)
root.addWidget(self.url_edit)
split = QSplitter(Qt.Horizontal)
self.listw = QListWidget()
self.listw.currentItemChanged.connect(self._on_select)
split.addWidget(self.listw)
detail = QWidget()
d = QVBoxLayout(detail)
d.setSpacing(6)
self.d_title = QLabel("")
self.d_title.setStyleSheet("font-size: 15px; font-weight: bold;")
self.d_meta = QLabel("")
d.addWidget(self.d_title)
d.addWidget(self.d_meta)
d.addWidget(QLabel("Zusammenfassung"))
self.d_summary = QTextEdit()
self.d_summary.setReadOnly(True)
self.d_summary.setMaximumHeight(180)
d.addWidget(self.d_summary)
regen = QPushButton("Zusammenfassung neu erstellen")
regen.clicked.connect(self._regenerate)
d.addWidget(regen)
d.addWidget(QLabel("Agenda"))
self.d_agenda = QListWidget()
self.d_agenda.setMaximumHeight(150)
d.addWidget(self.d_agenda)
d.addWidget(QLabel("Transkript"))
self.d_transcript = QTextEdit()
self.d_transcript.setReadOnly(True)
d.addWidget(self.d_transcript, 1)
split.addWidget(detail)
split.setSizes([320, 780])
root.addWidget(split, 1)
# ------------------------------ actions ------------------------------
def _auto_toggled(self, on):
if on:
self.poll.start(10_000)
else:
self.poll.stop()
def _url_changed(self):
self.url = self.url_edit.text().strip()
if self.url:
self.refresh()
def refresh(self):
if not self.url or (self._worker and self._worker.isRunning()):
return
self._worker = LibraryWorker("list", self.url, parent=self)
self._worker.done.connect(self._on_list)
self._worker.failed.connect(
lambda tag, msg: self.lib_status.setText(f"error: {msg}"))
self._worker.start()
def _on_list(self, tag, items):
self.items = items or []
self.lib_status.setText(f"{len(self.items)} recording(s)")
self.listw.blockSignals(True)
self.listw.clear()
keep_row = -1
for row, it in enumerate(self.items):
self.listw.addItem(QListWidgetItem(self._label(it)))
self.listw.item(row).setData(Qt.UserRole, it["id"])
if it["id"] == self.current_id:
keep_row = row
if keep_row >= 0:
self.listw.setCurrentRow(keep_row)
self.listw.blockSignals(False)
if self.current_id is None and self.listw.count() > 0:
self.listw.setCurrentRow(0) # newest, like the sync feel
self._on_select(self.listw.item(0), None)
# detail refresh when the selected recording's summary state changed
cur = self._current_item()
if cur and cur.get("summary") != self._summary_shown:
self._load_detail()
def _label(self, it):
started = it.get("started_at", "")
stamp = (f"{started[0:4]}-{started[4:6]}-{started[6:8]} "
f"{started[9:11]}:{started[11:13]}") if len(started) >= 13 \
else started
dur = fmt_hms(it.get("duration_ms", 0) / 1000)
bits = [stamp, dur]
if it.get("language"):
bits.append(it["language"])
if it.get("device"):
bits.append(it["device"])
return " · ".join(bits)
def _current_item(self):
return next((x for x in self.items if x["id"] == self.current_id), None)
def _on_select(self, current, _previous):
if current is None:
return
rid = current.data(Qt.UserRole)
if rid != self.current_id:
self.current_id = rid
self._summary_shown = None
self.d_transcript.setPlainText("")
self.d_summary.setPlainText("")
self._load_detail()
def _load_detail(self):
if not self.current_id:
return
cur = self._current_item()
if cur:
self._summary_shown = cur.get("summary")
self.d_title.setText(self._label(cur))
self.d_meta.setText(self._meta_line(cur))
self._render_agenda(cur)
if cur.get("summary") == "pending":
self.d_summary.setPlainText("wird erstellt …")
if self._worker and self._worker.isRunning():
return
self._worker = LibraryWorker("detail", self.url, rid=self.current_id,
parent=self)
self._worker.done.connect(self._on_detail)
self._worker.failed.connect(
lambda tag, msg: self.lib_status.setText(f"error: {msg}"))
self._worker.start()
def _meta_line(self, it):
parts = []
if it.get("summary") in (None, "pending"):
parts.append("Zusammenfassung: " +
("wird erstellt …" if it.get("summary") else ""))
n = len(it.get("agenda") or [])
if n:
parts.append(f"Agenda: {n} Punkte")
return " ".join(parts)
def _on_detail(self, tag, payload):
it, json_text, summary_text = payload
if it["id"] != self.current_id:
return # selection moved on
if summary_text:
self.d_summary.setPlainText(summary_text)
if json_text:
try:
segs = json.loads(json_text).get("segments", [])
self.d_transcript.setPlainText("\n".join(
f"[{fmt_hms(s['start'])}] {s['text'].strip()}"
for s in segs if s.get("text", "").strip()))
except ValueError:
self.d_transcript.setPlainText("(transcript unavailable)")
def _render_agenda(self, it):
self.d_agenda.clear()
results = it.get("agenda_results") or []
if results and "error" not in (results[0] or {}):
for r in results:
mark = "" if r.get("covered") else ""
line = f"{mark} {r.get('item', '')}"
if r.get("time"):
line += f" ({r['time']})"
self.d_agenda.addItem(QListWidgetItem(line))
elif it.get("agenda"):
for a in it["agenda"]:
self.d_agenda.addItem(QListWidgetItem(f"{a}"))
def _regenerate(self):
if not self.current_id:
return
cur = self._current_item()
if cur:
cur["summary"] = "pending"
self._summary_shown = None
self._load_detail()
self._worker = LibraryWorker("summary", self.url, rid=self.current_id,
force=True, parent=self)
self._worker.done.connect(lambda _t, _p: None)
self._worker.failed.connect(
lambda tag, msg: self.lib_status.setText(f"error: {msg}"))
self._worker.start()
def main():
app = QApplication(sys.argv)
app.setApplicationName("MeetRec")
win = MeetRecWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()