Разделы, фильтры и просмотр текстов в 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:
@@ -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
57
api/db/section.py
Normal 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
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {})
|
||||||
|
|||||||
@@ -38,19 +38,29 @@ 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
|
||||||
|
# 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)
|
info = ydl.extract_info(url, download=True)
|
||||||
|
|
||||||
title = info.get("title") or video_id
|
title = info.get("title") or video_id
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
if req.keep_video:
|
||||||
rel_video = str(video_path.relative_to(_PROJECT_ROOT))
|
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
44
api/route/sections.py
Normal 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}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.Row(
|
||||||
[
|
[
|
||||||
ft.Text(
|
ft.Text(
|
||||||
v.get("title") or v.get("uuid", ""),
|
v.get("title") or v.get("uuid", ""),
|
||||||
color=d.TEXT,
|
color=d.TEXT,
|
||||||
size=14,
|
size=14,
|
||||||
weight=ft.FontWeight.W_600,
|
weight=ft.FontWeight.W_600,
|
||||||
|
expand=True,
|
||||||
no_wrap=True,
|
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(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=3,
|
spacing=8,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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:
|
||||||
|
|||||||
Reference in New Issue
Block a user