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

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

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

24
.env
View File

@@ -1,5 +1,27 @@
# Настройки базы данных # Postgres
DB_USER=postgres DB_USER=postgres
DB_PASS=secretpassword DB_PASS=secretpassword
DB_NAME=test_db DB_NAME=test_db
DB_HOST=db
DB_PORT=5432 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

View File

@@ -1,51 +1,16 @@
# syntax=docker/dockerfile:1.5 # syntax=docker/dockerfile:1.5
FROM python:3.14-slim
# Builder: install build deps and build wheels
FROM python:3.14-slim AS builder
WORKDIR /app WORKDIR /app
#RUN apt-get update && apt-get install -y --no-install-recommends \ # Static ffmpeg/ffprobe as a cached image layer — no apt, no slow mirror downloads
# build-essential \ COPY --from=mwader/static-ffmpeg:7.1 /ffmpeg /ffprobe /usr/local/bin/
# ca-certificates \
# && rm -rf /var/lib/apt/lists/* # Deps first for layer caching; BuildKit cache mount speeds up rebuilds
#
COPY requirements.txt ./ COPY requirements.txt ./
# Build wheels into /wheels (use cache for pip downloads)
RUN --mount=type=cache,target=/root/.cache/pip \ 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 CMD ["python", "-m", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
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"]

14
Dockerfile.web Normal file
View 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"]

View File

@@ -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
View File

View File

@@ -1,28 +1,38 @@
from pydantic_settings import BaseSettings
import os 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): class Settings(BaseSettings):
# support both custom DB_* names and common POSTGRES_* names from postgres images DB_USER: str = os.getenv("DB_USER", "postgres")
DB_USER: str = _env("DB_USER", "POSTGRES_USER", default="postgres") DB_PASS: str = os.getenv("DB_PASS", "postgres")
DB_PASS: str = _env("DB_PASS", "POSTGRES_PASSWORD", default="postgres") DB_NAME: str = os.getenv("DB_NAME", "test_db")
DB_NAME: str = _env("DB_NAME", "POSTGRES_DB", default="test_db") DB_HOST: str = os.getenv("DB_HOST", "db")
DB_HOST: str = _env("DB_HOST", "POSTGRES_HOST", "DB_HOST", default="db") DB_PORT: str = os.getenv("DB_PORT", "5432")
DB_PORT: str = _env("DB_PORT", "POSTGRES_PORT", default="5432")
# If explicit DB_* variables are provided, build DATABASE_URL from them (priority). DEFAULT_VOSK_MODEL: str = os.getenv(
# Otherwise fall back to explicit DATABASE_URL or DATABASE_URL_ASYNC env. "DEFAULT_VOSK_MODEL", "models/vosk-model-small-ru-0.22"
_built_db_url: str | None = None )
if DB_USER and DB_PASS and DB_NAME and DB_HOST and DB_PORT: SUBTITLE_LANGS: str = os.getenv("SUBTITLE_LANGS", "ru,en")
_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="") # 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() settings = Settings()

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

View 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 import asyncio
from api.config import settings import logging
DATABASE_URL = settings.DATABASE_URL import asyncpg
engine = create_async_engine(DATABASE_URL, echo=False, future=True) 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) SessionLocal = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def wait_for_db(retries: int = 30, delay: float = 1.0) -> None: async def wait_for_postgres(retries: int = 30, delay: float = 1.0) -> None:
"""Wait until the database is available. Retries with exponential backoff. """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.
""" async def _create_database_if_missing() -> None:
last_exc = None admin = await asyncpg.connect(
for attempt in range(1, retries + 1): user=settings.DB_USER,
try: password=settings.DB_PASS,
async with engine.connect() as conn: database="postgres",
await conn.execute(text("SELECT 1")) host=settings.DB_HOST,
return port=int(settings.DB_PORT),
except Exception as e: )
last_exc = e try:
wait = min(delay * (2 ** (attempt - 1)), 5) exists = await admin.fetchval(
await asyncio.sleep(wait) "SELECT 1 FROM pg_database WHERE datname=$1", settings.DB_NAME
raise RuntimeError(f"Could not connect to DB after {retries} attempts") from last_exc )
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
View 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
View File

164
api/lib/llm.py Normal file
View 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
View 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 = (
"Сделай связную краткую выжимку следующего текста. "
"Выдели основные тезисы и логику изложения. Объём — 512 предложений.\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
View 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
View 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)

View File

@@ -1,22 +1,28 @@
import asyncio import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI 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(): logging.basicConfig(
# await wait_for_db() level=logging.INFO,
# await init_db() 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(default.router)
#app.include_router(subscription.router) app.include_router(pipeline.router)
#app.include_router(users.router) app.include_router(videos.router)
app.include_router(installing.router) app.include_router(llm.router)
app.include_router(media_convert.router)
app.include_router(mp3_ffmpeg_stream.router)
app.include_router(moviepy.router)

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

View File

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

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

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

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

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

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

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

28
api_legacy/config.py Normal file
View 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()

View 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
View 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)

View 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}

View File

@@ -1,20 +1,4 @@
services: 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: api:
build: build:
@@ -31,28 +15,46 @@ services:
- .:/app - .:/app
- ./data:/app/data - ./data:/app/data
- ./models:/app/models - ./models:/app/models
depends_on:
db:
condition: service_healthy
dns: dns:
- 8.8.8.8 - 8.8.8.8
- 1.1.1.1 - 1.1.1.1
#db: web:
# image: postgres:15-alpine build:
# container_name: db context: .
# restart: always dockerfile: Dockerfile.web
# environment: container_name: web
# POSTGRES_USER: ${DB_USER} restart: no
# POSTGRES_PASSWORD: ${DB_PASS} environment:
# POSTGRES_DB: ${DB_NAME} - API_BASE_URL=http://api:8000
# volumes: - PORT=8550
# - ./pg_data:/var/lib/postgresql/data - HTTP_TIMEOUT=1800
# ports: ports:
# - "5433:5432" - "8550:8550"
# healthcheck: depends_on:
# test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - api
# interval: 5s dns:
# timeout: 5s - 8.8.8.8
# retries: 10 - 1.1.1.1
# start_period: 10s
#volumes: db:
# pg_data: 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

View File

@@ -1,14 +1,12 @@
[project] [project]
name = "llm-infa" name = "llm-infa"
version = "0.2.0" version = "0.3.0"
description = "Add your description here" description = "YouTube -> транскрипция -> LLM-выжимка -> Postgres"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"asyncio>=4.0.0",
"asyncpg>=0.31.0", "asyncpg>=0.31.0",
"fastapi>=0.129.0", "fastapi>=0.129.0",
"moviepy>=2.2.1",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-settings>=2.13.0", "pydantic-settings>=2.13.0",
"sqlalchemy>=2.0.46", "sqlalchemy>=2.0.46",
@@ -17,3 +15,11 @@ dependencies = [
"vosk>=0.3.45", "vosk>=0.3.45",
"yt-dlp>=2026.2.4", "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",
]

View File

@@ -6,6 +6,4 @@ toml
pydantic pydantic
pydantic-settings pydantic-settings
yt-dlp yt-dlp
moviepy vosk
imageio-ffmpeg
vosk

352
uv.lock generated
View File

@@ -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" }, { 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]] [[package]]
name = "asyncpg" name = "asyncpg"
version = "0.31.0" 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" }, { 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.129.0" 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" }, { 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.3.1" 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/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/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/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/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/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" }, { 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/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/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/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/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/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" }, { 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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -221,42 +277,13 @@ 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" }, { 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]] [[package]]
name = "llm-infa" name = "llm-infa"
version = "0.2.0" version = "0.3.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "asyncio" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "moviepy" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@@ -266,12 +293,17 @@ dependencies = [
{ name = "yt-dlp" }, { name = "yt-dlp" },
] ]
[package.dev-dependencies]
web = [
{ name = "flet" },
{ name = "flet-web" },
{ name = "httpx" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "asyncio", specifier = ">=4.0.0" },
{ name = "asyncpg", specifier = ">=0.31.0" }, { name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.129.0" }, { name = "fastapi", specifier = ">=0.129.0" },
{ name = "moviepy", specifier = ">=2.2.1" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-settings", specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.13.0" },
{ name = "sqlalchemy", specifier = ">=2.0.46" }, { name = "sqlalchemy", specifier = ">=2.0.46" },
@@ -281,93 +313,20 @@ requires-dist = [
{ name = "yt-dlp", specifier = ">=2026.2.4" }, { name = "yt-dlp", specifier = ">=2026.2.4" },
] ]
[[package]] [package.metadata.requires-dev]
name = "moviepy" web = [
version = "2.2.1" { name = "flet", specifier = "==0.28.3" },
source = { registry = "https://pypi.org/simple" } { name = "flet-web", specifier = "==0.28.3" },
dependencies = [ { name = "httpx", specifier = ">=0.28.1" },
{ 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]] [[package]]
name = "numpy" name = "oauthlib"
version = "2.4.2" version = "3.3.1"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
{ 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" },
] ]
[[package]] [[package]]
@@ -456,6 +415,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" }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.32.5"
@@ -471,6 +468,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" }, { 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.46" version = "2.0.46"
@@ -577,6 +583,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" }, { 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]] [[package]]
name = "vosk" name = "vosk"
version = "0.3.45" version = "0.3.45"
@@ -595,6 +632,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" }, { 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]] [[package]]
name = "websockets" name = "websockets"
version = "16.0" version = "16.0"

0
web/__init__.py Normal file
View File

79
web/api_client.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

406
web/views/main_view.py Normal file
View 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)")