Compare commits

...

8 Commits

Author SHA1 Message Date
jze9
e62e71ce7e UI: опрос задач переживает обрывы сети/перезапуск api
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:43:26 +05:00
jze9
31eb8c3303 LLM max_tokens 1000→8000: reasoning-модели тратили весь лимит на размышления и возвращали пустой ответ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:39:47 +05:00
jze9
c88b0e4f6d Ретраи и дедупликация в задачах
- элемент плейлиста повторяется до 3 раз с паузой 30 с (разовые 403/429 от YouTube)
- yt-dlp: retries/fragment_retries/extractor_retries
- уже обработанные URL пропускаются — повторная постановка плейлиста
  доделывает только упавшее

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:33:01 +05:00
jze9
48cd01d0f9 expand_url: watch-ссылка с list= раскрывается в весь плейлист
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:10:17 +05:00
jze9
e87f6cd215 Фоновые задачи: плейлисты, длинные лекции, вкладка настроек
- таблица jobs + очередь с воркером: POST /jobs сразу отвечает, элементы
  плейлиста обрабатываются последовательно, есть отмена и живой прогресс
  (скачивание %, минуты распознанного Vosk-аудио)
- expand_url: плейлист раскрывается в список видео
- keep_video=false теперь качает только аудио (для 4-5ч лекций)
- настройки (LLM-ключ, модель, длина выжимки, хранение видео, путь Vosk)
  хранятся в Postgres: переживают перезапуск и перезагрузку страницы
- длина выжимки прокидывается в LLM-промпт ({n} предложений)
- UI: вкладки «Главная»/«Настройки», карточка задач с автообновлением
  каждые 3 сек, прогресс-бар, отмена, библиотека обновляется по мере
  готовности элементов

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:01:11 +05:00
jze9
bf854f2d6e Разделы, фильтры и просмотр текстов в UI; опция «не хранить видео»
- таблица sections (дерево через parent_id), у видео section_id
- API: CRUD /sections, фильтры /videos (поиск, раздел с потомками, метод),
  GET /videos/{id}/text, PATCH раздела, DELETE вместе с файлами
- pipeline: keep_video=false удаляет mp4 после расшифровки, section_id
- UI: панель разделов с вложенностью и счётчиками, поиск, фильтр по методу,
  диалоги полного текста/выжимки, привязка к разделу, удаление,
  переключатель «Сохранять видео на диске»
- compose: bind-mount ./web для правок без пересборки

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:07:55 +05:00
jze9
05ba753ade pyproject: flet в основные зависимости
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 11:14:17 +05:00
jze9
2697e01714 Рабочий пайплайн: 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>
2026-07-14 11:12:46 +05:00
59 changed files with 3705 additions and 283 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=8000
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,90 @@
# 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 не настроена, работает встроенная экстрактивная суммаризация
(выбор ключевых предложений, без нейросети).
## Плейлисты и длинные лекции
Обработка идёт через фоновые задачи: `POST /jobs` сразу возвращает id, элементы
плейлиста обрабатываются по очереди, прогресс виден в UI (скачивание %,
минуты распознанного аудио, номер видео в плейлисте). Если в настройках
выключено «Сохранять видео», для лекций качается только аудиодорожка —
в разы быстрее и легче.
Настройки (ключ LLM, модель, длина выжимки, хранение видео) сохраняются
в Postgres — переживают перезапуск контейнеров и перезагрузку страницы.
Вкладка «Настройки» в UI.
## API
```bash
# фоновая задача: видео или плейлист
curl -X POST localhost:8000/jobs/ \
-H 'Content-Type: application/json' \
-d '{"url": "https://www.youtube.com/playlist?list=..."}'
curl localhost:8000/jobs/ # статусы и прогресс
# синхронно, одно видео (для коротких роликов)
curl -X POST localhost:8000/pipeline/process \
-H 'Content-Type: application/json' \
-d '{"url": "https://youtu.be/...", "summary_max_sentences": 10}'
# каталог обработанных видео (фильтры: ?q=, ?section_id=, ?method=)
curl localhost:8000/videos/
# настройки
curl -X PUT localhost:8000/settings/ -H 'Content-Type: application/json' \
-d '{"llm_base_url": "https://openrouter.ai/api/v1", "llm_api_key": "sk-...", "llm_model": "..."}'
```
## Заметки
- Старый код (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,39 @@
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"))
# reasoning-модели тратят max_tokens и на размышления — 1000 им мало
LLM_MAX_TOKENS: int = int(os.getenv("LLM_MAX_TOKENS", "8000"))
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,76 @@
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.job # noqa: F401 - register model with Base.metadata
import api.models.section # noqa: F401 - register model with Base.metadata
import api.models.setting # noqa: F401 - register model with Base.metadata
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)
# create_all не добавляет колонки в уже существующие таблицы
await conn.execute(
text(
"ALTER TABLE videos ADD COLUMN IF NOT EXISTS section_id INTEGER "
"REFERENCES sections(id) ON DELETE SET NULL"
)
)
await conn.execute(
text("ALTER TABLE videos ADD COLUMN IF NOT EXISTS job_id VARCHAR")
)

53
api/db/job.py Normal file
View File

@@ -0,0 +1,53 @@
from sqlalchemy import select, update
from api.db.connection import SessionLocal
from api.models.job import Job
async def create_job(job_id: str, url: str) -> Job:
async with SessionLocal() as session:
j = Job(id=job_id, url=url, status="queued")
session.add(j)
await session.commit()
await session.refresh(j)
return j
async def get_job(job_id: str) -> Job | None:
async with SessionLocal() as session:
return await session.get(Job, job_id)
async def list_jobs(limit: int = 50) -> list[Job]:
async with SessionLocal() as session:
res = await session.execute(
select(Job).order_by(Job.created_at.desc()).limit(limit)
)
return list(res.scalars().all())
async def update_job(job_id: str, **fields) -> None:
async with SessionLocal() as session:
await session.execute(update(Job).where(Job.id == job_id).values(**fields))
await session.commit()
async def delete_job(job_id: str) -> bool:
async with SessionLocal() as session:
j = await session.get(Job, job_id)
if j is None:
return False
await session.delete(j)
await session.commit()
return True
async def mark_interrupted() -> None:
"""На старте приложения: задачи, шедшие до перезапуска, отмечаем прерванными."""
async with SessionLocal() as session:
await session.execute(
update(Job)
.where(Job.status.in_(("queued", "running")))
.values(status="interrupted", current_item="")
)
await session.commit()

57
api/db/section.py Normal file
View File

