Як зробити український Telegram у форматі TikTok. Або свій стрімінг-сервіс за $0
Мене звати Микита, я розробник. Я зібрав свайп-стрічку українського Telegram — почалося це як пет-проєкт на кілька вечорів. Свайпаєш пости, як у TikTok, тільки контент береться з українських каналів. Живе воно прямо в Telegram як міні-застосунок і не коштує мені майже ні копійки на місяць — якщо не рахувати електрику, яку з’їдає Raspberry Pi, десь 50 центів.
Ця стаття про дві речі. Перша — як підняти власний відео-стрімінг практично за нуль доларів на домашньому Raspberry Pi. Друга — навіщо я взагалі це робив: хотілося зберегти живий хаос Telegram, але щоб підписи були українською, а не російською, і не скотитися в ту саму бульбашку, у яку затягує будь-який великий рекомендаційний алгоритм.
Далі буде багато конкретики — залізо, стек, черга медіа під фіксований бюджет, детекція мови. Приклади коду — з робочого проєкту, місцями трохи спрощені для читабельності.
В цій статті ви дізнаєтесь:
- Чому зробити український «TikTok» — нетривіально, і скільки насправді коштує відео-стрімінг.
- Як умістити Telegram-аналог TikTok’у у 10 ГБ безкоштовного сховища (і чому цього вистачає).
- Як безкоштовно дати домашньому застосунку публічну адресу без «білого» IP.
- Як окупити хостинг за пару місяців, купивши Raspberry Pi тієї ж потужності.
- Як швидко наповнити «мертвий» згенерований фронтенд іконками та графікою.
- Як економно перехоплювати російські й іншомовні підписи та перекладати їх українською через fastText і Lapa.
Спочатку — де це живе
Головна ідея входу проста: не тягнути людину в окремий застосунок. Стрічка відкривається там, де українці й так проводять пів дня — у списку чатів Telegram. Це звичайний бот, якого можна закріпити зверху, і тоді він висить над усіма діалогами як щоденний інструмент.
Хто хоче відразу помацати — ось бот: @itsmyfeedbot
GitHub: github.com/nordost8/tiktok-gram
Тиснеш «Відкрити» — і замість чату відкривається повноекранна стрічка. Ніяких сторів, установок, окремих акаунтів. Telegram Mini Apps дають тобі вебв’ю з готовою авторизацією, і цього цілком достатньо.


Ось як це відчувається наживо: свайп угору — наступний пост уже готовий, без чорного екрана й підвантаження. Далі розберу, як така роздача виходить безкоштовною.
Повна схема: де все безкоштовно
Найпростіше пояснити архітектуру одним питанням — хто за що платить. І майже на кожному шарі відповідь «ніхто». Нижче вся схема на одній картинці, а далі розберу її по шарах.

Фронтенд і вся доставка — Cloudflare, безкоштовно. Користувач відкриває міні-застосунок, і весь його трафік іде через Cloudflare: DNS, TLS, CDN і Tunnel. Тунель — окрема магія і, мабуть, найкорисніша тут річ для будь-якого домашнього проєкту. Мій сервер стоїть удома за NAT, «білої» (публічної) IP-адреси в мене немає, провайдер її не дає. Раніше це означало б або оренду VPS як проксі, або платний сервіс прокидання портів. А Cloudflare Tunnel просто тримає постійне вихідне з’єднання від Pi назовні — і мій домен стає доступним з інтернету без проброшування портів, без «білого» IP і без проміжного VPS. І все це на безкоштовному тарифі.
Бекенд — на Raspberry Pi вдома, теж безкоштовно. Це один Raspberry Pi 4 Model B на 8 ГБ. Диск не microSD, а NVMe SSD через PCIe-HAT — на карті база даних із постійним записом померла б за кілька місяців, а NVMe тримає навантаження спокійно. На цьому Pi у Docker Compose живе взагалі все: Next.js з API на tRPC, Postgres і Redis, collector (раз на 15 хвилин тягне нові пости) і media-worker (качає відео). Плачу лише за електрику.
Для порівняння: орендувати в хмарі віртуалку, зіставну за ресурсами (4 vCPU / 8 ГБ), — це десь $25 на місяць. Тобто вже за два-три місяці такої оренди набігає ціна цілого Raspberry Pi, а Pi я купую один раз і далі плачу тільки за електрику.
Уся «серверна» — роутер і Raspberry Pi 4 з NVMe
Ось як виглядає вся моя «серверна»: роутер і той самий Raspberry Pi поруч в одному ящику. Це буквально те залізо, яке роздає відео-стрічку користувачам.
Стрімінг відео — Cloudflare R2, знову безкоштовно. Ось де головна економія. Безкоштовний тариф R2 — це 10 ГБ сховища, 1 млн операцій запису й 10 млн операцій читання на місяць, і, найважливіше, нуль за вихідний трафік (egress). У звичайних S3-подібних сервісів саме egress з’їдає весь бюджет відеосервісу, а тут його немає взагалі — скільки б людей не дивилися відео, за роздачу я не плачу. Навіть якщо колись вийти за free tier, самі цифри лишаються дуже гуманними: сховище коштує $0.015 за ГБ на місяць (тобто 100 ГБ відео — це близько півтора долара), а egress безкоштовний завжди.
І тут головна архітектурна деталь, без якої вся ця економія не працювала б: відео летить глядачеві напряму з R2, повз мій бекенд. Коли ти відкриваєш пост, сервер на Pi лише підписує коротке тимчасове посилання на файл у R2 — а самі байти відео браузер тягне вже прямо з мережі Cloudflare. Сам Pi відео через себе не пропускає. Тому навантаження на нього майже не залежить від того, скільки людей дивиться одночасно, а мій домашній інтернет-канал лишається вільним. Роздача — на плечах мережі Cloudflare, і за неї я не плачу. А чому 10 ГБ сховища взагалі вистачає — окрема історія, вона нижче.
Джерело контенту — Telegram API, переклад — Lapa. Тут важлива деталь: collector працює не як звичайний бот, а як userbot — окремий акаунт на окремому номері телефона, який я створив спеціально для стрічки. Цей акаунт підписаний на купу українських каналів (плюс на ті, які пропонують самі користувачі через форму), і саме з його стрічки collector забирає нові пости — так, ніби це робила б жива людина. А якщо трапляється російськомовний підпис, media-worker відправляє його на переклад українською моделлю Lapa — пост лишається, змінюється лише мова підпису.
Деплой автоматичний: після пуша в main GitHub Actions збирає ARM64-образи й публікує їх у GHCR (GitHub Container Registry), а на самому Pi Watchtower відстежує реєстр, підхоплює свіжий образ і перезапускає контейнер. Від пуша до оновлення на сервері не треба жодної ручної дії.
Разом за саму інфраструктуру виходить нуль на місяць — лишається тільки електрика. Одноразово — сам Pi і диск (близько 5к грн, або 2-3к за б/в). А захочеш більше ніж 10 ГБ сховища — доплата в Cloudflare копійчана.
10 гігабайт як спільна черга
Найбільше повозитися довелося саме з обмеженням у 10 ГБ. Відео важать багато, каналів десятки, нові пости прилітають постійно. Найочевидніший підхід — видаляти найстаріші файли, коли місце закінчується — я спробував першим, і він виявився несправедливим.
Проблема ось у чому. Новинні канали постять по двадцять разів на день, а якась психологія чи наука — раз на кілька днів. Якщо тримати в кеші просто N найсвіжіших постів по всій базі, то новини завжди зверху, а психологію витирає першою вже за кілька годин. Формально місце ділиться «чесно за часом», а насправді низькочастотні жанри просто зникають зі стрічки. Людина відкриває розділ і думає, що українського контенту по темі мало. А його просто затерли новини.
Рішення — дати кожному каналу гарантований шматок місця. Останні кілька постів кожного активного каналу захищені й ніколи не виселяються. Решта бюджету — спільний потік, у якому нові відео виштовхують найстаріші незахищені.
Виходить така собі двоконтурна система. Всередині одного каналу свіже відео замінює старе — канал ніби прокручує свій контент у межах виділеного місця. А між каналами діє справедливість: скільки б новини не постили, поза своїми захищеними постами вони конкурують за спільний потік нарівні з усіма.
Ось запит, який відбирає кандидатів на видалення. Ключове тут — віконна функція, що нумерує пости всередині кожного каналу окремо і виключає перші кілька:
WITH protected AS ( SELECT p.primary_media_id AS id FROM ( SELECT primary_media_id, channel_id, ROW_NUMBER() OVER ( PARTITION BY channel_id ORDER BY published_at DESC ) AS rn FROM telegram_posts WHERE status = 'ready' AND primary_media_id IS NOT NULL ) p JOIN telegram_channels c ON c.id = p.channel_id WHERE c.status = 'active' AND p.rn <= 5 ) SELECT m.id, m.storage_key, m.cached_size_bytes, p.published_at FROM telegram_post_media m JOIN telegram_posts p ON p.id = m.post_id WHERE m.cache_status = 'ready' AND m.id NOT IN (SELECT id FROM protected WHERE id IS NOT NULL) ORDER BY p.published_at ASC
PARTITION BY channel_id і є той самий «фіксований простір на канал»: п’ять свіжих постів кожного каналу лишаються поза списком на виселення, скільки б їх там не було в новин.
Воркер — єдина точка, через яку нові дані потрапляють у сховище. Якщо контролювати бюджет саме тут, перед кожним завантаженням, то перевищити його стає майже неможливо, а крон я лишив як підстраховку. Тримаю ліміт на 9.8 ГБ, трохи нижче за безкоштовні 10 — щоб мати запас під час завантаження.
І цих 10 ГБ мені поки що вистачає — і це не на око, а за реальними цифрами з бази. Зараз у добірці 82 активні канали, а в R2-кеші лежить 946 відеокліпів. Важать вони по-різному: медіана — близько 5 МБ, середнє — майже 10 МБ (кілька довгих відео тягнуть його вгору), сумарно виходить близько 9 ГБ. Тобто з мого ліміту 9.8 ГБ кеш майже повний — і так, майже під зав’язку, тримається місяцями. Фото сюди не рахуються: вони лежать окремо, у Postgres, і на R2-бюджет не тиснуть.
Щоб кеш не розпухав разом із базою, працює ковзне вікно. Раз відео роздається напряму з R2, сховищу не треба тримати весь архів кожного каналу — достатньо кількох найсвіжіших постів. У кожного активного каналу захищені останні пости (у мене — по 5), а все старіше виштовхується зі спільного потоку. Прямо зараз таким чином захищено лише 153 відео на ~1.9 ГБ — менш як чверть бюджету; решта ~7 ГБ — спільний потік найсвіжішого, що постійно оновлюється.
Тому розмір кеша впирається не в те, скільки в каналів історії (там можуть бути тисячі старих постів), а лише в те, скільки свіжого контенту «в польоті» просто зараз. Додаси ще каналів — у кеші буде приблизно та сама тисяча найсвіжіших кліпів, просто оновлюватимуться вони частіше.
Чесна обмовка: це справедливо, поки каналів помірна кількість. Захищений мінімум росте лінійно — 5 постів на канал. При 82 каналах він займає ~2 ГБ, і запас великий; але вже за пару сотень каналів сам тільки цей мінімум почне з’їдати помітну частину бюджету. Тоді достатньо або зменшити захист на канал, або підняти ліміт — наступні гігабайти в R2 коштують копійки ($0.015 за ГБ). Для мого масштабу цього ще довго вистачатиме з головою.
Як формується стрічка
Стрічка має дві вкладки: «Для вас» і «Підписки». Перед першим показом людина обирає теми — гумор, IT, мілітарі, психологія і так далі.


