diff --git a/api/db/connection.py b/api/db/connection.py index 4df04ab..1895d26 100644 --- a/api/db/connection.py +++ b/api/db/connection.py @@ -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" + ) + ) diff --git a/api/db/section.py b/api/db/section.py new file mode 100644 index 0000000..d574779 --- /dev/null +++ b/api/db/section.py @@ -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 diff --git a/api/db/video.py b/api/db/video.py index f48651e..d1b7264 100644 --- a/api/db/video.py +++ b/api/db/video.py @@ -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)) diff --git a/api/lib/llm.py b/api/lib/llm.py index 9c0949a..ec1a674 100644 --- a/api/lib/llm.py +++ b/api/lib/llm.py @@ -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 {}) diff --git a/api/lib/youtube.py b/api/lib/youtube.py index dd74736..44bc7c9 100644 --- a/api/lib/youtube.py +++ b/api/lib/youtube.py @@ -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 diff --git a/api/main.py b/api/main.py index ec75654..df4e0cd 100644 --- a/api/main.py +++ b/api/main.py @@ -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) diff --git a/api/route/pipeline.py b/api/route/pipeline.py index 5355134..c49c77e 100644 --- a/api/route/pipeline.py +++ b/api/route/pipeline.py @@ -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}") diff --git a/api/route/sections.py b/api/route/sections.py new file mode 100644 index 0000000..9d36138 --- /dev/null +++ b/api/route/sections.py @@ -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} diff --git a/api/route/videos.py b/api/route/videos.py index ad94229..e18de68 100644 --- a/api/route/videos.py +++ b/api/route/videos.py @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index d94c810..7daa9ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,8 @@ services: - HTTP_TIMEOUT=1800 ports: - "8550:8550" + volumes: + - ./web:/app/web depends_on: - api dns: diff --git a/web/api_client.py b/web/api_client.py index 1d7b2a2..b348110 100644 --- a/web/api_client.py +++ b/web/api_client.py @@ -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: diff --git a/web/views/main_view.py b/web/views/main_view.py index f3917c1..2faabf5 100644 --- a/web/views/main_view.py +++ b/web/views/main_view.py @@ -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: