Разделы, фильтры и просмотр текстов в 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:
jze9
2026-07-14 12:07:55 +05:00
parent 05ba753ade
commit bf854f2d6e
12 changed files with 612 additions and 43 deletions

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import sessionmaker
from api.config import settings
from api.models.base import Base
import api.models.section # noqa: F401 - register model with Base.metadata
import api.models.video # noqa: F401 - register model with Base.metadata
@@ -61,3 +62,10 @@ async def init_db() -> None:
await _create_database_if_missing()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# create_all не добавляет колонки в уже существующие таблицы
await conn.execute(
text(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS section_id INTEGER "
"REFERENCES sections(id) ON DELETE SET NULL"
)
)

57
api/db/section.py Normal file
View File

@@ -0,0 +1,57 @@
from sqlalchemy import func, select, update
from api.db.connection import SessionLocal
from api.models.section import Section
from api.models.video import Video
async def list_sections() -> list[dict]:
"""Плоский список разделов + количество видео в каждом (дерево строит клиент)."""
async with SessionLocal() as session:
res = await session.execute(select(Section).order_by(Section.name))
sections = list(res.scalars().all())
counts_res = await session.execute(
select(Video.section_id, func.count()).group_by(Video.section_id)
)
counts = dict(counts_res.all())
return [
{
"id": s.id,
"name": s.name,
"parent_id": s.parent_id,
"video_count": counts.get(s.id, 0),
}
for s in sections
]
async def create_section(name: str, parent_id: int | None) -> Section:
async with SessionLocal() as session:
if parent_id is not None:
parent = await session.get(Section, parent_id)
if parent is None:
raise ValueError(f"Parent section {parent_id} not found")
s = Section(name=name, parent_id=parent_id)
session.add(s)
await session.commit()
await session.refresh(s)
return s
async def delete_section(section_id: int) -> bool:
"""Удаляет раздел: дочерние разделы поднимаются к родителю, видео — без раздела."""
async with SessionLocal() as session:
s = await session.get(Section, section_id)
if s is None:
return False
await session.execute(
update(Section)
.where(Section.parent_id == section_id)
.values(parent_id=s.parent_id)
)
await session.execute(
update(Video).where(Video.section_id == section_id).values(section_id=None)
)
await session.delete(s)
await session.commit()
return True

View File

