Рабочий пайплайн: 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:
17
api_legacy/route/default.py
Normal file
17
api_legacy/route/default.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
import toml
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
version = "dev"
|
||||
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
||||
if pyproject_path.exists():
|
||||
try:
|
||||
data = toml.load(pyproject_path)
|
||||
version = data.get("project", {}).get("version", "dev")
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "msg": "LLM-infa API", "version": version}
|
||||
48
api_legacy/route/installing.py
Normal file
48
api_legacy/route/installing.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Импортируем необходимые модули
|
||||
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')
|
||||
344
api_legacy/route/media_convert.py
Normal file
344
api_legacy/route/media_convert.py
Normal file
@@ -0,0 +1,344 @@
|
||||
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)}
|
||||
97
api_legacy/route/moviepy.py
Normal file
97
api_legacy/route/moviepy.py
Normal file
@@ -0,0 +1,97 @@
|
||||
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")
|
||||
138
api_legacy/route/mp3_ffmpeg_stream.py
Normal file
138
api_legacy/route/mp3_ffmpeg_stream.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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
|
||||
94
api_legacy/route/subscription.py
Normal file
94
api_legacy/route/subscription.py
Normal file
@@ -0,0 +1,94 @@
|
||||
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]
|
||||
107
api_legacy/route/users.py
Normal file
107
api_legacy/route/users.py
Normal file
@@ -0,0 +1,107 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user