Skip to content

Повна конфігурація ​

Запуск власного сервера описує найпростіший режим: JSON, локальні файли, синхронізація. Ця сторінка — про решту: базу даних, хмарне сховище, потокове мовлення й живий ефір.

Що від чого залежить ​

Головне, що варто засвоїти одразу: вибір провайдера ні на що не впливає. Обидва дата-провайдери вміють усе, обидва медіа-провайдери теж. Ролі адмінів, джингли, бан-лист, завантаження пісень — усе це працює на json з локальними файлами так само, як на Turso з хмарою.

Лишилися залежності лише двох видів: технічні (щось потребує потоку або зовнішнього інструмента) і збереженість (на ефемерному хостингу немає сенсу писати те, що зникне).

ХочуЩо справді потрібно
Базове радіонічого
Кілька адмінів з роляминічого
Завантаження пісень, артівнічого
Модерація за IPRADIO_HOSTS_MODE=true
Спільний потікSTREAM_MODE=true + FFmpeg
Джингли між піснямиSTREAM_MODE=true
Живий ефірSTREAM_MODE=true + RADIO_HOSTS_MODE=true
Фонова музика під час паузиSTREAM_MODE=true + RADIO_HOSTS_MODE=true
Імпорт з YouTubeFFmpeg і завантажувач ytbdown (потребує python3 і його pip-залежностей)

Дві осі сховища незалежні: DATA_PROVIDER вирішує, де метадані, а MEDIA_STORAGE — де файли. Змішувати можна як завгодно, і на функціонал це не впливає — лише на те, куди фізично лягають байти.

Тоді навіщо взагалі хмара й зовнішня база

Заради збереженості, а не можливостей. На хостингу, що стирає диск при деплої, локальні файли не переживуть перезапуск — і тоді частина адмінських дій вимикається, бо їхній результат однаково зник би. Це єдиний випадок, коли вибір сховища щось змінює: Ефемерний хостинг.

Одна залежність жорстка

RADIO_HOSTS_MODE=true без STREAM_MODE=true не має сенсу: мікрофон ведучого нема куди підмішувати, якщо спільного потоку не існує. Сервер не спиняється — він попереджає в лозі й лишає живий ефір вимкненим.

База даних ​

Не потрібна для жодної можливості: json уміє те саме. Обирають її заради іншого — база витримує паралельні записи, не переписує файл цілком на кожну зміну і, головне, може жити поза диском сервера.

Один процес на теку

JSON-провайдер серіалізує записи в межах процесу. Кілька екземплярів сервера над однією текою — не підтримується; для такого сценарію потрібен sql.

Локальний файл — без жодних хмарних сервісів ​

Найчастіше пропускають саме це: DATA_PROVIDER=sql не вимагає акаунта Turso. Достатньо локального файлу SQLite:

ini
DATA_PROVIDER=sql
TURSO_DATABASE_URL=file:./data/sql/radio.db
# TURSO_AUTH_TOKEN не потрібен для file:

Токен обов'язковий лише для віддалених адрес (libsql://, https://): локальному файлу нема до чого автентифікуватись. Якщо вказати віддалену адресу без токена, сервер відмовиться стартувати й скаже це прямо.

Тобто щоб отримати кількох адмінів з привілеями, хмара не потрібна взагалі.

Віддалена Turso ​

ini
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 і накопичило бібліотеку:

bash
cd server
node scripts/migrate.js data --from json --to sql --sql-url file:./data/sql/radio.db

Зворотний напрямок — те саме з переставленими --from і --to. Для віддаленої бази до URL додається токен:

bash
node scripts/migrate.js data --from sql --to json \
  --sql-url libsql://<база>.turso.io --sql-token <токен>

Облікові дані не читаються з .env: віддалена база доступна лише тоді, коли її URL і токен вписані в команду. Так помилкова команда не влучить у сховище задеплоєного радіо. Спершу варто прогнати з --dry-run — він лише покаже, що буде скопійовано, і навіть не відкриє цільову базу.

Між двома базами ​

Радіо, що працює лише на SQL, зазвичай має дві бази: локальну для перевірок і віддалену для слухачів. Перенесення між ними — та сама команда, тільки кожен бік треба вказати окремо:

bash
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 — це просто зміна змінної й перезапуск: дані провайдерів не перетираються.

Локальне сховище ​

Усе, чим керує медіа-провайдер, лежить під однією текою й повторює розкладку бакета — так обидва провайдери влаштовані однаково:

text
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:

bash
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.

ini
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 з правом читання й запису в бакет.

Структура бакета ​

Сервер розкладає файли так і створює потрібні шляхи сам:

text
<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 навіть не ініціалізується.

Перенесення медіа між локальним сховищем і хмарою ​

bash
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 обмежує роботу частиною бібліотеки, наприклад лише артами:

bash
node scripts/migrate.js media --from cloud --to local --category arts …

Розкладка музики відрізняється

Локально треки лежать під media/music/, а в бакеті — просто day/ і night/, бо ключ об'єкта — це і є ідентифікатор треку. Команда враховує цю різницю; при ручному копіюванні файлів її легко не помітити.

Потокове мовлення ​

ini
STREAM_MODE=true
FFMPEG_PATH=ffmpeg

Потрібен FFmpeg у системі. Якщо він не в PATH, вкажіть повний шлях у FFMPEG_PATH — ця сама змінна використовується й для завантаження з YouTube.

Перемикання режиму діє лише після перезапуску сервера: спосіб доставки аудіо визначається на старті.

Що змінюється для клієнтів, описано в Режимах програвання — коротко, замість /api/audio/stream вони слухають /api/stream, а позицію беруть з подій.

Живий ефір ​

ini
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:19302

MAX_LIVE_HOST_SLOTS рахує ведучих і гостей разом: при значенні 1 ведучий займає єдине місце, і гість уже не приєднається.

Мережа ​

Персональний моніторинг ведучого працює через WebRTC, і це єдине місце в проєкті, що потребує відкритих UDP-портів:

UDP 40000–40099 (або ваш діапазон) — вхідні

Без них ефір усе одно працюватиме, але ведучий чутиме себе із затримкою спільного потоку, що робить розмову некомфортною. Навіщо цей канал взагалі — пояснено в Живому ефірі.

Якщо сервер за NAT, публічний STUN зазвичай достатній. Для складніших мереж знадобиться власний TURN, якого проєкт наразі не підтримує.

Права ​

Вихід в ефір потребує привілею radio_host, модерація — radio_moderator. Помічників з цими правами створює супер-адмін, і це працює на будь-якому дата-провайдері: json зберігає облікові записи так само, як sql.

Обмеження лишилось одне й воно не про сховище: на один обліковий запис живе одна сесія. Щоб в ефірі було двоє людей, потрібні два різні записи — а не певний провайдер.

Модерація за IP теж доступна скрізь: бан-лист зберігають обидва провайдери.

Донати ​

Один активний провайдер за раз — DONATIONS_PROVIDER вибирає, який саме, і кожен потребує лише своїх власних змінних:

ini
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 вимикається цілком, незалежно від того, наскільки коректно налаштований сам платіжний провайдер.

Порядок увімкнення ​

Радимо саме такий, бо кожен крок перевіряється окремо:

  1. Базове радіо на json + local. Переконайтеся, що музика грає.
  2. Адміни: створіть помічника, перевірте, що права діють. Провайдер міняти для цього не треба.
  3. Завантаження: залийте одну пісню й один арт через адмінку.
  4. Потік: STREAM_MODE=true. Перевірте, що /api/stream віддає аудіо.
  5. Джингли: тепер умова виконана.
  6. Живий ефір: RADIO_HOSTS_MODE=true і відкриті UDP-порти.

Зміну сховища — на sql чи cloud — можна робити будь-коли й окремо від цього списку: вона нічого не вмикає і нічого не вимикає, доки хостинг не оголошено ефемерним. Дані переносяться командою migrate.js data, файли — migrate.js media.

Після кожного кроку — перезапуск сервера й погляд у лог: більшість помилок конфігурації сервер повідомляє на старті явно.

Ефемерний хостинг ​

Частина платформ стирає файлову систему при кожному деплої. Щоб сервер не втрачав дані мовчки, це оголошується явно:

ini
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_PREFETCHtrue шукає тексти для всієї фонотеки на старті; типово вимкнено
TIME_ZONEвід нього залежить розклад день/ніч і час очищення логів
LOG_RETENTION_DAYSскільки днів живуть записи аудиту й історії програвань
CONFIRM_TRACK_CLEANUPдозволяє стартовому узгодженню видаляти пісні, чиї файли зникли з диска

Повний перелік із коментарями — у server/.env.example.