Разделы, фильтры и просмотр текстов в 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.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
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_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))
|
||||
|
||||
Reference in New Issue
Block a user