@@ -0,0 +1,57 @@
from sqlalchemy import func, select, update
from api.db.connection import SessionLocal
from api.models.section import Section
from api.models.video import Video
async def list_sections() -> list[dict]:
"""Плоский список разделов + количество видео в каждом (дерево строит клиент)."""
async with SessionLocal() as session:
res = await session.execute(select(Section).order_by(Section.name))
sections = list(res.scalars().all())
counts_res = await session.execute(
select(Video.section_id, func.count()).group_by(Video.section_id)
)
counts = dict(counts_res.all())
return [
{
"id": s.id,
"name": s.name,
"parent_id": s.parent_id,
"video_count": counts.get(s.id, 0),
}
for s in sections
]
async def create_section(name: str, parent_id: int | None) -> Section:
async with SessionLocal() as session:
if parent_id is not None:
parent = await session.get(Section, parent_id)
if parent is None:
raise ValueError(f"Parent section {parent_id} not found")
s = Section(name=name, parent_id=parent_id)
session.add(s)
await session.commit()
await session.refresh(s)
return s
async def delete_section(section_id: int) -> bool:
"""Удаляет раздел: дочерние разделы поднимаются к родителю, видео — без раздела."""
async with SessionLocal() as session:
s = await session.get(Section, section_id)
if s is None:
return False
await session.execute(
update(Section)
.where(Section.parent_id == section_id)
.values(parent_id=s.parent_id)
)
await session.execute(
update(Video).where(Video.section_id == section_id).values(section_id=None)
)
await session.delete(s)
await session.commit()
return True

21
api/db/setting.py Normal file
View File

@@ -0,0 +1,21 @@
from sqlalchemy import select
from api.db.connection import SessionLocal
from api.models.setting import Setting
async def get_all_settings() -> dict[str, str]:
async with SessionLocal() as session:
res = await session.execute(select(Setting))
return {s.key: s.value for s in res.scalars().all()}
async def set_settings(values: dict[str, str]) -> None:
async with SessionLocal() as session:
for key, value in values.items():
existing = await session.get(Setting, key)
if existing is None:
session.add(Setting(key=key, value=str(value)))
else:
existing.value = str(value)
await session.commit()

90
api/db/video.py Normal file
View File

