Повна конфігурація
Запуск власного сервера описує найпростіший режим: JSON, локальні файли, синхронізація. Ця сторінка — про решту: базу даних, хмарне сховище, потокове мовлення й живий ефір.
Що від чого залежить
Головне, що варто засвоїти одразу: вибір провайдера ні на що не впливає. Обидва дата-провайдери вміють усе, обидва медіа-провайдери теж. Ролі адмінів, джингли, бан-лист, завантаження пісень — усе це працює на json з локальними файлами так само, як на Turso з хмарою.
Лишилися залежності лише двох видів: технічні (щось потребує потоку або зовнішнього інструмента) і збереженість (на ефемерному хостингу немає сенсу писати те, що зникне).
| Хочу | Що справді потрібно |
|---|---|
| Базове радіо | нічого |
| Кілька адмінів з ролями | нічого |
| Завантаження пісень, артів | нічого |
| Модерація за IP | RADIO_HOSTS_MODE=true |
| Спільний потік | STREAM_MODE=true + FFmpeg |
| Джингли між піснями | STREAM_MODE=true |
| Живий ефір | STREAM_MODE=true + RADIO_HOSTS_MODE=true |
| Фонова музика під час паузи | STREAM_MODE=true + RADIO_HOSTS_MODE=true |
| Імпорт з YouTube | FFmpeg і завантажувач ytbdown (потребує python3 і його pip-залежностей) |
Дві осі сховища незалежні: DATA_PROVIDER вирішує, де метадані, а MEDIA_STORAGE — де файли. Змішувати можна як завгодно, і на функціонал це не впливає — лише на те, куди фізично лягають байти.
Тоді навіщо взагалі хмара й зовнішня база
Заради збереженості, а не можливостей. На хостингу, що стирає диск при деплої, локальні файли не переживуть перезапуск — і тоді частина адмінських дій вимикається, бо їхній результат однаково зник би. Це єдиний випадок, коли вибір сховища щось змінює: Ефемерний хостинг.
Одна залежність жорстка
RADIO_HOSTS_MODE=true без STREAM_MODE=true не має сенсу: мікрофон ведучого нема куди підмішувати, якщо спільного потоку не існує. Сервер не спиняється — він попереджає в лозі й лишає живий ефір вимкненим.
База даних
Не потрібна для жодної можливості: json уміє те саме. Обирають її заради іншого — база витримує паралельні записи, не переписує файл цілком на кожну зміну і, головне, може жити поза диском сервера.
Один процес на теку
JSON-провайдер серіалізує записи в межах процесу. Кілька екземплярів сервера над однією текою — не підтримується; для такого сценарію потрібен sql.
Локальний файл — без жодних хмарних сервісів
Найчастіше пропускають саме це: DATA_PROVIDER=sql не вимагає акаунта Turso. Достатньо локального файлу SQLite:
DATA_PROVIDER=sql
TURSO_DATABASE_URL=file:./data/sql/radio.db
# TURSO_AUTH_TOKEN не потрібен для file:Токен обов'язковий лише для віддалених адрес (libsql://, https://): локальному файлу нема до чого автентифікуватись. Якщо вказати віддалену адресу без токена, сервер відмовиться стартувати й скаже це прямо.
Тобто щоб отримати кількох адмінів з привілеями, хмара не потрібна взагалі.
Віддалена Turso
DATA_PROVIDER=sql
TURSO_DATABASE_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=<токен>Має сенс, коли сервер розгорнуто там, де файлова система ефемерна — типово для контейнерних хостингів, які стирають диск при кожному деплої.
Таблиці створюються самі
Схему не треба ні застосовувати, ні мігрувати: кожен домен виконує CREATE TABLE IF NOT EXISTS під час ініціалізації. Порожня база — робочий стан, сервер сам себе розгорне.
Довідкові визначення моделей лежать у server/db/ — вони саме довідкові, код працює через src/data/dataProvider/sql/.
Перенесення наявних даних
Якщо радіо вже працювало на json і накопичило бібліотеку:
cd server
node scripts/migrate.js data --from json --to sql --sql-url file:./data/sql/radio.dbЗворотний напрямок — те саме з переставленими --from і --to. Для віддаленої бази до URL додається токен:
node scripts/migrate.js data --from sql --to json \
--sql-url libsql://<база>.turso.io --sql-token <токен>Облікові дані не читаються з .env: віддалена база доступна лише тоді, коли її URL і токен вписані в команду. Так помилкова команда не влучить у сховище задеплоєного радіо. Спершу варто прогнати з --dry-run — він лише покаже, що буде скопійовано, і навіть не відкриє цільову базу.
Між двома базами
Радіо, що працює лише на SQL, зазвичай має дві бази: локальну для перевірок і віддалену для слухачів. Перенесення між ними — та сама команда, тільки кожен бік треба вказати окремо:
node scripts/migrate.js data --from sql --to sql \
--source-sql-url libsql://<база>.turso.io --source-sql-token <токен> \
--target-sql-url file:./data/sql/radio.dbПосторонні параметри (--source-sql-url, --target-sql-url, відповідні -token, а також --source-json-dir / --target-json-dir) мають вищий пріоритет за спільні --sql-url і --json-dir. Спільної форми достатньо, коли база одна; коли їх дві — жоден спільний параметр не зміг би їх розрізнити.
Джерело й ціль не можуть вказувати на одне сховище: ціль перед заповненням очищається, тож копіювання «саме в себе» команда відхиляє.
Копіюється весь набір: треки, тексти, зсуви, арти, налаштування, джингли, фонова музика, фрази, забанені адреси, адміни (разом із хешами паролів), журнал аудиту й історія програвань. Після запису CLI перечитує ціль окремим підключенням і показує, скільки записів справді там опинилось.
Логи міграцій пишуться в server/migration-history/.
Перемикання назад на json — це просто зміна змінної й перезапуск: дані провайдерів не перетираються.
Локальне сховище
Усе, чим керує медіа-провайдер, лежить під однією текою й повторює розкладку бакета — так обидва провайдери влаштовані однаково:
server/
├── data/ ← метадані
│ ├── json/
│ └── sql/
├── media/ ← медіа-провайдер
│ ├── music/day/
│ ├── music/night/
│ ├── jingles/day/
│ ├── jingles/night/
│ ├── background/day/
│ ├── background/night/
│ ├── phrases/day/
│ ├── phrases/night/
│ └── arts/
└── public/avatars/ ← ресурси застосунку, у репозиторіїАватарки слухачів свідомо лишені осторонь: це файли, що постачаються з репозиторієм і змінюються вручну, а не дані користувача.
Для інсталяцій, що працювали раніше
Раніше музика лежала в server/music/, а арти — в server/public/arts/. Сервер виявляє стару розкладку й відмовляється стартувати, щоб не запуститися з порожньою бібліотекою. Перенесення — одна команда з теки server:
node scripts/migrate.js layout --dry-run # показати план
node scripts/migrate.js layout # перенестиФайли переміщуються, не копіюються; порожні старі теки лишаються на місці — їх можна видалити вручну.
Узгодження з диском при старті
Кожен запуск звіряє ці теки з тим, що знає активний дата-провайдер, і сам дописує різницю — вручну ганяти migrate.js scan після заливки файлів прямо на диск не потрібно:
| Що звіряється | Додає відсутній запис | Видаляє запис без файлу |
|---|---|---|
Пісні (music/day, music/night) | так, з ID3-тегів | так, лише за CONFIRM_TRACK_CLEANUP=true |
| Арти виконавців | так (hasArt: true) | ні |
| Джингли, фонова музика, фрази | так, з тривалістю | ні |
Видалення пісень — єдиний деструктивний напрямок з усіх чотирьох, і єдиний, вимкнений за замовчуванням: якщо файл зник із диска (том тимчасово не змонтовано, збій диска), сервер лише попереджає в лозі й лишає застарілий запис, а не тре бібліотеку. Увімкнути справжнє видалення — CONFIRM_TRACK_CLEANUP=true.
Лише для локального сховища
Для MEDIA_STORAGE=cloud такого узгодження немає взагалі: перелік вмісту бакета — це реальні запити до R2, які тарифікуються, і робити їх на кожному старті недоцільно. Для хмари узгодження лишається ручним кроком: migrate.js data для метаданих, migrate.js scan — щоб дозаписати джингли, фонову музику й фрази, які вже лежать у бакеті, але не мають запису в дата-провайдері.
Хмарне сховище
Теж не потрібне для жодної можливості: локальний провайдер уміє все те саме — завантаження пісень, арти, джингли, фонову музику, фрази. Хмару обирають, коли диск сервера ненадійний або коли роздачу файлів хочеться зняти з самого сервера. Використовується Cloudflare R2 через S3-сумісний API.
MEDIA_STORAGE=cloud
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_BUCKET=radio
R2_ACCESS_KEY_ID=<ключ>
R2_SECRET_ACCESS_KEY=<секрет>
R2_REGION=auto
R2_PRESIGN_TTL_S=900
R2_ARTS_PREFIX=artsКлючі створюються в R2 як S3 API token з правом читання й запису в бакет.
Структура бакета
Сервер розкладає файли так і створює потрібні шляхи сам:
<bucket>/
├── day/Виконавець - Назва.mp3 ← ключ = ідентифікатор треку
├── night/Виконавець - Назва.mp3
├── arts/виконавець.jpg ← префікс з R2_ARTS_PREFIX
├── jingles/day/…mp3
├── jingles/night/…mp3
├── background/day/…mp3
├── background/night/…mp3
├── phrases/day/…mp3
└── phrases/night/…mp3Ключ аудіо — це буквально track з події sync, тож бакет можна наповнити й вручну, дотримуючись цієї схеми.
Доступ і посилання
Бакет не потрібно робити публічним: клієнти отримують тимчасові підписані посилання з терміном життя R2_PRESIGN_TTL_S (типово 15 хвилин). Підпис робиться самим сервером, без SDK.
R2_PUBLIC_BASE_URL — необов'язковий і потрібен лише для сумісності зі старими публічними посиланнями.
Перевірка без адмінки
Якщо завантаження в адмінці дає помилку, спершу переконайтеся, що всі чотири змінні (R2_ENDPOINT, R2_BUCKET, і обидва ключі) непорожні — за відсутності будь-якої з них клієнт R2 навіть не ініціалізується.
Перенесення медіа між локальним сховищем і хмарою
cd server
node scripts/migrate.js media --from local --to cloud --dry-run \
--r2-endpoint https://<акаунт>.r2.cloudflarestorage.com --r2-bucket <бакет> \
--r2-access-key-id <id> --r2-secret-access-key <ключ>Зворотний напрямок — --from cloud --to local. Прибравши --dry-run, команда запитає підтвердження й почне копіювання.
Це копіювання, а не дзеркалення: нічого не видаляється з жодного боку. Файл, який уже є в цілі з тим самим розміром, пропускається, тож перерваний запуск можна просто повторити — він забере лише те, чого бракує. Якщо розмір збігається, а вміст ні, є --overwrite.
--category обмежує роботу частиною бібліотеки, наприклад лише артами:
node scripts/migrate.js media --from cloud --to local --category arts …Розкладка музики відрізняється
Локально треки лежать під media/music/, а в бакеті — просто day/ і night/, бо ключ об'єкта — це і є ідентифікатор треку. Команда враховує цю різницю; при ручному копіюванні файлів її легко не помітити.
Потокове мовлення
STREAM_MODE=true
FFMPEG_PATH=ffmpegПотрібен FFmpeg у системі. Якщо він не в PATH, вкажіть повний шлях у FFMPEG_PATH — ця сама змінна використовується й для завантаження з YouTube.
Перемикання режиму діє лише після перезапуску сервера: спосіб доставки аудіо визначається на старті.
Що змінюється для клієнтів, описано в Режимах програвання — коротко, замість /api/audio/stream вони слухають /api/stream, а позицію беруть з подій.
Живий ефір
STREAM_MODE=true
RADIO_HOSTS_MODE=true
MAX_LIVE_HOST_SLOTS=2
HOST_MONITOR_ICE_PORT_MIN=40000
HOST_MONITOR_ICE_PORT_MAX=40099
HOST_MONITOR_STUN_URL=stun:stun.l.google.com:19302MAX_LIVE_HOST_SLOTS рахує ведучих і гостей разом: при значенні 1 ведучий займає єдине місце, і гість уже не приєднається.
Мережа
Персональний моніторинг ведучого працює через WebRTC, і це єдине місце в проєкті, що потребує відкритих UDP-портів:
UDP 40000–40099 (або ваш діапазон) — вхідніБез них ефір усе одно працюватиме, але ведучий чутиме себе із затримкою спільного потоку, що робить розмову некомфортною. Навіщо цей канал взагалі — пояснено в Живому ефірі.
Якщо сервер за NAT, публічний STUN зазвичай достатній. Для складніших мереж знадобиться власний TURN, якого проєкт наразі не підтримує.
Права
Вихід в ефір потребує привілею radio_host, модерація — radio_moderator. Помічників з цими правами створює супер-адмін, і це працює на будь-якому дата-провайдері: json зберігає облікові записи так само, як sql.
Обмеження лишилось одне й воно не про сховище: на один обліковий запис живе одна сесія. Щоб в ефірі було двоє людей, потрібні два різні записи — а не певний провайдер.
Модерація за IP теж доступна скрізь: бан-лист зберігають обидва провайдери.
Донати
Один активний провайдер за раз — DONATIONS_PROVIDER вибирає, який саме, і кожен потребує лише своїх власних змінних:
DONATIONS_ENABLED=true
DONATIONS_PROVIDER=liqpay # liqpay | stripe | donatello | kofi
# checkout: сервер сам створює платіж на точну суму, миттєвий вебхук —
# потребує зареєстрованого бізнесу (ФОП чи аналог) для виплат
LIQPAY_PUBLIC_KEY=
LIQPAY_PRIVATE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
PUBLIC_SERVER_URL=https://your-domain.example # потрібен LiqPay для callback
# matching: донатер платить на власній сторінці творця й вписує код у
# коментар — бізнес не потрібен, той самий підхід, що й у стрімерів
DONATELLO_API_TOKEN=
DONATELLO_PAGE_URL=https://donatello.to/your_page
DONATELLO_POLL_INTERVAL_S=30
KOFI_VERIFICATION_TOKEN=
KOFI_PAGE_URL=https://ko-fi.com/your_page
DONATION_MATCH_EXPIRY_MIN=15
DONATION_RETENTION_DAYS=365Написано точно за документацією провайдерів, але не обкатано на живих платежах
Інтеграції всіх чотирьох провайдерів (LiqPay, Stripe, Donatello, Ko-fi) реалізовані рівно за їхньою офіційною документацією API — формати запитів, перевірка підписів вебхуків, коди помилок. Жодна з них, однак, не пройшла повний цикл реальних платежів у продакшені. Перш ніж вмикати донати на робочому радіо, проженіть весь ланцюжок самі — тестовим режимом провайдера, де він є, або мінімальною реальною сумою — і будьте готові, що якийсь крайовий випадок поведінки платіжної системи міг лишитися неврахованим.
DONATIONS_ENABLED=true без коректно налаштованого провайдера — не фатально: сервер лише попереджає в лозі, а capabilities.donations лишається вимкненим, доки всі обов'язкові змінні обраного провайдера не заповнені.
Чому провайдерів два типи
liqpay/stripe — звичайний checkout: сервер сам виставляє точну суму й миттєво дізнається про оплату вебхуком, але для виплат потрібен зареєстрований бізнес. donatello/kofi розраховані на творців контенту без такої реєстрації — ціною точності: суму донатер вписує сам, а сервер зіставляє оплату із замовленням за кодом підтвердження. Детальніше про різницю в поведінці клієнта — у «Донатах».
Як і в аудиті, DONATION_RETENTION_DAYS (7-3650, типово 365) визначає, скільки історія донатів переживає в базі — і так само не рятує ефемерний диск: якщо активна конфігурація сховища не переживає перезапуск, capabilities.donations вимикається цілком, незалежно від того, наскільки коректно налаштований сам платіжний провайдер.
Порядок увімкнення
Радимо саме такий, бо кожен крок перевіряється окремо:
- Базове радіо на
json+local. Переконайтеся, що музика грає. - Адміни: створіть помічника, перевірте, що права діють. Провайдер міняти для цього не треба.
- Завантаження: залийте одну пісню й один арт через адмінку.
- Потік:
STREAM_MODE=true. Перевірте, що/api/streamвіддає аудіо. - Джингли: тепер умова виконана.
- Живий ефір:
RADIO_HOSTS_MODE=trueі відкриті UDP-порти.
Зміну сховища — на sql чи cloud — можна робити будь-коли й окремо від цього списку: вона нічого не вмикає і нічого не вимикає, доки хостинг не оголошено ефемерним. Дані переносяться командою migrate.js data, файли — migrate.js media.
Після кожного кроку — перезапуск сервера й погляд у лог: більшість помилок конфігурації сервер повідомляє на старті явно.
Ефемерний хостинг
Частина платформ стирає файлову систему при кожному деплої. Щоб сервер не втрачав дані мовчки, це оголошується явно:
EPHEMERAL_STORAGE=trueСервер запуститься за будь-якої конфігурації — радіо це передусім ефір, і невдале налаштування сховища не привід лишати слухачів без мовлення. Натомість при старті він перевіряє обидві осі, виводить попередження з переліком причин і вимикає ті можливості, результат яких не переживе перезапуск.
Ключове: довговічність визначається тим, куди фізично лягають байти, а не назвою провайдера.
| Налаштування | Переживає перезапуск |
|---|---|
DATA_PROVIDER=sql + libsql://… | так |
DATA_PROVIDER=sql + file:… | ні — це той самий диск |
DATA_PROVIDER=json | ні |
MEDIA_STORAGE=cloud | так |
MEDIA_STORAGE=local | ні |
Тобто локальний SQLite не рятує на ефемерному хостингу: він так само зникне разом із диском.
Що лишається доступним
| Працює | Вимкнено |
|---|---|
| Статистика та її вивантаження | Завантаження пісень, джинглів, фонової музики, фраз, артів |
| Керування чергою пісень | Редактор пісень, джинглів, фонової музики і фраз |
| Перемикач режиму день/ніч | Менеджер адмінів і власний профіль адміна |
| Перегляд налаштувань | Збереження налаштувань |
| Ефір, тексти, синхронізація | Журнал аудиту й історія програвань |
| Донати (навіть за увімкненого й налаштованого провайдера) |
Налаштування лишаються видимими, але лише для читання: бачити, що радіо робить зараз, корисно в будь-якому разі.
Обмеження діють на двох рівнях. Клієнт отримує їх у полі capabilities з /api/public/config і не показує відповідних кнопок, а самі роути відмовляють незалежно — сховати кнопку недостатньо, бо ендпоінт лишається доступним напряму.
Обидві осі рахуються окремо: віддалена база з локальним медіа лишає адмінів, аудит і налаштування працездатними, а вимикає лише те, що торкається файлів.
Якщо диск насправді постійний — просто не вмикайте прапорець, і всі можливості доступні за будь-якої комбінації провайдерів.
Змінні, про які легко забути
| Змінна | Навіщо |
|---|---|
CLIENT_ORIGIN | без вашого походження браузер заблокує і запити, і сокет |
ART_TOKEN_SECRET | підпис токенів доступу до медіа |
LYRICS_PREFETCH | true шукає тексти для всієї фонотеки на старті; типово вимкнено |
TIME_ZONE | від нього залежить розклад день/ніч і час очищення логів |
LOG_RETENTION_DAYS | скільки днів живуть записи аудиту й історії програвань |
CONFIRM_TRACK_CLEANUP | дозволяє стартовому узгодженню видаляти пісні, чиї файли зникли з диска |
Повний перелік із коментарями — у server/.env.example.