Разделы, фильтры и просмотр текстов в 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:
@@ -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