@@ -0,0 +1,90 @@
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,
section_id: int | None = None,
job_id: str | None = None,
) -> Video:
async with SessionLocal() as session:
v = Video(
uuid=uuid,
source_url=source_url,
section_id=section_id,
job_id=job_id,
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 get_video_by_url(url: str) -> Video | None:
async with SessionLocal() as session:
res = await session.execute(select(Video).where(Video.source_url == url))
return res.scalars().first()
async def list_videos(
*,
q: str | None = None,
section_ids: list[int] | None = None,
method: str | None = None,
) -> list[Video]:
"""Каталог с фильтрами: поиск по названию/URL, разделы (уже с потомками), метод."""
stmt = select(Video)
if q:
pattern = f"%{q}%"
stmt = stmt.where(Video.title.ilike(pattern) | Video.source_url.ilike(pattern))
if section_ids is not None:
stmt = stmt.where(Video.section_id.in_(section_ids))
if method:
stmt = stmt.where(Video.transcription_method.ilike(f"{method}%"))
stmt = stmt.order_by(Video.created_at.desc())
async with SessionLocal() as session:
res = await session.execute(stmt)
return list(res.scalars().all())
async def set_video_section(video_uuid: str, section_id: int | None) -> Video | None:
async with SessionLocal() as session:
res = await session.execute(select(Video).where(Video.uuid == video_uuid))
v = res.scalars().first()
if not v:
return None
v.section_id = section_id
await session.commit()
await session.refresh(v)
return v
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

146
api/lib/jobqueue.py Normal file
View File

@@ -0,0 +1,146 @@
"""Очередь фоновых задач: одно видео или плейлист, элементы обрабатываются
последовательно (чтобы не долбить YouTube и LLM параллельными запросами).
Живой прогресс держится в памяти (_live) и подмешивается в GET /jobs;
устойчивое состояние (счётчики, статус) — в таблице jobs.
"""
from __future__ import annotations
import asyncio
import logging
from api.db.job import get_job, update_job
from api.db.video import get_video_by_url
from api.lib.processing import PipelineError, process_single_video
from api.lib.youtube import expand_url
_ITEM_ATTEMPTS = 3 # попытки на один элемент (YouTube любит разовые 403/429)
_RETRY_DELAY = 30.0
logger = logging.getLogger("jobs")
_queue: asyncio.Queue[str] = asyncio.Queue()
_live: dict[str, str] = {}
_cancel: set[str] = set()
_params: dict[str, dict] = {}
def submit(job_id: str, params: dict) -> None:
_params[job_id] = params
_queue.put_nowait(job_id)
def live_status(job_id: str) -> str:
return _live.get(job_id, "")
def request_cancel(job_id: str) -> None:
_cancel.add(job_id)
def is_cancelled(job_id: str) -> bool:
return job_id in _cancel
async def worker() -> None:
"""Единственный воркер; запускается на старте приложения."""
logger.info("job worker started")
while True:
job_id = await _queue.get()
try:
await _run_job(job_id)
except Exception:
logger.exception("job %s crashed", job_id)
try:
await update_job(job_id, status="error", current_item="")
except Exception:
pass
finally:
_live.pop(job_id, None)
_cancel.discard(job_id)
_params.pop(job_id, None)
_queue.task_done()
async def _run_job(job_id: str) -> None:
job = await get_job(job_id)
if job is None or job.status == "cancelled":
return
params = _params.get(job_id, {})
loop = asyncio.get_running_loop()
await update_job(job_id, status="running", current_item="анализ ссылки…")
_live[job_id] = "анализ ссылки…"
plan = await loop.run_in_executor(None, expand_url, job.url)
entries = plan["entries"]
if not entries:
await update_job(job_id, status="error", error="Плейлист пуст", current_item="")
return
await update_job(
job_id, kind=plan["kind"], title=plan["title"], total_items=len(entries)
)
done = failed = 0
errors: list[str] = []
n = len(entries)
for i, entry in enumerate(entries, start=1):
if is_cancelled(job_id):
await update_job(job_id, status="cancelled", current_item="")
logger.info("job %s cancelled at item %d/%d", job_id, i, n)
return
label = f"{i}/{n} · {entry['title']}"
await update_job(job_id, current_item=label)
_live[job_id] = label
def _notify(msg: str, _label: str = label) -> None:
_live[job_id] = f"{_label}{msg}"
# дедупликация: повторная постановка плейлиста не переделывает готовое
if await get_video_by_url(entry["url"]) is not None:
done += 1
_live[job_id] = f"{label} — уже в библиотеке, пропуск"
logger.info("job %s item %d/%d already processed, skip", job_id, i, n)
await update_job(job_id, done_items=done)
continue
for attempt in range(1, _ITEM_ATTEMPTS + 1):
try:
await process_single_video(
entry["url"],
section_id=params.get("section_id"),
model_path=params.get("model_path"),
summary_max_sentences=params.get("summary_max_sentences"),
keep_video=params.get("keep_video"),
job_id=job_id,
progress=_notify,
)
done += 1
break
except Exception as e:
retryable = not (isinstance(e, PipelineError) and e.status == 400)
if retryable and attempt < _ITEM_ATTEMPTS and not is_cancelled(job_id):
logger.warning(
"job %s item %d/%d attempt %d failed, retrying: %s",
job_id, i, n, attempt, e,
)
_live[job_id] = (
f"{label} — ошибка, повтор {attempt}/{_ITEM_ATTEMPTS - 1} через {int(_RETRY_DELAY)} с"
)
await asyncio.sleep(_RETRY_DELAY)
continue
failed += 1
errors.append(f"{entry['title']}: {e}")
logger.warning("job %s item %d/%d failed: %s", job_id, i, n, e)
break
await update_job(
job_id, done_items=done, failed_items=failed, error="\n".join(errors)[:4000]
)
status = "error" if done == 0 and failed > 0 else "done"
await update_job(job_id, status=status, current_item="")
logger.info("job %s finished: %d ok, %d failed", job_id, done, failed)

170
api/lib/llm.py Normal file
View File

@@ -0,0 +1,170 @@
"""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
# у reasoning-моделей сюда входят и токены размышлений
max_tokens: int = 8000
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",
# urllib's default "Python-urllib/x.y" UA is blocked by some CDNs (Cloudflare 1010)
"User-Agent": "LLM-infa/0.3",
}
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", "User-Agent": "LLM-infa/0.3"}
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

229
api/lib/processing.py Normal file
View File

@@ -0,0 +1,229 @@
"""Ядро пайплайна: одно видео от URL до записи в БД.
Используется и синхронным роутом /pipeline/process, и фоновыми задачами /jobs.
Все дефолты (LLM, длина выжимки, хранение видео, Vosk-модель) берутся из
настроек в БД (таблица settings) с фолбэком на переменные окружения.
"""
from __future__ import annotations
import asyncio
import logging
import uuid as _uuid
from pathlib import Path
from typing import Callable, Optional
from api.config import settings
from api.db.setting import get_all_settings
from api.db.video import create_video
from api.lib.llm import LLMConfig
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
logger = logging.getLogger("processing")
_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 PipelineError(RuntimeError):
def __init__(self, message: str, status: int = 500):
super().__init__(message)
self.status = status
async def effective_defaults() -> dict:
"""Настройки из БД поверх переменных окружения."""
db = await get_all_settings()
base_url = db.get("llm_base_url") or settings.LLM_BASE_URL
model = db.get("llm_model") or settings.LLM_MODEL
llm_cfg = None
if base_url and model:
llm_cfg = LLMConfig(
base_url=base_url,
api_key=db.get("llm_api_key") or settings.LLM_API_KEY,
model=model,
timeout=settings.LLM_TIMEOUT,
max_tokens=settings.LLM_MAX_TOKENS,
temperature=settings.LLM_TEMPERATURE,
)
try:
max_sentences = int(db.get("summary_max_sentences") or 15)
except ValueError:
max_sentences = 15
return {
"llm_cfg": llm_cfg,
"summary_max_sentences": max_sentences,
"keep_video": db.get("keep_video", "1") != "0",
"model_path": db.get("vosk_model_path") or settings.DEFAULT_VOSK_MODEL,
}
def resolve_model_path(raw: str) -> Path:
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 PipelineError(
"model_path must point inside the project's models directory", status=400
)
if not p.exists():
raise PipelineError(f"Model not found: {p}", status=400)
return p
async def process_single_video(
url: str,
*,
model_path: str | None = None,
summary_max_sentences: int | None = None,
keep_video: bool | None = None,
section_id: int | None = None,
job_id: str | None = None,
llm_cfg: Optional[LLMConfig] = None,
system_prompt: str | None = None,
user_template: str | None = None,
progress: Optional[Callable[[str], None]] = None,
) -> dict:
"""Скачивает, расшифровывает, сокращает и сохраняет одно видео.
Параметры со значением None подтягиваются из настроек (БД/env).
progress(msg) — живой статус для UI задач.
"""
notify = progress or (lambda msg: None)
defaults = await effective_defaults()
if llm_cfg is None:
llm_cfg = defaults["llm_cfg"]
if summary_max_sentences is None:
summary_max_sentences = defaults["summary_max_sentences"]
if keep_video is None:
keep_video = defaults["keep_video"]
raw_model_path = model_path or defaults["model_path"]
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 (keep_video=%s)", video_uuid, url, keep_video)
notify("скачивание…")
try:
info = await loop.run_in_executor(
None,
lambda: download_video_with_subs(
url,
video_uuid,
_VIDEO_DIR,
settings.subtitle_langs_list,
# видео не сохраняем — достаточно аудио (для лекций в разы быстрее)
audio_only=not keep_video,
on_progress=lambda pct: notify(f"скачивание {pct}%"),
),
)
except Exception as e:
raise PipelineError(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 PipelineError("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)
notify("распознавание речи (Vosk)…")
resolved_model = resolve_model_path(raw_model_path)
try:
full_text = await loop.run_in_executor(
None,
lambda: transcribe_via_ffmpeg(
video_path,
resolved_model,
on_progress=lambda m: notify(f"распознано {m} мин аудио"),
),
)
method = f"vosk:{resolved_model.name}"
except Exception as e:
raise PipelineError(f"Transcription failed: {e}")
if not full_text.strip():
raise PipelineError("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")
notify("выжимка (LLM)…" if llm_cfg else "выжимка…")
try:
summary = await loop.run_in_executor(
None,
lambda: summarize(
full_text,
llm_cfg=llm_cfg,
system_prompt=system_prompt,
user_template=user_template,
max_sentences=summary_max_sentences,
),
)
except Exception as e:
raise PipelineError(f"Summarization failed: {e}")
text_summary_path.write_text(summary, encoding="utf-8")
if keep_video:
rel_video = str(video_path.relative_to(_PROJECT_ROOT))
else:
# пользователь просил не хранить видео: текст уже извлечён, файлы не нужны
video_path.unlink(missing_ok=True)
for p in _VIDEO_DIR.glob(f"{video_uuid}*.vtt"):
p.unlink(missing_ok=True)
rel_video = ""
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=url,
title=title,
video_path=rel_video,
text_full_path=rel_full,
text_summary_path=rel_summary,
transcription_method=method,
section_id=section_id,
job_id=job_id,
)
except Exception as e:
raise PipelineError(f"DB insert failed: {e}")
logger.info("[%s] done (method=%s)", video_uuid, method)
return {
"uuid": video_uuid,
"title": title,
"source_url": url,
"video_path": rel_video,
"text_full_path": rel_full,
"text_summary_path": rel_summary,
"transcription_method": method,
"summary_preview": summary[:300],
}

231
api/lib/summarize.py Normal file
View File

@@ -0,0 +1,231 @@
"""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 = (
"Сделай связную краткую выжимку следующего текста. "
"Выдели основные тезисы и логику изложения. Объём — примерно {n} предложений.\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,
max_sentences: int = 15,
) -> 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 _fmt(template: str, chunk: str) -> str:
try:
return template.format(text=chunk, n=max_sentences)
except (KeyError, IndexError):
# пользовательский шаблон без {n} / с лишними скобками
return template.replace("{text}", chunk)
def _one(chunk: str, template: str) -> str:
return chat_complete(
cfg,
[
{"role": "system", "content": sys_p},
{"role": "user", "content": _fmt(template, 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,
max_sentences=max_sentences,
)
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,
max_sentences=max_sentences,
)
except LLMError as e:
logger.warning("LLM summarization failed, falling back to extractive: %s", e)
return summarize_extractive(text, max_sentences=max_sentences)

133
api/lib/transcribe.py Normal file
View File

@@ -0,0 +1,133 @@
"""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,
on_progress=None,
) -> str:
"""Decode the source media to PCM s16le mono via ffmpeg and feed it to Vosk in chunks.
on_progress: callback(minutes: int) — вызывается каждые ~5 минут распознанного аудио.
"""
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)
# PCM s16le mono: sample_rate * 2 байта в секунду
minutes = bytes_read // (sample_rate * 2 * 60)
if minutes >= last_logged_mb + 5:
last_logged_mb = minutes
logger.info("Transcribed %d minutes of audio so far", minutes)
if on_progress is not None:
try:
on_progress(minutes)
except Exception:
pass
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

262
api/lib/youtube.py Normal file
View File

@@ -0,0 +1,262 @@
"""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", ".opus", ".ogg", ".aac"}
def expand_url(url: str) -> dict:
"""Определяет, видео это или плейлист, и возвращает список элементов.
Ссылка вида watch?v=…&list=… (видео внутри плейлиста) трактуется как весь
плейлист — именно её люди копируют из адресной строки.
Returns: {"kind": "video"|"playlist", "title": str,
"entries": [{"url": str, "title": str}, ...]}
"""
m = re.search(r"[?&]list=([A-Za-z0-9_\-]+)", url)
if m and "playlist?" not in url:
# RD… и LL/WL — авто-миксы и служебные списки, их не разворачиваем
list_id = m.group(1)
if not list_id.startswith(("RD", "LL", "WL")):
url = f"https://www.youtube.com/playlist?list={list_id}"
opts = {
"extract_flat": "in_playlist",
"quiet": True,
"no_warnings": True,
"skip_download": True,
}
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=False)
if info.get("_type") == "playlist":
entries = []
for e in info.get("entries") or []:
if not e:
continue
u = e.get("url") or ""
if u and not u.startswith("http"):
u = f"https://www.youtube.com/watch?v={u}"
if not u and e.get("id"):
u = f"https://www.youtube.com/watch?v={e['id']}"
if u:
entries.append({"url": u, "title": e.get("title") or u})
return {
"kind": "playlist",
"title": info.get("title") or url,
"entries": entries,
}
title = info.get("title") or url
return {"kind": "video", "title": title, "entries": [{"url": url, "title": title}]}
def download_video_with_subs(
url: str,
video_id: str,
video_dir: Path,
langs: Optional[list[str]] = None,
audio_only: bool = False,
on_progress=None,
) -> dict:
"""Download a single video, also requesting subtitles for the given languages.
audio_only: качает только аудиодорожку (для длинных лекций, когда видео
не сохраняется — в разы быстрее и меньше по объёму).
on_progress: callback(percent: int), вызывается с шагом ~5%.
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")
base_opts = {
"outtmpl": outtmpl,
"quiet": True,
"no_warnings": True,
"noplaylist": True, # ссылка с list= в задаче на одно видео не тянет весь плейлист
# YouTube периодически отвечает 403/сбросом соединения — повторяем сами
"retries": 10,
"fragment_retries": 10,
"extractor_retries": 3,
}
if audio_only:
base_opts["format"] = "ba/b"
else:
base_opts["format"] = "bv*+ba/b"
base_opts["merge_output_format"] = "mp4"
if on_progress is not None:
state = {"last": -5}
def _hook(dd: dict):
if dd.get("status") != "downloading":
return
total = dd.get("total_bytes") or dd.get("total_bytes_estimate")
if not total:
return
pct = int(dd.get("downloaded_bytes", 0) * 100 / total)
if pct >= state["last"] + 5:
state["last"] = pct
try:
on_progress(pct)
except Exception:
pass
base_opts["progress_hooks"] = [_hook]
sub_opts = {
**base_opts,
"writesubtitles": True,
"writeautomaticsub": True,
"subtitleslangs": langs,
"subtitlesformat": "vtt",
}
# YouTube often 429s the subtitle endpoints; a failed subtitle download must not
# kill the pipeline — retry without subs and let the Vosk fallback transcribe.
try:
with yt_dlp.YoutubeDL(sub_opts) as ydl:
info = ydl.extract_info(url, download=True)
except yt_dlp.utils.DownloadError as e:
logger.warning("Download with subtitles failed (%s); retrying without subs", e)
with yt_dlp.YoutubeDL(base_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,37 @@
import asyncio 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.db.job import mark_interrupted
from api.lib import jobqueue
from api.route import app_settings, default, jobs, llm, pipeline, sections, 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()
await mark_interrupted()
worker_task = asyncio.create_task(jobqueue.worker())
yield
worker_task.cancel()
app = FastAPI(title="LLM-infa API", version="0.4.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(jobs.router)
app.include_router(installing.router) app.include_router(videos.router)
app.include_router(media_convert.router) app.include_router(sections.router)
app.include_router(mp3_ffmpeg_stream.router) app.include_router(app_settings.router)
app.include_router(moviepy.router) app.include_router(llm.router)

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

59
api/route/app_settings.py Normal file
View File

@@ -0,0 +1,59 @@
"""Настройки приложения (LLM-ключи, дефолты пайплайна) — живут в Postgres,
переживают перезапуск контейнеров и перезагрузку страницы.
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel
from api.db.setting import get_all_settings, set_settings
router = APIRouter(prefix="/settings", tags=["settings"])
_KEYS = (
"llm_base_url",
"llm_api_key",
"llm_model",
"summary_max_sentences",
"keep_video",
"vosk_model_path",
)
class SettingsPayload(BaseModel):
llm_base_url: str | None = None
llm_api_key: str | None = None
llm_model: str | None = None
summary_max_sentences: int | None = None
keep_video: bool | None = None
vosk_model_path: str | None = None
@router.get("/")
async def get_settings():
db = await get_all_settings()
return {
"llm_base_url": db.get("llm_base_url", ""),
"llm_api_key": db.get("llm_api_key", ""),
"llm_model": db.get("llm_model", ""),
"summary_max_sentences": int(db.get("summary_max_sentences") or 15),
"keep_video": db.get("keep_video", "1") != "0",
"vosk_model_path": db.get("vosk_model_path", "models/vosk-model-small-ru-0.22"),
}
@router.put("/")
async def put_settings(body: SettingsPayload):
values: dict[str, str] = {}
for key in _KEYS:
v = getattr(body, key)
if v is None:
continue
if key == "keep_video":
values[key] = "1" if v else "0"
else:
values[key] = str(v)
if values:
await set_settings(values)
return await get_settings()

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

112
api/route/jobs.py Normal file
View File

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

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}

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

@@ -0,0 +1,80 @@
"""Синхронный запуск пайплайна для одного видео (для длинных видео и
плейлистов используйте /jobs — этот эндпоинт держит HTTP-соединение открытым).
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.lib.llm import LLMConfig
from api.lib.processing import PipelineError, process_single_video
router = APIRouter(prefix="/pipeline", tags=["pipeline"])
class LLMOverride(BaseModel):
base_url: str
api_key: str = ""
model: str
timeout: int = 120
max_tokens: int = 8000
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 # None → из настроек
summary_max_sentences: int | None = None
keep_video: bool | None = None
section_id: int | None = None
# Если задано — используется этот LLM; иначе сохранённые настройки/env;
# иначе встроенная экстрактивная выжимка.
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
@router.post("/process", response_model=ProcessResponse)
async def process_video(req: ProcessRequest):
llm_cfg = None
sys_p = user_t = None
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
try:
result = await process_single_video(
req.url,
model_path=req.model_path,
summary_max_sentences=req.summary_max_sentences,
keep_video=req.keep_video,
section_id=req.section_id,
llm_cfg=llm_cfg,
system_prompt=sys_p,
user_template=user_t,
)
except PipelineError as e:
raise HTTPException(status_code=e.status, detail=str(e))
return ProcessResponse(**result)

44
api/route/sections.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from api.db.section import create_section, delete_section, list_sections
router = APIRouter(prefix="/sections", tags=["sections"])
class SectionIn(BaseModel):
name: str
parent_id: int | None = None
class SectionOut(BaseModel):
id: int
name: str
parent_id: int | None
video_count: int
@router.get("/", response_model=list[SectionOut])
async def list_all():
return await list_sections()
@router.post("/", response_model=SectionOut)
async def create(body: SectionIn):
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Section name is empty")
try:
s = await create_section(name, body.parent_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return SectionOut(id=s.id, name=s.name, parent_id=s.parent_id, video_count=0)
@router.delete("/{section_id}")
async def delete(section_id: int):
ok = await delete_section(section_id)
if not ok:
raise HTTPException(status_code=404, detail="Section not found")
return {"ok": True}

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

@@ -0,0 +1,132 @@
from datetime import datetime
from pathlib import Path
from typing import List, Literal
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from api.db.section import list_sections
from api.db.video import (
delete_video,
get_video,
list_videos,
set_video_section,
)
router = APIRouter(prefix="/videos", tags=["videos"])
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class VideoOut(BaseModel):
uuid: str
source_url: str
section_id: int | None
title: str
video_path: str
text_full_path: str
text_summary_path: str
transcription_method: str
created_at: datetime
class SectionPatch(BaseModel):
section_id: int | None
def _to_out(v) -> VideoOut:
return VideoOut(
uuid=v.uuid,
source_url=v.source_url,
section_id=v.section_id,
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,
)
async def _descendant_ids(section_id: int) -> list[int]:
"""Раздел + все его потомки (фильтр по разделу захватывает подразделы)."""
sections = await list_sections()
children: dict[int | None, list[int]] = {}
for s in sections:
children.setdefault(s["parent_id"], []).append(s["id"])
ids, stack = [], [section_id]
while stack:
cur = stack.pop()
ids.append(cur)
stack.extend(children.get(cur, []))
return ids
@router.get("/", response_model=List[VideoOut])
async def list_all(
q: str | None = Query(default=None, description="Поиск по названию/URL"),
section_id: int | None = Query(default=None, description="Раздел (включая подразделы)"),
method: str | None = Query(default=None, description="Префикс метода: subtitles | vosk"),
):
section_ids = await _descendant_ids(section_id) if section_id is not None else None
return [
_to_out(v)
for v in await list_videos(q=q, section_ids=section_ids, method=method)
]
@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.get("/{video_uuid}/text")
async def get_text(video_uuid: str, kind: Literal["full", "summary"] = "full"):
v = await get_video(video_uuid)
if not v:
raise HTTPException(status_code=404, detail="Video not found")
rel = v.text_full_path if kind == "full" else v.text_summary_path
if not rel:
raise HTTPException(status_code=404, detail="Text file is not recorded")
p = (_PROJECT_ROOT / rel).resolve()
try:
p.relative_to(_PROJECT_ROOT)
except ValueError:
raise HTTPException(status_code=400, detail="Bad text path")
if not p.exists():
raise HTTPException(status_code=404, detail=f"File missing on disk: {rel}")
return {"uuid": video_uuid, "kind": kind, "text": p.read_text(encoding="utf-8", errors="ignore")}
@router.patch("/{video_uuid}", response_model=VideoOut)
async def patch_section(video_uuid: str, body: SectionPatch):
v = await set_video_section(video_uuid, body.section_id)
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, delete_files: bool = True):
v = await get_video(video_uuid)
if not v:
raise HTTPException(status_code=404, detail="Video not found")
if delete_files:
for rel in (v.video_path, v.text_full_path, v.text_summary_path):
if not rel:
continue
p = (_PROJECT_ROOT / rel).resolve()
try:
p.relative_to(_PROJECT_ROOT)
except ValueError:
continue
p.unlink(missing_ok=True)
# сопутствующие субтитры .vtt рядом с видео
for p in (_PROJECT_ROOT / "data" / "videos").glob(f"{video_uuid}*.vtt"):
p.unlink(missing_ok=True)
await delete_video(video_uuid)
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,48 @@ 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: volumes:
# test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - ./web:/app/web
# interval: 5s depends_on:
# timeout: 5s - api
# retries: 10 dns:
# start_period: 10s - 8.8.8.8
- 1.1.1.1
#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,13 @@
[project] [project]
name = "llm-infa" name = "llm-infa"
version = "0.2.0" version = "0.4.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", "flet>=0.28.3",
"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 +16,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

354
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,14 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { 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 = "flet" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@@ -266,12 +294,18 @@ 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 = "flet", specifier = ">=0.28.3" },
{ 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 +315,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 +417,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, { 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 +470,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { 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 +585,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, { 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 +634,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/4c/deb0861f7da9696f8a255f1731bb73e9412cca29c4b3888a3fcb2a930a59/vosk-0.3.45-py3-none-win_amd64.whl", hash = "sha256:6994ddc68556c7e5730c3b6f6bad13320e3519b13ce3ed2aa25a86724e7c10ac", size = 13997596, upload-time = "2022-12-14T23:13:31.15Z" }, { 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

134
web/api_client.py Normal file
View File

@@ -0,0 +1,134 @@
"""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()
# --- Settings (persisted server-side in Postgres) ---
def get_settings(self) -> dict:
return self._get("/settings/")
def save_settings(self, values: dict) -> dict:
return self._request("PUT", "/settings/", values)
# --- LLM ---
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},
)
# --- Jobs (видео и плейлисты, в фоне) ---
def create_job(self, payload: dict) -> dict:
return self._post("/jobs/", payload)
def list_jobs(self) -> list[dict]:
return self._get("/jobs/")
def cancel_job(self, job_id: str) -> dict:
return self._post(f"/jobs/{job_id}/cancel", None)
def delete_job(self, job_id: str) -> dict:
return self._request("DELETE", f"/jobs/{job_id}")
# --- Videos ---
def list_videos(
self,
q: str | None = None,
section_id: int | None = None,
method: str | None = None,
) -> list[dict]:
params: dict = {}
if q:
params["q"] = q
if section_id is not None:
params["section_id"] = section_id
if method:
params["method"] = method
return self._get("/videos/", params=params)
def video_text(self, uuid: str, kind: str) -> dict:
return self._get(f"/videos/{uuid}/text", params={"kind": kind})
def set_video_section(self, uuid: str, section_id: int | None) -> dict:
return self._request("PATCH", f"/videos/{uuid}", {"section_id": section_id})
def delete_video(self, uuid: str) -> dict:
return self._request("DELETE", f"/videos/{uuid}")
# --- Sections ---
def list_sections(self) -> list[dict]:
return self._get("/sections/")
def create_section(self, name: str, parent_id: int | None) -> dict:
return self._post("/sections/", {"name": name, "parent_id": parent_id})
def delete_section(self, section_id: int) -> dict:
return self._request("DELETE", f"/sections/{section_id}")
# --- internals ---
def _get(self, path: str, params: dict | None = None) -> dict | list:
try:
r = self._client.get(path, params=params)
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)
def _request(self, method: str, path: str, json: dict | None = None) -> dict:
try:
r = self._client.request(method, 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)

