Привет, Хабр! Я люблю слушать книги, но не все есть на Литрес и Storytel. Особенно это касается профессиональной литературы, фанфиков, статей и документов — всего того, что вряд ли когда-нибудь озвучат профессиональные дикторы.

Я поняла, что нейросетевые голоса уже достаточно хороши для комфортного прослушивания. И подумала: а что если сделать Telegram-бота, которому можно просто скинуть файл — а через пару минут слушать аудиокнигу в удобном плеере прямо в Telegram?

Так родился VoiceBooks — open-source сервис для озвучки книг, который работает полностью бесплатно.

В этой статье я разберу архитектуру open-source проекта: как организован парсинг 6 форматов в единый пайплайн, как работает фоновая генерация аудио без Celery и RabbitMQ, и как элегантно обойти лимиты Telegram Bot API на загрузку файлов.

Стек: Python 3.12, FastAPI, aiogram 3, Edge TTS, SQLAlchemy 2.0 + PostgreSQL. Деплой — Railway.

1. Что получилось

Бот принимает файлы форматов EPUB, FB2, PDF, DOCX, TXT или MOBI. Пользователь выбирает один из 7 нейросетевых голосов (предварительно прослушав превью) и запускает процесс.

Ключевые функции плеера (Telegram Mini App):

  • Воспроизведение с навигацией по распознанным главам книги.

  • Скорость от 0.75x до 2x.

  • Автосохранение позиции прослушивания (синхронизация между Telegram и Web).

  • Таймер сна и фоновое воспроизведение.

Всё это работает в рамках бесплатного пет-проекта.

2. Архитектура — как это работает

Один процесс — всё внутри

Бот, API, TTS-воркер и фронтенд живут в одном FastAPI-процессе. Для высоконагруженного enterprise-решения это антипаттерн, но для пет-проекта на Railway с ограничением по RAM (1–2 GB) — это оптимальный выбор. Жизненный цикл управляется через lifespan FastAPI: мы поднимаем соединения с БД, запускаем фоновую таску TTS-воркера и polling Telegram-бота, а при остановке контейнера корректно их завершаем (graceful shutdown).

База данных как очередь (DBaaQ)

Я намеренно отказалась от Celery, RabbitMQ или Redis. PostgreSQL выступает одновременно хранилищем данных и очередью задач. У каждого текстового фрагмента (Chunk) есть поле status (pending, processing, done).

Фоновый воркер опрашивает базу и забирает батч pending-чанков. Это избавляет от дополнительных зависимостей в инфраструктуре. Примечание: Так как воркер работает в единственном экземпляре (один процесс), обычного SELECT достаточно. При масштабировании до нескольких контейнеров запрос необходимо усилить блокировкой FOR UPDATE SKIP LOCKED, чтобы воркеры не разбирали одни и те же чанки.

Edge TTS: дёшево и сердито

Вместо ресурсоёмких офлайн-моделей (например, Silero TTS, требующего ~800 MB RAM даже на CPU), используется библиотека edge-tts. Это обёртка над недокументированным API Microsoft Read Aloud. Риск: API не является публичным, поэтому Microsoft теоретически может ограничить к нему доступ. На данный момент это работает стабильно, предоставляет десятки языков и нулевой overhead по RAM/CPU на сервере.

3. Парсинг книг — 6 форматов, единый пайплайн

Парсер построен по паттерну Strategy — единый интерфейс extract_chapters() маршрутизирует логику в зависимости от расширения файла. Каждый экстрактор возвращает унифицированную структуру: список кортежей (chapter_title, text).

EPUB: Парсим через ebooklib + BeautifulSoup. Нюанс формата: тег <title> обычно содержит название всей книги, а не главы. Поэтому ищем заголовки строго в тегах <h1>/<h2>/<h3>. FB2: Рекурсивный обход XML-дерева по тегам <section>. Обязательно ограничение глубины рекурсии (например, depth < 3), чтобы избежать фрагментации, где каждый абзац становится самостоятельной главой. PDF: Используем PyMuPDF. Если в документе есть Table of Contents (TOC), бьём текст по нему. Если нет — применяем фолбэк: каждая страница становится логическим чанком.

Алгоритм чанкирования

