Compare commits
2 Commits
564c9c2d2f
...
05ba753ade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ba753ade | ||
|
|
2697e01714 |
24
.env
24
.env
@@ -1,5 +1,27 @@
|
||||
# Настройки базы данных
|
||||
# Postgres
|
||||
DB_USER=postgres
|
||||
DB_PASS=secretpassword
|
||||
DB_NAME=test_db
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Pipeline defaults
|
||||
DEFAULT_VOSK_MODEL=models/vosk-model-small-ru-0.22
|
||||
SUBTITLE_LANGS=ru,en
|
||||
|
||||
# External LLM (OpenAI-compatible API). The LLM is NOT hosted here.
|
||||
# Leave empty to fall back to the built-in extractive summarizer.
|
||||
# Examples:
|
||||
# OpenAI: LLM_BASE_URL=https://api.openai.com/v1
|
||||
# LLM_MODEL=gpt-4o-mini
|
||||
# OpenRouter: LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
# LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# Ollama: LLM_BASE_URL=http://host.docker.internal:11434/v1
|
||||
# LLM_MODEL=llama3.1
|
||||
# vLLM: LLM_BASE_URL=http://vllm-host:8000/v1
|
||||
LLM_BASE_URL=
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=
|
||||
LLM_TIMEOUT=120
|
||||
LLM_MAX_TOKENS=1000
|
||||
LLM_TEMPERATURE=0.3
|
||||
|
||||
@@ -1,51 +1,16 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
|
||||
# Builder: install build deps and build wheels
|
||||
FROM python:3.14-slim AS builder
|
||||
FROM python:3.14-slim
|
||||
WORKDIR /app
|
||||
|
||||
#RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# build-essential \
|
||||
# ca-certificates \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
#
|
||||
# Static ffmpeg/ffprobe as a cached image layer — no apt, no slow mirror downloads
|
||||
COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /ffprobe /usr/local/bin/
|
||||
|
||||
# Deps first for layer caching; BuildKit cache mount speeds up rebuilds
|
||||
COPY requirements.txt ./
|
||||
# Build wheels into /wheels (use cache for pip downloads)
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip wheel --no-cache-dir -r requirements.txt -w /wheels
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Application code (data/, models/, pg_data/ excluded via .dockerignore)
|
||||
COPY . .
|
||||
|
||||
# Final image: small and without build tools
|
||||
FROM python:3.14-slim AS final
|
||||
WORKDIR /app
|
||||
|
||||
## minimal runtime deps (use static ffmpeg to avoid many apt deps)
|
||||
#RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# ca-certificates \
|
||||
# curl \
|
||||
# && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy wheels from builder and install
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir /wheels/* || pip install --no-cache-dir /wheels/* --no-deps
|
||||
|
||||
# Download a static ffmpeg build (faster than installing via apt and avoids many deps)
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
if [ "$arch" = "amd64" ]; then \
|
||||
FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"; \
|
||||
else \
|
||||
echo "Unsupported arch $arch"; exit 1; \
|
||||
fi; \
|
||||
curl -fsSL "$FFMPEG_URL" -o /tmp/ffmpeg.tar.xz; \
|
||||
tar -xJf /tmp/ffmpeg.tar.xz -C /tmp; \
|
||||
cp /tmp/ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/; \
|
||||
cp /tmp/ffmpeg-*-amd64-static/ffprobe /usr/local/bin/; \
|
||||
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe; \
|
||||
rm -rf /tmp/ffmpeg*
|
||||
|
||||
# Copy application code (exclude models/data via .dockerignore)
|
||||
COPY . ./api
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
14
Dockerfile.web
Normal file
14
Dockerfile.web
Normal file
@@ -0,0 +1,14 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
# install deps separately for layer caching (flet is ~100 MB)
|
||||
COPY web/requirements.txt /tmp/web-req.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r /tmp/web-req.txt
|
||||
|
||||
COPY web /app/web
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
EXPOSE 8550
|
||||
CMD ["python", "-m", "web.main"]
|
||||
71
README.md
71
README.md
@@ -0,0 +1,71 @@
|
||||
# LLM-infa
|
||||
|
||||
Пайплайн: **YouTube → скачивание видео → текст речи → LLM-выжимка → Postgres**.
|
||||
|
||||
Скачивает видео (yt-dlp), достаёт текст из субтитров (или распознаёт речь через Vosk,
|
||||
если субтитров нет), сокращает текст до краткой выжимки (через любой
|
||||
OpenAI-совместимый LLM API или встроенный экстрактивный алгоритм) и складывает всё
|
||||
в базу: пути к видео, полному тексту и выжимке.
|
||||
|
||||
## Состав
|
||||
|
||||
| Сервис | Порт | Что делает |
|
||||
|--------|------|------------|
|
||||
| `api` | 8000 | FastAPI: пайплайн, каталог видео, проверка LLM |
|
||||
| `web` | 8550 | Flet UI: запуск пайплайна и библиотека результатов |
|
||||
| `db` | 5433→5432 | Postgres 15, таблица `videos` |
|
||||
|
||||
Файлы складываются в `data/videos/` (mp4) и `data/text/` (`<uuid>.txt` — полный
|
||||
текст, `<uuid>_summary.txt` — выжимка); в БД хранятся пути и метаданные.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
# 1. Модель Vosk для распознавания без субтитров (однократно, ~90 МБ)
|
||||
mkdir -p models && cd models
|
||||
curl -LO https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip
|
||||
unzip vosk-model-small-ru-0.22.zip && rm vosk-model-small-ru-0.22.zip && cd ..
|
||||
|
||||
# 2. Поднять стек
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
UI: http://localhost:8550 · API-доки: http://localhost:8000/docs
|
||||
|
||||
## LLM для выжимки
|
||||
|
||||
LLM не разворачивается в контейнере — указывается любой внешний
|
||||
OpenAI-совместимый эндпоинт. Два способа:
|
||||
|
||||
1. **В `.env`** (используется по умолчанию для всех запросов):
|
||||
```
|
||||
LLM_BASE_URL=https://openrouter.ai/api/v1
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_MODEL=anthropic/claude-haiku-4.5
|
||||
```
|
||||
2. **Через UI / поле `llm` в запросе** — на каждый запрос отдельно.
|
||||
|
||||
Если LLM не настроена, работает встроенная экстрактивная суммаризация
|
||||
(выбор ключевых предложений, без нейросети).
|
||||
|
||||
## API
|
||||
|
||||
```bash
|
||||
# обработать видео целиком
|
||||
curl -X POST localhost:8000/pipeline/process \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url": "https://youtu.be/...", "summary_max_sentences": 10}'
|
||||
|
||||
# каталог обработанных видео
|
||||
curl localhost:8000/videos/
|
||||
|
||||
# проверить связь с LLM
|
||||
curl -X POST localhost:8000/llm/test
|
||||
```
|
||||
|
||||
## Заметки
|
||||
|
||||
- Старый код (users/subscriptions/media-convert) лежит в `api_legacy/` и в
|
||||
сборку не входит.
|
||||
- Если Docker Desktop не тянет образы (TLS timeout к registry), использовать
|
||||
системный демон: `docker context use default`.
|
||||
|
||||
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
@@ -1,28 +1,38 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
def _env(*names, default=None):
|
||||
for n in names:
|
||||
v = os.getenv(n)
|
||||
if v:
|
||||
return v
|
||||
return default
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# support both custom DB_* names and common POSTGRES_* names from postgres images
|
||||
DB_USER: str = _env("DB_USER", "POSTGRES_USER", default="postgres")
|
||||
DB_PASS: str = _env("DB_PASS", "POSTGRES_PASSWORD", default="postgres")
|
||||
DB_NAME: str = _env("DB_NAME", "POSTGRES_DB", default="test_db")
|
||||
DB_HOST: str = _env("DB_HOST", "POSTGRES_HOST", "DB_HOST", default="db")
|
||||
DB_PORT: str = _env("DB_PORT", "POSTGRES_PORT", default="5432")
|
||||
DB_USER: str = os.getenv("DB_USER", "postgres")
|
||||
DB_PASS: str = os.getenv("DB_PASS", "postgres")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "test_db")
|
||||
DB_HOST: str = os.getenv("DB_HOST", "db")
|
||||
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
||||
|
||||
# If explicit DB_* variables are provided, build DATABASE_URL from them (priority).
|
||||
# Otherwise fall back to explicit DATABASE_URL or DATABASE_URL_ASYNC env.
|
||||
_built_db_url: str | None = None
|
||||
if DB_USER and DB_PASS and DB_NAME and DB_HOST and DB_PORT:
|
||||
_built_db_url = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
DEFAULT_VOSK_MODEL: str = os.getenv(
|
||||
"DEFAULT_VOSK_MODEL", "models/vosk-model-small-ru-0.22"
|
||||
)
|
||||
SUBTITLE_LANGS: str = os.getenv("SUBTITLE_LANGS", "ru,en")
|
||||
|
||||
DATABASE_URL: str = _built_db_url or _env("DATABASE_URL", "DATABASE_URL_ASYNC", default="")
|
||||
# External LLM (OpenAI-compatible). The LLM itself is NOT hosted in this container —
|
||||
# point at any provider/proxy/local server that speaks /v1/chat/completions.
|
||||
LLM_BASE_URL: str = os.getenv("LLM_BASE_URL", "")
|
||||
LLM_API_KEY: str = os.getenv("LLM_API_KEY", "")
|
||||
LLM_MODEL: str = os.getenv("LLM_MODEL", "")
|
||||
LLM_TIMEOUT: int = int(os.getenv("LLM_TIMEOUT", "120"))
|
||||
LLM_MAX_TOKENS: int = int(os.getenv("LLM_MAX_TOKENS", "1000"))
|
||||
LLM_TEMPERATURE: float = float(os.getenv("LLM_TEMPERATURE", "0.3"))
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return (
|
||||
f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}"
|
||||
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
)
|
||||
|
||||
@property
|
||||
def subtitle_langs_list(self) -> list[str]:
|
||||
return [x.strip() for x in self.SUBTITLE_LANGS.split(",") if x.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
0
api/db/__init__.py
Normal file
0
api/db/__init__.py
Normal file
@@ -1,27 +1,63 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
from api.config import settings
|
||||
import logging
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
import asyncpg
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from api.config import settings
|
||||
from api.models.base import Base
|
||||
import api.models.video # noqa: F401 - register model with Base.metadata
|
||||
|
||||
|
||||
logger = logging.getLogger("db")
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False, future=True)
|
||||
SessionLocal = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def wait_for_db(retries: int = 30, delay: float = 1.0) -> None:
|
||||
"""Wait until the database is available. Retries with exponential backoff.
|
||||
async def wait_for_postgres(retries: int = 30, delay: float = 1.0) -> None:
|
||||
"""Wait for the postgres server to accept connections (using the default 'postgres' db)."""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
conn = await asyncpg.connect(
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASS,
|
||||
database="postgres",
|
||||
host=settings.DB_HOST,
|
||||
port=int(settings.DB_PORT),
|
||||
)
|
||||
await conn.close()
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
wait = min(delay * (2 ** (attempt - 1)), 5)
|
||||
logger.info("Waiting for postgres (attempt %s/%s): %s", attempt, retries, e)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError(f"Could not connect to postgres after {retries} attempts") from last_exc
|
||||
|
||||
Raises RuntimeError if DB is still unavailable after retries.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
wait = min(delay * (2 ** (attempt - 1)), 5)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError(f"Could not connect to DB after {retries} attempts") from last_exc
|
||||
|
||||
async def _create_database_if_missing() -> None:
|
||||
admin = await asyncpg.connect(
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASS,
|
||||
database="postgres",
|
||||
host=settings.DB_HOST,
|
||||
port=int(settings.DB_PORT),
|
||||
)
|
||||
try:
|
||||
exists = await admin.fetchval(
|
||||
"SELECT 1 FROM pg_database WHERE datname=$1", settings.DB_NAME
|
||||
)
|
||||
if not exists:
|
||||
await admin.execute(f'CREATE DATABASE "{settings.DB_NAME}"')
|
||||
finally:
|
||||
await admin.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
await _create_database_if_missing()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
53
api/db/video.py
Normal file
53
api/db/video.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.db.connection import SessionLocal
|
||||
from api.models.video import Video
|
||||
|
||||
|
||||
async def create_video(
|
||||
*,
|
||||
uuid: str,
|
||||
source_url: str,
|
||||
title: str,
|
||||
video_path: str,
|
||||
text_full_path: str,
|
||||
text_summary_path: str,
|
||||
transcription_method: str,
|
||||
) -> Video:
|
||||
async with SessionLocal() as session:
|
||||
v = Video(
|
||||
uuid=uuid,
|
||||
source_url=source_url,
|
||||
title=title or "",
|
||||
video_path=video_path or "",
|
||||
text_full_path=text_full_path or "",
|
||||
text_summary_path=text_summary_path or "",
|
||||
transcription_method=transcription_method or "",
|
||||
)
|
||||
session.add(v)
|
||||
await session.commit()
|
||||
await session.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
async def get_video(video_uuid: str) -> Video | None:
|
||||
async with SessionLocal() as session:
|
||||
res = await session.execute(select(Video).where(Video.uuid == video_uuid))
|
||||
return res.scalars().first()
|
||||
|
||||
|
||||
async def list_videos() -> list[Video]:
|
||||
async with SessionLocal() as session:
|
||||
res = await session.execute(select(Video).order_by(Video.created_at.desc()))
|
||||
return list(res.scalars().all())
|
||||
|
||||
|
||||
async def delete_video(video_uuid: str) -> bool:
|
||||
async with SessionLocal() as session:
|
||||
res = await session.execute(select(Video).where(Video.uuid == video_uuid))
|
||||
v = res.scalars().first()
|
||||
if not v:
|
||||
return False
|
||||
await session.delete(v)
|
||||
await session.commit()
|
||||
return True
|
||||
0
api/lib/__init__.py
Normal file
0
api/lib/__init__.py
Normal file
164
api/lib/llm.py
Normal file
164
api/lib/llm.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Generic OpenAI-compatible chat-completions client.
|
||||
|
||||
The LLM is *not* deployed in this container — point this client at any external
|
||||
endpoint that speaks the OpenAI Chat Completions wire format:
|
||||
- https://api.openai.com/v1
|
||||
- https://openrouter.ai/api/v1
|
||||
- https://api.anthropic.com (via OpenAI-compat proxies)
|
||||
- http://host.docker.internal:11434/v1 (local Ollama)
|
||||
- http://vllm-host:8000/v1 (vLLM / llama.cpp server / LM Studio)
|
||||
|
||||
Configure once via environment (LLM_BASE_URL, LLM_API_KEY, LLM_MODEL),
|
||||
or override per-request through /pipeline/process by passing an `llm` block.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger("llm")
|
||||
|
||||
|
||||
class LLMConfig(BaseModel):
|
||||
"""Wire-level config for an OpenAI-compatible chat-completions endpoint."""
|
||||
|
||||
base_url: str = Field(..., description="Base URL ending in /v1 (or equivalent)")
|
||||
api_key: str = ""
|
||||
# `model` is optional so the same config can be used for /v1/models discovery
|
||||
# before the user has picked one.
|
||||
model: str = ""
|
||||
timeout: int = 120
|
||||
max_tokens: int = 1000
|
||||
temperature: float = 0.3
|
||||
extra_headers: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LLMError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def from_settings() -> Optional[LLMConfig]:
|
||||
"""Build LLMConfig from environment variables, or return None if not configured."""
|
||||
if not settings.LLM_BASE_URL or not settings.LLM_MODEL:
|
||||
return None
|
||||
return LLMConfig(
|
||||
base_url=settings.LLM_BASE_URL,
|
||||
api_key=settings.LLM_API_KEY,
|
||||
model=settings.LLM_MODEL,
|
||||
timeout=settings.LLM_TIMEOUT,
|
||||
max_tokens=settings.LLM_MAX_TOKENS,
|
||||
temperature=settings.LLM_TEMPERATURE,
|
||||
)
|
||||
|
||||
|
||||
def chat_complete(cfg: LLMConfig, messages: list[dict]) -> str:
|
||||
"""Synchronous Chat Completions call. Returns assistant message content."""
|
||||
url = cfg.base_url.rstrip("/") + "/chat/completions"
|
||||
payload: dict = {
|
||||
"model": cfg.model,
|
||||
"messages": messages,
|
||||
"max_tokens": cfg.max_tokens,
|
||||
"temperature": cfg.temperature,
|
||||
}
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if cfg.api_key:
|
||||
headers["Authorization"] = f"Bearer {cfg.api_key}"
|
||||
headers.update(cfg.extra_headers or {})
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=cfg.timeout) as resp:
|
||||
raw = resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
err_body = ""
|
||||
raise LLMError(f"LLM HTTP {e.code} {e.reason}: {err_body[:500]}")
|
||||
except Exception as e:
|
||||
raise LLMError(f"LLM request failed: {e}")
|
||||
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
raise LLMError(f"LLM returned no choices: {data}")
|
||||
msg = choices[0].get("message") or {}
|
||||
content = msg.get("content")
|
||||
if content is None:
|
||||
raise LLMError(f"LLM choice has no content: {choices[0]}")
|
||||
return content.strip()
|
||||
except LLMError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise LLMError(f"Failed to parse LLM response: {e}")
|
||||
|
||||
|
||||
def ping(cfg: LLMConfig) -> str:
|
||||
"""Tiny round-trip to verify connectivity. Returns the assistant reply text."""
|
||||
return chat_complete(
|
||||
cfg,
|
||||
[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Reply with the single word OK."},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def list_models(cfg: LLMConfig) -> list[dict]:
|
||||
"""GET {base_url}/models. Returns a normalized list of {id, name, owned_by?}.
|
||||
|
||||
Compatible with OpenAI, OpenRouter, Ollama OpenAI-compat, vLLM, LM Studio.
|
||||
"""
|
||||
url = cfg.base_url.rstrip("/") + "/models"
|
||||
headers = {"Accept": "application/json"}
|
||||
if cfg.api_key:
|
||||
headers["Authorization"] = f"Bearer {cfg.api_key}"
|
||||
headers.update(cfg.extra_headers or {})
|
||||
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=cfg.timeout) as resp:
|
||||
raw = resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
err_body = ""
|
||||
raise LLMError(f"LLM HTTP {e.code} {e.reason}: {err_body[:500]}")
|
||||
except Exception as e:
|
||||
raise LLMError(f"List models failed: {e}")
|
||||
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except Exception as e:
|
||||
raise LLMError(f"Failed to parse models response: {e}")
|
||||
|
||||
items = data.get("data") or data.get("models") or data.get("results") or []
|
||||
out: list[dict] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
out.append({"id": item, "name": item})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mid = item.get("id") or item.get("name") or item.get("model")
|
||||
if not mid:
|
||||
continue
|
||||
entry = {"id": mid, "name": item.get("name") or mid}
|
||||
owned = item.get("owned_by") or item.get("organization")
|
||||
if owned:
|
||||
entry["owned_by"] = owned
|
||||
out.append(entry)
|
||||
out.sort(key=lambda x: x["id"].lower())
|
||||
return out
|
||||
221
api/lib/summarize.py
Normal file
221
api/lib/summarize.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Summarization with two backends:
|
||||
|
||||
* `summarize_llm` — uses any external OpenAI-compatible API (configured via
|
||||
`LLMConfig`). Long transcripts are map-reduced: per-chunk summaries are
|
||||
concatenated and re-summarized.
|
||||
* `summarize_extractive` — pure-Python TF-IDF-ish fallback (RU + EN aware).
|
||||
|
||||
`summarize(text, llm_cfg=...)` picks the LLM path when a config is supplied,
|
||||
otherwise falls back to extractive. The pipeline calls this entry point.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Optional
|
||||
|
||||
from api.lib.llm import LLMConfig, LLMError, chat_complete
|
||||
|
||||
|
||||
logger = logging.getLogger("summarize")
|
||||
|
||||
|
||||
_RU_STOPWORDS = {
|
||||
"и", "в", "во", "не", "на", "я", "что", "тот", "быть", "с", "со", "а", "весь",
|
||||
"как", "это", "но", "он", "она", "оно", "мы", "вы", "они", "к", "у", "из", "за",
|
||||
"по", "до", "от", "для", "же", "или", "бы", "если", "так", "там", "тут", "когда",
|
||||
"где", "кто", "какой", "этот", "эта", "эти", "мой", "твой", "наш", "ваш",
|
||||
"свой", "его", "её", "их", "был", "была", "было", "были", "есть", "нет", "да",
|
||||
"ну", "вот", "ещё", "еще", "уже", "только", "очень", "может", "можно", "надо",
|
||||
"будет", "будут", "всё", "все", "этого", "этом", "этой", "тоже", "также", "чтобы",
|
||||
"кого", "чего", "чем", "тем", "том", "нем", "ней", "себя", "себе", "мне", "тебе",
|
||||
"нам", "вам", "нас", "вас", "про", "без", "над", "под", "через", "при", "об",
|
||||
"о", "обо", "ли", "вон", "сюда", "туда", "оттуда", "потом", "теперь", "тогда",
|
||||
"сейчас", "иногда", "всегда", "никогда", "просто", "именно", "ведь", "значит",
|
||||
"хотя", "что-то", "кто-то",
|
||||
}
|
||||
|
||||
_EN_STOPWORDS = {
|
||||
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were", "be", "been",
|
||||
"being", "have", "has", "had", "do", "does", "did", "will", "would", "should",
|
||||
"could", "may", "might", "must", "shall", "can", "i", "you", "he", "she", "it",
|
||||
"we", "they", "them", "my", "your", "his", "her", "its", "our", "their", "this",
|
||||
"that", "these", "those", "in", "on", "at", "by", "for", "with", "about", "as",
|
||||
"of", "to", "from", "into", "so", "not", "no", "yes", "if", "then", "than",
|
||||
"when", "where", "why", "how", "what", "which", "who", "whom", "whose", "me",
|
||||
"us", "him", "just", "only", "also", "too", "very", "there", "here", "out",
|
||||
"up", "down", "over", "under", "between", "through", "again", "more", "most",
|
||||
"some", "any", "all", "each", "every", "such", "while", "because", "until",
|
||||
"during", "above", "below", "now", "ever", "never",
|
||||
}
|
||||
|
||||
_STOPWORDS = _RU_STOPWORDS | _EN_STOPWORDS
|
||||
|
||||
_WORD_RE = re.compile(r"[\w']+", re.UNICODE)
|
||||
_SENT_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+(?=[А-ЯA-Z\"«„])")
|
||||
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = (
|
||||
"Ты ассистент, который делает краткую и точную выжимку текста. "
|
||||
"Сохраняй ключевые факты, имена, цифры, причинно-следственные связи. "
|
||||
"Если оригинал на русском — пиши по-русски, иначе — на языке оригинала. "
|
||||
"Не добавляй информацию, которой нет в исходном тексте."
|
||||
)
|
||||
|
||||
DEFAULT_USER_TEMPLATE = (
|
||||
"Сделай связную краткую выжимку следующего текста. "
|
||||
"Выдели основные тезисы и логику изложения. Объём — 5–12 предложений.\n\n"
|
||||
"---\n{text}\n---"
|
||||
)
|
||||
|
||||
DEFAULT_REDUCE_TEMPLATE = (
|
||||
"Ниже — последовательность кратких выжимок частей одного длинного текста. "
|
||||
"Объедини их в единую связную выжимку, сохранив главные факты и логику.\n\n"
|
||||
"---\n{text}\n---"
|
||||
)
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> list[str]:
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if not text:
|
||||
return []
|
||||
parts = _SENT_SPLIT_RE.split(text)
|
||||
if len(parts) <= 1:
|
||||
parts = re.split(r"(?<=[.!?…])\s+", text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _chunk_by_chars(text: str, max_chars: int) -> list[str]:
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
chunks: list[str] = []
|
||||
cur: list[str] = []
|
||||
cur_len = 0
|
||||
for s in _split_sentences(text):
|
||||
if cur_len + len(s) + 1 > max_chars and cur:
|
||||
chunks.append(" ".join(cur))
|
||||
cur, cur_len = [], 0
|
||||
cur.append(s)
|
||||
cur_len += len(s) + 1
|
||||
if cur:
|
||||
chunks.append(" ".join(cur))
|
||||
return chunks
|
||||
|
||||
|
||||
def summarize_extractive(
|
||||
text: str,
|
||||
*,
|
||||
max_sentences: int = 15,
|
||||
min_sentences: int = 3,
|
||||
ratio: float = 0.15,
|
||||
) -> str:
|
||||
"""Frequency-based extractive summary. Picks top sentences by mean word weight."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
sentences = _split_sentences(text)
|
||||
if not sentences:
|
||||
return ""
|
||||
|
||||
target = max(min_sentences, min(max_sentences, int(len(sentences) * ratio)))
|
||||
target = max(1, min(target, len(sentences)))
|
||||
if len(sentences) <= target:
|
||||
return " ".join(sentences)
|
||||
|
||||
words = _WORD_RE.findall(text.lower())
|
||||
freq = Counter(w for w in words if w not in _STOPWORDS and len(w) > 2)
|
||||
if not freq:
|
||||
return " ".join(sentences[:target])
|
||||
|
||||
max_f = max(freq.values())
|
||||
norm = {w: c / max_f for w, c in freq.items()}
|
||||
|
||||
scored: list[tuple[int, float]] = []
|
||||
for i, s in enumerate(sentences):
|
||||
sw = _WORD_RE.findall(s.lower())
|
||||
if not sw:
|
||||
continue
|
||||
score = sum(norm.get(w, 0.0) for w in sw) / len(sw)
|
||||
scored.append((i, score))
|
||||
if not scored:
|
||||
return " ".join(sentences[:target])
|
||||
|
||||
top = sorted(scored, key=lambda x: -x[1])[:target]
|
||||
top.sort(key=lambda x: x[0])
|
||||
return " ".join(sentences[i] for i, _ in top)
|
||||
|
||||
|
||||
def summarize_llm(
|
||||
text: str,
|
||||
cfg: LLMConfig,
|
||||
*,
|
||||
system_prompt: Optional[str] = None,
|
||||
user_template: Optional[str] = None,
|
||||
reduce_template: Optional[str] = None,
|
||||
chunk_chars: int = 10000,
|
||||
) -> str:
|
||||
"""Summarize via an external OpenAI-compatible API. Map-reduce for long texts."""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
sys_p = system_prompt or DEFAULT_SYSTEM_PROMPT
|
||||
user_t = user_template or DEFAULT_USER_TEMPLATE
|
||||
reduce_t = reduce_template or DEFAULT_REDUCE_TEMPLATE
|
||||
|
||||
def _one(chunk: str, template: str) -> str:
|
||||
return chat_complete(
|
||||
cfg,
|
||||
[
|
||||
{"role": "system", "content": sys_p},
|
||||
{"role": "user", "content": template.format(text=chunk)},
|
||||
],
|
||||
)
|
||||
|
||||
chunks = _chunk_by_chars(text, chunk_chars)
|
||||
logger.info("LLM summarize: %d chunk(s), total chars=%d", len(chunks), len(text))
|
||||
|
||||
if len(chunks) == 1:
|
||||
return _one(chunks[0], user_t)
|
||||
|
||||
partials: list[str] = []
|
||||
for i, ch in enumerate(chunks, start=1):
|
||||
logger.info("LLM summarize chunk %d/%d", i, len(chunks))
|
||||
partials.append(_one(ch, user_t))
|
||||
|
||||
combined = "\n\n".join(partials)
|
||||
if len(combined) > chunk_chars:
|
||||
# If even concatenated partials are too long, recurse: summarize partials in batches.
|
||||
return summarize_llm(
|
||||
combined,
|
||||
cfg,
|
||||
system_prompt=sys_p,
|
||||
user_template=reduce_t,
|
||||
reduce_template=reduce_t,
|
||||
chunk_chars=chunk_chars,
|
||||
)
|
||||
logger.info("LLM reduce step over %d partials", len(partials))
|
||||
return _one(combined, reduce_t)
|
||||
|
||||
|
||||
def summarize(
|
||||
text: str,
|
||||
*,
|
||||
llm_cfg: Optional[LLMConfig] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
user_template: Optional[str] = None,
|
||||
max_sentences: int = 15,
|
||||
) -> str:
|
||||
"""Primary entry point. Uses LLM if configured, else extractive fallback."""
|
||||
if llm_cfg is not None:
|
||||
try:
|
||||
return summarize_llm(
|
||||
text,
|
||||
llm_cfg,
|
||||
system_prompt=system_prompt,
|
||||
user_template=user_template,
|
||||
)
|
||||
except LLMError as e:
|
||||
logger.warning("LLM summarization failed, falling back to extractive: %s", e)
|
||||
return summarize_extractive(text, max_sentences=max_sentences)
|
||||
119
api/lib/transcribe.py
Normal file
119
api/lib/transcribe.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Vosk-based fallback transcription: stream PCM from ffmpeg into a Vosk recognizer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from vosk import KaldiRecognizer, Model
|
||||
except Exception:
|
||||
Model = None
|
||||
KaldiRecognizer = None
|
||||
|
||||
|
||||
logger = logging.getLogger("transcribe")
|
||||
|
||||
|
||||
class VoskModelManager:
|
||||
"""Loads a Vosk model. Captures the model's stderr to Python logs while loading."""
|
||||
|
||||
def __init__(self, model_path: Path):
|
||||
if Model is None or KaldiRecognizer is None:
|
||||
raise RuntimeError("vosk is not installed in runtime")
|
||||
self.model = self._load_with_stderr_capture(model_path)
|
||||
|
||||
@staticmethod
|
||||
def _load_with_stderr_capture(path: Path):
|
||||
log = logging.getLogger("vosk.loader")
|
||||
r_fd, w_fd = os.pipe()
|
||||
saved_stderr = os.dup(sys.stderr.fileno())
|
||||
os.dup2(w_fd, sys.stderr.fileno())
|
||||
os.close(w_fd)
|
||||
|
||||
def reader(fd: int) -> None:
|
||||
with os.fdopen(fd, "rb") as fh:
|
||||
for raw in iter(fh.readline, b""):
|
||||
log.info(raw.decode(errors="ignore").rstrip())
|
||||
|
||||
t = threading.Thread(target=reader, args=(r_fd,), daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
return Model(str(path))
|
||||
finally:
|
||||
try:
|
||||
os.dup2(saved_stderr, sys.stderr.fileno())
|
||||
finally:
|
||||
os.close(saved_stderr)
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
def transcribe_via_ffmpeg(media_path: Path, model_path: Path, sample_rate: int = 16000) -> str:
|
||||
"""Decode the source media to PCM s16le mono via ffmpeg and feed it to Vosk in chunks."""
|
||||
if Model is None or KaldiRecognizer is None:
|
||||
raise RuntimeError("vosk is not installed in runtime")
|
||||
if shutil.which("ffmpeg") is None:
|
||||
raise RuntimeError("ffmpeg is not available in PATH")
|
||||
if not media_path.exists():
|
||||
raise FileNotFoundError(media_path)
|
||||
|
||||
mgr = VoskModelManager(model_path)
|
||||
recognizer = KaldiRecognizer(mgr.model, sample_rate)
|
||||
try:
|
||||
recognizer.SetWords(True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(media_path),
|
||||
"-f",
|
||||
"s16le",
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-vn",
|
||||
"-",
|
||||
]
|
||||
logger.info("Spawning ffmpeg streaming decode for %s", media_path.name)
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if proc.stdout is None:
|
||||
proc.kill()
|
||||
raise RuntimeError("ffmpeg did not provide stdout")
|
||||
|
||||
try:
|
||||
bytes_read = 0
|
||||
last_logged_mb = 0
|
||||
while True:
|
||||
chunk = proc.stdout.read(4000)
|
||||
if not chunk:
|
||||
break
|
||||
recognizer.AcceptWaveform(chunk)
|
||||
bytes_read += len(chunk)
|
||||
mb = bytes_read // (1024 * 1024)
|
||||
if mb >= last_logged_mb + 5:
|
||||
last_logged_mb = mb
|
||||
logger.info("Transcribed %d MB of audio so far", mb)
|
||||
|
||||
result = json.loads(recognizer.FinalResult())
|
||||
text = result.get("text", "")
|
||||
logger.info("Transcription finished, length=%d", len(text))
|
||||
return text
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
172
api/lib/youtube.py
Normal file
172
api/lib/youtube.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""yt-dlp wrapper: download a YouTube video together with available subtitles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yt_dlp
|
||||
|
||||
|
||||
logger = logging.getLogger("youtube")
|
||||
|
||||
|
||||
_VIDEO_EXTS = {".mp4", ".mkv", ".webm", ".m4a", ".mp3", ".mov"}
|
||||
|
||||
|
||||
def download_video_with_subs(
|
||||
url: str,
|
||||
video_id: str,
|
||||
video_dir: Path,
|
||||
langs: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Download a single video, also requesting subtitles for the given languages.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"title": str,
|
||||
"video_path": Path,
|
||||
"subtitle_path": Path | None,
|
||||
"subtitle_lang": str | None,
|
||||
"subtitle_kind": "manual" | "auto" | None,
|
||||
}
|
||||
"""
|
||||
if langs is None:
|
||||
langs = ["ru", "en"]
|
||||
video_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
outtmpl = str(video_dir / f"{video_id}.%(ext)s")
|
||||
|
||||
ydl_opts = {
|
||||
"outtmpl": outtmpl,
|
||||
"format": "bv*+ba/b",
|
||||
"merge_output_format": "mp4",
|
||||
"writesubtitles": True,
|
||||
"writeautomaticsub": True,
|
||||
"subtitleslangs": langs,
|
||||
"subtitlesformat": "vtt",
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
|
||||
title = info.get("title") or video_id
|
||||
|
||||
# Locate the merged video file
|
||||
video_path = video_dir / f"{video_id}.mp4"
|
||||
if not video_path.exists():
|
||||
for p in video_dir.glob(f"{video_id}.*"):
|
||||
if p.suffix.lower() in _VIDEO_EXTS:
|
||||
video_path = p
|
||||
break
|
||||
|
||||
sub_path, sub_lang, sub_kind = _find_subtitle(video_dir, video_id, langs, info)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"video_path": video_path,
|
||||
"subtitle_path": sub_path,
|
||||
"subtitle_lang": sub_lang,
|
||||
"subtitle_kind": sub_kind,
|
||||
}
|
||||
|
||||
|
||||
def _find_subtitle(
|
||||
video_dir: Path,
|
||||
video_id: str,
|
||||
langs: list[str],
|
||||
info: dict,
|
||||
) -> tuple[Optional[Path], Optional[str], Optional[str]]:
|
||||
"""Pick the best subtitle file among downloaded ones, preferring manual subs."""
|
||||
requested = info.get("requested_subtitles") or {}
|
||||
manual_keys = set(info.get("subtitles", {}).keys())
|
||||
|
||||
# Build a list of (lang, kind, path) candidates from yt-dlp's reported requested_subtitles
|
||||
candidates: list[tuple[str, str, Path]] = []
|
||||
for lang_code, sub_info in requested.items():
|
||||
filepath = sub_info.get("filepath")
|
||||
if not filepath:
|
||||
continue
|
||||
p = Path(filepath)
|
||||
if not p.exists():
|
||||
continue
|
||||
kind = "manual" if lang_code in manual_keys else "auto"
|
||||
candidates.append((lang_code, kind, p))
|
||||
|
||||
# Fallback: glob the directory
|
||||
if not candidates:
|
||||
for p in video_dir.glob(f"{video_id}*.vtt"):
|
||||
m = re.match(rf"^{re.escape(video_id)}\.([A-Za-z0-9_\-]+)\.vtt$", p.name)
|
||||
lang_code = m.group(1) if m else "unknown"
|
||||
candidates.append((lang_code, "auto", p))
|
||||
|
||||
if not candidates:
|
||||
return None, None, None
|
||||
|
||||
def score(c: tuple[str, str, Path]) -> tuple[int, int]:
|
||||
lang, kind, _ = c
|
||||
try:
|
||||
lang_rank = next(
|
||||
i for i, l in enumerate(langs) if lang == l or lang.startswith(l + "-")
|
||||
)
|
||||
except StopIteration:
|
||||
lang_rank = len(langs)
|
||||
kind_rank = 0 if kind == "manual" else 1
|
||||
return (lang_rank, kind_rank)
|
||||
|
||||
candidates.sort(key=score)
|
||||
lang, kind, path = candidates[0]
|
||||
return path, lang, kind
|
||||
|
||||
|
||||
def vtt_to_text(vtt_content: str) -> str:
|
||||
"""Convert a VTT subtitle file to a deduplicated plain-text string.
|
||||
|
||||
YouTube auto-captions ship as rolling cues — each new cue extends the previous.
|
||||
Strategy: per cue block, keep only the last text line, then drop consecutive
|
||||
duplicates and identical sentence fragments.
|
||||
"""
|
||||
blocks = re.split(r"\r?\n\s*\r?\n", vtt_content)
|
||||
out: list[str] = []
|
||||
last = ""
|
||||
|
||||
for block in blocks:
|
||||
block = block.strip()
|
||||
if not block:
|
||||
continue
|
||||
text_lines: list[str] = []
|
||||
for raw in block.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("WEBVTT"):
|
||||
continue
|
||||
if line.startswith(("NOTE", "STYLE", "Kind:", "Language:", "Region:")):
|
||||
continue
|
||||
if "-->" in line:
|
||||
continue
|
||||
if re.match(r"^\d+$", line):
|
||||
continue
|
||||
line = re.sub(r"<[^>]+>", "", line)
|
||||
line = re.sub(r"\s+", " ", line).strip()
|
||||
if line:
|
||||
text_lines.append(line)
|
||||
if not text_lines:
|
||||
continue
|
||||
candidate = text_lines[-1]
|
||||
if candidate == last:
|
||||
continue
|
||||
# Avoid the rolling-caption case where a cue strictly extends the previous one
|
||||
if last and candidate.startswith(last):
|
||||
out[-1] = candidate
|
||||
last = candidate
|
||||
continue
|
||||
if last and last.startswith(candidate):
|
||||
continue
|
||||
out.append(candidate)
|
||||
last = candidate
|
||||
|
||||
return " ".join(out)
|
||||
40
api/main.py
40
api/main.py
@@ -1,22 +1,28 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from api.route import default, subscription
|
||||
# renamed route modules for clarity
|
||||
from api.route import users, installing, media_convert, mp3_ffmpeg_stream, moviepy
|
||||
#from api.db.subscription import init_db
|
||||
#from api.db.connection import wait_for_db
|
||||
|
||||
app = FastAPI(title="LLM-infa API")
|
||||
from api.db.connection import wait_for_postgres, init_db
|
||||
from api.route import default, llm, pipeline, videos
|
||||
|
||||
#@app.on_event("startup")
|
||||
#async def on_startup():
|
||||
# await wait_for_db()
|
||||
# await init_db()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await wait_for_postgres()
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="LLM-infa API", version="0.3.0", lifespan=lifespan)
|
||||
|
||||
app.include_router(default.router)
|
||||
#app.include_router(subscription.router)
|
||||
#app.include_router(users.router)
|
||||
app.include_router(installing.router)
|
||||
app.include_router(media_convert.router)
|
||||
app.include_router(mp3_ffmpeg_stream.router)
|
||||
app.include_router(moviepy.router)
|
||||
app.include_router(pipeline.router)
|
||||
app.include_router(videos.router)
|
||||
app.include_router(llm.router)
|
||||
|
||||
0
api/route/__init__.py
Normal file
0
api/route/__init__.py
Normal 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
|
||||
|
||||
95
api/route/llm.py
Normal file
95
api/route/llm.py
Normal 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}
|
||||
201
api/route/pipeline.py
Normal file
201
api/route/pipeline.py
Normal 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],
|
||||
)
|
||||
55
api/route/videos.py
Normal file
55
api/route/videos.py
Normal 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}
|
||||
28
api_legacy/config.py
Normal file
28
api_legacy/config.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
import os
|
||||
|
||||
def _env(*names, default=None):
|
||||
for n in names:
|
||||
v = os.getenv(n)
|
||||
if v:
|
||||
return v
|
||||
return default
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# support both custom DB_* names and common POSTGRES_* names from postgres images
|
||||
DB_USER: str = _env("DB_USER", "POSTGRES_USER", default="postgres")
|
||||
DB_PASS: str = _env("DB_PASS", "POSTGRES_PASSWORD", default="postgres")
|
||||
DB_NAME: str = _env("DB_NAME", "POSTGRES_DB", default="test_db")
|
||||
DB_HOST: str = _env("DB_HOST", "POSTGRES_HOST", "DB_HOST", default="db")
|
||||
DB_PORT: str = _env("DB_PORT", "POSTGRES_PORT", default="5432")
|
||||
|
||||
# If explicit DB_* variables are provided, build DATABASE_URL from them (priority).
|
||||
# Otherwise fall back to explicit DATABASE_URL or DATABASE_URL_ASYNC env.
|
||||
_built_db_url: str | None = None
|
||||
if DB_USER and DB_PASS and DB_NAME and DB_HOST and DB_PORT:
|
||||
_built_db_url = f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||
|
||||
DATABASE_URL: str = _built_db_url or _env("DATABASE_URL", "DATABASE_URL_ASYNC", default="")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
27
api_legacy/db/connection.py
Normal file
27
api_legacy/db/connection.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
import asyncio
|
||||
from api.config import settings
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
SessionLocal = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
async def wait_for_db(retries: int = 30, delay: float = 1.0) -> None:
|
||||
"""Wait until the database is available. Retries with exponential backoff.
|
||||
|
||||
Raises RuntimeError if DB is still unavailable after retries.
|
||||
"""
|
||||
last_exc = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
wait = min(delay * (2 ** (attempt - 1)), 5)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError(f"Could not connect to DB after {retries} attempts") from last_exc
|
||||
22
api_legacy/main.py
Normal file
22
api_legacy/main.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import asyncio
|
||||
from fastapi import FastAPI
|
||||
from api.route import default, subscription
|
||||
# renamed route modules for clarity
|
||||
from api.route import users, installing, media_convert, mp3_ffmpeg_stream, moviepy
|
||||
#from api.db.subscription import init_db
|
||||
#from api.db.connection import wait_for_db
|
||||
|
||||
app = FastAPI(title="LLM-infa API")
|
||||
|
||||
#@app.on_event("startup")
|
||||
#async def on_startup():
|
||||
# await wait_for_db()
|
||||
# await init_db()
|
||||
|
||||
app.include_router(default.router)
|
||||
#app.include_router(subscription.router)
|
||||
#app.include_router(users.router)
|
||||
app.include_router(installing.router)
|
||||
app.include_router(media_convert.router)
|
||||
app.include_router(mp3_ffmpeg_stream.router)
|
||||
app.include_router(moviepy.router)
|
||||
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}
|
||||
@@ -1,20 +1,4 @@
|
||||
|
||||
services:
|
||||
#bot:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.bot
|
||||
# container_name: telegram_bot
|
||||
# restart: no
|
||||
# env_file:
|
||||
# - .env
|
||||
# environment:
|
||||
# - BOT_TOKEN=${BOT_TOKEN}
|
||||
# command: python -m bot.main
|
||||
# depends_on:
|
||||
# - db
|
||||
# volumes:
|
||||
# - .:/app
|
||||
|
||||
api:
|
||||
build:
|
||||
@@ -31,28 +15,46 @@ services:
|
||||
- .:/app
|
||||
- ./data:/app/data
|
||||
- ./models:/app/models
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
|
||||
#db:
|
||||
# image: postgres:15-alpine
|
||||
# container_name: db
|
||||
# restart: always
|
||||
# environment:
|
||||
# POSTGRES_USER: ${DB_USER}
|
||||
# POSTGRES_PASSWORD: ${DB_PASS}
|
||||
# POSTGRES_DB: ${DB_NAME}
|
||||
# volumes:
|
||||
# - ./pg_data:/var/lib/postgresql/data
|
||||
# ports:
|
||||
# - "5433:5432"
|
||||
# healthcheck:
|
||||
# test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
# interval: 5s
|
||||
# timeout: 5s
|
||||
# retries: 10
|
||||
# start_period: 10s
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
container_name: web
|
||||
restart: no
|
||||
environment:
|
||||
- API_BASE_URL=http://api:8000
|
||||
- PORT=8550
|
||||
- HTTP_TIMEOUT=1800
|
||||
ports:
|
||||
- "8550:8550"
|
||||
depends_on:
|
||||
- api
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 1.1.1.1
|
||||
|
||||
#volumes:
|
||||
# pg_data:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
volumes:
|
||||
- ./pg_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
[project]
|
||||
name = "llm-infa"
|
||||
version = "0.2.0"
|
||||
description = "Add your description here"
|
||||
version = "0.3.0"
|
||||
description = "YouTube -> транскрипция -> LLM-выжимка -> Postgres"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"asyncio>=4.0.0",
|
||||
"asyncpg>=0.31.0",
|
||||
"fastapi>=0.129.0",
|
||||
"moviepy>=2.2.1",
|
||||
"flet>=0.28.3",
|
||||
"pydantic>=2.12.5",
|
||||
"pydantic-settings>=2.13.0",
|
||||
"sqlalchemy>=2.0.46",
|
||||
@@ -17,3 +16,11 @@ dependencies = [
|
||||
"vosk>=0.3.45",
|
||||
"yt-dlp>=2026.2.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
# Flet UI (запускается в контейнере web на python 3.12; классический API flet)
|
||||
web = [
|
||||
"flet==0.28.3",
|
||||
"flet-web==0.28.3",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,4 @@ toml
|
||||
pydantic
|
||||
pydantic-settings
|
||||
yt-dlp
|
||||
moviepy
|
||||
imageio-ffmpeg
|
||||
vosk
|
||||
vosk
|
||||
|
||||
354
uv.lock
generated
354
uv.lock
generated
@@ -32,15 +32,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncio"
|
||||
version = "4.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/ea/26c489a11f7ca862d5705db67683a7361ce11c23a7b98fc6c2deaeccede2/asyncio-4.0.0.tar.gz", hash = "sha256:570cd9e50db83bc1629152d4d0b7558d6451bb1bfd5dfc2e935d96fc2f40329b", size = 5371, upload-time = "2025-08-05T02:51:46.605Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/64/eff2564783bd650ca25e15938d1c5b459cda997574a510f7de69688cb0b4/asyncio-4.0.0-py3-none-any.whl", hash = "sha256:c1eddb0659231837046809e68103969b2bef8b0400d59cfa6363f6b5ed8cc88b", size = 5555, upload-time = "2025-08-05T02:51:45.767Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
@@ -153,15 +144,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decorator"
|
||||
version = "5.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.129.0"
|
||||
@@ -178,6 +160,32 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flet"
|
||||
version = "0.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", marker = "platform_system != 'Pyodide'" },
|
||||
{ name = "oauthlib", marker = "platform_system != 'Pyodide'" },
|
||||
{ name = "repath" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/d0/9ba4ee34972e9e0cf54b1f7d17c695491632421f81301993f2aec8d12105/flet-0.28.3-py3-none-any.whl", hash = "sha256:649bfc4af7933956ecf44963df6c0d997bff9ceeaf89d3c86d96803840cab83e", size = 463000, upload-time = "2025-05-20T19:44:58.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flet-web"
|
||||
version = "0.28.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "flet" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8a/01f73ae7123090b2974f0c5ba70d7d9fc463f47f6e12daeca59fdc40e1a9/flet_web-0.28.3-py3-none-any.whl", hash = "sha256:919c13f374e7cee539d29a8ccd6decb739528ef257c88e60af4df1ebab045bfb", size = 3137744, upload-time = "2025-05-20T19:27:32.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.1"
|
||||
@@ -187,7 +195,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
|
||||
@@ -196,7 +203,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
|
||||
@@ -212,6 +218,56 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -221,42 +277,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imageio"
|
||||
version = "2.37.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imageio-ffmpeg"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llm-infa"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "asyncio" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "moviepy" },
|
||||
{ name = "flet" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "sqlalchemy" },
|
||||
@@ -266,12 +294,18 @@ dependencies = [
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
web = [
|
||||
{ name = "flet" },
|
||||
{ name = "flet-web" },
|
||||
{ name = "httpx" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "asyncio", specifier = ">=4.0.0" },
|
||||
{ name = "asyncpg", specifier = ">=0.31.0" },
|
||||
{ name = "fastapi", specifier = ">=0.129.0" },
|
||||
{ name = "moviepy", specifier = ">=2.2.1" },
|
||||
{ name = "flet", specifier = ">=0.28.3" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.13.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||
@@ -281,93 +315,20 @@ requires-dist = [
|
||||
{ name = "yt-dlp", specifier = ">=2026.2.4" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moviepy"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "decorator" },
|
||||
{ name = "imageio" },
|
||||
{ name = "imageio-ffmpeg" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pillow" },
|
||||
{ name = "proglog" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/61/15f9476e270f64c78a834e7459ca045d669f869cec24eed26807b8cd479d/moviepy-2.2.1.tar.gz", hash = "sha256:c80cb56815ece94e5e3e2d361aa40070eeb30a09d23a24c4e684d03e16deacb1", size = 58431438, upload-time = "2025-05-21T19:31:52.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/73/7d3b2010baa0b5eb1e4dfa9e4385e89b6716be76f2fa21a6c0fe34b68e5a/moviepy-2.2.1-py3-none-any.whl", hash = "sha256:6b56803fec2ac54b557404126ac1160e65448e03798fa282bd23e8fab3795060", size = 129871, upload-time = "2025-05-21T19:31:50.11Z" },
|
||||
[package.metadata.requires-dev]
|
||||
web = [
|
||||
{ name = "flet", specifier = "==0.28.3" },
|
||||
{ name = "flet-web", specifier = "==0.28.3" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.2"
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proglog"
|
||||
version = "0.1.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/af/c108866c452eda1132f3d6b3cb6be2ae8430c97e9309f38ca9dbd430af37/proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c", size = 8794, upload-time = "2025-05-09T14:36:18.316Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/1b/f7ea6cde25621cd9236541c66ff018f4268012a534ec31032bcb187dc5e7/proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c", size = 6337, upload-time = "2025-05-09T14:36:16.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -456,6 +417,44 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "repath"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/e1/824989291d0f01886074fdf9504ba54598f5665bc4dd373b589b87e76608/repath-0.9.0.tar.gz", hash = "sha256:8292139bac6a0e43fd9d70605d4e8daeb25d46672e484ed31a24c7ce0aef0fb7", size = 5492, upload-time = "2019-10-08T00:25:22.3Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ed/92e9b8a3ffc562f21df14ef2538f54e911df29730e1f0d79130a4edc86e7/repath-0.9.0-py3-none-any.whl", hash = "sha256:ee079d6c91faeb843274d22d8f786094ee01316ecfe293a1eb6546312bb6a318", size = 4738, upload-time = "2019-10-08T00:25:20.842Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
@@ -471,6 +470,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.46"
|
||||
@@ -577,6 +585,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vosk"
|
||||
version = "0.3.45"
|
||||
@@ -595,6 +634,53 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/4c/deb0861f7da9696f8a255f1731bb73e9412cca29c4b3888a3fcb2a930a59/vosk-0.3.45-py3-none-win_amd64.whl", hash = "sha256:6994ddc68556c7e5730c3b6f6bad13320e3519b13ce3ed2aa25a86724e7c10ac", size = 13997596, upload-time = "2022-12-14T23:13:31.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
|
||||
0
web/__init__.py
Normal file
0
web/__init__.py
Normal file
79
web/api_client.py
Normal file
79
web/api_client.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Thin httpx wrapper for the FastAPI backend.
|
||||
|
||||
The browser never talks to FastAPI directly — Flet's Python process is the
|
||||
only client of the API. So no CORS, no auth surface exposed publicly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from web.config import API_BASE_URL, HTTP_TIMEOUT
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str = API_BASE_URL, timeout: float = HTTP_TIMEOUT):
|
||||
self._client = httpx.Client(base_url=base_url, timeout=timeout)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
# --- LLM ---
|
||||
|
||||
def llm_status(self) -> dict:
|
||||
return self._get("/llm/config")
|
||||
|
||||
def llm_models(self, base_url: str, api_key: str) -> list[dict]:
|
||||
data = self._post(
|
||||
"/llm/models",
|
||||
{"base_url": base_url, "api_key": api_key},
|
||||
)
|
||||
return data.get("models", [])
|
||||
|
||||
def llm_test(self, base_url: str, api_key: str, model: str) -> dict:
|
||||
return self._post(
|
||||
"/llm/test",
|
||||
{"base_url": base_url, "api_key": api_key, "model": model},
|
||||
)
|
||||
|
||||
# --- Pipeline / videos ---
|
||||
|
||||
def pipeline_process(self, payload: dict) -> dict:
|
||||
return self._post("/pipeline/process", payload)
|
||||
|
||||
def list_videos(self) -> list[dict]:
|
||||
return self._get("/videos/")
|
||||
|
||||
# --- internals ---
|
||||
|
||||
def _get(self, path: str) -> dict | list:
|
||||
try:
|
||||
r = self._client.get(path)
|
||||
except httpx.HTTPError as e:
|
||||
raise ApiError(f"Сеть: {e}")
|
||||
return self._unwrap(r)
|
||||
|
||||
def _post(self, path: str, json: dict | None) -> dict:
|
||||
try:
|
||||
r = self._client.post(path, json=json)
|
||||
except httpx.HTTPError as e:
|
||||
raise ApiError(f"Сеть: {e}")
|
||||
return self._unwrap(r)
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(r: httpx.Response):
|
||||
try:
|
||||
data = r.json()
|
||||
except Exception:
|
||||
data = None
|
||||
if r.is_success:
|
||||
return data
|
||||
detail = ""
|
||||
if isinstance(data, dict):
|
||||
detail = str(data.get("detail") or data.get("message") or "")
|
||||
if not detail:
|
||||
detail = r.text[:300] if r.text else r.reason_phrase
|
||||
raise ApiError(f"HTTP {r.status_code}: {detail}")
|
||||
12
web/config.py
Normal file
12
web/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
|
||||
# URL of the FastAPI service inside the docker network.
|
||||
# In docker-compose this resolves to the `api` service container.
|
||||
API_BASE_URL = os.getenv("API_BASE_URL", "http://api:8000")
|
||||
|
||||
# Port on which the Flet web server listens (exposed by docker-compose).
|
||||
PORT = int(os.getenv("PORT", "8550"))
|
||||
|
||||
# Pipeline can take many minutes for long videos; keep the HTTP timeout generous.
|
||||
HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "1800"))
|
||||
91
web/designer.py
Normal file
91
web/designer.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Centralized colors and reusable Flet widgets.
|
||||
|
||||
Palette is inspired by the dark-purple theme of the related project
|
||||
(api-copp): #2F184B base surface, #9b72cf accent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import flet as ft
|
||||
|
||||
|
||||
BG = "#14101e"
|
||||
SURFACE = "#2f184b"
|
||||
SURFACE_2 = "#3a2160"
|
||||
SURFACE_3 = "#1c1230"
|
||||
BORDER = "#4a2f6f"
|
||||
ACCENT = "#9b72cf"
|
||||
ACCENT_2 = "#c0a4e8"
|
||||
TEXT = "#ece5f5"
|
||||
MUTED = "#a89ec0"
|
||||
DANGER = "#e35a72"
|
||||
OK = "#6dd58c"
|
||||
|
||||
|
||||
def card(*controls: ft.Control, padding: int = 22) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Column(controls=list(controls), spacing=12, tight=True),
|
||||
bgcolor=SURFACE,
|
||||
border=ft.border.all(1, BORDER),
|
||||
border_radius=12,
|
||||
padding=padding,
|
||||
)
|
||||
|
||||
|
||||
def primary_button(text: str, on_click) -> ft.ElevatedButton:
|
||||
return ft.ElevatedButton(
|
||||
text=text,
|
||||
on_click=on_click,
|
||||
bgcolor=ACCENT,
|
||||
color="#ffffff",
|
||||
style=ft.ButtonStyle(
|
||||
shape=ft.RoundedRectangleBorder(radius=8),
|
||||
padding=ft.padding.symmetric(horizontal=18, vertical=12),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def secondary_button(text: str, on_click) -> ft.OutlinedButton:
|
||||
return ft.OutlinedButton(
|
||||
text=text,
|
||||
on_click=on_click,
|
||||
style=ft.ButtonStyle(
|
||||
color=TEXT,
|
||||
side=ft.BorderSide(1, BORDER),
|
||||
shape=ft.RoundedRectangleBorder(radius=8),
|
||||
padding=ft.padding.symmetric(horizontal=18, vertical=12),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def text_field(
|
||||
label: str,
|
||||
value: str = "",
|
||||
hint: str | None = None,
|
||||
password: bool = False,
|
||||
on_change=None,
|
||||
expand: bool | int | None = True,
|
||||
) -> ft.TextField:
|
||||
return ft.TextField(
|
||||
label=label,
|
||||
value=value,
|
||||
hint_text=hint or "",
|
||||
password=password,
|
||||
can_reveal_password=password,
|
||||
on_change=on_change,
|
||||
bgcolor=SURFACE_3,
|
||||
color=TEXT,
|
||||
border_color=BORDER,
|
||||
focused_border_color=ACCENT,
|
||||
cursor_color=ACCENT_2,
|
||||
label_style=ft.TextStyle(color=MUTED, size=12),
|
||||
text_size=14,
|
||||
expand=expand,
|
||||
)
|
||||
|
||||
|
||||
def section_title(text: str) -> ft.Text:
|
||||
return ft.Text(text, color=TEXT, size=18, weight=ft.FontWeight.W_600)
|
||||
|
||||
|
||||
def hint(text: str) -> ft.Text:
|
||||
return ft.Text(text, color=MUTED, size=12)
|
||||
45
web/main.py
Normal file
45
web/main.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import flet as ft
|
||||
|
||||
from web.api_client import ApiClient
|
||||
from web.config import PORT
|
||||
import web.designer as d
|
||||
from web.views.main_view import MainView
|
||||
|
||||
|
||||
def main(page: ft.Page) -> None:
|
||||
page.title = "LLM-infa"
|
||||
page.theme_mode = ft.ThemeMode.DARK
|
||||
page.bgcolor = d.BG
|
||||
page.padding = 0
|
||||
page.scroll = ft.ScrollMode.HIDDEN
|
||||
page.window.width = 1100
|
||||
page.fonts = {}
|
||||
|
||||
client = ApiClient()
|
||||
view = MainView(page, client)
|
||||
page.add(view.build())
|
||||
|
||||
# probe env-configured LLM and initial library load
|
||||
try:
|
||||
status = client.llm_status()
|
||||
if status.get("configured"):
|
||||
view._set_status(ok=True)
|
||||
if status.get("base_url") and not view.base_url.value:
|
||||
view.base_url.value = status["base_url"]
|
||||
if status.get("model") and not view.selected_model:
|
||||
view.selected_model = status["model"]
|
||||
view.model_input.value = status["model"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
view._refresh_library()
|
||||
page.update()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ft.app(
|
||||
target=main,
|
||||
view=ft.AppView.WEB_BROWSER,
|
||||
port=PORT,
|
||||
host="0.0.0.0",
|
||||
)
|
||||
5
web/requirements.txt
Normal file
5
web/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
# classic flet API (ft.app/ft.Colors/page.window); 0.70+ is a breaking rewrite
|
||||
flet==0.28.3
|
||||
# web-server runtime for ft.app(view=WEB_BROWSER) — separate package since 0.24
|
||||
flet-web==0.28.3
|
||||
httpx>=0.27
|
||||
0
web/views/__init__.py
Normal file
0
web/views/__init__.py
Normal file
406
web/views/main_view.py
Normal file
406
web/views/main_view.py
Normal file
@@ -0,0 +1,406 @@
|
||||
"""Main page of the LLM-infa Flet web app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import flet as ft
|
||||
|
||||
from web.api_client import ApiClient, ApiError
|
||||
import web.designer as d
|
||||
|
||||
|
||||
class MainView:
|
||||
def __init__(self, page: ft.Page, client: ApiClient):
|
||||
self.page = page
|
||||
self.client = client
|
||||
|
||||
self.models: list[dict] = []
|
||||
self.selected_model: str = ""
|
||||
|
||||
# ── LLM config fields ──────────────────────────────────────────────
|
||||
self.base_url = d.text_field("Base URL", hint="https://api.openai.com/v1", expand=True)
|
||||
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
|
||||
|
||||
# Searchable model combobox
|
||||
self.model_input = d.text_field(
|
||||
"Модель",
|
||||
hint="Сначала нажмите «Загрузить модели»",
|
||||
on_change=self._on_model_search,
|
||||
expand=True,
|
||||
)
|
||||
self._model_list_col = ft.Column(spacing=2, scroll=ft.ScrollMode.AUTO)
|
||||
self._model_list_wrap = ft.Container(
|
||||
content=self._model_list_col,
|
||||
height=260,
|
||||
bgcolor=d.SURFACE_3,
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=8,
|
||||
padding=4,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
self.llm_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
|
||||
self.llm_status_chip = ft.Container(
|
||||
content=ft.Text("LLM не подключена", size=12, color=d.MUTED),
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=4),
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=99,
|
||||
)
|
||||
self.btn_load = d.primary_button("Загрузить модели", self._load_models)
|
||||
self.btn_test = d.secondary_button("Проверить связь", self._test_conn)
|
||||
|
||||
# ── Pipeline fields ────────────────────────────────────────────────
|
||||
self.yt_url = d.text_field(
|
||||
"Ссылка на YouTube",
|
||||
hint="https://youtu.be/…",
|
||||
expand=True,
|
||||
)
|
||||
self.vosk_path = d.text_field(
|
||||
"Vosk-модель (fallback, когда нет субтитров)",
|
||||
value="models/vosk-model-small-ru-0.22",
|
||||
expand=True,
|
||||
)
|
||||
self.n_sentences = ft.TextField(
|
||||
label="Предложений в выжимке",
|
||||
value="15",
|
||||
input_filter=ft.NumbersOnlyInputFilter(),
|
||||
bgcolor=d.SURFACE_3,
|
||||
color=d.TEXT,
|
||||
border_color=d.BORDER,
|
||||
focused_border_color=d.ACCENT,
|
||||
label_style=ft.TextStyle(color=d.MUTED, size=12),
|
||||
text_size=14,
|
||||
width=220,
|
||||
)
|
||||
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
|
||||
self.btn_run = d.primary_button("▶ Запустить", self._run_pipeline)
|
||||
|
||||
# Result block (initially hidden)
|
||||
self._result_col = ft.Column(spacing=6, visible=False)
|
||||
|
||||
# Library
|
||||
self._library_col = ft.Column(spacing=8)
|
||||
|
||||
# ─────────────────────── build ───────────────────────────────────────
|
||||
|
||||
def build(self) -> ft.Control:
|
||||
return ft.Column(
|
||||
controls=[
|
||||
self._build_topbar(),
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._build_llm_card(),
|
||||
self._build_pipeline_card(),
|
||||
self._build_library_card(),
|
||||
],
|
||||
spacing=20,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
expand=True,
|
||||
),
|
||||
],
|
||||
expand=True,
|
||||
spacing=0,
|
||||
)
|
||||
|
||||
def _build_topbar(self) -> ft.Container:
|
||||
return ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Row(
|
||||
[
|
||||
ft.Container(
|
||||
width=14,
|
||||
height=14,
|
||||
border_radius=4,
|
||||
gradient=ft.LinearGradient(
|
||||
begin=ft.alignment.top_left,
|
||||
end=ft.alignment.bottom_right,
|
||||
colors=[d.ACCENT, "#5b8def"],
|
||||
),
|
||||
),
|
||||
ft.Text("LLM-infa", color=d.TEXT, size=17, weight=ft.FontWeight.W_600),
|
||||
],
|
||||
spacing=10,
|
||||
),
|
||||
ft.Container(expand=True),
|
||||
self.llm_status_chip,
|
||||
],
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||||
bgcolor=ft.Colors.with_opacity(0.6, d.SURFACE),
|
||||
border=ft.border.only(bottom=ft.BorderSide(1, d.BORDER)),
|
||||
)
|
||||
|
||||
def _build_llm_card(self) -> ft.Container:
|
||||
return d.card(
|
||||
d.section_title("Подключение к LLM"),
|
||||
d.hint(
|
||||
"Укажите любой OpenAI-совместимый провайдер. "
|
||||
"Нейронка внутри Docker не разворачивается — "
|
||||
"указываем URL внешнего сервиса."
|
||||
),
|
||||
ft.Row([self.base_url, self.api_key], spacing=12),
|
||||
ft.Row([self.btn_load, self.btn_test, self.llm_msg], spacing=10),
|
||||
ft.Divider(color=d.BORDER, height=1),
|
||||
self.model_input,
|
||||
self._model_list_wrap,
|
||||
)
|
||||
|
||||
def _build_pipeline_card(self) -> ft.Container:
|
||||
return d.card(
|
||||
d.section_title("Обработать видео"),
|
||||
d.hint(
|
||||
"Pipeline: yt-dlp → субтитры (или Vosk fallback) "
|
||||
"→ выжимка через выбранную LLM → запись в БД."
|
||||
),
|
||||
self.yt_url,
|
||||
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
|
||||
ft.Row([self.btn_run, self.pipeline_msg], spacing=10),
|
||||
self._result_col,
|
||||
)
|
||||
|
||||
def _build_library_card(self) -> ft.Container:
|
||||
return d.card(
|
||||
ft.Row(
|
||||
[
|
||||
d.section_title("Библиотека"),
|
||||
ft.Container(expand=True),
|
||||
d.secondary_button("Обновить", lambda _: self._refresh_library()),
|
||||
]
|
||||
),
|
||||
d.hint("Все обработанные видео и пути к файлам."),
|
||||
self._library_col,
|
||||
)
|
||||
|
||||
# ─────────────────── model combobox ──────────────────────────────────
|
||||
|
||||
def _on_model_search(self, e):
|
||||
q = (self.model_input.value or "").strip().lower()
|
||||
filtered = [m for m in self.models if q in m["id"].lower()] if q else self.models
|
||||
self._render_model_list(filtered)
|
||||
|
||||
def _render_model_list(self, items: list[dict]):
|
||||
self._model_list_col.controls = [
|
||||
ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Text(m["id"], color=d.TEXT, size=13, expand=True, no_wrap=True),
|
||||
ft.Text(m.get("owned_by", ""), color=d.MUTED, size=11),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
ink=True,
|
||||
bgcolor=d.SURFACE_2,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=8),
|
||||
border_radius=6,
|
||||
on_click=lambda e, mid=m["id"]: self._select_model(mid),
|
||||
)
|
||||
for m in items
|
||||
]
|
||||
if not items:
|
||||
self._model_list_col.controls = [
|
||||
ft.Text("Ничего не найдено", color=d.MUTED, size=13)
|
||||
]
|
||||
self._model_list_wrap.visible = bool(self.models)
|
||||
self.page.update()
|
||||
|
||||
def _select_model(self, mid: str):
|
||||
self.selected_model = mid
|
||||
self.model_input.value = mid
|
||||
self._model_list_wrap.visible = False
|
||||
self.page.update()
|
||||
|
||||
# ─────────────────── LLM actions ─────────────────────────────────────
|
||||
|
||||
def _load_models(self, _):
|
||||
base_url = self.base_url.value.strip()
|
||||
if not base_url:
|
||||
self._set_llm_msg("Укажите Base URL", error=True)
|
||||
return
|
||||
|
||||
self._set_llm_msg("Загружаю…")
|
||||
self.btn_load.disabled = True
|
||||
self.page.update()
|
||||
|
||||
def _task():
|
||||
try:
|
||||
self.models = self.client.llm_models(base_url, self.api_key.value.strip())
|
||||
self._render_model_list(self.models)
|
||||
self._set_llm_msg(f"Загружено: {len(self.models)} моделей", ok=True)
|
||||
self._set_status(ok=True)
|
||||
except ApiError as e:
|
||||
self._set_llm_msg(str(e), error=True)
|
||||
self._set_status(ok=False)
|
||||
finally:
|
||||
self.btn_load.disabled = False
|
||||
self.page.update()
|
||||
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
|
||||
def _test_conn(self, _):
|
||||
if not self.selected_model:
|
||||
self._set_llm_msg("Сначала выберите модель", error=True)
|
||||
return
|
||||
|
||||
self._set_llm_msg("Проверяю…")
|
||||
self.btn_test.disabled = True
|
||||
self.page.update()
|
||||
|
||||
def _task():
|
||||
try:
|
||||
r = self.client.llm_test(
|
||||
self.base_url.value.strip(),
|
||||
self.api_key.value.strip(),
|
||||
self.selected_model,
|
||||
)
|
||||
preview = (r.get("reply") or "")[:80]
|
||||
self._set_llm_msg(f"OK · {preview}", ok=True)
|
||||
self._set_status(ok=True)
|
||||
except ApiError as e:
|
||||
self._set_llm_msg(str(e), error=True)
|
||||
self._set_status(ok=False)
|
||||
finally:
|
||||
self.btn_test.disabled = False
|
||||
self.page.update()
|
||||
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
|
||||
# ─────────────────── pipeline ─────────────────────────────────────────
|
||||
|
||||
def _run_pipeline(self, _):
|
||||
url = self.yt_url.value.strip()
|
||||
if not url:
|
||||
self._set_pipeline_msg("Укажите ссылку на видео", error=True)
|
||||
return
|
||||
|
||||
payload: dict = {
|
||||
"url": url,
|
||||
"model_path": self.vosk_path.value.strip() or None,
|
||||
"summary_max_sentences": int(self.n_sentences.value or 15),
|
||||
}
|
||||
if self.base_url.value.strip() and self.selected_model:
|
||||
payload["llm"] = {
|
||||
"base_url": self.base_url.value.strip(),
|
||||
"api_key": self.api_key.value.strip(),
|
||||
"model": self.selected_model,
|
||||
}
|
||||
|
||||
self._set_pipeline_msg("Обработка видео… (может занять несколько минут)")
|
||||
self._result_col.visible = False
|
||||
self.btn_run.disabled = True
|
||||
self.page.update()
|
||||
|
||||
def _task():
|
||||
try:
|
||||
r = self.client.pipeline_process(payload)
|
||||
self._render_result(r)
|
||||
self._set_pipeline_msg("Готово ✓", ok=True)
|
||||
self._refresh_library()
|
||||
except ApiError as e:
|
||||
self._set_pipeline_msg(str(e), error=True)
|
||||
finally:
|
||||
self.btn_run.disabled = False
|
||||
self.page.update()
|
||||
|
||||
threading.Thread(target=_task, daemon=True).start()
|
||||
|
||||
def _render_result(self, r: dict):
|
||||
self._result_col.controls = [
|
||||
ft.Divider(color=d.BORDER, height=1),
|
||||
ft.Text(r.get("title") or r.get("uuid", ""), color=d.ACCENT_2, size=15, weight=ft.FontWeight.W_600),
|
||||
*[
|
||||
self._kv(k, v)
|
||||
for k, v in [
|
||||
("UUID", r.get("uuid", "")),
|
||||
("Источник", r.get("source_url", "")),
|
||||
("Метод", r.get("transcription_method", "")),
|
||||
("Видео", r.get("video_path", "")),
|
||||
("Полный текст", r.get("text_full_path", "")),
|
||||
("Выжимка", r.get("text_summary_path", "")),
|
||||
]
|
||||
],
|
||||
ft.Text("Превью выжимки:", color=d.MUTED, size=12),
|
||||
ft.Container(
|
||||
content=ft.Text(r.get("summary_preview", ""), color=d.TEXT, size=13, selectable=True, no_wrap=False),
|
||||
padding=12,
|
||||
bgcolor=d.SURFACE_3,
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=8,
|
||||
),
|
||||
]
|
||||
self._result_col.visible = True
|
||||
|
||||
# ─────────────────── library ─────────────────────────────────────────
|
||||
|
||||
def _refresh_library(self):
|
||||
try:
|
||||
items = self.client.list_videos()
|
||||
except ApiError as e:
|
||||
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
|
||||
self.page.update()
|
||||
return
|
||||
|
||||
if not items:
|
||||
self._library_col.controls = [ft.Text("Пока пусто", color=d.MUTED, size=13)]
|
||||
else:
|
||||
self._library_col.controls = [self._lib_card(v) for v in items]
|
||||
self.page.update()
|
||||
|
||||
def _lib_card(self, v: dict) -> ft.Container:
|
||||
created = v.get("created_at", "")[:19].replace("T", " ")
|
||||
return ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Text(
|
||||
v.get("title") or v.get("uuid", ""),
|
||||
color=d.TEXT,
|
||||
size=14,
|
||||
weight=ft.FontWeight.W_600,
|
||||
no_wrap=True,
|
||||
),
|
||||
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Text(f"uuid: {v.get('uuid', '')}", color=d.MUTED, size=11, selectable=True, expand=True),
|
||||
ft.Text(created, color=d.MUTED, size=11),
|
||||
]
|
||||
),
|
||||
self._kv("Метод", v.get("transcription_method", "")),
|
||||
self._kv("Видео", v.get("video_path", "")),
|
||||
self._kv("Выжимка", v.get("text_summary_path", "")),
|
||||
],
|
||||
spacing=3,
|
||||
tight=True,
|
||||
),
|
||||
padding=12,
|
||||
bgcolor=d.SURFACE_2,
|
||||
border=ft.border.all(1, d.BORDER),
|
||||
border_radius=10,
|
||||
)
|
||||
|
||||
# ─────────────────── helpers ──────────────────────────────────────────
|
||||
|
||||
def _kv(self, key: str, value: str) -> ft.Row:
|
||||
return ft.Row(
|
||||
[
|
||||
ft.Text(key, color=d.MUTED, size=12, width=120),
|
||||
ft.Text(value, color=d.TEXT, size=12, selectable=True, expand=True, no_wrap=False),
|
||||
],
|
||||
)
|
||||
|
||||
def _set_llm_msg(self, text: str, *, ok: bool = False, error: bool = False):
|
||||
self.llm_msg.value = text
|
||||
self.llm_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
|
||||
|
||||
def _set_pipeline_msg(self, text: str, *, ok: bool = False, error: bool = False):
|
||||
self.pipeline_msg.value = text
|
||||
self.pipeline_msg.color = d.OK if ok else (d.DANGER if error else d.MUTED)
|
||||
|
||||
def _set_status(self, *, ok: bool):
|
||||
chip = self.llm_status_chip
|
||||
chip.content.value = "LLM подключена" if ok else "Ошибка подключения"
|
||||
chip.content.color = d.OK if ok else d.DANGER
|
||||
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else "rgba(227,90,114,.4)")
|
||||
Reference in New Issue
Block a user