39
web/main.py Normal file
View File

@@ -0,0 +1,39 @@
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())
# настройки живут на сервере (БД) — заполняем поля сохранёнными значениями
try:
view.apply_settings(client.get_settings())
except Exception:
pass
view._refresh_library()
view._start_jobs_poll()
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

869
web/views/main_view.py Normal file
View File

@@ -0,0 +1,869 @@
"""Main page of the LLM-infa Flet web app: вкладки «Главная» и «Настройки»."""
from __future__ import annotations
import threading
import time
import flet as ft
from web.api_client import ApiClient, ApiError
import web.designer as d
_JOB_STATUS = {
"queued": ("в очереди", None),
"running": ("выполняется", None),
"done": ("готово", "ok"),
"error": ("ошибка", "danger"),
"cancelled": ("отменена", None),
"interrupted": ("прервана", "danger"),
}
class MainView:
def __init__(self, page: ft.Page, client: ApiClient):
self.page = page
self.client = client
self.models: list[dict] = []
self.selected_model: str = ""
self.sections: list[dict] = []
self.current_section_id: int | None = None # None = все видео
self._polling = False
# ── Settings tab fields ────────────────────────────────────────────
self.base_url = d.text_field("Base URL", hint="https://openrouter.ai/api/v1", expand=True)
self.api_key = d.text_field("API Key", hint="sk-…", password=True, expand=True)
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)
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.keep_video_sw = ft.Switch(
label="Сохранять видео на диске (иначе качается только аудио)",
value=True,
active_color=d.ACCENT,
label_style=ft.TextStyle(color=d.TEXT, size=13),
)
self.settings_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_save = d.primary_button("💾 Сохранить настройки", self._save_settings)
# ── Pipeline (main tab) ────────────────────────────────────────────
self.yt_url = d.text_field(
"Ссылка на YouTube: видео или плейлист",
hint="https://youtu.be/… или https://www.youtube.com/playlist?list=…",
expand=True,
)
self.pipeline_section_dd = self._dropdown("Раздел для новых видео", width=280)
self.pipeline_msg = ft.Text("", color=d.MUTED, size=13, expand=True)
self.btn_run = d.primary_button("В очередь", self._run_pipeline)
# ── Jobs ───────────────────────────────────────────────────────────
self._jobs_col = ft.Column(spacing=6)
# ── Library ────────────────────────────────────────────────────────
self.search_field = d.text_field(
"Поиск по названию / URL", on_change=None, expand=True
)
self.search_field.on_submit = lambda _: self._refresh_library()
self.method_dd = self._dropdown("Метод", width=190)
self.method_dd.options = [
ft.dropdown.Option("", "все"),
ft.dropdown.Option("subtitles", "субтитры"),
ft.dropdown.Option("vosk", "vosk"),
]
self.method_dd.value = ""
self.method_dd.on_change = lambda _: self._refresh_library()
self._sections_col = ft.Column(spacing=2)
self._library_col = ft.Column(spacing=8, expand=True)
self.lib_msg = ft.Text("", color=d.MUTED, size=12)
# ─────────────────────── small factories ─────────────────────────────
def _dropdown(self, label: str, width: int | None = None) -> ft.Dropdown:
return ft.Dropdown(
label=label,
options=[],
width=width,
bgcolor=d.SURFACE_3,
border_color=d.BORDER,
focused_border_color=d.ACCENT,
label_style=ft.TextStyle(color=d.MUTED, size=12),
text_size=13,
color=d.TEXT,
)
# ─────────────────────── build ───────────────────────────────────────
def build(self) -> ft.Control:
main_tab = ft.Container(
content=ft.Column(
[
self._build_pipeline_card(),
self._build_jobs_card(),
self._build_library_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
settings_tab = ft.Container(
content=ft.Column(
[
self._build_llm_card(),
self._build_defaults_card(),
],
spacing=20,
scroll=ft.ScrollMode.AUTO,
),
padding=ft.padding.symmetric(horizontal=24, vertical=20),
expand=True,
)
tabs = ft.Tabs(
selected_index=0,
animation_duration=200,
label_color=d.ACCENT_2,
unselected_label_color=d.MUTED,
indicator_color=d.ACCENT,
divider_color=d.BORDER,
tabs=[
ft.Tab(text="Главная", content=main_tab),
ft.Tab(text="Настройки", content=settings_tab),
],
expand=True,
)
return ft.Column(
controls=[self._build_topbar(), tabs],
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)),
)
# ── Settings tab cards ────────────────────────────────────────────────
def _build_llm_card(self) -> ft.Container:
return d.card(
d.section_title("Подключение к LLM"),
d.hint(
"Любой OpenAI-совместимый провайдер (OpenRouter, OpenAI, локальная Ollama…). "
"Ключ и модель хранятся на сервере в БД и переживают перезапуск."
),
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_defaults_card(self) -> ft.Container:
return d.card(
d.section_title("Параметры обработки"),
d.hint("Применяются ко всем новым задачам."),
ft.Row([self.vosk_path, self.n_sentences], spacing=12),
self.keep_video_sw,
ft.Divider(color=d.BORDER, height=1),
ft.Row([self.btn_save, self.settings_msg], spacing=10),
)
# ── Main tab cards ────────────────────────────────────────────────────
def _build_pipeline_card(self) -> ft.Container:
return d.card(
d.section_title("Обработать видео или плейлист"),
d.hint(
"Задача уходит в фон: качаем, расшифровываем (субтитры или Vosk), "
"сокращаем через LLM, пишем в БД. Плейлисты и лекции на 45 часов — ок."
),
self.yt_url,
ft.Row(
[self.btn_run, self.pipeline_section_dd, self.pipeline_msg],
spacing=12,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
)
def _build_jobs_card(self) -> ft.Container:
return d.card(
ft.Row(
[
d.section_title("Задачи"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._start_jobs_poll()),
]
),
self._jobs_col,
)
def _build_library_card(self) -> ft.Container:
left = ft.Container(
width=260,
content=ft.Column(
[
ft.Row(
[
ft.Text("Разделы", color=d.TEXT, size=14, weight=ft.FontWeight.W_600),
ft.Container(expand=True),
ft.IconButton(
icon=ft.Icons.ADD,
icon_color=d.ACCENT_2,
icon_size=18,
tooltip="Добавить раздел",
on_click=self._add_section_dialog,
),
]
),
self._sections_col,
],
spacing=6,
tight=True,
),
)
right = ft.Column(
[
ft.Row([self.search_field, self.method_dd], spacing=10),
self.lib_msg,
self._library_col,
],
spacing=10,
expand=True,
tight=True,
)
return d.card(
ft.Row(
[
d.section_title("Библиотека"),
ft.Container(expand=True),
d.secondary_button("Обновить", lambda _: self._refresh_library()),
]
),
ft.Row(
[left, ft.Container(width=1, bgcolor=d.BORDER), right],
spacing=16,
vertical_alignment=ft.CrossAxisAlignment.START,
),
)
# ─────────────────── settings load/save ──────────────────────────────
def apply_settings(self, s: dict) -> None:
"""Заполняет поля значениями с сервера (вызывается при старте сессии)."""
self.base_url.value = s.get("llm_base_url", "")
self.api_key.value = s.get("llm_api_key", "")
self.selected_model = s.get("llm_model", "")
self.model_input.value = self.selected_model
self.n_sentences.value = str(s.get("summary_max_sentences", 15))
self.keep_video_sw.value = bool(s.get("keep_video", True))
self.vosk_path.value = s.get("vosk_model_path", "models/vosk-model-small-ru-0.22")
self._set_status(ok=bool(self.base_url.value and self.selected_model))
def _save_settings(self, _):
model = self.selected_model or (self.model_input.value or "").strip()
payload = {
"llm_base_url": self.base_url.value.strip(),
"llm_api_key": self.api_key.value.strip(),
"llm_model": model,
"summary_max_sentences": int(self.n_sentences.value or 15),
"keep_video": bool(self.keep_video_sw.value),
"vosk_model_path": self.vosk_path.value.strip(),
}
try:
self.client.save_settings(payload)
self.settings_msg.value = "Сохранено ✓ (хранится в БД, переживёт перезапуск)"
self.settings_msg.color = d.OK
self._set_status(ok=bool(payload["llm_base_url"] and model))
except ApiError as e:
self.settings_msg.value = str(e)
self.settings_msg.color = d.DANGER
self.page.update()
# ─────────────────── 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.llm_msg.value = "Не забудьте нажать «Сохранить настройки»"
self.llm_msg.color = d.MUTED
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)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
finally:
self.btn_load.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
def _test_conn(self, _):
model = self.selected_model or (self.model_input.value or "").strip()
if not 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(),
model,
)
preview = (r.get("reply") or "")[:80]
self._set_llm_msg(f"OK · {preview}", ok=True)
except ApiError as e:
self._set_llm_msg(str(e), error=True)
finally:
self.btn_test.disabled = False
self.page.update()
threading.Thread(target=_task, daemon=True).start()
# ─────────────────── pipeline → jobs ──────────────────────────────────
def _run_pipeline(self, _):
url = self.yt_url.value.strip()
if not url:
self._set_pipeline_msg("Укажите ссылку на видео или плейлист", error=True)
return
payload: dict = {"url": url}
if self.pipeline_section_dd.value:
payload["section_id"] = int(self.pipeline_section_dd.value)
try:
self.client.create_job(payload)
self.yt_url.value = ""
self._set_pipeline_msg("Задача поставлена в очередь ✓", ok=True)
except ApiError as e:
self._set_pipeline_msg(str(e), error=True)
self.page.update()
self._start_jobs_poll()
# ─────────────────── jobs ─────────────────────────────────────────────
def _start_jobs_poll(self):
if self._polling:
return
self._polling = True
threading.Thread(target=self._poll_loop, daemon=True).start()
def _poll_loop(self):
prev: dict = {}
fails = 0
try:
while True:
try:
jobs = self.client.list_jobs()
self._render_jobs(jobs)
state = {j["id"]: (j["done_items"], j["status"]) for j in jobs}
if state != prev:
prev = state
self._refresh_library() # там же page.update()
else:
self.page.update()
fails = 0
if not any(j["status"] in ("queued", "running") for j in jobs):
break
except Exception:
# обрыв сети/перезапуск api — не глушим опрос, пробуем ещё
fails += 1
if fails >= 10:
break
time.sleep(3)
finally:
self._polling = False
def _render_jobs(self, jobs: list[dict]):
if not jobs:
self._jobs_col.controls = [
ft.Text("Задач ещё не было", color=d.MUTED, size=13)
]
return
self._jobs_col.controls = [self._job_row(j) for j in jobs]
def _job_row(self, j: dict) -> ft.Container:
label, tone = _JOB_STATUS.get(j["status"], (j["status"], None))
color = d.OK if tone == "ok" else (d.DANGER if tone == "danger" else d.MUTED)
active = j["status"] in ("queued", "running")
chip = ft.Container(
content=ft.Text(label, size=11, color=color),
padding=ft.padding.symmetric(horizontal=8, vertical=2),
border=ft.border.all(1, color),
border_radius=99,
)
head = [
chip,
ft.Text(
(j["title"] or j["url"])[:90],
color=d.TEXT,
size=13,
weight=ft.FontWeight.W_600,
expand=True,
no_wrap=True,
),
]
if j["kind"] == "playlist":
head.append(ft.Text("плейлист", color=d.ACCENT_2, size=11))
if j["total_items"]:
head.append(
ft.Text(f"{j['done_items']}/{j['total_items']}", color=d.MUTED, size=12)
)
if active:
head.append(
ft.IconButton(
icon=ft.Icons.STOP_CIRCLE_OUTLINED,
icon_color=d.DANGER,
icon_size=17,
tooltip="Отменить (после текущего элемента)",
on_click=lambda e, jid=j["id"]: self._cancel_job(jid),
)
)
else:
head.append(
ft.IconButton(
icon=ft.Icons.CLOSE,
icon_color=d.MUTED,
icon_size=15,
tooltip="Убрать из списка",
on_click=lambda e, jid=j["id"]: self._delete_job(jid),
)
)
rows: list[ft.Control] = [ft.Row(head, spacing=8)]
if active:
total = j["total_items"] or 0
value = (j["done_items"] / total) if total else None
rows.append(
ft.ProgressBar(value=value, color=d.ACCENT, bgcolor=d.SURFACE_3, height=4)
)
if j.get("live"):
rows.append(ft.Text(j["live"][:140], color=d.ACCENT_2, size=11))
if j.get("error"):
rows.append(
ft.Text(j["error"][:300], color=d.DANGER, size=11, no_wrap=False)
)
if j["failed_items"]:
rows.append(
ft.Text(f"с ошибкой: {j['failed_items']}", color=d.DANGER, size=11)
)
return ft.Container(
content=ft.Column(rows, spacing=5, tight=True),
padding=10,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
def _cancel_job(self, job_id: str):
try:
self.client.cancel_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
def _delete_job(self, job_id: str):
try:
self.client.delete_job(job_id)
except ApiError as e:
self.lib_msg.value = str(e)
self._start_jobs_poll()
# ─────────────────── sections ─────────────────────────────────────────
def _section_options(self, *, none_label: str) -> list[ft.dropdown.Option]:
"""Options списка разделов с отступами по глубине дерева."""
opts = [ft.dropdown.Option("", none_label)]
children: dict[int | None, list[dict]] = {}
for s in self.sections:
children.setdefault(s["parent_id"], []).append(s)
def walk(parent_id: int | None, depth: int):
for s in children.get(parent_id, []):
opts.append(ft.dropdown.Option(str(s["id"]), " " * depth + s["name"]))
walk(s["id"], depth + 1)
walk(None, 0)
return opts
def _render_sections(self):
children: dict[int | None, list[dict]] = {}
for s in self.sections:
children.setdefault(s["parent_id"], []).append(s)
def item(sid: int | None, name: str, count: int | None, depth: int, deletable: bool):
selected = self.current_section_id == sid
row = [
ft.Text(name, color=d.ACCENT_2 if selected else d.TEXT, size=13, expand=True, no_wrap=True),
]
if count is not None:
row.append(ft.Text(str(count), color=d.MUTED, size=11))
if deletable:
row.append(
ft.IconButton(
icon=ft.Icons.DELETE_OUTLINE,
icon_color=d.MUTED,
icon_size=15,
tooltip="Удалить раздел (видео останутся)",
on_click=lambda e, s=sid: self._delete_section(s),
)
)
return ft.Container(
content=ft.Row(row, spacing=4),
padding=ft.padding.only(left=10 + depth * 16, right=2, top=2, bottom=2),
bgcolor=d.SURFACE_2 if selected else None,
border_radius=6,
ink=True,
on_click=lambda e, s=sid: self._select_section(s),
)
controls = [item(None, "Все видео", None, 0, deletable=False)]
def walk(parent_id: int | None, depth: int):
for s in children.get(parent_id, []):
controls.append(item(s["id"], s["name"], s["video_count"], depth, deletable=True))
walk(s["id"], depth + 1)
walk(None, 0)
if not self.sections:
controls.append(ft.Text("Разделов пока нет — добавьте «+»", color=d.MUTED, size=12))
self._sections_col.controls = controls
def _select_section(self, section_id: int | None):
self.current_section_id = section_id
self._refresh_library()
def _delete_section(self, section_id: int):
try:
self.client.delete_section(section_id)
if self.current_section_id == section_id:
self.current_section_id = None
except ApiError as e:
self.lib_msg.value = str(e)
self._refresh_library()
def _add_section_dialog(self, _):
name_field = d.text_field("Название раздела", expand=True)
parent_dd = self._dropdown("Родительский раздел", width=None)
parent_dd.options = self._section_options(none_label="— корневой —")
parent_dd.value = str(self.current_section_id or "")
def _create(_):
name = (name_field.value or "").strip()
if not name:
return
try:
parent = int(parent_dd.value) if parent_dd.value else None
self.client.create_section(name, parent)
except ApiError as e:
self.lib_msg.value = str(e)
self.page.close(dlg)
self._refresh_library()
dlg = ft.AlertDialog(
modal=True,
bgcolor=d.SURFACE,
title=ft.Text("Новый раздел", color=d.TEXT, size=16),
content=ft.Container(
width=420,
content=ft.Column([name_field, parent_dd], spacing=12, tight=True),
),
actions=[
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
d.primary_button("Создать", _create),
],
)
self.page.open(dlg)
# ─────────────────── library ─────────────────────────────────────────
def _refresh_library(self):
self.lib_msg.value = ""
try:
self.sections = self.client.list_sections()
items = self.client.list_videos(
q=(self.search_field.value or "").strip() or None,
section_id=self.current_section_id,
method=self.method_dd.value or None,
)
except ApiError as e:
self._library_col.controls = [ft.Text(str(e), color=d.DANGER, size=13)]
self.page.update()
return
self._render_sections()
self.pipeline_section_dd.options = self._section_options(none_label="— без раздела —")
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", " ")
section_dd = self._dropdown("Раздел", width=230)
section_dd.options = self._section_options(none_label="— без раздела —")
section_dd.value = str(v.get("section_id") or "")
section_dd.on_change = lambda e, u=v["uuid"]: self._assign_section(u, e.control.value)
return ft.Container(
content=ft.Column(
[
ft.Row(
[
ft.Text(
v.get("title") or v.get("uuid", ""),
color=d.TEXT,
size=14,
weight=ft.FontWeight.W_600,
expand=True,
no_wrap=True,
),
ft.Text(created, color=d.MUTED, size=11),
ft.IconButton(
icon=ft.Icons.DELETE_OUTLINE,
icon_color=d.DANGER,
icon_size=17,
tooltip="Удалить видео и файлы",
on_click=lambda e, vid=v: self._confirm_delete_video(vid),
),
]
),
ft.Text(v.get("source_url", ""), color=d.MUTED, size=11, selectable=True),
self._kv("Метод", v.get("transcription_method", "")),
self._kv("Видео", v.get("video_path") or "не сохранено"),
ft.Row(
[
d.secondary_button("Выжимка", lambda e, vid=v: self._show_text(vid, "summary")),
d.secondary_button("Полный текст", lambda e, vid=v: self._show_text(vid, "full")),
ft.Container(expand=True),
section_dd,
],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
],
spacing=5,
tight=True,
),
padding=12,
bgcolor=d.SURFACE_2,
border=ft.border.all(1, d.BORDER),
border_radius=10,
)
def _assign_section(self, uuid: str, value: str):
try:
self.client.set_video_section(uuid, int(value) if value else None)
except ApiError as e:
self.lib_msg.value = str(e)
self._refresh_library()
def _confirm_delete_video(self, v: dict):
def _do(_):
try:
self.client.delete_video(v["uuid"])
except ApiError as e:
self.lib_msg.value = str(e)
self.page.close(dlg)
self._refresh_library()
dlg = ft.AlertDialog(
modal=True,
bgcolor=d.SURFACE,
title=ft.Text("Удалить видео?", color=d.TEXT, size=16),
content=ft.Text(
f"«{v.get('title') or v['uuid']}»\n"
"Будут удалены запись в БД и файлы (видео, тексты).",
color=d.MUTED,
size=13,
),
actions=[
ft.TextButton("Отмена", on_click=lambda e: self.page.close(dlg)),
ft.TextButton(
"Удалить",
style=ft.ButtonStyle(color=d.DANGER),
on_click=_do,
),
],
)
self.page.open(dlg)
def _show_text(self, v: dict, kind: str):
try:
data = self.client.video_text(v["uuid"], kind)
except ApiError as e:
self.lib_msg.value = str(e)
self.page.update()
return
title = ("Выжимка — " if kind == "summary" else "Полный текст — ") + (
v.get("title") or v["uuid"]
)
dlg = ft.AlertDialog(
bgcolor=d.SURFACE,
title=ft.Text(title, color=d.TEXT, size=15, no_wrap=False),
content=ft.Container(
width=780,
height=440,
content=ft.Column(
[ft.Text(data.get("text", ""), color=d.TEXT, size=13, selectable=True)],
scroll=ft.ScrollMode.AUTO,
),
bgcolor=d.SURFACE_3,
border=ft.border.all(1, d.BORDER),
border_radius=8,
padding=12,
),
actions=[ft.TextButton("Закрыть", on_click=lambda e: self.page.close(dlg))],
)
self.page.open(dlg)
# ─────────────────── 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 "LLM не настроена"
chip.content.color = d.OK if ok else d.MUTED
chip.border = ft.border.all(1, "rgba(109,213,140,.4)" if ok else d.BORDER)