Я свідомо не ставив жодного складного
function scorePost(post, interestSlugs, subscribedChannelIds) {
const hoursAgo = (Date.now() - post.publishedAt.getTime()) / 3.6e6;
const freshness = Math.max(0, 72 - hoursAgo) / 72;
const engagement =
post.internalLikesCount * 3 +
post.internalSavesCount * 4 +
post.internalViewsCount * 0.1;
const categoryBoost =
post.categorySlug && interestSlugs.has(post.categorySlug) ? 20 : 0;
const videoBoost = post.mediaType === "video" ? 35 : 0;
const subscriptionBoost = subscribedChannelIds.has(post.channelId) ? 120 : 0;
return freshness * 30 + engagement + categoryBoost + videoBoost + subscriptionBoost;
}
Спершу йдуть свіжі пости з каналів, на які ви підписалися (так, у Моїй Стрічці теж можна підписуватися на канали), далі сортуємо за реакціями, щоб показати найгарячіше серед користувачів стрічки. Плюс окреме правило проти монополії. Стрічка вантажиться не вся одразу, а порціями приблизно по десять постів — застосунок підвантажує наступну порцію, коли ти догортуєш до кінця поточної. Оця порція і є «сторінка». Так от, у межах однієї сторінки не може бути більше двох постів з одного каналу. Без цього обмеження один активний канал забив би цілу порцію, і ти б гортав поспіль десять відео з одного джерела. Це той самий принцип справедливості, що і в черзі медіа, тільки вже на рівні показу.
Окремо тримаю фото-каруселі. У Telegram купа сильних каналів живе не відео, а добірками картинок і мемів. Вертикальний свайп плюс горизонтальний всередині поста — це трохи мороки з жестами у вебв’ю, але без каруселей стрічка втрачала б половину живого контенту. Частина таких каруселей іде навіть із власним саундтреком — картинки гортаються під музику, майже як у TikTok.