@@ -13,11 +13,13 @@ async def create_video(
text_full_path: str,
text_summary_path: str,
transcription_method: str,
section_id: int | None = None,
) -> Video:
async with SessionLocal() as session:
v = Video(
uuid=uuid,
source_url=source_url,
section_id=section_id,
title=title or "",
video_path=video_path or "",
text_full_path=text_full_path or "",
@@ -36,12 +38,39 @@ async def get_video(video_uuid: str) -> Video | None:
return res.scalars().first()
async def list_videos() -> list[Video]:
async def list_videos(
*,
q: str | None = None,
section_ids: list[int] | None = None,
method: str | None = None,
) -> list[Video]:
"""Каталог с фильтрами: поиск по названию/URL, разделы (уже с потомками), метод."""
stmt = select(Video)
if q:
pattern = f"%{q}%"
stmt = stmt.where(Video.title.ilike(pattern) | Video.source_url.ilike(pattern))
if section_ids is not None:
stmt = stmt.where(Video.section_id.in_(section_ids))
if method:
stmt = stmt.where(Video.transcription_method.ilike(f"{method}%"))
stmt = stmt.order_by(Video.created_at.desc())
async with SessionLocal() as session:
res = await session.execute(select(Video).order_by(Video.created_at.desc()))
res = await session.execute(stmt)
return list(res.scalars().all())
async def set_video_section(video_uuid: str, section_id: int | None) -> Video | None:
async with SessionLocal() as session:
res = await session.execute(select(Video).where(Video.uuid == video_uuid))
v = res.scalars().first()
if not v:
return None
v.section_id = section_id
await session.commit()
await session.refresh(v)
return v
async def delete_video(video_uuid: str) -> bool:
async with SessionLocal() as session:
res = await session.execute(select(Video).where(Video.uuid == video_uuid))

View File

@@ -68,7 +68,12 @@ def chat_complete(cfg: LLMConfig, messages: list[dict]) -> str:
"max_tokens": cfg.max_tokens,
"temperature": cfg.temperature,
}
headers = {"Content-Type": "application/json", "Accept": "application/json"}
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
# urllib's default "Python-urllib/x.y" UA is blocked by some CDNs (Cloudflare 1010)
"User-Agent": "LLM-infa/0.3",
}
if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}"
headers.update(cfg.extra_headers or {})
@@ -121,7 +126,7 @@ def list_models(cfg: LLMConfig) -> list[dict]:
Compatible with OpenAI, OpenRouter, Ollama OpenAI-compat, vLLM, LM Studio.
"""
url = cfg.base_url.rstrip("/") + "/models"
headers = {"Accept": "application/json"}
headers = {"Accept": "application/json", "User-Agent": "LLM-infa/0.3"}
if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}"
headers.update(cfg.extra_headers or {})

View File

@@ -38,20 +38,30 @@ def download_video_with_subs(
outtmpl = str(video_dir / f"{video_id}.%(ext)s")
ydl_opts = {
base_opts = {
"outtmpl": outtmpl,
"format": "bv*+ba/b",
"merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
}
sub_opts = {
**base_opts,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": langs,
"subtitlesformat": "vtt",
"quiet": True,
"no_warnings": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# YouTube often 429s the subtitle endpoints; a failed subtitle download must not
# kill the pipeline — retry without subs and let the Vosk fallback transcribe.
try:
with yt_dlp.YoutubeDL(sub_opts) as ydl:
info = ydl.extract_info(url, download=True)
except yt_dlp.utils.DownloadError as e:
logger.warning("Download with subtitles failed (%s); retrying without subs", e)
with yt_dlp.YoutubeDL(base_opts) as ydl:
info = ydl.extract_info(url, download=True)
title = info.get("title") or video_id

View File

@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from api.db.connection import wait_for_postgres, init_db
from api.route import default, llm, pipeline, videos
from api.route import default, llm, pipeline, sections, videos
logging.basicConfig(
@@ -25,4 +25,5 @@ app = FastAPI(title="LLM-infa API", version="0.3.0", lifespan=lifespan)
app.include_router(default.router)
app.include_router(pipeline.router)
app.include_router(videos.router)
app.include_router(sections.router)
app.include_router(llm.router)

View File

@@ -42,6 +42,8 @@ class ProcessRequest(BaseModel):
url: str
model_path: str | None = None # path inside models/, used when subtitles are unavailable
summary_max_sentences: int = 15
keep_video: bool = True # False: файл видео удаляется после расшифровки
section_id: int | None = None
# If supplied, this LLM is used for the summary; otherwise env-configured LLM;
# otherwise the built-in extractive fallback.
llm: LLMOverride | None = None
@@ -170,7 +172,14 @@ async def process_video(req: ProcessRequest):
raise HTTPException(status_code=500, detail=f"Summarization failed: {e}")
text_summary_path.write_text(summary, encoding="utf-8")
rel_video = str(video_path.relative_to(_PROJECT_ROOT))
if req.keep_video:
rel_video = str(video_path.relative_to(_PROJECT_ROOT))
else:
# пользователь просил не хранить видео: текст уже извлечён, файлы не нужны
video_path.unlink(missing_ok=True)
for p in _VIDEO_DIR.glob(f"{video_uuid}*.vtt"):
p.unlink(missing_ok=True)
rel_video = ""
rel_full = str(text_full_path.relative_to(_PROJECT_ROOT))
rel_summary = str(text_summary_path.relative_to(_PROJECT_ROOT))
@@ -183,6 +192,7 @@ async def process_video(req: ProcessRequest):
text_full_path=rel_full,
text_summary_path=rel_summary,
transcription_method=method,
section_id=req.section_id,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"DB insert failed: {e}")

44
api/route/sections.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.db.section import create_section, delete_section, list_sections
router = APIRouter(prefix="/sections", tags=["sections"])
class SectionIn(BaseModel):
name: str
parent_id: int | None = None
class SectionOut(BaseModel):
id: int
name: str
parent_id: int | None
video_count: int
@router.get("/", response_model=list[SectionOut])
async def list_all():
return await list_sections()
@router.post("/", response_model=SectionOut)
async def create(body: SectionIn):
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Section name is empty")
try:
s = await create_section(name, body.parent_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return SectionOut(id=s.id, name=s.name, parent_id=s.parent_id, video_count=0)
@router.delete("/{section_id}")
async def delete(section_id: int):
ok = await delete_section(section_id)
if not ok:
raise HTTPException(status_code=404, detail="Section not found")
return {"ok": True}

View File

@@ -1,18 +1,28 @@
from datetime import datetime
from typing import List
from pathlib import Path
from typing import List, Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from api.db.video import delete_video, get_video, list_videos
from api.db.section import list_sections
from api.db.video import (
delete_video,
get_video,
list_videos,
set_video_section,
)
router = APIRouter(prefix="/videos", tags=["videos"])
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class VideoOut(BaseModel):
uuid: str
source_url: str
section_id: int | None
title: str
video_path: str
text_full_path: str
@@ -21,10 +31,15 @@ class VideoOut(BaseModel):
created_at: datetime
class SectionPatch(BaseModel):
section_id: int | None
def _to_out(v) -> VideoOut:
return VideoOut(
uuid=v.uuid,
source_url=v.source_url,
section_id=v.section_id,
title=v.title,
video_path=v.video_path,
text_full_path=v.text_full_path,
@@ -34,9 +49,31 @@ def _to_out(v) -> VideoOut:
)
async def _descendant_ids(section_id: int) -> list[int]:
"""Раздел + все его потомки (фильтр по разделу захватывает подразделы)."""
sections = await list_sections()
children: dict[int | None, list[int]] = {}
for s in sections:
children.setdefault(s["parent_id"], []).append(s["id"])
ids, stack = [], [section_id]
while stack:
cur = stack.pop()
ids.append(cur)
stack.extend(children.get(cur, []))
return ids
@router.get("/", response_model=List[VideoOut])
async def list_all():
return [_to_out(v) for v in await list_videos()]
async def list_all(
q: str | None = Query(default=None, description="Поиск по названию/URL"),
section_id: int | None = Query(default=None, description="Раздел (включая подразделы)"),
method: str | None = Query(default=None, description="Префикс метода: subtitles | vosk"),
):
section_ids = await _descendant_ids(section_id) if section_id is not None else None
return [
_to_out(v)
for v in await list_videos(q=q, section_ids=section_ids, method=method)
]
@router.get("/{video_uuid}", response_model=VideoOut)
@@ -47,9 +84,49 @@ async def get_one(video_uuid: str):
return _to_out(v)
@router.delete("/{video_uuid}")
async def delete_one(video_uuid: str):
ok = await delete_video(video_uuid)
if not ok:
@router.get("/{video_uuid}/text")
async def get_text(video_uuid: str, kind: Literal["full", "summary"] = "full"):
v = await get_video(video_uuid)
if not v:
raise HTTPException(status_code=404, detail="Video not found")
rel = v.text_full_path if kind == "full" else v.text_summary_path
if not rel:
raise HTTPException(status_code=404, detail="Text file is not recorded")
p = (_PROJECT_ROOT / rel).resolve()
try:
p.relative_to(_PROJECT_ROOT)
except ValueError:
raise HTTPException(status_code=400, detail="Bad text path")
if not p.exists():
raise HTTPException(status_code=404, detail=f"File missing on disk: {rel}")
return {"uuid": video_uuid, "kind": kind, "text": p.read_text(encoding="utf-8", errors="ignore")}
@router.patch("/{video_uuid}", response_model=VideoOut)
async def patch_section(video_uuid: str, body: SectionPatch):
v = await set_video_section(video_uuid, body.section_id)
if not v:
raise HTTPException(status_code=404, detail="Video not found")
return _to_out(v)
@router.delete("/{video_uuid}")
async def delete_one(video_uuid: str, delete_files: bool = True):
v = await get_video(video_uuid)
if not v:
raise HTTPException(status_code=404, detail="Video not found")
if delete_files:
for rel in (v.video_path, v.text_full_path, v.text_summary_path):
if not rel:
continue
p = (_PROJECT_ROOT / rel).resolve()
try:
p.relative_to(_PROJECT_ROOT)
except ValueError:
continue
p.unlink(missing_ok=True)
# сопутствующие субтитры .vtt рядом с видео
for p in (_PROJECT_ROOT / "data" / "videos").glob(f"{video_uuid}*.vtt"):
p.unlink(missing_ok=True)
await delete_video(video_uuid)
return {"ok": True}

View File

@@ -34,6 +34,8 @@ services:
- HTTP_TIMEOUT=1800
ports:
- "8550:8550"
volumes:
- ./web:/app/web
depends_on:
- api
dns:

View File

@@ -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:

View File

@@ -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: