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