Ще на старті можна обрати, як стрічка виглядає. Є дві теми оформлення: «Красива» — з насиченими барвами й акцентами, і «Мінімал» — спокійніша, для тих, хто не любить зайвого. Дрібниця, але вона задає настрій усього застосунку.
До речі, про графіку. Іконки й дрібні візуальні елементи я не малював руками — просто дав своєму агенту ключ від ChatGPT Image 1 (ця модель уміє генерувати з прозорим фоном) і попросив збагатити інтерфейс іконками та іншими графічними деталями. Вийшло швидко і на диво органічно.
Мова: російськомовні підписи стають українськими
Це та частина, заради якої все й затівалося. Навіть у добірці українських каналів час від часу прилітають підписи російською — репости, цитати, змішані редакції. Якщо будуєш простір, де мова має значення, це не можна пускати самопливом.
Пайплайн двоступеневий. Спершу fastText з моделлю lid.176 визначає мову тексту. Якщо українська або її ймовірність висока — не чіпаємо. Якщо текст короткий чи модель непевна — теж не чіпаємо, щоб не псувати нормальні пости. А от коли впевнено бачимо іншу мову, зокрема російську — відправляємо на переклад.
def detect_language(body: str) -> LangDetectResult:
cleaned = re.sub(r"\s+", " ", body.strip())
if len(cleaned) < MIN_TEXT_LEN:
return LangDetectResult(should_translate=False, skip_reason="too_short")
# fastText повертає мітки виду "__label__uk" з їхніми ймовірностями
labels, probs = model.predict(cleaned, k=3)
ranked = {label.removeprefix("__label__"): p for label, p in zip(labels, probs)}
lang, conf = max(ranked.items(), key=lambda kv: kv[1])
if lang == "uk" or ranked.get("uk", 0) >= UK_SKIP_IF_PROB:
return LangDetectResult(should_translate=False, skip_reason="ukrainian")
if conf < MIN_TRANSLATE_CONF:
return LangDetectResult(should_translate=False, skip_reason="low_confidence")
return LangDetectResult(language=lang, should_translate=True)
Перекладає українська модель — Lapa (LapaLLM на базі Gemma). Для мене це принципово: для української мови хотілося саме українського інструмента, а не «згладжування» через великий закордонний API. Запит звичайний, OpenAI-сумісний, з жорстким проханням повернути тільки JSON:
system = (
"Ти перекладач для українського новинного застосунку. "
"Переклади текст поста з Telegram українською. "
"Збережи емодзі, хештеги, згадки, посилання та абзаци. "
"Поверни ТІЛЬКИ JSON: {\"translation_uk\": string}"
)
Джерела я не тримаю закритим клубом власника бота. Є каталог з пошуком і категоріями, а ще форма, через яку будь-хто може запропонувати канал у добірку.


