Разделы, фильтры и просмотр текстов в 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.config import settings
from api.models.base import Base 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 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() await _create_database_if_missing()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) 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_full_path: str,
text_summary_path: str, text_summary_path: str,
transcription_method: str, transcription_method: str,
section_id: int | None = None,
) -> Video: ) -> Video:
async with SessionLocal() as session: async with SessionLocal() as session:
v = Video( v = Video(
uuid=uuid, uuid=uuid,
source_url=source_url, source_url=source_url,
section_id=section_id,
title=title or "", title=title or "",
video_path=video_path or "", video_path=video_path or "",
text_full_path=text_full_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() 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: 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()) 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 def delete_video(video_uuid: str) -> bool:
async with SessionLocal() as session: async with SessionLocal() as session:
res = await session.execute(select(Video).where(Video.uuid == video_uuid)) 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, "max_tokens": cfg.max_tokens,
"temperature": cfg.temperature, "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: if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}" headers["Authorization"] = f"Bearer {cfg.api_key}"
headers.update(cfg.extra_headers or {}) 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. Compatible with OpenAI, OpenRouter, Ollama OpenAI-compat, vLLM, LM Studio.
""" """
url = cfg.base_url.rstrip("/") + "/models" url = cfg.base_url.rstrip("/") + "/models"
headers = {"Accept": "application/json"} headers = {"Accept": "application/json", "User-Agent": "LLM-infa/0.3"}
if cfg.api_key: if cfg.api_key:
headers["Authorization"] = f"Bearer {cfg.api_key}" headers["Authorization"] = f"Bearer {cfg.api_key}"
headers.update(cfg.extra_headers or {}) 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") outtmpl = str(video_dir / f"{video_id}.%(ext)s")
ydl_opts = { base_opts = {
"outtmpl": outtmpl, "outtmpl": outtmpl,
"format": "bv*+ba/b", "format": "bv*+ba/b",
"merge_output_format": "mp4", "merge_output_format": "mp4",
"quiet": True,
"no_warnings": True,
}
sub_opts = {
**base_opts,
"writesubtitles": True, "writesubtitles": True,
"writeautomaticsub": True, "writeautomaticsub": True,
"subtitleslangs": langs, "subtitleslangs": langs,
"subtitlesformat": "vtt", "subtitlesformat": "vtt",
"quiet": True,
"no_warnings": True,
} }
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # YouTube often 429s the subtitle endpoints; a failed subtitle download must not
info = ydl.extract_info(url, download=True) # 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 title = info.get("title") or video_id

View File

@@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from api.db.connection import wait_for_postgres, init_db 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( 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(default.router)
app.include_router(pipeline.router) app.include_router(pipeline.router)
app.include_router(videos.router) app.include_router(videos.router)
app.include_router(sections.router)
app.include_router(llm.router) app.include_router(llm.router)

View File

@@ -42,6 +42,8 @@ class ProcessRequest(BaseModel):
url: str url: str
model_path: str | None = None # path inside models/, used when subtitles are unavailable model_path: str | None = None # path inside models/, used when subtitles are unavailable
summary_max_sentences: int = 15 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; # If supplied, this LLM is used for the summary; otherwise env-configured LLM;
# otherwise the built-in extractive fallback. # otherwise the built-in extractive fallback.
llm: LLMOverride | None = None llm: LLMOverride | None = None
@@ -170,7 +172,14 @@ async def process_video(req: ProcessRequest):
raise HTTPException(status_code=500, detail=f"Summarization failed: {e}") raise HTTPException(status_code=500, detail=f"Summarization failed: {e}")
text_summary_path.write_text(summary, encoding="utf-8") 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_full = str(text_full_path.relative_to(_PROJECT_ROOT))
rel_summary = str(text_summary_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_full_path=rel_full,
text_summary_path=rel_summary, text_summary_path=rel_summary,
transcription_method=method, transcription_method=method,
section_id=req.section_id,
) )
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"DB insert failed: {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 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 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"]) router = APIRouter(prefix="/videos", tags=["videos"])
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class VideoOut(BaseModel): class VideoOut(BaseModel):
uuid: str uuid: str
source_url: str source_url: str
section_id: int | None
title: str title: str
video_path: str video_path: str
text_full_path: str text_full_path: str
@@ -21,10 +31,15 @@ class VideoOut(BaseModel):
created_at: datetime created_at: datetime
class SectionPatch(BaseModel):
section_id: int | None
def _to_out(v) -> VideoOut: def _to_out(v) -> VideoOut:
return VideoOut( return VideoOut(
uuid=v.uuid, uuid=v.uuid,
source_url=v.source_url, source_url=v.source_url,
section_id=v.section_id,
title=v.title, title=v.title,
video_path=v.video_path, video_path=v.video_path,
text_full_path=v.text_full_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]) @router.get("/", response_model=List[VideoOut])
async def list_all(): async def list_all(
return [_to_out(v) for v in await list_videos()] 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) @router.get("/{video_uuid}", response_model=VideoOut)
@@ -47,9 +84,49 @@ async def get_one(video_uuid: str):
return _to_out(v) return _to_out(v)
@router.delete("/{video_uuid}") @router.get("/{video_uuid}/text")
async def delete_one(video_uuid: str): async def get_text(video_uuid: str, kind: Literal["full", "summary"] = "full"):
ok = await delete_video(video_uuid) v = await get_video(video_uuid)
if not ok: if not v:
raise HTTPException(status_code=404, detail="Video not found") 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} return {"ok": True}

