Как «Тиндер для фильмов» из проекта на выходные дорос до 3000 пользователей, ML-микросервиса и Telegram Mini App
У меня есть привычка — превращать любые бытовые затыки в пет-проекты. Как-то раз я написал бота, который советовал, что надеть по погоде. В этот раз затея оказалась масштабнее и засосала меня почти на год.
Всё началось с того, что я женился. Сразу же всплыл классический семейный квест: «Что посмотрим вечером?». Сценарий стандартный: чипсы уже на кровати, выбирать лень, и вы полчаса уныло листаете топы Кинопоиска. Один хочет хоррор, второй — мелодраму. В какой-то момент я подумал: если люди не могут договориться сами, пусть за них это сделает алгоритм.
Так родился KinoMatch. Механика простая: вы свайпаете фильмы, и когда вкусы совпадают — случается мэтч.
Сейчас проект прошел путь от сырого MVP до версии 5.8.0. Под капотом теперь крутятся отдельный ML-микросервис, Telegram Mini App и база на 16 000 тайтлов. Почти 3000 реальных пользователей уже нащелкали 262 тысячи свайпов.
Ниже — честный разбор без лишней воды. Расскажу, как строил архитектуру, где наступил на грабли (iOS Safari — это отдельный вид боли) и что в итоге показала статистика с продакшена. Кода почти не будет, объясню всё на пальцах.
Концепт: свайпы, мэтч и никакого рефреша страницы
Идея работает именно потому, что она элементарная. Заходишь на сайт или в бота, видишь карточку фильма. Свайп вправо — лайк, влево — мимо. Каждый клик потихоньку обучает профиль вкусов.
Дальше создаешь комнату, кидаешь ссылку жене или другу. Вы листаете каждый свою ленту, а сервер в фоне ищет пересечения. Как только оба лайкнули один и тот же фильм, на экранах одновременно бахает анимация «Это мэтч» с постером. Спор окончен, можно включать кино.
Сначала это казалось просто забавной свайпалкой на вечер. Но в процессе разработки стало понятно: самое сложное — не прикрутить анимацию карточек, а выдать людям такие фильмы, чтобы им обоим было не противно. И как откопать в базе то самое кино по запросу «что-нибудь, чтобы порыдать».
Архитектура: модульный монолит вместо спагетти
На старте я пообещал себе не разводить помойку в коде. Проект написан как модульный монолит на Django. Вся логика разбита на независимые приложения со своими зонами ответственности:
- users — пользователи, профили вкусов, авторизация.
- movies — каталог, парсинг, поиск.
- matchmaking — комнаты и рекомендации.
- Плюс отдельные модули под геймификацию, аналитику, поддержку и ТГ-бота.
Всего 7 изолированных блоков. Можно спокойно ковырять один, не держа в голове устройство остальных.
Почему монолит, а не модные микросервисы? Потому что микросервисы для соло-разработчика — это петля на шее. Это сетевые задержки, распределенные транзакции и возня с деплоем. Один процесс и одна база данных экономят кучу времени. А изоляцию обеспечивают жесткие внутренние границы: модуль комнат не лезет в базу пользователей напрямую, а общается с ним через сервисный слой.
Наружу я в итоге вынес только одну вещь — машинное обучение. И на то была веская причина весом в три гигабайта.
Реалтайм: Django Channels и конфетти
Мэтч должен прилетать мгновенно. Если заставить пользователя обновлять страницу руками, магия пропадет. Эту проблему решили Django Channels и WebSockets.
Когда кто-то лайкает фильм, сервер тут же проверяет лобби. Если есть совпадение, он мгновенно вещает в сокет всем участникам комнаты id фильма и постер. Фронтенд ловит событие, блокирует экран и взрывает конфетти (с помощью JS-библиотеки canvas-confetti).
Отлаживал я это весело. Открывал два разных браузера на одном мониторе и сидел лайкал фильмы за двоих, имитируя совместный выбор. Выглядело со стороны странно, зато все рассинхроны выловил до того, как позвал жену тестировать альфа-версию.
Откуда дровишки: собираем базу без мусора
Базу наполняет фоновый скрипт. Он аккуратно стучится в API TMDB (The Movie Database), обходит лимиты и забирает фильмы по фильтрам: отсекает всё, что ниже определенного рейтинга и количества голосов, чтобы не тащить откровенный шлак.
Дальше — жесткая фильтрация под русскоязычный рынок. Нет нормального описания на русском? Мимо.
Отдельный квест — возрастные рейтинги. TMDB отдает дикую кашу: где-то американские R и PG-13, где-то европейские стандарты. Пришлось писать конвертер, который приводит всё к привычным 12+, 16+ и 18+.
В итоге получилось около 16 тысяч чистых тайтлов — с описаниями, картинками и категориями. Кстати, аниме пришлось вынести в отдельный тип: оказалось, среди пользователей ценителей японской анимации гораздо больше, чем я думал.
Рекомендательный движок: почему режиссер важнее жанра
Главная цель проекта — уйти от тупого подмешивания «популярного контента». Лента должна подстраиваться под человека, а в комнате — подстраиваться под группу.
Система собирает профиль вкусов по ходу свайпов. Для каждого нового фильма высчитывается внутренний балл. И тут вскрылся неочевидный факт: совпадение по режиссеру для алгоритма оказалось в три раза важнее, чем совпадение по жанру или стране. Если вы дизлайкнули фильм, все похожие тайтлы сразу получают огромный штраф и улетают в подвал выдачи. Статистика это подтвердила: условный Кристофер Нолан предсказывает лайк точнее, чем тег «фантастика».
А вот как собрать ленту для компании, где у всех разные вкусы? Если показывать только то, что безоговорочно нравится всем, лента опустеет через три свайпа — у пяти человек пересечение интересов обычно стремится к нулю.
Я использовал подход Soft Least Misery («мягкое наименьшее зло»):
Минимум защищает компанию от фильмов, которые кому-то одному категорически противны (никто не должен страдать). А среднее значение подтягивает варианты, которые устраивают большинство. В итоге кино, которое идеально подходит четверым и оценивается как «ну, нормально» пятым, получает шанс.
В лобби также выводится процент совместимости вкусов. Считаю его через косинусное сходство векторов — сразу видно, кому в компании лучше вообще не давать пульт.
Как я сэкономил 3 Гб на сервере, вынеся ML
Поначалу поиска на сайте не было. Листай что дают. Потом я прикрутил семантический поиск, чтобы система понимала контекст. Теперь можно вбить: «грустный фильм про космос», «без мистики» или «не Марвел» — и алгоритм поймет отрицание. Работает даже сленг вроде «хочу стекло» или банальные опечатки типа bynthcntkkfh (Интерстеллар в английской раскладке).
Внутри крутится мультиязычная модель на базе sentence-transformers. Векторы хранятся прямо в PostgreSQL через расширение pgvector, поиск идет по косинусной близости.
Сначала я запихал всю эту математику прямо в Django-монолит. Это было ошибкой. Библиотеки со всеми зависимостями раздули Docker-образ до 3 Гб. Память улетала со свистом, а обычный перезапуск бэкенда занимал вечность.
Я вынес ML в микросервис на FastAPI. Теперь Django ничего не знает про нейросети, а просто шлет HTTP-запрос и получает готовый вектор. Результат:
- Основной Docker-образ похудел на 3 Гб.
- Рестарт бэка занимает пару секунд.
- Переиндексация базы ускорилась в разы.
Тут микросервис оправдан — у него своя специфика работы с памятью и другой цикл обновлений.
Фронтенд: удаление 4600 строк кода и боль с Safari
Первый фронт был написан на чистом JS и стандартных шаблонах Django. С ростом фич это превратилось в ад.
В версии 5.0.0 я психанул и выкинул 4600 строк старого JS-кода, переписав всё на Vue 3, Pinia и Vite. Получилось шустрое SPA. Появились плавные переходы, скелетоны загрузки и режим Offline-First (если в метро отвалился интернет, свайпы копятся в очереди на телефоне и улетают на сервер при подключении).
Но без страданий не обошлось, спасибо Apple. На iOS элементы интерфейса при свайпах начали мерцать. Оказалось, Safari так странно оптимизирует слои на GPU. Пришлось лечить шаманским CSS: will-change: opacity.
Потом выяснилось, что Safari намертво блокирует окно установки PWA-приложения, если в коде перед вызовом стоит хоть один await. Пришлось переделывать логику на строго синхронный вызов внутри обработчика клика. В доках этого нет, доходил методом тыка.
Telegram Mini App: вход без паролей
В версии 5.1.0 KinoMatch стал доступен прямо внутри мессенджера. Нажал кнопку в боте — и ты сразу авторизован.
Логин идет через initData от Telegram. Бэкенд проверяет валидность подписи с помощью токена бота. Если всё ок — пускаем.
Звать друзей стало проще: кнопка внутри приложения открывает список диалогов ТГ, и другу улетает красивая карточка с приглашением. Если до этого пользовались сайтом, аккаунты можно связать в профиле, чтобы не терять историю.
И тут прилетел привет от WebView в iOS. Куки внутри Telegram на айфонах живут своей жизнью, из-за чего юзеры ловили бесконечный цикл авторизации (401 ошибка). Пришлось городить дублирующую систему: бэк сначала ищет токен в заголовках, и только если его там нет — лезет в куки. Помогло.
Геймификация: зачем это нужно
Просто так листать карточки быстро надоедает. Я добавил систему уровней и достижений. За каждый свайп и мэтч капает опыт (всего 10 уровней).
В базе зашито 100 ачивок. Есть шуточные: например, статус «Токсик» дают за 25 дизлайков подряд. Есть скрытые награды для любителей аниме. Уровень отображается в комнате рядом с аватаркой — сразу видно, кто тут киноман, а кто просто мимо проходил
Инфраструктура: стек проекта
Всё упаковано в Docker Compose. На сервере крутятся:
- Веб-сервер Daphne (ASGI);
- PostgreSQL + pgvector;
- Redis (в качестве брокера);
- ML-сервис на FastAPI;
- Telegram-бот;
- Celery воркеры под тяжелые задачи (вроде обновления векторов поиска).
Настроены бэкапы в облако, мониторинг через хелсчеки и тесты на pytest. Ну и пришлось обвесить сайт куки-баннерами и согласием на обработку данных по 152-ФЗ. Скучно, но надо.
Что в итоге показала статистика
А теперь к сухим цифрм с даашборда. На сегодняшний день имеем:
- Пользователи: 2952 человека.
- База: 16 204 фильма.
- Свайпы: 262 132 штуки за всё время.
- Сессии: 4980 комнат было создано.
- Мэтчи: 2099 успешных совпадений.
За последний месяц пользователи сделали 50 506 свайпов. Средний лайк-рейт — 17%. То есть влево карточки кидают в 5 раз чаще, чем вправо. И это нормально: алгоритм должен находить именно то, что зацепит, а не пихать всё подряд.
Но главная гордость — другая цифра:
Конверсия в мэтч составляет 71%. В 7 из 10 созданных комнат люди реально находят общий фильм.
Значит, проект со своей задачей справляется. Проблема выбора фильма на вечер существует, и ее можно решить кодом.
Проект живет, крутится на kinomatch.ru и в боте @KinomatchAppBot.