Разделы, фильтры и просмотр текстов в UI; опция «не хранить видео»
- таблица sections (дерево через parent_id), у видео section_id
- API: CRUD /sections, фильтры /videos (поиск, раздел с потомками, метод),
GET /videos/{id}/text, PATCH раздела, DELETE вместе с файлами
- pipeline: keep_video=false удаляет mp4 после расшифровки, section_id
- UI: панель разделов с вложенностью и счётчиками, поиск, фильтр по методу,
диалоги полного текста/выжимки, привязка к разделу, удаление,
переключатель «Сохранять видео на диске»
- compose: bind-mount ./web для правок без пересборки
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,14 +44,46 @@ class ApiClient:
|
||||
def pipeline_process(self, payload: dict) -> dict:
|
||||
return self._post("/pipeline/process", payload)
|
||||
|
||||
def list_videos(self) -> list[dict]:
|
||||
return self._get("/videos/")
|
||||
def list_videos(
|
||||
self,
|
||||
q: str | None = None,
|
||||
section_id: int | None = None,
|
||||
method: str | None = None,
|
||||
) -> list[dict]:
|
||||
params: dict = {}
|
||||
if q:
|
||||
params["q"] = q
|
||||
if section_id is not None:
|
||||
params["section_id"] = section_id
|
||||
if method:
|
||||
params["method"] = method
|
||||
return self._get("/videos/", params=params)
|
||||
|
||||
def video_text(self, uuid: str, kind: str) -> dict:
|
||||
return self._get(f"/videos/{uuid}/text", params={"kind": kind})
|
||||
|
||||
def set_video_section(self, uuid: str, section_id: int | None) -> dict:
|
||||
return self._request("PATCH", f"/videos/{uuid}", {"section_id": section_id})
|
||||
|
||||
def delete_video(self, uuid: str) -> dict:
|
||||
return self._request("DELETE", f"/videos/{uuid}")
|
||||
|
||||
# --- Sections ---
|
||||
|
||||
def list_sections(self) -> list[dict]:
|
||||
return self._get("/sections/")
|
||||
|
||||
def create_section(self, name: str, parent_id: int | None) -> dict:
|
||||
return self._post("/sections/", {"name": name, "parent_id": parent_id})
|
||||
|
||||
def delete_section(self, section_id: int) -> dict:
|
||||
return self._request("DELETE", f"/sections/{section_id}")
|
||||
|
||||
# --- internals ---
|
||||
|
||||
def _get(self, path: str) -> dict | list:
|
||||
def _get(self, path: str, params: dict | None = None) -> dict | list:
|
||||
try:
|
||||
r = self._client.get(path)
|
||||
r = self._client.get(path, params=params)
|
||||
except httpx.HTTPError as e:
|
||||
raise ApiError(f"Сеть: {e}")
|
||||
return self._unwrap(r)
|
||||
@@ -63,6 +95,13 @@ class ApiClient:
|
||||
raise ApiError(f"Сеть: {e}")
|
||||
return self._unwrap(r)
|
||||
|
||||
def _request(self, method: str, path: str, json: dict | None = None) -> dict:
|
||||
try:
|
||||
r = self._client.request(method, path, json=json)
|
||||
except httpx.HTTPError as e:
|
||||
raise ApiError(f"Сеть: {e}")
|
||||
return self._unwrap(r)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(r: httpx.Response):
|
||||
try:
|
||||
|
||||
@@ -17,8 +17,11 @@ class MainView:
|
||||
self.models: list[dict] = []
|
||||
self.selected_model: str = ""
|
||||
|
||||
self.sections: list[dict] = []
|
||||
self.current_section_id: int | None = None # None = все видео
|
||||
|
||||
# ── LLM config fields ──────────────────────────────────────────────
|
||||
self.base_url = d.text_field("Base URL", hint="https://api.openai.com/v1", expand=True)
|
||||
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
|
||||
@@ -72,14 +75,51 @@ class MainView:
|
||||
text_size=14,
|
||||
width=220,
|
||||
)
|
||||
self.keep_video_sw = ft.Switch(
|
||||
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.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
|
||||
self.btn_run = d.primary_button("▶ Запустить", self._run_pipeline)
|
||||
|
||||
# Result block (initially hidden)
|
||||
self._result_col = ft.Column(spacing=6, visible=False)
|
||||
|
||||
# Library
|
||||
self._library_col = ft.Column(spacing=8)
|
||||
# ── Library controls ───────────────────────────────────────────────
|
||||
self.search_field = d.text_field(
|
||||
"Поиск по названию / URL", on_change=None, expand=True
|
||||
)
|
||||
self.search_field.on_submit = lambda _: self._refresh_library()
|
||||
self.method_dd = self._dropdown("Метод", width=190)
|
||||
self.method_dd.options = [
|
||||
ft.dropdown.Option("", "все"),
|
||||
ft.dropdown.Option("subtitles", "субтитры"),
|
||||
ft.dropdown.Option("vosk", "vosk"),
|
||||
]
|
||||
self.method_dd.value = ""
|
||||
self.method_dd.on_change = lambda _: self._refresh_library()
|
||||
|
||||
self._sections_col = ft.Column(spacing=2)
|
||||
self._library_col = ft.Column(spacing=8, expand=True)
|
||||
self.lib_msg = ft.Text("", color=d.MUTED, size=12)
|
||||
|
||||
# ─────────────────────── small factories ─────────────────────────────
|
||||
|
||||
def _dropdown(self, label: str, width: int | None = None) -> ft.Dropdown:
|
||||
return ft.Dropdown(
|
||||
label=label,
|
||||
options=[],
|
||||
width=width,
|
||||
bgcolor=d.SURFACE_3,
|
||||
border_color=d.BORDER,
|
||||
focused_border_color=d.ACCENT,
|
||||
label_style=ft.TextStyle(color=d.MUTED, size=12),
|
||||
text_size=13,
|
||||
color=d.TEXT,
|
||||
)
|
||||
|
||||
# ─────────────────────── build ───────────────────────────────────────
|
||||
|
||||
@@ -158,11 +198,49 @@ class MainView:
|
||||
),
|
||||
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,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
|
||||
self._result_col,
|
||||
)
|
||||
|
||||
def _build_library_card(self) -> ft.Container:
|
||||
left = ft.Container(
|
||||
width=260,
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(
|
||||
[
|
||||
ft.Text("Разделы", color=d.TEXT, size=14, weight=ft.FontWeight.W_600),
|
||||
ft.Container(expand=True),
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.ADD,
|
||||
icon_color=d.ACCENT_2,
|
||||
icon_size=18,
|
||||
tooltip="Добавить раздел",
|
||||
on_click=self._add_section_dialog,
|
||||
),
|
||||
]
|
||||
),
|
||||
self._sections_col,
|
||||
],
|
||||
spacing=6,
|
||||
tight=True,
|
||||
),
|
||||
)
|
||||
right = ft.Column(
|
||||
[
|
||||
ft.Row([self.search_field, self.method_dd], spacing=10),
|
||||
self.lib_msg,
|
||||
self._library_col,
|
||||
],
|
||||
spacing=10,
|
||||
expand=True,
|
||||
tight=True,
|
||||
)
|
||||
return d.card(
|
||||
ft.Row(
|
||||
[
|
||||
@@ -171,8 +249,11 @@ class MainView:
|
||||
d.secondary_button("Обновить", lambda _: self._refresh_library()),
|
||||
]
|
||||
),
|
||||
d.hint("Все обработанные видео и пути к файлам."),
|
||||
self._library_col,
|
||||
ft.Row(
|
||||
[left, ft.Container(width=1, bgcolor=d.BORDER), right],
|
||||
spacing=16,
|
||||
vertical_alignment=ft.CrossAxisAlignment.START,
|
||||
),
|
||||
)
|
||||
|
||||
# ─────────────────── model combobox ──────────────────────────────────
|
||||
@@ -280,7 +361,10 @@ class MainView:
|
||||
"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),
|
||||
}
|
||||
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(),
|
||||
@@ -317,7 +401,7 @@ class MainView:
|
||||
("UUID", r.get("uuid", "")),
|
||||
("Источник", r.get("source_url", "")),
|
||||
("Метод", r.get("transcription_method", "")),
|
||||
("Видео", r.get("video_path", "")),
|
||||
("Видео", r.get("video_path") or "не сохранено"),
|
||||
("Полный текст", r.get("text_full_path", "")),
|
||||
("Выжимка", r.get("text_summary_path", "")),
|
||||
]
|
||||
@@ -333,16 +417,131 @@ class MainView:
|
||||
]
|
||||
self._result_col.visible = True
|
||||
|
||||
# ─────────────────── sections ─────────────────────────────────────────
|
||||
|
||||
def _section_options(self, *, none_label: str) -> list[ft.dropdown.Option]:
|
||||
"""Options списка разделов с отступами по глубине дерева."""
|
||||
opts = [ft.dropdown.Option("", none_label)]
|
||||
children: dict[int | None, list[dict]] = {}
|
||||
for s in self.sections:
|
||||
children.setdefault(s["parent_id"], []).append(s)
|
||||
|
||||
def walk(parent_id: int | None, depth: int):
|
||||
for s in children.get(parent_id, []):
|
||||
opts.append(ft.dropdown.Option(str(s["id"]), " " * depth + s["name"]))
|
||||
walk(s["id"], depth + 1)
|
||||
|
||||
walk(None, 0)
|
||||
return opts
|
||||
|
||||
def _render_sections(self):
|
||||
children: dict[int | None, list[dict]] = {}
|
||||
for s in self.sections:
|
||||
children.setdefault(s["parent_id"], []).append(s)
|
||||
|
||||
def item(sid: int | None, name: str, count: int | None, depth: int, deletable: bool):
|
||||
selected = self.current_section_id == sid
|
||||
row = [
|
||||
ft.Text(name, color=d.ACCENT_2 if selected else d.TEXT, size=13, expand=True, no_wrap=True),
|
||||
]
|
||||
if count is not None:
|
||||
row.append(ft.Text(str(count), color=d.MUTED, size=11))
|
||||
if deletable:
|
||||
row.append(
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.DELETE_OUTLINE,
|
||||
icon_color=d.MUTED,
|
||||
icon_size=15,
|
||||
tooltip="Удалить раздел (видео останутся)",
|
||||
on_click=lambda e, s=sid: self._delete_section(s),
|
||||
)
|
||||
)
|
||||
return ft.Container(
|
||||
content=ft.Row(row, spacing=4),
|
||||
padding=ft.padding.only(left=10 + depth * 16, right=2, top=2, bottom=2),
|
||||
bgcolor=d.SURFACE_2 if selected else None,
|
||||
border_radius=6,
|
||||
ink=True,
|
||||
on_click=lambda e, s=sid: self._select_section(s),
|
||||
)
|
||||
|
||||
controls = [item(None, "Все видео", None, 0, deletable=False)]
|
||||
|
||||
def walk(parent_id: int | None, depth: int):
|
||||
for s in children.get(parent_id, []):
|
||||
controls.append(item(s["id"], s["name"], s["video_count"], depth, deletable=True))
|
||||
walk(s["id"], depth + 1)
|
||||
|
||||
walk(None, 0)
|
||||
if not self.sections:
|
||||
controls.append(ft.Text("Разделов пока нет — добавьте «+»", color=d.MUTED, size=12))
|
||||
self._sections_col.controls = controls
|
||||
|
||||
def _select_section(self, section_id: int | None):
|
||||
self.current_section_id = section_id
|
||||
self._refresh_library()
|
||||
|
||||
def _delete_section(self, section_id: int):
|
||||
try:
|
||||
self.client.delete_section(section_id)
|
||||
if self.current_section_id == section_id:
|
||||
self.current_section_id = None
|
||||
except ApiError as e:
|
||||
self.lib_msg.value = str(e)
|
||||
self._refresh_library()
|
||||
|
||||
def _add_section_dialog(self, _):
|
||||
name_field = d.text_field("Название раздела", expand=True)
|
||||
parent_dd = self._dropdown("Родительский раздел", width=None)
|
||||
parent_dd.options = self._section_options(none_label="— корневой —")
|
||||
parent_dd.value = str(self.current_section_id or "")
|
||||
|
||||
def _create(_):
|
||||
name = (name_field.value or "").strip()
|
||||
if not name:
|
||||
return
|
||||
try:
|
||||
parent = int(parent_dd.value) if parent_dd.value else None
|
||||
self.client.create_section(name, parent)
|
||||
except ApiError as e:
|
||||
self.lib_msg.value = str(e)
|
||||
self.page.close(dlg)
|
||||
self._refresh_library()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True,
|
||||
bgcolor=d.SURFACE,
|
||||
title=ft.Text("Новый раздел", color=d.TEXT, size=16),
|
||||
content=ft.Container(
|
||||
width=420,
|
||||
content=ft.Column([name_field, parent_dd], spacing=12, tight=True),
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
|
||||
d.primary_button("Создать", _create),
|
||||
],
|
||||
)
|
||||
self.page.open(dlg)
|
||||
|
||||
# ─────────────────── library ─────────────────────────────────────────
|
||||
|
||||
def _refresh_library(self):
|
||||
self.lib_msg.value = ""
|
||||
try:
|
||||
items = self.client.list_videos()
|
||||
self.sections = self.client.list_sections()
|
||||
items = self.client.list_videos(
|
||||
q=(self.search_field.value or "").strip() or None,
|
||||
section_id=self.current_section_id,
|
||||
method=self.method_dd.value or None,
|
||||
)
|
||||
except ApiError as e:
|
||||
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
self._render_sections()
|
||||
self.pipeline_section_dd.options = self._section_options(none_label="— без раздела —")
|
||||
|
||||
if not items:
|
||||
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
|
||||
else:
|
||||
@@ -351,28 +550,49 @@ class MainView:
|
||||
|
||||
def _lib_card(self, v: dict) -> ft.Container:
|
||||
created = v.get("created_at", "")[:19].replace("T", " ")
|
||||
section_dd = self._dropdown("Раздел", width=230)
|
||||
section_dd.options = self._section_options(none_label="— без раздела —")
|
||||
section_dd.value = str(v.get("section_id") or "")
|
||||
section_dd.on_change = lambda e, u=v["uuid"]: self._assign_section(u, e.control.value)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(
|
||||
v.get("title") or v.get("uuid", ""),
|
||||
color=d.TEXT,
|
||||
size=14,
|
||||
weight=ft.FontWeight.W_600,
|
||||
no_wrap=True,
|
||||
),
|
||||
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Text(f"uuid: {v.get('uuid', '')}", color=d.MUTED, size=11, selectable=True, expand=True),
|
||||
ft.Text(
|
||||
v.get("title") or v.get("uuid", ""),
|
||||
color=d.TEXT,
|
||||
size=14,
|
||||
weight=ft.FontWeight.W_600,
|
||||
expand=True,
|
||||
no_wrap=True,
|
||||
),
|
||||
ft.Text(created, color=d.MUTED, size=11),
|
||||
ft.IconButton(
|
||||
icon=ft.Icons.DELETE_OUTLINE,
|
||||
icon_color=d.DANGER,
|
||||
icon_size=17,
|
||||
tooltip="Удалить видео и файлы",
|
||||
on_click=lambda e, vid=v: self._confirm_delete_video(vid),
|
||||
),
|
||||
]
|
||||
),
|
||||
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
|
||||
self._kv("Метод", v.get("transcription_method", "")),
|
||||
self._kv("Видео", v.get("video_path", "")),
|
||||
self._kv("Выжимка", v.get("text_summary_path", "")),
|
||||
self._kv("Видео", v.get("video_path") or "не сохранено"),
|
||||
ft.Row(
|
||||
[
|
||||
d.secondary_button("Выжимка", lambda e, vid=v: self._show_text(vid, "summary")),
|
||||
d.secondary_button("Полный текст", lambda e, vid=v: self._show_text(vid, "full")),
|
||||
ft.Container(expand=True),
|
||||
section_dd,
|
||||
],
|
||||
spacing=8,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
],
|
||||
spacing=3,
|
||||
spacing=5,
|
||||
tight=True,
|
||||
),
|
||||
padding=12,
|
||||
@@ -381,6 +601,73 @@ class MainView:
|
||||
border_radius=10,
|
||||
)
|
||||
|
||||
def _assign_section(self, uuid: str, value: str):
|
||||
try:
|
||||
self.client.set_video_section(uuid, int(value) if value else None)
|
||||
except ApiError as e:
|
||||
self.lib_msg.value = str(e)
|
||||
self._refresh_library()
|
||||
|
||||
def _confirm_delete_video(self, v: dict):
|
||||
def _do(_):
|
||||
try:
|
||||
self.client.delete_video(v["uuid"])
|
||||
except ApiError as e:
|
||||
self.lib_msg.value = str(e)
|
||||
self.page.close(dlg)
|
||||
self._refresh_library()
|
||||
|
||||
dlg = ft.AlertDialog(
|
||||
modal=True,
|
||||
bgcolor=d.SURFACE,
|
||||
title=ft.Text("Удалить видео?", color=d.TEXT, size=16),
|
||||
content=ft.Text(
|
||||
f"«{v.get('title') or v['uuid']}»\n"
|
||||
"Будут удалены запись в БД и файлы (видео, тексты).",
|
||||
color=d.MUTED,
|
||||
size=13,
|
||||
),
|
||||
actions=[
|
||||
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
|
||||
ft.TextButton(
|
||||
"Удалить",
|
||||
style=ft.ButtonStyle(color=d.DANGER),
|
||||
on_click=_do,
|
||||
),
|
||||
],
|
||||
)
|
||||
self.page.open(dlg)
|
||||
|
||||
def _show_text(self, v: dict, kind: str):
|
||||
try:
|
||||
data = self.client.video_text(v["uuid"], kind)
|
||||
except ApiError as e:
|
||||
self.lib_msg.value = str(e)
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
title = ("Выжимка — " if kind == "summary" else "Полный текст — ") + (
|
||||
v.get("title") or v["uuid"]
|
||||
)
|
||||
dlg = ft.AlertDialog(
|
||||
bgcolor=d.SURFACE,
|
||||
title=ft.Text(title, color=d.TEXT, size=15, no_wrap=False),
|
||||
content=ft.Container(
|
||||
width=780,
|
||||
height=440,
|
||||
content=ft.Column(
|
||||
[ft.Text(data.get("text", ""), color=d.TEXT, size=13, selectable=True)],
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
bgcolor=d.SURFACE_3,
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
),
|
||||
actions=[ft.TextButton("Закрыть", on_click=lambda e: self.page.close(dlg))],
|
||||
)
|
||||
self.page.open(dlg)
|
||||
|
||||
# ─────────────────── helpers ──────────────────────────────────────────
|
||||
|
||||
def _kv(self, key: str, value: str) -> ft.Row:
|
||||
|
||||
Reference in New Issue
Block a user