Разделы, фильтры и просмотр текстов в 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:
jze9
2026-07-14 12:07:55 +05:00
parent 05ba753ade
commit bf854f2d6e
12 changed files with 612 additions and 43 deletions

View File

@@ -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"
)
)

57
api/db/section.py Normal file
View 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

View File

@@ -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))