View File

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

View File

@@ -44,14 +44,46 @@ class ApiClient:
def pipeline_process(self, payload: dict) -> dict: def pipeline_process(self, payload: dict) -> dict:
return self._post("/pipeline/process", payload) return self._post("/pipeline/process", payload)
def list_videos(self) -> list[dict]: def list_videos(
return self._get("/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 --- # --- internals ---
def _get(self, path: str) -> dict | list: def _get(self, path: str, params: dict | None = None) -> dict | list:
try: try:
r = self._client.get(path) r = self._client.get(path, params=params)
except httpx.HTTPError as e: except httpx.HTTPError as e:
raise ApiError(f"Сеть: {e}") raise ApiError(f"Сеть: {e}")
return self._unwrap(r) return self._unwrap(r)
@@ -63,6 +95,13 @@ class ApiClient:
raise ApiError(f"Сеть: {e}") raise ApiError(f"Сеть: {e}")
return self._unwrap(r) 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 @staticmethod
def _unwrap(r: httpx.Response): def _unwrap(r: httpx.Response):
try: try:

View File

@@ -17,8 +17,11 @@ class MainView:
self.models: list[dict] = [] self.models: list[dict] = []
self.selected_model: str = "" self.selected_model: str = ""
self.sections: list[dict] = []
self.current_section_id: int | None = None # None = все видео
# ── LLM config fields ────────────────────────────────────────────── # ── 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) self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
# Searchable model combobox # Searchable model combobox
@@ -72,14 +75,51 @@ class MainView:
text_size=14, text_size=14,
width=220, 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.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) # Result block (initially hidden)
self._result_col = ft.Column(spacing=6, visible=False) self._result_col = ft.Column(spacing=6, visible=False)
# Library # ── Library controls ───────────────────────────────────────────────
self._library_col = ft.Column(spacing=8) 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 ─────────────────────────────────────── # ─────────────────────── build ───────────────────────────────────────
@@ -158,11 +198,49 @@ class MainView:
), ),
self.yt_url, self.yt_url,
ft.Row([self.vosk_path, self.n_sentences], spacing=12), 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), ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
self._result_col, self._result_col,
) )
def _build_library_card(self) -> ft.Container: 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( return d.card(
ft.Row( ft.Row(
[ [
@@ -171,8 +249,11 @@ class MainView:
d.secondary_button("Обновить", lambda _: self._refresh_library()), d.secondary_button("Обновить", lambda _: self._refresh_library()),
] ]
), ),
d.hint("Все обработанные видео и пути к файлам."), ft.Row(
self._library_col, [left, ft.Container(width=1, bgcolor=d.BORDER), right],
spacing=16,
vertical_alignment=ft.CrossAxisAlignment.START,
),
) )
# ─────────────────── model combobox ────────────────────────────────── # ─────────────────── model combobox ──────────────────────────────────
@@ -280,7 +361,10 @@ class MainView:
"url": url, "url": url,
"model_path": self.vosk_path.value.strip() or None, "model_path": self.vosk_path.value.strip() or None,
"summary_max_sentences": int(self.n_sentences.value or 15), "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: if self.base_url.value.strip() and self.selected_model:
payload["llm"] = { payload["llm"] = {
"base_url": self.base_url.value.strip(), "base_url": self.base_url.value.strip(),
@@ -317,7 +401,7 @@ class MainView:
("UUID", r.get("uuid", "")), ("UUID", r.get("uuid", "")),
("Источник", r.get("source_url", "")), ("Источник", r.get("source_url", "")),
("Метод", r.get("transcription_method", "")), ("Метод", r.get("transcription_method", "")),
("Видео", r.get("video_path", "")), ("Видео", r.get("video_path") or "не сохранено"),
("Полный текст", r.get("text_full_path", "")), ("Полный текст", r.get("text_full_path", "")),
("Выжимка", r.get("text_summary_path", "")), ("Выжимка", r.get("text_summary_path", "")),
] ]
@@ -333,16 +417,131 @@ class MainView:
] ]
self._result_col.visible = True 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 ───────────────────────────────────────── # ─────────────────── library ─────────────────────────────────────────
def _refresh_library(self): def _refresh_library(self):
self.lib_msg.value = ""
try: 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: except ApiError as e:
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)] self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
self.page.update() self.page.update()
return return
self._render_sections()
self.pipeline_section_dd.options = self._section_options(none_label="— без раздела —")
if not items: if not items:
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)] self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
else: else:
@@ -351,28 +550,49 @@ class MainView:
def _lib_card(self, v: dict) -> ft.Container: def _lib_card(self, v: dict) -> ft.Container:
created = v.get("created_at", "")[:19].replace("T", " ") 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( return ft.Container(
content=ft.Column( 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.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.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("transcription_method", "")),
self._kv("Видео", v.get("video_path", "")), self._kv("Видео", v.get("video_path") or "не сохранено"),
self._kv("Выжимка", v.get("text_summary_path", "")), 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, tight=True,
), ),
padding=12, padding=12,
@@ -381,6 +601,73 @@ class MainView:
border_radius=10, 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 ────────────────────────────────────────── # ─────────────────── helpers ──────────────────────────────────────────
def _kv(self, key: str, value: str) -> ft.Row: def _kv(self, key: str, value: str) -> ft.Row: