Фоновые задачи: плейлисты, длинные лекции, вкладка настроек

- таблица jobs + очередь с воркером: POST /jobs сразу отвечает, элементы
  плейлиста обрабатываются последовательно, есть отмена и живой прогресс
  (скачивание %, минуты распознанного Vosk-аудио)
- expand_url: плейлист раскрывается в список видео
- keep_video=false теперь качает только аудио (для 4-5ч лекций)
- настройки (LLM-ключ, модель, длина выжимки, хранение видео, путь Vosk)
  хранятся в Postgres: переживают перезапуск и перезагрузку страницы
- длина выжимки прокидывается в LLM-промпт ({n} предложений)
- UI: вкладки «Главная»/«Настройки», карточка задач с автообновлением
  каждые 3 сек, прогресс-бар, отмена, библиотека обновляется по мере
  готовности элементов

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-14 15:01:11 +05:00
parent bf854f2d6e
commit e87f6cd215
18 changed files with 1059 additions and 292 deletions

View File

@@ -1,7 +1,8 @@
"""Main page of the LLM-infa Flet web app."""
"""Main page of the LLM-infa Flet web app: вкладки «Главная» и «Настройки»."""
from __future__ import annotations
import threading
import time
import flet as ft
@@ -9,6 +10,16 @@ from web.api_client import ApiClient, ApiError
import web.designer as d
_JOB_STATUS = {
"queued": ("в очереди", None),
"running": ("выполняется", None),
"done": ("готово", "ok"),
"error": ("ошибка", "danger"),
"cancelled": ("отменена", None),
"interrupted": ("прервана", "danger"),
}
class MainView:
def __init__(self, page: ft.Page, client: ApiClient):
self.page = page
@@ -20,14 +31,15 @@ class MainView:
self.sections: list[dict] = []
self.current_section_id: int | None = None # None = все видео
# ── LLM config fields ──────────────────────────────────────────────
self._polling = False
# ── Settings tab fields ────────────────────────────────────────────
self.base_url = d.text_field("Base URL", hint="https://openrouter.ai/api/v1", expand=True)
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
# Searchable model combobox
self.model_input = d.text_field(
"Модель",
hint="Сначала нажмите «Загрузить модели»",
hint="Нажмите «Загрузить модели» и выберите из списка",
on_change=self._on_model_search,
expand=True,
)
@@ -44,7 +56,7 @@ class MainView:
self.llm_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.llm_status_chip = ft.Container(
content=ft.Text("LLM не подключена", size=12, color=d.MUTED),
content=ft.Text("LLM не настроена", size=12, color=d.MUTED),
padding=ft.padding.symmetric(horizontal=10, vertical=4),
border=ft.border.all(1, d.BORDER),
border_radius=99,
@@ -52,12 +64,6 @@ class MainView:
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
# ── Pipeline fields ────────────────────────────────────────────────
self.yt_url = d.text_field(
"Ссылка на YouTube",
hint="https://youtu.be/…",
expand=True,
)
self.vosk_path = d.text_field(
"Vosk-модель (fallback, когда нет субтитров)",
value="models/vosk-model-small-ru-0.22",
@@ -76,19 +82,28 @@ class MainView:
width=220,
)
self.keep_video_sw = ft.Switch(
label="Сохранять видео на диске",
label="Сохранять видео на диске (иначе качается только аудио)",
value=True,
active_color=d.ACCENT,
label_style=ft.TextStyle(color=d.TEXT, size=13),
)
self.pipeline_section_dd = self._dropdown("Раздел для нового видео", width=280)
self.settings_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_save = d.primary_button("💾 Сохранить настройки", self._save_settings)
# ── Pipeline (main tab) ────────────────────────────────────────────
self.yt_url = d.text_field(
"Ссылка на YouTube: видео или плейлист",
hint="https://youtu.be/… или https://www.youtube.com/playlist?list=…",
expand=True,
)
self.pipeline_section_dd = self._dropdown("Раздел для новых видео", width=280)
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_run = d.primary_button("Запустить", self._run_pipeline)
self.btn_run = d.primary_button("В очередь", self._run_pipeline)
# Result block (initially hidden)
self._result_col = ft.Column(spacing=6, visible=False)
# ── Jobs ───────────────────────────────────────────────────────────
self._jobs_col = ft.Column(spacing=6)
# ── Library controls ───────────────────────────────────────────────
# ── Library ────────────────────────────────────────────────────────
self.search_field = d.text_field(
"Поиск по названию / URL", on_change=None, expand=True
)
@@ -124,24 +139,47 @@ class MainView:
# ─────────────────────── build ───────────────────────────────────────
def build(self) -> ft.Control:
return ft.Column(
controls=[
self._build_topbar(),
ft.Container(
content=ft.Column(
[
self._build_llm_card(),
self._build_pipeline_card(),
self._build_library_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
),
main_tab = ft.Container(
content=ft.Column(
[
self._build_pipeline_card(),
self._build_jobs_card(),
self._build_library_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
settings_tab = ft.Container(
content=ft.Column(
[
self._build_llm_card(),
self._build_defaults_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
tabs = ft.Tabs(
selected_index=0,
animation_duration=200,
label_color=d.ACCENT_2,
unselected_label_color=d.MUTED,
indicator_color=d.ACCENT,
divider_color=d.BORDER,
tabs=[
ft.Tab(text="Главная", content=main_tab),
ft.Tab(text="Настройки", content=settings_tab),
],
expand=True,
)
return ft.Column(
controls=[self._build_topbar(), tabs],
expand=True,
spacing=0,
)
@@ -174,13 +212,14 @@ class MainView:
border=ft.border.only(bottom=ft.BorderSide(1, d.BORDER)),
)
# ── Settings tab cards ────────────────────────────────────────────────
def _build_llm_card(self) -> ft.Container:
return d.card(
d.section_title("Подключение к LLM"),
d.hint(
"Укажите любой OpenAI-совместимый провайдер. "
"Нейронка внутри Docker не разворачивается — "
"указываем URL внешнего сервиса."
"Любой OpenAI-совместимый провайдер (OpenRouter, OpenAI, локальная Ollama…). "
"Ключ и модель хранятся на сервере в БД и переживают перезапуск."
),
ft.Row([self.base_url, self.api_key], spacing=12),
ft.Row([self.btn_load, self.btn_test, self.llm_msg], spacing=10),
@@ -189,22 +228,43 @@ class MainView:
self._model_list_wrap,
)
def _build_defaults_card(self) -> ft.Container:
return d.card(
d.section_title("Параметры обработки"),
d.hint("Применяются ко всем новым задачам."),
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
self.keep_video_sw,
ft.Divider(color=d.BORDER, height=1),
ft.Row([self.btn_save, self.settings_msg], spacing=10),
)
# ── Main tab cards ────────────────────────────────────────────────────
def _build_pipeline_card(self) -> ft.Container:
return d.card(
d.section_title("Обработать видео"),
d.section_title("Обработать видео или плейлист"),
d.hint(
"Pipeline: yt-dlp → субтитры (или Vosk fallback) "
"→ выжимка через выбранную LLM → запись в БД."
"Задача уходит в фон: качаем, расшифровываем (субтитры или Vosk), "
"сокращаем через LLM, пишем в БД. Плейлисты и лекции на 45 часов — ок."
),
self.yt_url,
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
ft.Row(
[self.pipeline_section_dd, self.keep_video_sw],
spacing=18,
[self.btn_run, self.pipeline_section_dd, self.pipeline_msg],
spacing=12,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
self._result_col,
)
def _build_jobs_card(self) -> ft.Container:
return d.card(
ft.Row(
[
d.section_title("Задачи"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._start_jobs_poll()),
]
),
self._jobs_col,
)
def _build_library_card(self) -> ft.Container:
@@ -256,6 +316,39 @@ class MainView:
),
)
# ─────────────────── settings load/save ──────────────────────────────
def apply_settings(self, s: dict) -> None:
"""Заполняет поля значениями с сервера (вызывается при старте сессии)."""
self.base_url.value = s.get("llm_base_url", "")
self.api_key.value = s.get("llm_api_key", "")
self.selected_model = s.get("llm_model", "")
self.model_input.value = self.selected_model
self.n_sentences.value = str(s.get("summary_max_sentences", 15))
self.keep_video_sw.value = bool(s.get("keep_video", True))
self.vosk_path.value = s.get("vosk_model_path", "models/vosk-model-small-ru-0.22")
self._set_status(ok=bool(self.base_url.value and self.selected_model))
def _save_settings(self, _):
model = self.selected_model or (self.model_input.value or "").strip()
payload = {
"llm_base_url": self.base_url.value.strip(),
"llm_api_key": self.api_key.value.strip(),
"llm_model": model,
"summary_max_sentences": int(self.n_sentences.value or 15),
"keep_video": bool(self.keep_video_sw.value),
"vosk_model_path": self.vosk_path.value.strip(),
}
try:
self.client.save_settings(payload)
self.settings_msg.value = "Сохранено ✓ (хранится в БД, переживёт перезапуск)"
self.settings_msg.color = d.OK
self._set_status(ok=bool(payload["llm_base_url"] and model))
except ApiError as e:
self.settings_msg.value = str(e)
self.settings_msg.color = d.DANGER
self.page.update()
# ─────────────────── model combobox ──────────────────────────────────
def _on_model_search(self, e):
@@ -292,6 +385,8 @@ class MainView:
self.selected_model = mid
self.model_input.value = mid
self._model_list_wrap.visible = False
self.llm_msg.value = "Не забудьте нажать «Сохранить настройки»"
self.llm_msg.color = d.MUTED
self.page.update()
# ─────────────────── LLM actions ─────────────────────────────────────
@@ -311,10 +406,8 @@ class MainView:
self.models = self.client.llm_models(base_url, self.api_key.value.strip())
self._render_model_list(self.models)
self._set_llm_msg(f"Загружено: {len(self.models)} моделей", ok=True)
self._set_status(ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
self._set_status(ok=False)
finally:
self.btn_load.disabled = False
self.page.update()
@@ -322,7 +415,8 @@ class MainView:
threading.Thread(target=_task, daemon=True).start()
def _test_conn(self, _):
if not self.selected_model:
model = self.selected_model or (self.model_input.value or "").strip()
if not model:
self._set_llm_msg("Сначала выберите модель", error=True)
return
@@ -335,87 +429,164 @@ class MainView:
r = self.client.llm_test(
self.base_url.value.strip(),
self.api_key.value.strip(),
self.selected_model,
model,
)
preview = (r.get("reply") or "")[:80]
self._set_llm_msg(f"OK · {preview}", ok=True)
self._set_status(ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
self._set_status(ok=False)
finally:
self.btn_test.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
# ─────────────────── pipeline ─────────────────────────────────────────
# ─────────────────── pipeline → jobs ──────────────────────────────────
def _run_pipeline(self, _):
url = self.yt_url.value.strip()
if not url:
self._set_pipeline_msg("Укажите ссылку на видео", error=True)
self._set_pipeline_msg("Укажите ссылку на видео или плейлист", error=True)
return
payload: dict = {
"url": url,
"model_path": self.vosk_path.value.strip() or None,
"summary_max_sentences": int(self.n_sentences.value or 15),
"keep_video": bool(self.keep_video_sw.value),
}
payload: dict = {"url": url}
if self.pipeline_section_dd.value:
payload["section_id"] = int(self.pipeline_section_dd.value)
if self.base_url.value.strip() and self.selected_model:
payload["llm"] = {
"base_url": self.base_url.value.strip(),
"api_key": self.api_key.value.strip(),
"model": self.selected_model,
}
self._set_pipeline_msg("Обработка видео… (может занять несколько минут)")
self._result_col.visible = False
self.btn_run.disabled = True
try:
self.client.create_job(payload)
self.yt_url.value = ""
self._set_pipeline_msg("Задача поставлена в очередь ✓", ok=True)
except ApiError as e:
self._set_pipeline_msg(str(e), error=True)
self.page.update()
self._start_jobs_poll()
def _task():
try:
r = self.client.pipeline_process(payload)
self._render_result(r)
self._set_pipeline_msg("Готово ✓", ok=True)
self._refresh_library()
except ApiError as e:
self._set_pipeline_msg(str(e), error=True)
finally:
self.btn_run.disabled = False
self.page.update()
# ─────────────────── jobs ─────────────────────────────────────────────
threading.Thread(target=_task, daemon=True).start()
def _start_jobs_poll(self):
if self._polling:
return
self._polling = True
threading.Thread(target=self._poll_loop, daemon=True).start()
def _render_result(self, r: dict):
self._result_col.controls = [
ft.Divider(color=d.BORDER, height=1),
ft.Text(r.get("title") or r.get("uuid", ""), color=d.ACCENT_2, size=15, weight=ft.FontWeight.W_600),
*[
self._kv(k, v)
for k, v in [
("UUID", r.get("uuid", "")),
("Источник", r.get("source_url", "")),
("Метод", r.get("transcription_method", "")),
("Видео", r.get("video_path") or "не сохранено"),
("Полный текст", r.get("text_full_path", "")),
("Выжимка", r.get("text_summary_path", "")),
]
],
ft.Text("Превью выжимки:", color=d.MUTED, size=12),
ft.Container(
content=ft.Text(r.get("summary_preview", ""), color=d.TEXT, size=13, selectable=True, no_wrap=False),
padding=12,
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
def _poll_loop(self):
prev: dict = {}
try:
while True:
try:
jobs = self.client.list_jobs()
except (ApiError, Exception):
break
self._render_jobs(jobs)
state = {j["id"]: (j["done_items"], j["status"]) for j in jobs}
if state != prev:
prev = state
self._refresh_library() # там же page.update()
else:
self.page.update()
if not any(j["status"] in ("queued", "running") for j in jobs):
break
time.sleep(3)
finally:
self._polling = False
def _render_jobs(self, jobs: list[dict]):
if not jobs:
self._jobs_col.controls = [
ft.Text("Задач ещё не было", color=d.MUTED, size=13)
]
return
self._jobs_col.controls = [self._job_row(j) for j in jobs]
def _job_row(self, j: dict) -> ft.Container:
label, tone = _JOB_STATUS.get(j["status"], (j["status"], None))
color = d.OK if tone == "ok" else (d.DANGER if tone == "danger" else d.MUTED)
active = j["status"] in ("queued", "running")
chip = ft.Container(
content=ft.Text(label, size=11, color=color),
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border=ft.border.all(1, color),
border_radius=99,
)
head = [
chip,
ft.Text(
(j["title"] or j["url"])[:90],
color=d.TEXT,
size=13,
weight=ft.FontWeight.W_600,
expand=True,
no_wrap=True,
),
]
self._result_col.visible = True
if j["kind"] == "playlist":
head.append(ft.Text("плейлист", color=d.ACCENT_2, size=11))
if j["total_items"]:
head.append(
ft.Text(f"{j['done_items']}/{j['total_items']}", color=d.MUTED, size=12)
)
if active:
head.append(
ft.IconButton(
icon=ft.Icons.STOP_CIRCLE_OUTLINED,
icon_color=d.DANGER,
icon_size=17,
tooltip="Отменить (после текущего элемента)",
on_click=lambda e, jid=j["id"]: self._cancel_job(jid),
)
)
else:
head.append(
ft.IconButton(
icon=ft.Icons.CLOSE,
icon_color=d.MUTED,
icon_size=15,
tooltip="Убрать из списка",
on_click=lambda e, jid=j["id"]: self._delete_job(jid),
)
)
rows: list[ft.Control] = [ft.Row(head, spacing=8)]
if active:
total = j["total_items"] or 0
value = (j["done_items"] / total) if total else None
rows.append(
ft.ProgressBar(value=value, color=d.ACCENT, bgcolor=d.SURFACE_3, height=4)
)
if j.get("live"):
rows.append(ft.Text(j["live"][:140], color=d.ACCENT_2, size=11))
if j.get("error"):
rows.append(
ft.Text(j["error"][:300], color=d.DANGER, size=11, no_wrap=False)
)
if j["failed_items"]:
rows.append(
ft.Text(f"с ошибкой: {j['failed_items']}", color=d.DANGER, size=11)
)
return ft.Container(
content=ft.Column(rows, spacing=5, tight=True),
padding=10,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
def _cancel_job(self, job_id: str):
try:
self.client.cancel_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
def _delete_job(self, job_id: str):
try:
self.client.delete_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
# ─────────────────── sections ─────────────────────────────────────────
@@ -688,6 +859,6 @@ class MainView:
def _set_status(self, *, ok: bool):
chip = self.llm_status_chip
chip.content.value = "LLM подключена" if ok else "Ошибка подключения"
chip.content.color = d.OK if ok else d.DANGER
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else "rgba(227,90,114,.4)")
chip.content.value = "LLM настроена" if ok else "LLM не настроена"
chip.content.color = d.OK if ok else d.MUTED
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else d.BORDER)