- таблица 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>
133 lines
4.3 KiB
Python
133 lines
4.3 KiB
Python
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import List, Literal
|
||
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
|
||
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
|
||
text_summary_path: str
|
||
transcription_method: str
|
||
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,
|
||
text_summary_path=v.text_summary_path,
|
||
transcription_method=v.transcription_method,
|
||
created_at=v.created_at,
|
||
)
|
||
|
||
|
||
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(
|
||
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)
|
||
async def get_one(video_uuid: str):
|
||
v = await get_video(video_uuid)
|
||
if not v:
|
||
raise HTTPException(status_code=404, detail="Video not found")
|
||
return _to_out(v)
|
||
|
||
|
||
@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}
|