- таблица jobs + очередь с воркером: POST /jobs сразу отвечает, элементы
плейлиста обрабатываются последовательно, есть отмена и живой прогресс
(скачивание %, минуты распознанного Vosk-аудио)
- expand_url: плейлист раскрывается в список видео
- keep_video=false теперь качает только аудио (для 4-5ч лекций)
- настройки (LLM-ключ, модель, длина выжимки, хранение видео, путь Vosk)
хранятся в Postgres: переживают перезапуск и перезагрузку страницы
- длина выжимки прокидывается в LLM-промпт ({n} предложений)
- UI: вкладки «Главная»/«Настройки», карточка задач с автообновлением
каждые 3 сек, прогресс-бар, отмена, библиотека обновляется по мере
готовности элементов
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
3.1 KiB
Python
113 lines
3.1 KiB
Python
"""Фоновые задачи: видео или плейлист любой длины.
|
|
|
|
POST ставит задачу в очередь и сразу возвращает id; прогресс — в GET /jobs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid as _uuid
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from api.db.job import create_job, delete_job, get_job, list_jobs
|
|
from api.lib import jobqueue
|
|
|
|
|
|
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
|
|
|
|
|
class JobIn(BaseModel):
|
|
url: str
|
|
section_id: int | None = None
|
|
# None → берутся из сохранённых настроек
|
|
keep_video: bool | None = None
|
|
summary_max_sentences: int | None = None
|
|
model_path: str | None = None
|
|
|
|
|
|
class JobOut(BaseModel):
|
|
id: str
|
|
url: str
|
|
kind: str
|
|
title: str
|
|
status: str
|
|
total_items: int
|
|
done_items: int
|
|
failed_items: int
|
|
current_item: str
|
|
live: str
|
|
error: str
|
|
created_at: datetime
|
|
|
|
|
|
def _to_out(j) -> JobOut:
|
|
return JobOut(
|
|
id=j.id,
|
|
url=j.url,
|
|
kind=j.kind,
|
|
title=j.title,
|
|
status=j.status,
|
|
total_items=j.total_items,
|
|
done_items=j.done_items,
|
|
failed_items=j.failed_items,
|
|
current_item=j.current_item,
|
|
live=jobqueue.live_status(j.id) or j.current_item,
|
|
error=j.error,
|
|
created_at=j.created_at,
|
|
)
|
|
|
|
|
|
@router.post("/", response_model=JobOut)
|
|
async def create(body: JobIn):
|
|
url = body.url.strip()
|
|
if not url:
|
|
raise HTTPException(status_code=400, detail="URL is empty")
|
|
job_id = _uuid.uuid4().hex
|
|
j = await create_job(job_id, url)
|
|
jobqueue.submit(
|
|
job_id,
|
|
{
|
|
"section_id": body.section_id,
|
|
"keep_video": body.keep_video,
|
|
"summary_max_sentences": body.summary_max_sentences,
|
|
"model_path": body.model_path,
|
|
},
|
|
)
|
|
return _to_out(j)
|
|
|
|
|
|
@router.get("/", response_model=list[JobOut])
|
|
async def list_all(limit: int = 50):
|
|
return [_to_out(j) for j in await list_jobs(limit)]
|
|
|
|
|
|
@router.get("/{job_id}", response_model=JobOut)
|
|
async def get_one(job_id: str):
|
|
j = await get_job(job_id)
|
|
if j is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
return _to_out(j)
|
|
|
|
|
|
@router.post("/{job_id}/cancel")
|
|
async def cancel(job_id: str):
|
|
j = await get_job(job_id)
|
|
if j is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
if j.status not in ("queued", "running"):
|
|
raise HTTPException(status_code=400, detail=f"Job is {j.status}")
|
|
jobqueue.request_cancel(job_id)
|
|
return {"ok": True, "note": "остановится после текущего элемента"}
|
|
|
|
|
|
@router.delete("/{job_id}")
|
|
async def delete(job_id: str):
|
|
j = await get_job(job_id)
|
|
if j is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
if j.status in ("queued", "running"):
|
|
raise HTTPException(status_code=400, detail="Сначала отмените задачу")
|
|
await delete_job(job_id)
|
|
return {"ok": True}
|