Edge TTS оптимально работает с фрагментами текста до 900 символов. Алгоритм разрезания текста имеет три уровня деградации, чтобы не рвать смысл:

  1. Поиск последней границы предложения (.!? + пробел).

  2. Фолбэк на последний пробел (чтобы не разорвать слово).

  3. Hard cut по лимиту (только если попалась непрерывная строка длиннее 900 символов, например, длинный URL).

4. TTS Worker — фоновая озвучка

Воркер работает в бесконечном цикле while True с интервалом asyncio.sleep(5). Главная задача воркера — приоритизация. Если пользователь начал слушать книгу и находится на 50-м чанке, нет смысла тратить ресурсы на генерацию 1-го чанка другой книги. SQL-запрос сортирует pending чанки на основе current_chunk_index текущего активного пользователя.

Retry-стратегия примитивна, но надёжна: если генерация через Edge TTS падает (обычно из-за сетевого таймаута), статус чанка откатывается обратно в pending, и воркер попытается обработать его на следующем цикле.

5. Telegram Bot (aiogram 3) и обход лимитов

Для интеграции с Telegram используется aiogram 3.x с Router-архитектурой.

Local Bot API Server Стандартный API Telegram не позволяет ботам скачивать файлы размером более 20 МБ. Для профессиональной литературы в PDF это блокер. Решение — запуск собственного Local Bot API Server. В Dockerfile мы поднимаем бинарник telegram-bot-api, который увеличивает лимит до 2 ГБ, а в коде инициализируем сессию через него:

Python

local_server = TelegramAPIServer.from_base(settings.telegram_api_url)
session = AiohttpSession(api=local_server)
bot = Bot(token=settings.bot_token, session=session)

6. Web App — Telegram Mini App (TMA)

Фронтенд написан на ванильном HTML/CSS/JS. Причины две: критичность скорости загрузки для TMA и отсутствие необходимости поддерживать сложную инфраструктуру сборки (Webpack/Vite) для утилитарного интерфейса. Весь бандл весит около 95 КБ.

Для создания иллюзии непрерывного воспроизведения (насколько это позволяет стандартный тег <audio>) JS-клиент предзагружает следующий чанк за несколько секунд до окончания текущего. Для реализации полноценного true gapless без микропауз потребовалось бы переписать плеер на Web Audio API, что пока избыточно для пет-проекта.

Позиция прослушивания (индекс чанка и секунда) отправляется на сервер при постановке на паузу или закрытии TMA.

7. Грабли, на которые я наступила

1. Сюрпризы SQLAlchemy при смене владельца записей. При объединении Telegram-аккаунта с Web-аккаунтом необходимо перенести книги и удалить старый профиль. Если у связи User.books стоит cascade="all, delete-orphan", то простое удаление пользователя удалит и все перенесённые книги (ORM помнит их состояние в памяти). Решение: Вместо костылей с raw SQL, правильно очищать связи на уровне объектов перед удалением: old_user.books.clear(), затем session.flush(), и только потом session.delete(old_user).

2. UNIQUE constraint при миграции telegram_id. Нельзя перевесить telegram_id на новый аккаунт одним коммитом, пока он числится за старым. Обязателен строгий порядок: сначала обнулить ID у старого пользователя -> session.flush() -> присвоить ID новому.

3. TelegramConflictError при редеплое на Railway. При деплое Railway на несколько секунд оставляет работать старый контейнер параллельно с новым. Два инстанса aiogram, делающие polling, немедленно роняют друг друга. Решение: При старте приложения делаем await bot.delete_webhook(drop_pending_updates=True) и добавляем искусственную паузу asyncio.sleep(3), давая старому контейнеру время умереть.

Заключение

Что работает отлично:

  • Архитектура "Всё в одном процессе" экономит ресурсы и упрощает деплой.

  • DBaaQ на базе PostgreSQL полностью закрывает потребности асинхронной обработки без оверхеда на поддержку брокеров.

  • Edge TTS выдаёт качество звука, несопоставимое с бесплатными офлайн-аналогами.

Зоны роста:

  • Переход на HLS-стриминг (HTTP Live Streaming) вместо отдачи сырых MP3-файлов чанками.

  • Внедрение asyncio.Event для пробуждения воркера: это уберёт 5-секундную задержку поллинга, заставляя воркер начинать генерацию моментально при добавлении книги.

Ссылки

Буду рада вопросам и пулл-реквестам!