Плани на майбутнє
Поки що стрічка — це переважно відео й фото. Але левова частка українського Telegram живе в чистому тексті: коротка новина, влучний допис, гарячий тред у коментах. Показувати такий контент відео-стрічкою безглуздо, тож для нього я хочу зробити окремий формат.
Ідея така: суто текстові пости отримають власну історію, як сторіс у Instagram чи Telegram. Не одна стіна тексту, а хмаринки, які з’являються по черзі — перша, друга, третя — і кожну встигаєш прочитати за пару секунд, поки вона на екрані.
Наповнювати ці історії я планую не всім підряд, а найціннішим:
- найважливіші новини — їх відбиратиме сигнальна система, яка оцінює, наскільки подія справді значуща, а не просто часто згадувана;
- найсмішніші коментарі — те, що робить український Telegram українським Telegram;
- коментарі, які зібрали найбільше лайків — колективний вибір самих читачів.
Тобто замість того, щоб гортати десятки текстових постів, ти за кілька історій отримуєш вичавку: головне за день, найдотепніше і найпопулярніше. Той самий підхід, що й до відео — зберегти хаос, але подати його так, щоб не потонути.
Що я свідомо не робив
Не будував окрему соцмережу із завантаженням контенту — джерело правди лишається в Telegram, звідти й береться хаос. Не ставив непрозорий рекомендаційний движок — бо не хотів за будь-яку ціну втримувати людину в застосунку. Не виносив продукт в App Store — він живе прямо в Telegram і відкривається за один клік.
Це свідомий вибір. Щойно з’являється «розумний» алгоритм заради утримання, він починає оптимізувати те, що легко виміряти, — і різноманіття джерел зазвичай відпадає першим.
Підсумок
Технічно я хотів показати одну просту річ: власний відео-стрімінг сьогодні реально підняти майже без грошей. Домашній Raspberry Pi 4 з NVMe, Docker, Cloudflare Tunnel і R2 з нульовим egress — і в тебе працює невеликий сервіс, який роздає відео без окремих витрат на трафік. А якщо колись знадобиться більше ніж 10 ГБ сховища — доплата в Cloudflare невелика. Про масштаб я не говорю: користувачів поки десятки. Найцікавіше для мене було інше — чесно вписатися у фіксований безкоштовний бюджет.
А по суті це просто спроба зробити свайп, який не зраджує середовище: живий хаос українського Telegram у зручному форматі, з підписами українською і без бульбашки. Мене свого часу зачепило саме те, якою мовою зі мною говорять різні стрічки і скільки насправді різних джерел я в них бачу. Якщо колись гортатимете щось подібне — можливо, зверніть на це увагу й ви.
14 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментаріввітаю, шановне панство. вихідний код стрічки з інструкцією деплою доступний тут:
github.com/nordost8/tiktok-gram
Це дуже класна ідея
прикольно але «сервер вдома» це добре лише якщо це якась децентрализована система де кожен вузол е нодою сам в собі і клиент і не просто підключаються, а підіймать власні вузли
для вашої задачі дійсно краше нормальний навітьс амий дешевий хостинг (зараз майже всі підтримують хостування аппок)
просто тому, що «домашній сервер» мае безліч вузьких місць на які ви не влините, аж малинка тим паче
dou.ua/...rums/topic/60825/#3102733
Не працює

Спробуй використовувати YAML замість JSON, LLM з ним працюють краще
Сервер дома — це щось з20-річної давнини, коли я був студентом... Ну є ж Hetzner $6 (2 VCPU) and 9$ (4 VCPU), можно взяти ще дешевше в інших місцях.
Сервер вдома це круто та респект. Що за стереотипи?
І після цього робота сайта/сервіса буде залежати від вашого інтернет-провайдера і взагалі від вашого будинку?
Так, що тут такого?
Схоже на тролінг з вашого боку, розважайтеся на здоров’ячко :)
Ні, ніякого тролінгу. Давайте обговоримо це раціонально:
1. Автор пише: «свій стрімінг-сервіс за $0» — тобто ціль це мінімізація операційних витрат. Мені кейс подобається і як автор вирішив це питання також. Якщо мета — нуль доларів на місяць, то обрати VPS за 6$ — це вже не нуль. А стаття «свій стрімінг-сервіс за 6$» вже не так круто виглядає. :) До речі, compute дорожчає: той VPS за 6$ на Hetzner вже коштує 7$. www.hetzner.com/cloud/cost-optimized
Якщо би автор писав: «Розробка відмовостійкого стрімінг-сервісу, що масштабується», тоді дійсно вибір самохосту вдома на Raspberry був би сумнівним. Хоча хто я такий, щоб судити? Може хтось і таке зробить.
2. Про надійність домашнього інтернету. Я не знаю який сетап у автора, але зараз домашні інтернети доволі стабільні: оптоволокно заводять вже у квартиру, є ДБЖ, тому зробити аптайм у 99,5% реально (це даунтайм десь у3-4 години на місяць). Нагадаю, SLA в Амазоні для EC2 single instance також 99,5%. Це ж пет-проджект, 99,999% не треба.
Це на роботі я страждаю, а вдома з пет-проєктами дійсно розважаюсь! :)
Якщо є розумний будинок — без домашнього сервера зробити автоматизацію можна, але це божевілля.
Ммммм... Але ж стаття про автоматизацію сервіса, конкретно телеграм-апки, до чого тут розумний дім?