Рабочий пайплайн: YouTube -> транскрипция -> выжимка -> Postgres

- новый api/: /pipeline/process (yt-dlp, субтитры или Vosk, LLM/экстрактивная
  суммаризация), /videos, /llm; старый код перенесён в api_legacy/
- web/: Flet UI (flet 0.28.3 + flet-web, порт 8550)
- Dockerfile.api: ffmpeg слоем из mwader/static-ffmpeg, pip с кэш-маунтом
- requirements/pyproject: убраны moviepy, imageio-ffmpeg, битый asyncio
- README с инструкцией запуска и загрузкой Vosk-модели

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-14 11:12:46 +05:00
parent 564c9c2d2f
commit 2697e01714
51 changed files with 2313 additions and 284 deletions

0
api/route/__init__.py Normal file
View File

View File

@@ -1,16 +1,19 @@
from fastapi import APIRouter
import toml
from pathlib import Path
import toml
from fastapi import APIRouter
router = APIRouter()
@router.get("/")
async def root():
version = "dev"
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
if pyproject_path.exists():
pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
if pyproject.exists():
try:
data = toml.load(pyproject_path)
data = toml.load(pyproject)
version = data.get("project", {}).get("version", "dev")
except Exception:
pass

View File

@@ -1,48 +0,0 @@
# Импортируем необходимые модули
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
import yt_dlp
import tempfile
from fastapi.responses import FileResponse
import os
router = APIRouter(prefix="/yt-dlp", tags=["yt-dlp"])
class DownloadRequest(BaseModel):
url: str
format: str = "best"
audio_only: bool = False
@router.post("/download")
async def download_video(data: DownloadRequest):
"""
Скачать видео по ссылке через yt-dlp и вернуть файл пользователю.
"""
save_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../data'))
os.makedirs(save_dir, exist_ok=True)
# choose output dir and format
if data.audio_only:
out_dir = os.path.join(save_dir, 'audio')
os.makedirs(out_dir, exist_ok=True)
outtmpl = os.path.join(out_dir, '%(title)s.%(ext)s')
ydl_format = data.format if data.format != "best" else "bestaudio[ext=m4a]/bestaudio"
else:
outtmpl = os.path.join(save_dir, '%(title)s.%(ext)s')
ydl_format = data.format
ydl_opts = {
'outtmpl': outtmpl,
'format': ydl_format,
# Use aria2c for parallel segmented downloading to speed up large files
'external_downloader': 'aria2c',
'external_downloader_args': ['-x', '16', '-s', '16', '-k', '1M'],
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(data.url, download=True)
filename = ydl.prepare_filename(info)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Ошибка загрузки: {e}")
if not os.path.exists(filename):
raise HTTPException(status_code=404, detail="Файл не найден после загрузки")
return FileResponse(filename, filename=os.path.basename(filename), media_type='application/octet-stream')

95
api/route/llm.py Normal file
View File

@@ -0,0 +1,95 @@
"""Endpoints for inspecting and testing the external LLM hookup.
The LLM is not hosted here — the user supplies a base URL + model + key.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.lib.llm import (
LLMConfig,
LLMError,
chat_complete,
from_settings,
list_models,
ping,
)
router = APIRouter(prefix="/llm", tags=["llm"])
class LLMConfigIn(BaseModel):
base_url: str
api_key: str = ""
# model is optional here so /llm/models can be called before a model is picked
model: str = ""
timeout: int = 120
max_tokens: int = 1000
temperature: float = 0.3
extra_headers: dict[str, str] = {}
class LLMStatus(BaseModel):
configured: bool
base_url: str | None = None
model: str | None = None
class ChatIn(BaseModel):
config: LLMConfigIn | None = None
messages: list[dict]
@router.get("/config", response_model=LLMStatus)
async def get_status():
cfg = from_settings()
if cfg is None:
return LLMStatus(configured=False)
return LLMStatus(configured=True, base_url=cfg.base_url, model=cfg.model)
@router.post("/test")
async def test_connection(cfg: LLMConfigIn | None = None):
"""Send a tiny ping to verify the endpoint accepts our requests."""
config = LLMConfig(**cfg.model_dump()) if cfg else from_settings()
if config is None:
raise HTTPException(
status_code=400,
detail="No LLM config in body and none configured in environment",
)
try:
reply = ping(config)
except LLMError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"ok": True, "model": config.model, "reply": reply}
@router.post("/models")
async def models(cfg: LLMConfigIn | None = None):
"""List available models from the configured (or supplied) endpoint."""
config = LLMConfig(**cfg.model_dump()) if cfg else from_settings()
if config is None:
raise HTTPException(
status_code=400,
detail="No LLM config in body and none configured in environment",
)
try:
items = list_models(config)
except LLMError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"models": items, "count": len(items)}
@router.post("/chat")
async def chat(body: ChatIn):
"""Pass-through chat endpoint — useful for sanity-testing prompts."""
config = LLMConfig(**body.config.model_dump()) if body.config else from_settings()
if config is None:
raise HTTPException(status_code=400, detail="No LLM config supplied")
try:
reply = chat_complete(config, body.messages)
except LLMError as e:
raise HTTPException(status_code=502, detail=str(e))
return {"reply": reply, "model": config.model}

View File

@@ -1,344 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi.responses import FileResponse
import os
from pathlib import Path
import uuid
from typing import List
import asyncio
import subprocess
import shutil
import uuid
import wave
router = APIRouter(prefix="/convert", tags=["convert"])
@router.get("/list-audio", response_model=List[str])
async def list_audio_files():
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "audio"
if not base_dir.exists():
return []
files = [f.name for f in base_dir.iterdir() if f.is_file()]
return files
class DownloadAudioRequest(BaseModel):
filename: str
@router.post("/download-audio")
async def download_audio_file(data: DownloadAudioRequest):
base_dir = Path(__file__).resolve().parent.parent.parent / "data" / "audio"
src = (base_dir / data.filename).resolve()
try:
src.relative_to(base_dir.parent)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(str(src), filename=src.name, media_type="audio/mpeg")
class VideoToMp3Request(BaseModel):
filename: str
def _run_moviepy(src: Path, dest: Path):
from moviepy import VideoFileClip
clip = VideoFileClip(str(src))
try:
if clip.audio is None:
raise RuntimeError("Source file has no audio stream")
clip.audio.write_audiofile(str(dest), codec="libmp3lame", bitrate="192k")
finally:
clip.close()
@router.post("/video-to-mp3")
async def video_to_mp3(req: VideoToMp3Request):
try:
import moviepy # noqa: F401
except Exception:
raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Rebuild image or install moviepy/imageio-ffmpeg")
data_dir = Path(__file__).resolve().parent.parent.parent / "data"
src = (data_dir / req.filename).resolve()
try:
src.relative_to(data_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
out_dir = data_dir / "audio"
out_dir.mkdir(parents=True, exist_ok=True)
dest = out_dir / f"{src.stem}.mp3"
try:
await asyncio.get_running_loop().run_in_executor(None, _run_moviepy, src, dest)
except Exception as e:
raise HTTPException(status_code=500, detail=f"conversion failed: {e}")
return FileResponse(str(dest), filename=dest.name, media_type="audio/mpeg")
class VideoToWavRequest(BaseModel):
filename: str
def _run_moviepy_to_wav(src: Path, dest: Path):
from moviepy import VideoFileClip
clip = VideoFileClip(str(src))
try:
if clip.audio is None:
raise RuntimeError("Source file has no audio stream")
clip.audio.write_audiofile(str(dest), fps=16000, nbytes=2)
finally:
clip.close()
@router.post("/video-to-wav")
async def video_to_wav(req: VideoToWavRequest):
try:
import moviepy # noqa: F401
except Exception:
raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Rebuild image or install moviepy/imageio-ffmpeg")
data_dir = Path(__file__).resolve().parent.parent.parent / "data"
src = (data_dir / req.filename).resolve()
try:
src.relative_to(data_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
out_dir = data_dir / "audio"
out_dir.mkdir(parents=True, exist_ok=True)
dest = out_dir / f"{src.stem}.wav"
try:
await asyncio.get_running_loop().run_in_executor(None, _run_moviepy_to_wav, src, dest)
except Exception as e:
raise HTTPException(status_code=500, detail=f"conversion failed: {e}")
return FileResponse(str(dest), filename=dest.name, media_type="audio/wav")
# MoviePy video-to-audio endpoints moved to api/route/moviepy.py
class Mp3ToTextRequest(BaseModel):
filename: str
model_path: str
def _run_ffmpeg(src: Path, dest: Path):
cmd = [
"ffmpeg",
"-y",
"-i",
str(src),
"-ac",
"1",
"-ar",
"16000",
"-vn",
"-f",
"wav",
str(dest),
]
subprocess.run(cmd, check=True)
@router.post("/mp3-to-text")
async def mp3_to_text(req: Mp3ToTextRequest):
logger = __import__("logging").getLogger("convert.mp3-to-text")
try:
if shutil.which("ffmpeg") is None:
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg in the environment/container.")
except Exception:
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg in the environment/container.")
data_dir = Path(__file__).resolve().parent.parent.parent / "data"
audio_dir = data_dir / "audio"
src = (audio_dir / req.filename).resolve()
try:
src.relative_to(audio_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
audio_dir.mkdir(parents=True, exist_ok=True)
dest = audio_dir / f"{src.stem}_{uuid.uuid4().hex}.wav"
try:
logger.info("Starting ffmpeg conversion %s -> %s", src.name, dest.name)
await asyncio.get_running_loop().run_in_executor(None, _run_ffmpeg, src, dest)
logger.info("ffmpeg conversion finished")
except subprocess.CalledProcessError as e:
raise HTTPException(status_code=500, detail=f"ffmpeg conversion failed: {e}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"conversion failed: {e}")
try:
from api.lib.audio_processing import VoskModelManager
except Exception as e:
try:
dest.unlink()
except Exception:
pass
raise HTTPException(status_code=500, detail=f"Internal import error: {e}")
try:
logger.info("Starting transcription stage for %s", dest.name)
project_root = Path(__file__).resolve().parent.parent.parent
models_dir = project_root / "models"
model_path = Path(req.model_path)
if not model_path.is_absolute():
model_path = project_root / req.model_path
model_path = model_path.resolve()
try:
model_path.relative_to(models_dir)
except Exception:
raise HTTPException(status_code=400, detail="model_path must point inside the project's models directory")
if not model_path.exists():
raise HTTPException(status_code=400, detail=f"Model path not found: {model_path}")
mgr = VoskModelManager(model_path)
res = mgr.transcribe_wav_with_words(dest)
text = res.get("text", "")
# Save plain transcript (no speaker split, no chapters)
text_dir = project_root / "data" / "text"
text_dir.mkdir(parents=True, exist_ok=True)
base_name = dest.stem
json_path = text_dir / f"{base_name}.json"
txt_path = text_dir / f"{base_name}.txt"
with open(json_path, "w", encoding="utf-8") as fh:
import json as _json
_json.dump({"text": text}, fh, ensure_ascii=False, indent=2)
with open(txt_path, "w", encoding="utf-8") as fh:
fh.write(text)
logger.info("Transcription stage finished for %s", dest.name)
finally:
try:
dest.unlink()
except Exception:
pass
return {"text": text}
class WavToTextRequest(BaseModel):
filename: str
model_path: str
use_embedding: bool = False
@router.post("/wav-to-text")
async def wav_to_text(req: WavToTextRequest):
logger = __import__("logging").getLogger("convert.wav-to-text")
data_dir = Path(__file__).resolve().parent.parent.parent / "data"
audio_dir = data_dir / "audio"
src = (audio_dir / req.filename).resolve()
try:
src.relative_to(audio_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
try:
from api.lib.audio_processing import VoskModelManager
except Exception as e:
raise HTTPException(status_code=500, detail=f"Internal import error: {e}")
project_root = Path(__file__).resolve().parent.parent.parent
models_dir = project_root / "models"
model_path = Path(req.model_path)
if not model_path.is_absolute():
model_path = project_root / req.model_path
model_path = model_path.resolve()
try:
model_path.relative_to(models_dir)
except Exception:
raise HTTPException(status_code=400, detail="model_path must point inside the project's models directory")
if not model_path.exists():
raise HTTPException(status_code=400, detail=f"Model path not found: {model_path}")
temp_dest = None
to_cleanup = False
try:
if shutil.which("ffmpeg"):
temp_dest = audio_dir / f"{src.stem}_mono_{uuid.uuid4().hex}.wav"
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-ac",
"1",
"-ar",
"16000",
"-sample_fmt",
"s16",
str(temp_dest),
]
try:
logger.info("Normalizing WAV via ffmpeg: %s -> %s", src.name, temp_dest.name)
subprocess.run(cmd, check=True)
src_for_transcription = temp_dest
to_cleanup = True
except subprocess.CalledProcessError as e:
raise HTTPException(status_code=500, detail=f"ffmpeg normalization failed: {e}")
else:
try:
with wave.open(str(src), "rb") as wf:
channels = wf.getnchannels()
rate = wf.getframerate()
sampwidth = wf.getsampwidth()
if channels != 1 or rate != 16000 or sampwidth != 2:
raise HTTPException(status_code=415, detail=("WAV must be mono PCM16 16kHz. "
"Install ffmpeg in the runtime or convert the file before calling this endpoint."))
src_for_transcription = src
except wave.Error as e:
raise HTTPException(status_code=400, detail=f"Invalid WAV file: {e}")
mgr = VoskModelManager(model_path)
res = mgr.transcribe_wav_with_words(src_for_transcription)
text = res.get("text", "")
words = res.get("words", [])
# Save plain transcript (no speaker split, no chapters)
text_dir = project_root / "data" / "text"
text_dir.mkdir(parents=True, exist_ok=True)
base_name = src.stem
json_path = text_dir / f"{base_name}.json"
txt_path = text_dir / f"{base_name}.txt"
import json as _json
with open(json_path, "w", encoding="utf-8") as fh:
_json.dump({"text": text}, fh, ensure_ascii=False, indent=2)
with open(txt_path, "w", encoding="utf-8") as fh:
fh.write(text)
logger.info("WAV transcription finished: %s", src.name)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Transcription failed: {e}")
finally:
if to_cleanup and temp_dest is not None:
try:
temp_dest.unlink()
except Exception:
pass
return {"text": text, "json": str(json_path.name), "txt": str(txt_path.name)}

View File

@@ -1,97 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from fastapi.responses import FileResponse
from pathlib import Path
import asyncio
router = APIRouter(prefix="/moviepy", tags=["moviepy"])
class VideoToMp3Request(BaseModel):
filename: str
class VideoToWavRequest(BaseModel):
filename: str
def _run_moviepy(src: Path, dest: Path):
from moviepy import VideoFileClip
clip = VideoFileClip(str(src))
try:
if clip.audio is None:
raise RuntimeError("Source file has no audio stream")
clip.audio.write_audiofile(str(dest), codec="libmp3lame", bitrate="192k")
finally:
clip.close()
def _run_moviepy_to_wav(src: Path, dest: Path):
from moviepy import VideoFileClip
clip = VideoFileClip(str(src))
try:
if clip.audio is None:
raise RuntimeError("Source file has no audio stream")
clip.audio.write_audiofile(str(dest), fps=16000, nbytes=2)
finally:
clip.close()
@router.post("/video-to-mp3")
async def video_to_mp3(req: VideoToMp3Request):
try:
import moviepy # noqa: F401
except Exception:
raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Install moviepy/imageio-ffmpeg")
project_root = Path(__file__).resolve().parent.parent.parent
data_dir = project_root / "data"
src = (data_dir / req.filename).resolve()
try:
src.relative_to(data_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
out_dir = data_dir / "audio"
out_dir.mkdir(parents=True, exist_ok=True)
dest = out_dir / f"{src.stem}.mp3"
try:
await asyncio.get_running_loop().run_in_executor(None, _run_moviepy, src, dest)
except Exception as e:
raise HTTPException(status_code=500, detail=f"conversion failed: {e}")
return FileResponse(str(dest), filename=dest.name, media_type="audio/mpeg")
@router.post("/video-to-wav")
async def video_to_wav(req: VideoToWavRequest):
try:
import moviepy # noqa: F401
except Exception:
raise HTTPException(status_code=503, detail="moviepy is not installed in the runtime. Install moviepy/imageio-ffmpeg")
project_root = Path(__file__).resolve().parent.parent.parent
data_dir = project_root / "data"
src = (data_dir / req.filename).resolve()
try:
src.relative_to(data_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
out_dir = data_dir / "audio"
out_dir.mkdir(parents=True, exist_ok=True)
dest = out_dir / f"{src.stem}.wav"
try:
await asyncio.get_running_loop().run_in_executor(None, _run_moviepy_to_wav, src, dest)
except Exception as e:
raise HTTPException(status_code=500, detail=f"conversion failed: {e}")
return FileResponse(str(dest), filename=dest.name, media_type="audio/wav")

View File

@@ -1,138 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from pathlib import Path
import subprocess
import shutil
import json
import logging
try:
from vosk import Model, KaldiRecognizer
except Exception:
Model = None
KaldiRecognizer = None
router = APIRouter(prefix="/convert", tags=["convert"])
class Mp3ToTextRequest(BaseModel):
filename: str
model_path: str
@router.post("/mp3-to-text-ffmpeg")
async def mp3_to_text_ffmpeg(req: Mp3ToTextRequest):
"""Convert MP3 -> PCM via ffmpeg (stdout) and transcribe with Vosk.
This endpoint requires `ffmpeg` available in PATH. It decodes the input
MP3 to raw PCM s16le 16k mono and streams it into Vosk recognizer.
"""
logger = logging.getLogger("convert.mp3")
if Model is None or KaldiRecognizer is None:
raise HTTPException(status_code=503, detail="vosk is not installed in runtime")
project_root = Path(__file__).resolve().parent.parent.parent
audio_dir = project_root / "data" / "audio"
models_dir = project_root / "models"
src = (audio_dir / req.filename).resolve()
try:
src.relative_to(audio_dir)
except Exception:
raise HTTPException(status_code=400, detail="Invalid filename")
if not src.exists() or not src.is_file():
raise HTTPException(status_code=404, detail="Source file not found")
model_path = Path(req.model_path)
if not model_path.is_absolute():
model_path = project_root / req.model_path
model_path = model_path.resolve()
try:
model_path.relative_to(models_dir)
except Exception:
raise HTTPException(status_code=400, detail="model_path must point inside the project's models directory")
if not model_path.exists():
raise HTTPException(status_code=400, detail=f"Model path not found: {model_path}")
if shutil.which("ffmpeg") is None:
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg to use this endpoint.")
try:
from api.lib.audio_processing import FFmpegConverter, VoskModelManager
except Exception as e:
raise HTTPException(status_code=500, detail=f"Internal import error: {e}")
if shutil.which("ffmpeg") is None:
raise HTTPException(status_code=503, detail="ffmpeg is not available in the runtime. Install ffmpeg to use this endpoint.")
# Stream decode via ffmpeg to temporary process and feed recognizer
try:
mgr = VoskModelManager(model_path)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load Vosk model: {e}")
cmd = [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-f",
"s16le",
"-acodec",
"pcm_s16le",
"-ac",
"1",
"-ar",
"16000",
"-",
]
logger.info("Starting ffmpeg streaming decode")
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start ffmpeg: {e}")
if proc.stdout is None:
proc.kill()
raise HTTPException(status_code=500, detail="ffmpeg did not provide stdout")
recognizer = None
try:
recognizer = KaldiRecognizer(mgr.model, 16000)
try:
recognizer.SetWords(True)
except Exception:
pass
bytes_read = 0
total = None
try:
total = src.stat().st_size
except Exception:
total = None
last_pct = 0
while True:
chunk = proc.stdout.read(4000)
if not chunk:
break
recognizer.AcceptWaveform(chunk)
bytes_read += len(chunk)
if total:
pct = int(bytes_read * 100 / total)
if pct - last_pct >= 5:
last_pct = pct
logger.info("Decoding+transcription progress: %s%%", pct)
result = json.loads(recognizer.FinalResult())
text = result.get("text", "")
logger.info("Transcription finished; result length=%d", len(text))
return {"text": text}
finally:
try:
proc.kill()
except Exception:
pass

201
api/route/pipeline.py Normal file
View File

@@ -0,0 +1,201 @@
"""End-to-end pipeline: download → subtitles-or-transcribe → summarize → persist."""
from __future__ import annotations
import asyncio
import logging
import uuid as _uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.config import settings
from api.db.video import create_video
from api.lib.llm import LLMConfig, from_settings as llm_from_settings
from api.lib.summarize import summarize
from api.lib.transcribe import transcribe_via_ffmpeg
from api.lib.youtube import download_video_with_subs, vtt_to_text
router = APIRouter(prefix="/pipeline", tags=["pipeline"])
logger = logging.getLogger("pipeline")
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_VIDEO_DIR = _PROJECT_ROOT / "data" / "videos"
_TEXT_DIR = _PROJECT_ROOT / "data" / "text"
_MODELS_DIR = _PROJECT_ROOT / "models"
class LLMOverride(BaseModel):
base_url: str
api_key: str = ""
model: str
timeout: int = 120
max_tokens: int = 1000
temperature: float = 0.3
extra_headers: dict[str, str] = {}
system_prompt: str | None = None
user_template: str | None = None
class ProcessRequest(BaseModel):
url: str
model_path: str | None = None # path inside models/, used when subtitles are unavailable
summary_max_sentences: int = 15
# If supplied, this LLM is used for the summary; otherwise env-configured LLM;
# otherwise the built-in extractive fallback.
llm: LLMOverride | None = None
class ProcessResponse(BaseModel):
uuid: str
title: str
source_url: str
video_path: str
text_full_path: str
text_summary_path: str
transcription_method: str
summary_preview: str
def _resolve_model_path(req_model_path: str | None) -> Path:
raw = req_model_path or settings.DEFAULT_VOSK_MODEL
p = Path(raw)
if not p.is_absolute():
p = _PROJECT_ROOT / raw
p = p.resolve()
try:
p.relative_to(_MODELS_DIR.resolve())
except ValueError:
raise HTTPException(
status_code=400,
detail="model_path must point inside the project's models directory",
)
if not p.exists():
raise HTTPException(status_code=400, detail=f"Model not found: {p}")
return p
@router.post("/process", response_model=ProcessResponse)
async def process_video(req: ProcessRequest):
video_uuid = _uuid.uuid4().hex
_VIDEO_DIR.mkdir(parents=True, exist_ok=True)
_TEXT_DIR.mkdir(parents=True, exist_ok=True)
loop = asyncio.get_running_loop()
logger.info("[%s] downloading %s", video_uuid, req.url)
try:
info = await loop.run_in_executor(
None,
download_video_with_subs,
req.url,
video_uuid,
_VIDEO_DIR,
settings.subtitle_langs_list,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Download failed: {e}")
title: str = info["title"]
video_path: Path = info["video_path"]
sub_path: Path | None = info["subtitle_path"]
if not video_path.exists():
raise HTTPException(status_code=500, detail="Video file missing after download")
full_text = ""
method = ""
if sub_path is not None and sub_path.exists():
try:
content = sub_path.read_text(encoding="utf-8", errors="ignore")
full_text = vtt_to_text(content)
if full_text.strip():
method = f"subtitles:{info.get('subtitle_kind') or 'auto'}:{info.get('subtitle_lang') or 'unknown'}"
logger.info("[%s] using subtitles (%s)", video_uuid, method)
except Exception as e:
logger.warning("[%s] subtitle parse failed: %s", video_uuid, e)
full_text = ""
if not full_text.strip():
logger.info("[%s] no usable subtitles, falling back to Vosk", video_uuid)
model_path = _resolve_model_path(req.model_path)
try:
full_text = await loop.run_in_executor(
None, transcribe_via_ffmpeg, video_path, model_path
)
method = f"vosk:{model_path.name}"
except Exception as e:
raise HTTPException(status_code=500, detail=f"Transcription failed: {e}")
if not full_text.strip():
raise HTTPException(
status_code=500, detail="No subtitles and transcription returned empty text"
)
text_full_path = _TEXT_DIR / f"{video_uuid}.txt"
text_summary_path = _TEXT_DIR / f"{video_uuid}_summary.txt"
text_full_path.write_text(full_text, encoding="utf-8")
if req.llm is not None:
llm_cfg = LLMConfig(
base_url=req.llm.base_url,
api_key=req.llm.api_key,
model=req.llm.model,
timeout=req.llm.timeout,
max_tokens=req.llm.max_tokens,
temperature=req.llm.temperature,
extra_headers=req.llm.extra_headers,
)
sys_p = req.llm.system_prompt
user_t = req.llm.user_template
else:
llm_cfg = llm_from_settings()
sys_p = None
user_t = None
try:
summary = await loop.run_in_executor(
None,
lambda: summarize(
full_text,
llm_cfg=llm_cfg,
system_prompt=sys_p,
user_template=user_t,
max_sentences=req.summary_max_sentences,
),
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Summarization failed: {e}")
text_summary_path.write_text(summary, encoding="utf-8")
rel_video = str(video_path.relative_to(_PROJECT_ROOT))
rel_full = str(text_full_path.relative_to(_PROJECT_ROOT))
rel_summary = str(text_summary_path.relative_to(_PROJECT_ROOT))
try:
await create_video(
uuid=video_uuid,
source_url=req.url,
title=title,
video_path=rel_video,
text_full_path=rel_full,
text_summary_path=rel_summary,
transcription_method=method,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"DB insert failed: {e}")
logger.info("[%s] done (method=%s)", video_uuid, method)
return ProcessResponse(
uuid=video_uuid,
title=title,
source_url=req.url,
video_path=rel_video,
text_full_path=rel_full,
text_summary_path=rel_summary,
transcription_method=method,
summary_preview=summary[:300],
)

View File

@@ -1,94 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from api.db.subscription import (
create_subscription,
get_subscription_by_user,
get_subscription_by_uuid,
update_subscription,
delete_subscription,
list_subscriptions,
extend_subscription_by_user,
)
import uuid
from datetime import datetime, timedelta
router = APIRouter(prefix="/subscription", tags=["subscription"])
class SubscriptionInfo(BaseModel):
user_id: str
uuid: str
until: datetime
active: bool
subscription_link: Optional[str] = None
connect_link: Optional[str] = None
class SubscriptionCreate(BaseModel):
user_id: str
months: int
subscription_link: Optional[str] = None
connect_link: Optional[str] = None
class SubscriptionUpdate(BaseModel):
until: Optional[datetime]
user_id: Optional[str]
active: Optional[bool] = None
subscription_link: Optional[str] = None
connect_link: Optional[str] = None
@router.get("/{user_id}", response_model=Optional[SubscriptionInfo])
async def get_user_subscription(user_id: str):
sub = await get_subscription_by_user(user_id)
if not sub:
return None
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until)
@router.post("/new", response_model=SubscriptionInfo)
async def create_subscription_endpoint(data: SubscriptionCreate):
sub_id = str(uuid.uuid4())
until = datetime.now() + timedelta(days=30 * data.months)
sub = await create_subscription(data.user_id, sub_id, until, subscription_link=data.subscription_link, connect_link=data.connect_link, active=True)
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until)
@router.post("/extend", response_model=SubscriptionInfo)
async def extend_user_subscription(data: SubscriptionCreate):
sub = await extend_subscription_by_user(data.user_id, data.months)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
@router.get("/by-uuid/{sub_uuid}", response_model=Optional[SubscriptionInfo])
async def get_subscription_uuid(sub_uuid: str):
sub = await get_subscription_by_uuid(sub_uuid)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
@router.patch("/{sub_uuid}", response_model=SubscriptionInfo)
async def update_subscription_endpoint(sub_uuid: str, data: SubscriptionUpdate):
sub = await update_subscription(sub_uuid, until=data.until, user_id=data.user_id, active=data.active, subscription_link=data.subscription_link, connect_link=data.connect_link)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found")
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
@router.delete("/{sub_uuid}")
async def delete_subscription_endpoint(sub_uuid: str):
ok = await delete_subscription(sub_uuid)
if not ok:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"ok": True}
@router.get("/", response_model=List[SubscriptionInfo])
async def list_all_subscriptions():
subs = await list_subscriptions()
return [SubscriptionInfo(user_id=s.user_id, uuid=s.uuid, until=s.until) for s in subs]

View File

@@ -1,107 +0,0 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, List
from api.db.user import create_user, get_user, get_user_by_telegram, update_user, delete_user, list_users
from api.db.subscription import (
create_subscription,
get_subscription_by_user,
update_subscription,
delete_subscription,
)
from api.route.subscription import SubscriptionInfo
router = APIRouter(prefix="/users", tags=["users"])
class UserOut(BaseModel):
id: str
telegram_id: str
name: Optional[str]
subscription: Optional[SubscriptionInfo] = None
class UserCreate(BaseModel):
telegram_id: str
name: Optional[str]
class UserUpdate(BaseModel):
name: Optional[str]
@router.post("/", response_model=UserOut)
async def api_create_user(data: UserCreate):
existing = await get_user_by_telegram(data.telegram_id)
if existing:
raise HTTPException(status_code=400, detail="User already exists")
u = await create_user(data.telegram_id, data.name)
return UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
@router.get("/{user_id}", response_model=UserOut)
async def api_get_user(user_id: str, expand: Optional[str] = None):
u = await get_user(user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
out = UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
if expand == "subscription":
sub = await get_subscription_by_user(u.id)
if sub:
out.subscription = SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
return out
@router.post("/{user_id}/subscription", response_model=SubscriptionInfo)
async def api_create_user_subscription(user_id: str, data: dict):
# expected keys: months, subscription_link (opt), connect_link (opt)
months = int(data.get("months", 1))
subscription_link = data.get("subscription_link")
connect_link = data.get("connect_link")
import uuid as _uuid
sub_id = str(_uuid.uuid4())
from datetime import datetime, timedelta
until = datetime.now() + timedelta(days=30 * months)
sub = await create_subscription(user_id, sub_id, until, subscription_link=subscription_link, connect_link=connect_link, active=True)
return SubscriptionInfo(user_id=sub.user_id, uuid=sub.uuid, until=sub.until, active=sub.active, subscription_link=sub.subscription_link, connect_link=sub.connect_link)
@router.patch("/{user_id}/subscription", response_model=SubscriptionInfo)
async def api_update_user_subscription(user_id: str, data: dict):
sub = await get_subscription_by_user(user_id)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found for user")
updated = await update_subscription(sub.uuid, until=data.get("until"), user_id=data.get("user_id"), active=data.get("active"), subscription_link=data.get("subscription_link"), connect_link=data.get("connect_link"))
return SubscriptionInfo(user_id=updated.user_id, uuid=updated.uuid, until=updated.until, active=updated.active, subscription_link=updated.subscription_link, connect_link=updated.connect_link)
@router.delete("/{user_id}/subscription")
async def api_delete_user_subscription(user_id: str):
sub = await get_subscription_by_user(user_id)
if not sub:
raise HTTPException(status_code=404, detail="Subscription not found for user")
ok = await delete_subscription(sub.uuid)
if not ok:
raise HTTPException(status_code=500, detail="Failed to delete subscription")
return {"ok": True}
@router.get("/", response_model=List[UserOut])
async def api_list_users():
users = await list_users()
return [UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name) for u in users]
@router.patch("/{user_id}", response_model=UserOut)
async def api_update_user(user_id: str, data: UserUpdate):
u = await update_user(user_id, name=data.name)
if not u:
raise HTTPException(status_code=404, detail="User not found")
return UserOut(id=u.id, telegram_id=u.telegram_id, name=u.name)
@router.delete("/{user_id}")
async def api_delete_user(user_id: str):
ok = await delete_user(user_id)
if not ok:
raise HTTPException(status_code=404, detail="User not found")
return {"ok": True}

55
api/route/videos.py Normal file
View File

@@ -0,0 +1,55 @@
from datetime import datetime
from typing import List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.db.video import delete_video, get_video, list_videos
router = APIRouter(prefix="/videos", tags=["videos"])
class VideoOut(BaseModel):
uuid: str
source_url: str
title: str
video_path: str
text_full_path: str
text_summary_path: str
transcription_method: str
created_at: datetime
def _to_out(v) -> VideoOut:
return VideoOut(
uuid=v.uuid,
source_url=v.source_url,
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,
)
@router.get("/", response_model=List[VideoOut])
async def list_all():
return [_to_out(v) for v in await list_videos()]
@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.delete("/{video_uuid}")
async def delete_one(video_uuid: str):
ok = await delete_video(video_uuid)
if not ok:
raise HTTPException(status_code=404, detail="Video not found")
return {"ok": True}