คู่มือติดตั้งแพลตฟอร์ม

คู่มือนี้สำหรับผู้ดูแลระบบวิทยุออนไลน์ (ชื่อทางเทคนิค radio) ติดตั้งด้วย Docker: คำสั่งเดียวติดตั้งทุกอย่างบนเซิร์ฟเวอร์เครื่องเดียว ในโฟลเดอร์ /opt/radio ถ้าเป็นคลัสเตอร์ใช้ Helm chart ทุกแบบรันโค้ดชุดเดียวกัน ต่างกันแค่ค่าตั้งใน .env (หรือค่าของ Helm) การติดตั้งโดยตรงแบบไม่ใช้ Docker ยังมีอธิบายไว้ในภาคผนวกที่ไม่แนะนำ

เพิ่งเริ่มดูแลเซิร์ฟเวอร์? คู่มือติดตั้งบน Ubuntu ทีละขั้น พาทำทุกขั้นด้วยแพ็กเกจ radio_<version>_all.deb: เตรียมเซิร์ฟเวอร์ DNS ใช้ SSH กับโปรแกรมอ่านหน้าจอ sudo radio-setup ตั้งค่าครั้งแรกในเว็บ สำรองข้อมูล อัปเดต และแก้ปัญหา คู่มือนี้เป็นเอกสารอ้างอิง

สิ่งที่ติดตั้ง

ส่วนประกอบ หน้าที่
app เว็บไซต์ หน้าผู้ฟัง หน้าผู้ดูแล API การเข้าสู่ระบบ และรับการเรียกกลับจาก Icecast และ Liquidsoap ปรับฐานข้อมูล (migration) เองเมื่อเริ่มทำงาน
worker ระบบออกอากาศ: Liquidsoap หนึ่งโปรเซสต่อสถานี รับสัญญาณสดของดีเจ (พอร์ตดีเจ) งานเบื้องหลัง รายการและโฆษณาตามเวลา การแจ้งเตือน สถิติผู้ฟัง และปรับค่าตั้งของ Icecast ให้ตรงกับสถานีเสมอ
icecast Icecast 2.4 ส่งสตรีมให้ผู้ฟังผ่าน Caddy ที่ <ที่อยู่>/stream/<สถานี> ถามแอปก่อนรับผู้ฟังทุกคน (โควตา จำกัดประเทศ สถิติ)
db PostgreSQL 16 แยกข้อมูลแต่ละสถานีด้วย row-level security
caddy Reverse proxy: HTTPS พร้อมใบรับรองอัตโนมัติ (รวมโดเมนย่อยของสถานีและโดเมนของสถานีเอง) หรือ HTTP ธรรมดาในเครือข่ายภายใน
backup สำรองข้อมูลแบบเข้ารหัสทุกวัน (restic) ลงเครื่องนี้ และ S3 ถ้าตั้งไว้
asterisk (ไม่บังคับ) ฟังทางโทรศัพท์ผ่าน SIP trunk

ความต้องการขั้นต่ำ

  • เซิร์ฟเวอร์ Linux 64 บิต (x86_64/amd64 หรือ arm64) ที่มี Docker: ทดสอบแล้วกับ Ubuntu 22.04, 24.04 และ Debian 12 ตัวติดตั้งติดตั้ง Docker ให้บนระบบเหล่านี้ ใช้ NAS (Synology, QNAP) หรือ container LXC ของ Proxmox ได้ด้วย (ดูด้านล่าง)
  • 2 คอร์ หน่วยความจำ 4 GB ดิสก์ 30 GB สำหรับไม่เกิน 5 สถานี (โปรไฟล์ small) บวกคลังเสียงและข้อมูลสำรอง
  • โดเมนที่ชี้มาที่เซิร์ฟเวอร์ (แนะนำ) หรือใช้แค่หมายเลข IP ก็ได้ (ดู "เลือกวิธีเข้าถึงแพลตฟอร์ม")

ไม่มีขนาดเครื่องตายตัว ขึ้นกับจำนวนสถานี และที่สำคัญที่สุดคือจำนวนผู้ฟัง:

  • สถานีที่เปิดอยู่แต่ละสถานีรัน Liquidsoap หนึ่งโปรเซสใน worker ซีพียูและหน่วยความจำเพิ่มตามจำนวนสถานี
  • แบนด์วิดท์ของผู้ฟังประมาณจำนวนผู้ฟัง × bitrate เช่น ผู้ฟัง 500 คนที่ 128 kbps ต้องใช้ upload ประมาณ 64 Mbit/s
  • เกณฑ์ที่ทดสอบ: npm run bench -- <base URL> <slug สถานี> ตรวจว่าหน้าสถานีตอบที่ p95 ต่ำกว่า 300 ms และ API สถานะที่ p95 ต่ำกว่า 100 ms เมื่อมีผู้ใช้พร้อมกัน 50 คน สคริปต์รายงาน p97.5 ซึ่งเข้มกว่า ต้องมีแพ็กเกจสำหรับพัฒนา (npm ci)

วัดเครื่องของคุณเองด้วยสคริปต์ bench และจำนวนผู้ฟังจริงก่อนรับปากเรื่องความจุ

โปรไฟล์ขนาด

ตัวติดตั้งเลือกโปรไฟล์จากหน่วยความจำและซีพียูของเครื่อง แล้วให้คุณยืนยัน โปรไฟล์กำหนดเพดานหน่วยความจำและซีพียูของทุก container และหน่วยความจำของ PostgreSQL ส่วนที่ทำงานหนักจึงไม่แย่งทรัพยากรของส่วนอื่นจนหมด

โปรไฟล์ เครื่อง สถานี shard ของระบบออกอากาศ Connection pooling (PgBouncer)
small 2 vCPU, RAM 4 GB ไม่เกิน 5 1 ไม่ใช้
medium 4 vCPU, RAM 8 GB ไม่เกิน 15 2 ใช้
large 8 vCPU, RAM 16 GB ไม่เกิน 40 4 ใช้

วัดจริงด้วย 5 สถานีที่เล่นเล่นเพลงอัตโนมัติบนโปรไฟล์ small: ใช้หน่วยความจำรวมประมาณ 780 MB ในนั้นระบบออกอากาศใช้ประมาณ 575 MB และซีพียูหนึ่งในสามถึงครึ่งคอร์ แบนด์วิดท์ของผู้ฟังคิดเพิ่มต่างหาก (ผู้ฟัง × bitrate)

เลือกวิธีเข้าถึงแพลตฟอร์ม

ตัวติดตั้งถามเรื่องนี้เป็นอย่างแรก เปลี่ยนภายหลังได้โดยรันตัวติดตั้งอีกครั้ง (รหัสลับและข้อมูลทั้งหมดยังอยู่)

แบบ ที่อยู่ ต้องมีอะไร อะไรใช้ไม่ได้
โดเมน (domain แนะนำ) https://radio.example.th โดเมนที่ DNS ชี้มาที่เซิร์ฟเวอร์ และพอร์ต 80 กับ 443 ที่อินเทอร์เน็ตเข้าถึงได้ ไม่มี ใช้ได้ทุกความสามารถ
IP แบบ HTTPS (ip-https) https://203.0.113.10 IP สาธารณะที่อินเทอร์เน็ตเข้าถึงพอร์ต 80 และ 443 ได้ Caddy ขอใบรับรองของ Let's Encrypt สำหรับหมายเลข IP (อายุประมาณ 6 วัน ต่ออายุอัตโนมัติ) โดเมนย่อยของสถานี, พาสคีย์ (ต้องใช้ชื่อโดเมน), LINE และ Telegram อาจไม่รับที่อยู่ที่ไม่มีชื่อโดเมน
IP แบบ HTTPS ด้วย IP ส่วนตัว (ip-https ใบรับรองจาก Caddy เอง) https://192.168.1.20 ไม่ต้องมีอะไร (ใช้แบบนี้ด้วยเมื่อ Let's Encrypt ออกใบรับรอง IP ให้ไม่ได้) เหมือนแถวบน และเบราว์เซอร์เตือนว่า "ไม่ปลอดภัย" จนกว่าจะติดตั้งใบรับรองหลักของ Caddy ในแต่ละเครื่อง การแจ้งเตือนแบบพุช LINE Telegram และเครื่องเล่นที่ฝังในเว็บไซต์อื่นใช้ไม่ได้
IP แบบ HTTP ธรรมดา ในเครือข่ายภายใน (ip-http) http://192.168.1.20 ไม่ต้องมีอะไร ไม่มีการเข้ารหัสเลย เบราว์เซอร์ไม่ให้ใช้ไมโครโฟน (ห้องจัดรายการบนเว็บ) แขกรับเชิญทางไกล (WebRTC) การแจ้งเตือนแบบพุช และพาสคีย์ LINE และ Telegram ส่งข้อความเข้ามาไม่ได้ ส่วนการฟัง หน้าผู้ดูแล และดีเจที่ใช้ BUTT/OBS ใช้ได้ ใช้ในเครือข่ายที่ไว้ใจได้เท่านั้น

ทุกแบบ สตรีมของผู้ฟังอยู่ที่ <ที่อยู่>/stream/<สถานี> Caddy ส่งต่อให้ Icecast จึงเปิดแค่พอร์ต 80 และ 443 (และพอร์ตดีเจ) โดเมนแยกสำหรับสตรีม (เช่น stream.radio.example.th) จะใช้หรือไม่ก็ได้

โดเมนไม่ได้ตายตัวตั้งแต่ติดตั้ง หลังติดตั้งแล้วผู้ดูแลระบบเพิ่มและเปลี่ยนโดเมนได้ในเว็บที่ การจัดการแพลตฟอร์ม → โดเมนและที่อยู่ (โดเมนของสถานีเอง ที่อยู่หลัก ที่อยู่สตรีม) Caddy ขอใบรับรองของชื่อใหม่ได้เองโดยไม่ต้องเริ่มใหม่ ค่าที่ตัวติดตั้งเขียนลง .env (BASE_URL, BASE_DOMAIN, ICECAST_PUBLIC_URL, HARBOR_PUBLIC_HOST) เป็นแค่ค่าเริ่มต้นเท่านั้น

DNS (แบบโดเมน)

สร้างระเบียนเหล่านี้ก่อนหรือหลังติดตั้งทันที (ตัวติดตั้งตรวจให้ และให้ติดตั้งต่อได้ถ้ายังไม่พร้อม radioctl doctor ตรวจซ้ำได้ภายหลัง) ใบรับรองจะออกทันทีที่ชื่อชี้มาที่เซิร์ฟเวอร์

ชื่อ ชนิด ใช้ทำอะไร
radio.example.th A (และ AAAA สำหรับ IPv6) → IP ของเซิร์ฟเวอร์ เว็บไซต์ สตรีม (/stream/…) และที่อยู่สำหรับดีเจ
*.radio.example.th A → IP ของเซิร์ฟเวอร์ เฉพาะเมื่อต้องการโดเมนย่อยของสถานี (<สถานี>.radio.example.th) ระเบียน wildcard ตอบทุกชื่อใต้โดเมนนี้
stream.radio.example.th A → IP ของเซิร์ฟเวอร์ เฉพาะเมื่อเลือกใช้โดเมนแยกสำหรับสตรีม

ติดตั้งด้วยคำสั่งเดียว

บนเซิร์ฟเวอร์ ด้วยสิทธิ์ root:

curl -fsSL https://github.com/<owner>/<repo>/releases/latest/download/install.sh | sudo bash

<owner>/<repo> คือคลัง GitHub ที่ออกรุ่นนั้น สคริปต์ดาวน์โหลดรุ่นนั้นมาไว้ที่ /opt/radio แล้วรันตัวติดตั้งจากที่นั่น ถ้ามีไฟล์อยู่แล้ว (ไฟล์รุ่น radio-<version>.tar.gz ที่แตกแล้ว หรือ git clone) ให้วางไว้ที่ /opt/radio แล้วรัน:

cd /opt/radio
sudo bash scripts/install.sh

บน Ubuntu วิธีที่ง่ายที่สุดคือแพ็กเกจ Debian: sudo apt install ./radio_<version>_all.deb วางโฟลเดอร์เดียวกันไว้ที่ /opt/radio และเพิ่มคำสั่ง radio-setup (เหมือน sudo bash /opt/radio/scripts/install.sh) ดูคู่มือติดตั้งบน Ubuntu ทีละขั้น

ใช้ได้กับ Ubuntu 22.04 หรือ 24.04 และ Debian 12 บน x86_64 หรือ arm64 คำถามแรกคือภาษา (ไทยหรืออังกฤษ) ทุกคำถามเป็นข้อความธรรมดา พิมพ์คำตอบแล้วกด Enter ถ้าไม่พิมพ์อะไรจะใช้ค่าเริ่มต้นที่แสดงไว้ ตัวติดตั้งจะ:

  1. ตรวจระบบ ซีพียู หน่วยความจำ พื้นที่ดิสก์ และนาฬิกา แล้วแนะนำโปรไฟล์ขนาด
  2. ติดตั้ง Docker Engine และ Compose plugin จากคลัง apt ของ Docker ถ้ายังไม่มี และตั้งการหมุนเวียน log ของ Docker
  3. ถามวิธีเข้าถึง (โดเมน, IP แบบ HTTPS, IP แบบ HTTP) และรายละเอียดของที่อยู่: แบบโดเมนถามโดเมน จะใช้โดเมนย่อยของสถานีหรือไม่ โดเมนสตรีมแยก (ไม่บังคับ) แล้วตรวจ DNS ให้ แบบ IP ถามหมายเลข IP (เสนอค่าที่ตรวจพบ ถ้าอยู่หลังเราเตอร์จะบอกทั้ง IP ของเครื่องและ IP สาธารณะ)
  4. ถามอีเมลผู้ดูแลระบบ (ใช้กับ Let's Encrypt ด้วย) เซิร์ฟเวอร์อีเมล (ไม่บังคับ) โฟลเดอร์เก็บข้อมูลสำรอง และที่เก็บ S3 สำหรับข้อมูลสำรอง (ไม่บังคับ)
  5. สร้างรหัสผ่านและรหัสลับทั้งหมดด้วย openssl แล้วเขียนลง /opt/radio/.env ซึ่งอ่านได้เฉพาะ root
  6. เปิดพอร์ตใน ufw ถ้าไฟร์วอลล์เปิดใช้อยู่
  7. ติดตั้งคำสั่ง radioctl (ดูแลระบบ) และ radio (คำสั่งผู้ดูแลของแพลตฟอร์ม) ใน /usr/local/bin
  8. เตรียมอิมเมจ (ดาวน์โหลด หรือสร้างบนเครื่องเมื่อไม่ได้กำหนดที่มาของอิมเมจ หรือดาวน์โหลดไม่ได้และมีซอร์สโค้ดอยู่) เริ่มทุกบริการ แล้วรอจนทุกบริการพร้อม
  9. แสดงที่อยู่ของหน้าติดตั้ง

จากนั้นเปิด <ที่อยู่>/install ทันที แล้วทำตาม "เริ่มใช้งานครั้งแรก" ด้านล่าง คนแรกที่เปิดหน้านี้เป็นผู้สร้างผู้ดูแลระบบ (หรือสั่ง sudo radio create-admin you@example.th) เก็บสำเนา .env ไว้ที่ปลอดภัยนอกเซิร์ฟเวอร์ ต้องใช้ RESTIC_PASSWORD ในไฟล์นี้เพื่ออ่านข้อมูลสำรอง

แบบไม่ถาม (สำหรับระบบอัตโนมัติ) ให้ใส่คำตอบเป็นตัวเลือก:

sudo bash scripts/install.sh --yes --lang th --mode domain --domain radio.example.th --wildcard \
  --email admin@example.th --profile small \
  --s3-repository s3:https://s3.ap-southeast-1.amazonaws.com/my-bucket/radio \
  --s3-access-key AKIA... --s3-secret-key ... --s3-region ap-southeast-1
sudo bash scripts/install.sh --yes --mode ip-https --ip 203.0.113.10 --email admin@example.th
sudo bash scripts/install.sh --yes --mode ip-http --ip 192.168.1.20 --email admin@example.th

--help แสดงตัวเลือกทั้งหมด เช่น --stream-domain, --dj-host, --ip-tls letsencrypt|internal, --domain-tls internal (ใบรับรองของ Caddy เอง สำหรับชื่อที่รู้จักเฉพาะในเครือข่ายขององค์กร), --version, --image-prefix ghcr.io/<owner> (ที่มาของอิมเมจ), --build (สร้างอิมเมจบนเครื่อง), --offline ไฟล์, --http-port/--https-port, --dj-port-start, --dry-run (ตรวจเครื่องและเขียน .env อย่างเดียว) รันตัวติดตั้งซ้ำได้อย่างปลอดภัย: เก็บรหัสลับเดิม เพิ่มรหัสที่ขาด ดึงอิมเมจใหม่ และเริ่มส่วนที่เปลี่ยนใหม่ ใช้ซ่อม อัปเกรด หรือเปลี่ยนวิธีเข้าถึงได้

พอร์ตที่ต้องเปิด (ตัวติดตั้งเปิดให้ถ้า ufw เปิดใช้อยู่):

พอร์ต ใช้โดย
80/tcp, 443/tcp, 443/udp Caddy (เว็บไซต์ สตรีม ใบรับรอง HTTP/3) แบบ ip-http ใช้แค่ 80
8100–8119/tcp (small), 8100–8139 (medium), 8100–8199 (large) สัญญาณสดของดีเจ (BUTT/OBS) สถานีละหนึ่งพอร์ต ช่วงคือ HARBOR_PORT_START ถึง HARBOR_PORT_END
5060/udp, 10000–10100/udp Asterisk เฉพาะเมื่อใช้การฟังทางโทรศัพท์

อย่าเปิด 3000, 5432, 6432, 8000 หรือ 9100 ขึ้นไป เป็นพอร์ตภายใน และ Docker ไม่ได้เปิดออกนอกเครื่อง

ค่าที่ตัวติดตั้งเขียน

ค่าใน .env แบบโดเมน แบบ IP
PUBLIC_MODE domain ip-https หรือ ip-http (Caddy ใช้ deploy/caddy/<แบบ>.Caddyfile)
BASE_URL https://radio.example.th https://203.0.113.10 หรือ http://192.168.1.20 (มี :พอร์ต ถ้าไม่ใช่ 443/80)
BASE_DOMAIN โดเมน เฉพาะเมื่อใช้โดเมนย่อยของสถานี ว่าง
STREAM_PATH /stream /stream
ICECAST_PUBLIC_URL BASE_URL/stream หรือ https://<โดเมนสตรีม> BASE_URL/stream
STREAM_DOMAIN โดเมนสตรีมแยก (ถ้ามี) ว่าง
HARBOR_PUBLIC_HOST โดเมน (หรือ --dj-host) IP
PUBLIC_IP, PUBLIC_IP_HOST, IP_TLS_ISSUER ว่าง IP, IP ในรูปแบบ URL (IPv6 อยู่ในวงเล็บเหลี่ยม), letsencrypt หรือ internal
DOMAIN_TLS_ISSUER public หรือ internal ไม่ใช้
HTTPS_PORT_SUFFIX :<พอร์ต> เมื่อ HTTPS_PORT ไม่ใช่ 443 นอกนั้นว่าง เหมือนกัน

ใบรับรองของ Caddy เอง (IP ส่วนตัว)

เมื่อใช้ใบรับรองจากผู้ออกใบรับรองของ Caddy เอง เบราว์เซอร์จะเตือนจนกว่าแต่ละเครื่องจะเชื่อถือใบรับรองหลักของ Caddy วิธีทำให้เชื่อถือ:

sudo docker compose -f /opt/radio/docker-compose.yml cp caddy:/data/caddy/pki/authorities/local/root.crt ./radio-root.crt

ติดตั้ง radio-root.crt เป็นใบรับรองหลักที่เชื่อถือในคอมพิวเตอร์หรือโทรศัพท์แต่ละเครื่อง (Windows: ดับเบิลคลิก → Install certificate → Local machine → Trusted Root Certification Authorities, macOS: Keychain Access → System → Always trust, Android และ iOS: การตั้งค่า → ความปลอดภัย → ติดตั้งใบรับรอง แล้วบน iOS เปิดใช้ที่ ทั่วไป → เกี่ยวกับ → การตั้งค่าความเชื่อถือใบรับรอง) ทำเฉพาะกับอุปกรณ์ขององค์กรคุณเองเท่านั้น

อิมเมจและการติดตั้งแบบออฟไลน์

อิมเมจคือ ghcr.io/<owner>/radio-app, radio-engine, radio-icecast, radio-backup และ radio-pgbouncer สำหรับ linux/amd64 และ linux/arm64 เผยแพร่โดย GitHub Actions ของคลังโค้ด (<owner> คือเจ้าของคลัง) RADIO_IMAGE_PREFIX ใน .env บอกที่มาของอิมเมจ (ghcr.io/<owner> ไฟล์รุ่นตั้งให้เอง) ถ้าว่าง หรือดาวน์โหลดไม่ได้และมีซอร์สโค้ดในโฟลเดอร์ ตัวติดตั้งจะสร้างอิมเมจบนเครื่อง (RADIO_BUILD=true ครั้งแรกใช้เวลาหลายนาที) ในช่วงเปลี่ยนผ่าน อิมเมจชุดเดียวกันยังเผยแพร่ในชื่อเดิม ghcr.io/<owner>/saint-radio-* ด้วย

แต่ละรุ่นบน GitHub มีไฟล์เหล่านี้:

ไฟล์ คืออะไร
install.sh ตัวติดตั้งบรรทัดเดียวของรุ่นนั้น
radio_<version>_all.deb แพ็กเกจ Debian/Ubuntu: โฟลเดอร์เดียวกับไฟล์รุ่นใน /opt/radio พร้อมคำสั่ง radio-setup, radioctl และ radio ไม่มีไฟล์ .env อยู่ในแพ็กเกจ
radio-<version>.tar.gz โฟลเดอร์ติดตั้ง: ไฟล์ Compose, deploy/, scripts/, docs/, .env.example, VERSION, release.env, คู่มือแบบข้อความธรรมดา INSTALL-UBUNTU.*.txt และซอร์สโค้ดที่เซิร์ฟเวอร์ใช้สร้างอิมเมจเอง
radio-images-<version>-amd64.tar.gz, -arm64.tar.gz อิมเมจทั้งหมดของรุ่นสำหรับสถาปัตยกรรมหนึ่ง (รวม PostgreSQL และ Caddy) สำหรับเครื่องที่ไม่มีอินเทอร์เน็ต
SHA256SUMS ค่าตรวจสอบไฟล์

ติดตั้งแบบไม่มีอินเทอร์เน็ต (ต้องมี Docker อยู่แล้ว):

sudo mkdir -p /opt/radio
sudo tar xzf radio-1.5.0.tar.gz -C /opt/radio --strip-components=1
cd /opt/radio
sudo bash scripts/install.sh --offline radio-images-1.5.0-amd64.tar.gz

--offline โหลดอิมเมจด้วย docker load และไม่ดาวน์โหลดอะไรเลย การอัปเดตทำแบบเดียวกัน: sudo radioctl update --offline radio-images-1.6.0-amd64.tar.gz (ถ้าไฟล์ในรุ่นใหม่เปลี่ยน ให้แตก radio-1.6.0.tar.gz ทับ /opt/radio ก่อน)

ติดตั้งและอัปเดตจาก GitHub ด้วย git

scripts/radio-git ติดตั้งระบบเป็นสำเนา git ของ repository แล้วอัปเดตจากที่นั่น ใช้กับ repository แบบ private ได้ เพราะเซิร์ฟเวอร์มี deploy key ของตัวเองที่อ่านได้อย่างเดียว และไม่ดาวน์โหลดอะไรจากหน้าเว็บของ GitHub คัดลอกไฟล์ไปที่เซิร์ฟเวอร์ (scp, WinSCP หรือวางเนื้อหาลงไฟล์) แล้วรัน

sudo bash radio-git install
  1. ติดตั้ง git, SSH client และ curl ถ้ายังไม่มี
  2. สร้าง deploy key (/root/.ssh/radio_deploy) แสดง public key แยกบรรทัดพร้อมหน้าที่ต้องไปเพิ่ม (https://github.com/OWNER/REPO/settings/keys/new ไม่ต้องติ๊ก Allow write access) และเก็บสำเนาไว้ในโฟลเดอร์บ้านของคุณ (~/radio-deploy-key.pub) ให้ดาวน์โหลดไปวางได้ถ้าคัดลอกจากหน้าจอไม่สะดวก แล้วรอจน GitHub รับ key host key ของ GitHub ได้จาก API ของ GitHub ผ่าน HTTPS และ Ed25519 key ที่ GitHub เผยแพร่ จึงไม่มีคำถาม "are you sure"
  3. clone สาขาลงใน /opt/radio แล้วรันตัวติดตั้ง (ตัวเลือกหลัง -- ส่งต่อให้ตัวติดตั้ง เช่น sudo bash radio-git install -- --mode domain --domain radio.example.th)
  4. หลังจากนั้นใช้ sudo radio-git เป็นคำสั่งบนเซิร์ฟเวอร์ได้เลย

ถ้าเครื่องติดตั้งไว้แล้วด้วย .deb หรือ .tar.gz คำสั่งนี้จะเปลี่ยนให้อัปเดตผ่าน git: สำรองข้อมูลก่อน ถอนแพ็กเกจ (ไม่ลบ .env วอลุ่มของ Docker และข้อมูลสำรอง) เปลี่ยนโฟลเดอร์เป็นสำเนา git แล้วรันตัวติดตั้งอีกครั้งด้วยการตั้งค่าเดิม

คำสั่ง ทำอะไร
sudo radio-git update ดึงสาขาล่าสุด แสดงรายการ commit ใหม่และ migration ฐานข้อมูลที่เพิ่ม ถามยืนยัน แล้วสำรองข้อมูลและย้ายระบบไปโค้ดใหม่ (radioctl update)
sudo radio-git rollback กลับไปใช้โค้ดก่อนการอัปเดตครั้งล่าสุด (ไม่ย้อน migration ฐานข้อมูล ต้องกู้คืนข้อมูลสำรองเอง)
sudo radio-git status เวอร์ชันและ commit ที่ใช้อยู่ และ GitHub มีโค้ดใหม่กว่าหรือไม่
sudo radio-git key แสดง deploy key อีกครั้งและทดสอบการเชื่อมต่อ

ตัวเลือก: --lang th|en, --yes, --branch ชื่อสาขา, --pull-images (ใช้อิมเมจที่เผยแพร่บน ghcr.io แทนการสร้างบนเซิร์ฟเวอร์ อิมเมจของ repository แบบ private ต้อง docker login ghcr.io หรือตั้งแพ็กเกจเป็น public), --ssh-443 (เมื่อเครือข่ายปิดพอร์ต 22), --no-backup, --dry-run ค่าที่เลือกเก็บไว้ใน /etc/radio-git.conf การอัปเดตจะหยุดถ้ามีการแก้ไฟล์โปรแกรมใน /opt/radio บนเซิร์ฟเวอร์ หรือถ้าประวัติบน GitHub ไม่ต่อจากโค้ดในเครื่อง

ติดตั้งเอง (ไม่ใช้ตัวติดตั้ง)

ตัวติดตั้งแค่ทำขั้นตอนเหล่านี้ให้อัตโนมัติ คุณทำเองได้:

  1. ติดตั้ง Docker Engine พร้อม Compose plugin แล้ววางไฟล์ไว้ที่ /opt/radio
  2. cp .env.example .env && chmod 600 .env แล้วแก้ .env: ค่าที่อยู่ตามตารางด้านบน, ACME_EMAIL (Let's Encrypt), POSTGRES_PASSWORD และ DB_APP_PASSWORD, SESSION_SECRET, IP_HASH_SALT, DB_SCOPE_SECRET (สตริงสุ่มยาว เช่นจาก openssl rand -hex 32), ICECAST_SOURCE_PASSWORD, ICECAST_ADMIN_PASSWORD, RESTIC_PASSWORD (เข้ารหัสข้อมูลสำรอง เก็บสำเนาไว้นอกเซิร์ฟเวอร์), SMTP_URL, MAIL_FROM (ถ้า SMTP_URL ว่าง อีเมลจะถูกเขียนเป็นไฟล์ ดู "เริ่มใช้งานครั้งแรก"), RADIO_IMAGE_PREFIX และ RADIO_VERSION
  3. คัดลอกค่าของโปรไฟล์ขนาดหนึ่งโปรไฟล์ deploy/profiles/small.env (หรือ medium, large) ลงใน .env แทนบรรทัดชื่อเดียวกัน และตั้ง HARBOR_PORT_END เป็น HARBOR_PORT_START + HARBOR_PORT_COUNT − 1 ถ้าใช้มากกว่าหนึ่ง shard ให้รัน radioctl scale --shards N ภายหลัง คำสั่งนี้เขียนบริการที่เพิ่มให้
  4. เริ่มระบบ: docker compose up -d ถ้าจะสร้างอิมเมจเอง: docker compose -f docker-compose.yml -f docker-compose.build.yml build ก่อน

docker-compose.yml ตั้งชื่อโปรเจกต์ Compose ว่า radio ไม่ว่าโฟลเดอร์จะชื่ออะไร วอลุ่มจึงชื่อ radio_db-data, radio_radio-data และอื่น ๆ เสมอ Docker Compose ตั้ง DATABASE_URL, DATABASE_ADMIN_URL, DATA_DIR, ICECAST_HOST, LIQUIDSOAP_CALLBACK_URL, LIQUIDSOAP_CONTROL_HOST, LIQUIDSOAP_HARBOR_HOST, IVR_AGI_PUBLIC_HOST, ICECAST_TRUSTED_PROXIES, ICECAST_CONFIG_FILE, ICECAST_LOG_DIR, ICECAST_STANDBY, WORKER_METRICS_PORT และ INSTANCE_NAME เอง ค่าเหล่านี้ใน .env จะไม่ถูกใช้ DATABASE_ADMIN_URL (บัญชีผู้ดูแลฐานข้อมูล) ให้เฉพาะบริการ app ซึ่งเป็นผู้ปรับฐานข้อมูล รหัสลับของการสำรองข้อมูลให้เฉพาะบริการ backup

เมื่อ NODE_ENV=production (ค่าเริ่มต้นใน image) app และ worker จะไม่ยอมเริ่มทำงานถ้า SESSION_SECRET สั้นกว่า 32 ตัวอักษรหรือยังเป็นค่าตัวอย่าง IP_HASH_SALT ยังเป็น dev-ip-salt หรือค่าตัวอย่าง หรือรหัสผ่าน Icecast ยังเป็น hackme-… หรือ change-me… ข้อความแจ้งข้อผิดพลาดบอกว่าต้องเปลี่ยนค่าใด

การติดตั้งที่ทำก่อนใช้ชื่อ "radio"

การติดตั้งรุ่นก่อนหน้านี้อยู่ที่ /opt/saint-radio หรือโฟลเดอร์ radio-platform และวอลุ่มของ Docker ตั้งชื่อตามโฟลเดอร์นั้น (saint-radio_db-data, …) docker-compose.yml ใหม่ใช้ชื่อโปรเจกต์ radio เสมอ การติดตั้งแบบนี้จึงต้องย้ายหนึ่งครั้ง ไม่อย่างนั้นจะเริ่มด้วยฐานข้อมูลว่าง radioctl และตัวติดตั้งตรวจพบและหยุดพร้อมบอกวิธีแก้ ให้แตกรุ่นใหม่ทับโฟลเดอร์เดิม แล้ว:

cd /opt/saint-radio
sudo bash scripts/radioctl migrate-name --dry-run    # แสดงแผน ไม่เปลี่ยนอะไร
sudo bash scripts/radioctl migrate-name

คำสั่งนี้สำรองข้อมูล หยุด container เดิม คัดลอกทุกวอลุ่มไปเป็น radio_* ย้ายโฟลเดอร์ไปที่ /opt/radio (ที่เดิมเหลือเป็นลิงก์) ย้าย /var/backups/saint-radio ไปเป็น /var/backups/radio ตั้งชื่อใหม่ให้อิมเมจที่มีในเครื่อง เริ่มระบบ แล้วเทียบจำนวนสถานี ผู้ใช้ และไฟล์เสียงก่อนและหลังย้าย วอลุ่มเดิมไม่ถูกแก้ไข เมื่อตรวจเว็บและสตรีมแล้ว ลบด้วยคำสั่ง docker volume rm ที่คำสั่งนี้แสดง รันซ้ำก็ไม่ทำอะไรเพิ่ม รายละเอียดในคู่มือดูแลระบบ

NAS (Synology, QNAP)

NAS ใช้ไฟล์ Compose ของตัวเอง deploy/nas/docker-compose.yml: พอร์ตเว็บ 8080 (HTTP) และ 8443 (HTTPS) เพราะ NAS ใช้ 80, 443, 5000 และ 5001 อยู่แล้ว ไม่สร้างอิมเมจบน NAS ไม่ใช้เครือข่ายของเครื่องโฮสต์ ข้อมูลสำรองอยู่ที่ deploy/nas/backups ในโฟลเดอร์โปรเจกต์ ต้องใช้ DSM 7.2 กับ Container Manager หรือ QTS/QuTS hero กับ Container Station 3 รุ่น x86_64 หรือ arm64 และหน่วยความจำอย่างน้อย 4 GB

  1. คัดลอกโฟลเดอร์รุ่นไปไว้บน NAS เช่น /volume1/docker/radio (Synology) หรือ /share/Container/radio (QNAP)

  2. เขียนค่าตั้งผ่าน SSH (Synology: แผงควบคุม → Terminal & SNMP → เปิด SSH):

    cd /volume1/docker/radio
    sudo bash scripts/install.sh --nas
    

    คำถามเหมือนกัน (บน NAS ส่วนใหญ่เลือก "IP แบบ HTTP" สำหรับเครือข่ายภายใน) เขียน deploy/nas/.env และถ้ามีคำสั่ง docker compose ก็เริ่มระบบให้ ไม่ติดตั้ง Docker และไม่แก้ไฟร์วอลล์ของ NAS ถ้าไม่มี SSH ให้คัดลอก .env.example เป็น deploy/nas/.env แล้วกรอกค่าตาม "ติดตั้งเอง"

  3. ถ้ายังไม่ได้เริ่มระบบ (ไม่มี docker compose ทาง SSH): Synology Container Manager → โปรเจกต์ → สร้าง ตั้งชื่อโปรเจกต์ radio พาธ …/radio/deploy/nas ที่มา "ใช้ docker-compose.yml ที่มีอยู่" → ถัดไป → เสร็จ บน QNAP ให้เริ่มผ่าน SSH ในโฟลเดอร์นั้น: cd /share/Container/radio/deploy/nas && sudo docker compose up -d (หน้า "สร้างแอปพลิเคชัน" ของ Container Station ใช้โฟลเดอร์แบบอ้างอิงที่ไฟล์นี้ต้องใช้ไม่ได้)

  4. เปิด http://<IP ของ NAS>:8080/install

ถ้าต้องให้เข้าจากอินเทอร์เน็ตด้วยโดเมน: ตั้งเราเตอร์ส่งต่อพอร์ต 80 → 8080 และ 443 → 8443 ไปที่ NAS แล้วรัน sudo bash scripts/install.sh --nas --mode domain (Let's Encrypt ตรวจผ่านพอร์ต 80) ส่งต่อช่วงพอร์ตดีเจ (8100 ขึ้นไป) ด้วยถ้าดีเจเชื่อมต่อจากข้างนอก อิมเมจ: NAS ดาวน์โหลดเอง (RADIO_IMAGE_PREFIX) หรือโหลดไฟล์อิมเมจของรุ่นก่อนด้วย sudo docker load -i radio-images-<version>-<arch>.tar.gz แล้วใช้ --offline สำรอง deploy/nas/backups และ deploy/nas/.env ออกนอก NAS ด้วย (Hyper Backup, HBS 3) ดูแลระบบด้วย sudo bash scripts/radioctl … ผ่าน SSH

Proxmox VE

ใช้เครื่องเสมือน (VM) Ubuntu 24.04 (ง่ายที่สุด ทุกอย่างเหมือนเซิร์ฟเวอร์ทั่วไป) หรือ container LXC:

  1. สร้าง container แบบ unprivileged ด้วย Ubuntu 24.04 หรือ Debian 12 อย่างน้อย 2 คอร์ หน่วยความจำ 4 GB ดิสก์ 30 GB
  2. ก่อนเริ่ม container ให้เปิด Options → Features → nesting และ keyctl (หรือใน /etc/pve/lxc/<id>.conf: features: keyctl=1,nesting=1) ถ้าไม่เปิด Docker จะรัน container ไม่ได้
  3. เริ่ม container แล้วรันตัวติดตั้งบรรทัดเดียวข้างใน ตัวติดตั้งตรวจพบ LXC และเตือนเรื่องสองตัวเลือกนี้

ถ้า Docker ใน LXC แจ้ง "permission denied" เรื่อง sysctl หรือ overlay ให้ใช้ VM แทน ถ้า container อยู่หลัง NAT ให้ส่งต่อพอร์ต 80, 443 และช่วงพอร์ตดีเจไปที่ที่อยู่ของ container

เพิ่มเติม: ฟังทางโทรศัพท์

  1. คัดลอกตัวอย่าง SIP trunk แล้วกรอกข้อมูลของผู้ให้บริการ:

    cp deploy/asterisk/pjsip_trunk.example.conf deploy/asterisk/pjsip_trunk.conf
    
  2. ใน .env ตั้ง IVR_AGI_PORT=4573 และเพิ่ม ivr ใน COMPOSE_PROFILES (เช่น COMPOSE_PROFILES=ivr หรือ pgbouncer,ivr สำหรับ medium และ large)

  3. สร้างและเริ่ม: sudo docker compose -f /opt/radio/docker-compose.yml build asterisk && sudo radioctl up

ดู "ฟังทางโทรศัพท์ (Asterisk)" ในหัวข้อตั้งค่าความสามารถเสริม

Kubernetes (Helm)

chart อยู่ที่ deploy/helm/radio CI ตรวจด้วย helm lint, helm template และ kubeconform ยังไม่เคยรันบนคลัสเตอร์จริง ควรลองบนคลัสเตอร์ทดสอบก่อนใช้งานจริง

chart เปลี่ยนชื่อจาก saint-radio เป็น radio แต่ออบเจกต์ Kubernetes ที่สร้างยังใช้ชื่อ <release>-saint-radio-… เหมือนเดิม (ค่า nameOverride เริ่มต้นเป็น saint-radio) เพราะการเปลี่ยนชื่อจะทำให้ release ที่มีอยู่ได้วอลุ่ม Secret และที่อยู่ LoadBalancer ชุดใหม่ อัปเกรด release เดิมด้วยโฟลเดอร์ chart ใหม่ได้ตามปกติ การติดตั้งใหม่ตั้ง nameOverride: radio ได้

สิ่งที่ต้องมี

  • PostgreSQL 16 ภายนอก chart (แนะนำฐานข้อมูลแบบ managed)
  • ที่เก็บไฟล์แบบ S3 (แนะนำ) ถ้าเก็บในเครื่อง ต้องใช้ volume แบบ ReadWriteMany
  • volume แบบ ReadWriteMany (NFS, CephFS, EFS, Filestore ฯลฯ) สำหรับสคริปต์สถานี ไฟล์ HLS ไฟล์ส่งออก และไฟล์ชั่วคราว worker เป็นผู้เขียน app เป็นผู้ให้บริการไฟล์
  • ingress controller (ค่าเริ่มต้นใช้ nginx) และใบรับรอง TLS ของ baseDomain, *.baseDomain และ streamDomain เช่น จาก cert-manager
  • image ใน registry ที่คลัสเตอร์ดึงได้: ใช้ ghcr.io/<owner>/radio-app และ -engine ที่เผยแพร่ไว้ (amd64 และ arm64) หรือสร้างเอง เช่น docker build --target app -t registry.example.th/radio-app:0.3.0 . และ --target engine -t registry.example.th/radio-engine:0.3.0 แล้ว docker push

ค่าที่ต้องตั้ง

ค่า ใส่อะไร
image.repository, image.tag บังคับ: ชื่อ image โดยไม่มีส่วนท้าย เช่น ghcr.io/<owner>/radio หรือ registry.example.th/radio chart จะเติม -app (เว็บแอป และ migration job) และ -engine (worker, Icecast, relay) ให้เอง
nameOverride ชื่อที่อยู่ในชื่อออบเจกต์ ว่าง = saint-radio (คงไว้สำหรับ release ที่มีอยู่) การติดตั้งใหม่ใช้ radio ได้
baseUrl, baseDomain, streamDomain โดเมนของคุณ
harborPublicHost ที่ดีเจเชื่อมต่อ {shard} จะถูกแทนด้วยหมายเลข worker เช่น dj{shard}.radio.example.th
database.url URL ของ role ของ app (ไม่ใช่ superuser)
database.adminUrl URL ของผู้ดูแล ใช้เฉพาะ migration job (pod อื่นไม่ได้รับค่านี้)
database.appPassword ไม่บังคับ รหัสผ่าน role ของ app ถ้าเว้นว่างใช้รหัสผ่านใน database.url (ถ้าตั้ง ต้องตรงกัน)
secrets.sessionSecret, secrets.ipHashSalt ข้อความสุ่มยาว ๆ (บังคับ)
secrets.icecastSourcePassword, secrets.icecastAdminPassword บังคับ
secrets.icecastRelayPassword ต้องมีเมื่อ relays.enabled เป็น true
secrets.smtpUrl, mailFrom อีเมล
secrets.s3AccessKeyId, secrets.s3SecretAccessKey, storage.* ที่เก็บไฟล์
secrets.transcribeApiKey, secrets.omiseSecretKey ความสามารถเสริม
secrets.dbScopeSecret ไม่บังคับ ใช้ลงลายมือชื่อขอบเขตสถานีในฐานข้อมูล (เว้นว่าง = สร้างจาก secrets.sessionSecret)
secrets.metricsToken, secrets.alertWebhookSecret ไม่บังคับ สำหรับ metrics ของ Prometheus และเว็บฮุกการแจ้งเตือนที่มีลายเซ็น (ดูหัวข้อการติดตามระบบ)
settings: TRANSCRIBE_URL, TRANSCRIBE_MODEL, TRANSCRIBE_COMMAND, TTS_COMMAND, RTC_ICE_SERVERS, LICENSE_ENFORCE, UPDATE_CHECK_URL, WEBHOOK_ALLOW_HTTP, WEBHOOK_ALLOW_PRIVATE, EXTERNAL_OUTPUT_ALLOW_PRIVATE, ALERT_EXTERNAL_OUTPUT_MINUTES, LOGIN_RATE_LIMIT, IVR_AGI_PORT, ALERT_WEBHOOK_URL, ALERT_WEBHOOK_ALLOW_PRIVATE, ALERT_RENOTIFY_HOURS, ALERT_DISK_PERCENT, METRICS_ALLOW_IPS ค่าที่ว่างจะไม่ถูกส่ง
monitoring.* Prometheus metrics: enabled, workerPort (ค่าเริ่มต้น 9464), annotation ของ pod, ServiceMonitor (ดูการติดตามระบบ)
extraEnv ตัวแปรอื่น ๆ เป็นรายการคู่ name/value
worker.shards, worker.harborPorts จำนวน pod ของ worker และจำนวนพอร์ตดีเจต่อ pod (ต้องพอกับจำนวนสถานี)
icecast.trustedProxies IP ของ proxy หน้า Icecast เพื่อให้เห็น IP จริงของผู้ฟัง
relays.enabled, relays.replicas edge relay
geoip.secretName, geoip.fileName ฐานข้อมูล GeoIP จาก Secret ที่คุณสร้าง
ingress.*, persistence.*, app.autoscaling.* ค่าเฉพาะของคลัสเตอร์

ถ้าต้องการจัดการ secret เอง ให้สร้าง Secret แล้วตั้ง existingSecret คีย์ที่ใช้คือ SESSION_SECRET, IP_HASH_SALT, DATABASE_URL, DATABASE_ADMIN_URL, DB_APP_PASSWORD, ICECAST_SOURCE_PASSWORD, ICECAST_ADMIN_PASSWORD, ICECAST_RELAY_PASSWORD, SMTP_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, TRANSCRIBE_API_KEY, OMISE_SECRET_KEY และคีย์ที่ไม่บังคับ DB_SCOPE_SECRET, METRICS_TOKEN, ALERT_WEBHOOK_SECRET คีย์ SESSION_SECRET, IP_HASH_SALT, DATABASE_URL, DATABASE_ADMIN_URL, ICECAST_SOURCE_PASSWORD และ ICECAST_ADMIN_PASSWORD จำเป็นต้องมี ถ้า Secret ขาดคีย์ใด pod นั้นจะไม่เริ่มทำงาน (CreateContainerConfigError)

ติดตั้ง

helm install radio deploy/helm/radio -f my-values.yaml

สิ่งที่ chart สร้าง

ออบเจกต์ หน้าที่
Migration Job รัน node dist/src/cli.js migrate ก่อนติดตั้งและอัปเกรดทุกครั้ง pod ของ app และ worker ได้ MIGRATE_ON_START=false จะได้ไม่แย่งกัน migrate มีเฉพาะ job นี้ที่ได้รับ DATABASE_ADMIN_URL
app Deployment + Service เว็บ (ค่าเริ่มต้น 2 replica ตั้ง autoscaling ได้) probe ใช้ /health
worker StatefulSet + headless Service worker ระบบออกอากาศ หมายเลข pod คือหมายเลข shard
Service …-dj-<n> shard ละ 1 ตัว (ค่าเริ่มต้นเป็น LoadBalancer) สำหรับพอร์ตดีเจชี้ชื่อ harborPublicHost แต่ละชื่อไปที่ service ของ shard นั้น
icecast Deployment + Service Icecast หลัก (1 replica)
relay StatefulSet + Service edge relay เมื่อ relays.enabled เป็น true
Ingress baseDomain และ *.baseDomain ไปที่ app, streamDomain ไปที่ Icecast หรือ relay และกัน /internal
PersistentVolumeClaim volume ข้อมูลที่ใช้ร่วมกัน (ยกเว้นตั้ง persistence.existingClaim)
Secret สร้างจาก secrets.* (ยกเว้นตั้ง existingSecret) สร้างเป็น hook ก่อนติดตั้งและอัปเกรด เพื่อให้ migration job อ่านได้ helm uninstall จะไม่ลบ Secret นี้ ให้ลบเองด้วย kubectl delete secret <release>-saint-radio (ถ้าตั้ง nameOverride: <release>-<nameOverride>)

ingress ครอบคลุมแค่โดเมนหลัก โดเมนย่อย และโดเมนสตรีม (chart ให้บริการสตรีมที่ streamDomain ไม่ใช่ใต้ /stream) ถ้าสถานีใช้โดเมนของตัวเอง ต้องเพิ่มกฎ ingress และใบรับรองเอง

ทุกคอนเทนเนอร์ทำงานเป็นผู้ใช้ 10001 ยกระดับสิทธิ์ไม่ได้ และตัด Linux capabilities ออกทั้งหมด

หลังติดตั้ง เปิด <baseUrl>/install หรือสร้างผู้ดูแลด้วย kubectl exec deploy/radio-saint-radio-app -- node dist/src/cli.js create-admin you@example.th (ชื่อคือ <release>-saint-radio-app) ใน pod ของ app คำสั่ง create-admin ไม่ทำ migration เพราะมีแต่ migration job ที่มีการเชื่อมต่อของผู้ดูแล ถ้ายังมี migration ค้าง คำสั่งจะหยุดพร้อมข้อความแจ้ง

เริ่มใช้งานครั้งแรก

เปิด https://<โดเมนของคุณ>/install หน้าติดตั้งบนเว็บจะ

  1. ตรวจความพร้อม: PostgreSQL, migration, ที่เก็บไฟล์, FFmpeg (บังคับ) และ Icecast, SMTP (แค่เตือน) เมื่อใช้ Docker Liquidsoap อยู่ใน container ของ worker ไม่ใช่ในเว็บแอป และ migration ทำไปแล้วตอนแอปเริ่มทำงาน
  2. สร้างผู้ดูแลระบบคนแรก เข้าสู่ระบบด้วยรหัสที่ส่งทางอีเมล ไม่มีรหัสผ่าน
  3. สร้างองค์กรและสถานีแรก (ไม่บังคับ) องค์กรนี้ได้แพ็กเกจ Unlimited
  4. พาผู้ดูแลเข้าสู่ระบบทันที แล้วเปิดศูนย์ควบคุมแพลตฟอร์ม

หลังติดตั้งเสร็จ /install จะพากลับหน้าแรก ถ้าต้องการเพิ่มผู้ดูแลระบบภายหลัง ใช้ sudo radio create-admin <อีเมล> (ดูหัวข้อคำสั่งผู้ดูแล)

ทำอะไรต่อ

  1. อีเมล: ตั้ง SMTP_URL และ MAIL_FROM แล้ว restart app ถ้าไม่ตั้ง SMTP_URL อีเมลทุกฉบับ (รวมรหัสเข้าสู่ระบบ) จะถูกเขียนเป็นไฟล์ใน DATA_DIR/mail-outbox
  2. แพ็กเกจ: ศูนย์ควบคุมแพลตฟอร์ม → แพ็กเกจ ระบบสร้างไว้ 4 แพ็กเกจ: Community, Standard, Pro และ Unlimited ปรับขีดจำกัดหรือเพิ่มแพ็กเกจเองได้
  3. องค์กรและสถานี: ศูนย์ควบคุมแพลตฟอร์ม → องค์กรและสถานี
  4. ตั้งค่าสถานี: ในแต่ละสถานี อัปโหลดเสียง สร้างเพลย์ลิสต์และผังรายการ แล้วเชิญทีมงาน
  5. ความปลอดภัย: เพิ่มพาสคีย์ให้บัญชีของคุณเอง แล้วพิจารณานโยบายพาสคีย์ของทีมงาน (ดูรายการตรวจความปลอดภัย)
  6. หน้าสถานะ: ศูนย์ควบคุมแพลตฟอร์ม → สุขภาพระบบ ตั้งเป้าหมายความพร้อมใช้งานและจำนวนนาทีก่อนแจ้งเตือน

ตัวแปรสภาพแวดล้อม (environment variables)

"ว่าง" หมายถึงไม่ได้ตั้งค่า สวิตช์เปิดปิดจะเปิดเฉพาะเมื่อค่าเป็น true ตรงตัว (ยกเว้น MIGRATE_ON_START ที่ปิดเฉพาะเมื่อเป็น false)

ค่าหลัก

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
NODE_ENV development ตั้งเป็น production บนเครื่องใช้งานจริง
PORT 3000 พอร์ตที่ app ฟัง
HOST 0.0.0.0 ที่อยู่ที่ app ฟัง ถ้าเครื่องเดียวมี proxy ในเครื่อง ใช้ 127.0.0.1 ถ้าอยู่ใน container ให้คง 0.0.0.0
PUBLIC_MODE domain วิธีเข้าถึงแพลตฟอร์ม: domain, ip-https หรือ ip-http (ดู "เลือกวิธีเข้าถึงแพลตฟอร์ม") Docker Compose ใช้ค่านี้เลือกค่าตั้งของ Caddy
BASE_URL http://localhost:3000 URL สาธารณะของแพลตฟอร์ม ใช้ในลิงก์และอีเมลพาสคีย์ใช้โดเมนนี้และต้องเป็น HTTPS เป็นค่าเริ่มต้นเท่านั้น ภายหลังเปลี่ยนได้ที่ การจัดการแพลตฟอร์ม → โดเมนและที่อยู่
BASE_DOMAIN ว่าง โดเมนย่อยของสถานี <slug>.BASE_DOMAIN (ต้องมีระเบียน DNS แบบ wildcard) ถ้าว่าง ใช้แค่ลิงก์ /s/<slug>
STREAM_PATH /stream พาธใต้ BASE_URL ที่ Caddy ส่งสตรีมต่อให้ Icecast
SESSION_SECRET dev-only-change-me-please-32-bytes-min ใช้ลงนาม session และกุญแจภายใน (รวมถึงลิงก์ตรวจสิทธิ์ผู้ฟังของ Icecast) ต้องยาวและสุ่ม เมื่อ NODE_ENV=production app จะไม่เริ่มทำงานถ้าสั้นกว่า 32 ตัวอักษรหรือเป็นค่าตัวอย่าง
IP_HASH_SALT dev-ip-salt salt สำหรับแฮช IP ผู้ฟัง ระบบไม่เก็บ IP จริง ห้ามเปลี่ยนบ่อย เมื่อ NODE_ENV=production ระบบไม่ยอมรับค่าเริ่มต้นและค่าตัวอย่าง
DATA_DIR ./data โฟลเดอร์ข้อมูล: media, stations, hls, mail-outbox, exports, tmp และอื่น ๆ
LOGIN_RATE_LIMIT 8 จำนวนครั้งที่ขอเข้าสู่ระบบได้ต่อ IP ต่อ 10 นาที การกรอกรหัสได้ 20 ครั้งหรือค่านี้ถ้ามากกว่า เพิ่มค่าได้ถ้าสำนักงานใหญ่ใช้ NAT เดียวกัน
LOGIN_CODES_PER_HOUR 5 เมื่อ production, 1000 ในกรณีอื่น จำนวนรหัสเข้าสู่ระบบที่ส่งให้อีเมลหนึ่งได้ต่อชั่วโมง
LOGIN_FAILED_PER_HOUR 15 จำนวนครั้งที่กรอกรหัสผิดได้ต่ออีเมลต่อชั่วโมง นับรวมทุกรหัสของอีเมลนั้น
TRUST_PROXY loopback,linklocal,uniquelocal เครื่องที่ต่อเข้ามาโดยตรงเครื่องใดส่ง X-Forwarded-For ได้ (IP ของผู้ใช้ที่ใช้จำกัดจำนวนครั้ง บล็อกตามประเทศ และบันทึก) ค่าเริ่มต้นเชื่อ loopback และเครือข่ายภายใน (proxy บนเครื่องเดียวกัน Docker หรือ Kubernetes) true เชื่อทุกเครื่อง (ใช้เฉพาะเมื่อไม่มีใครเข้าถึง app ได้โดยตรง) false ไม่ใช้ header นี้ หรือระบุรายการ IP ช่วง CIDR และชื่อ loopback, linklocal และ uniquelocal คั่นด้วยจุลภาค proxy ที่อยู่นอกเครือข่ายภายในต้องระบุไว้ในรายการ
POSTGRES_PASSWORD ไม่มี เฉพาะ Docker Compose: รหัสผ่านบัญชี postgres ใน container db
STREAM_DOMAIN ว่าง เฉพาะ Docker Compose: โฮสต์แยกสำหรับสตรีม (ไม่บังคับ ทุกอย่างบนโฮสต์นี้ส่งไป Icecast)
ACME_EMAIL ไม่มี เฉพาะ Docker Compose: อีเมลสำหรับ Let's Encrypt
PUBLIC_IP, PUBLIC_IP_HOST ว่าง Docker Compose แบบ IP: IP ของเซิร์ฟเวอร์ และค่าเดียวกันในรูปแบบ URL (IPv6 อยู่ในวงเล็บเหลี่ยม)
IP_TLS_ISSUER letsencrypt Docker Compose แบบ ip-https: letsencrypt (ใบรับรอง IP โปรไฟล์ "shortlived" ถ้าไม่ได้ใช้ของ Caddy เองแทน) หรือ internal (ของ Caddy เอง)
DOMAIN_TLS_ISSUER public Docker Compose แบบ domain: public (Let's Encrypt) หรือ internal (ของ Caddy เอง สำหรับชื่อที่รู้จักเฉพาะในเครือข่ายขององค์กร)
HTTP_PORT, HTTPS_PORT, HTTPS_PORT_SUFFIX 80, 443, ว่าง Docker Compose: พอร์ตของ Caddy บนเครื่อง ส่วนต่อท้าย (:8443) ทำให้การเปลี่ยนจาก HTTP ไป HTTPS คงพอร์ตที่ไม่ใช่ 443 ไว้
RADIO_IMAGE_PREFIX, RADIO_VERSION, ICECAST_VERSION, RADIO_BUILD ว่าง, latest, RADIO_VERSION, ว่าง Docker Compose: อิมเมจ <prefix>/radio-*:<version> (prefix ว่าง: local/radio-* สร้างบนเครื่องเมื่อ RADIO_BUILD=true)

ฐานข้อมูล

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
DATABASE_URL postgres://radio_app:radio_app@127.0.0.1:5432/radio การเชื่อมต่อของ app ต้องเป็น role ที่ไม่ใช่ superuser และอยู่ใต้ row-level security
DATABASE_ADMIN_URL ไม่มี การเชื่อมต่อของผู้ดูแล ใช้เฉพาะ migration ต้องเป็นผู้ใช้คนละคนกับใน DATABASE_URL ถ้าตั้งแค่ DATABASE_URL คำสั่ง migrate (และ app ตอนเริ่มทำงาน) จะแค่ตรวจว่า migration ครบแล้ว ถ้าไม่ตั้งทั้งสองตัว ใช้ postgres://postgres@127.0.0.1:5432/radio
DB_SCOPE_SECRET สร้างจาก SESSION_SECRET ใช้ลงลายมือชื่อขอบเขตสถานีของทุกธุรกรรมในฐานข้อมูล SQL ที่รันด้วย role ของ app จึงสลับตัวเองไปใช้สถานีอื่นไม่ได้ (ดูรายการตรวจความปลอดภัย) ไม่บังคับ ถ้าเว้นว่างจะสร้างจาก SESSION_SECRET ถ้าตั้ง ต้องเป็นข้อความสุ่มอย่างน้อย 32 ตัวอักษร app, worker และ migration (ใน Helm คือ migration job) ต้องได้ค่าเดียวกัน
DATABASE_POOL_URL ว่าง การเชื่อมต่อที่สอง (ไม่บังคับ) สำหรับ pool คำสั่ง query ของแอป เช่นผ่าน PgBouncer (postgres://radio_app:…@pgbouncer:6432/radio โปรไฟล์ medium และ large ตั้งให้) LISTEN/NOTIFY และ migration ใช้ DATABASE_URL เสมอ
DB_APP_PASSWORD รหัสผ่านใน DATABASE_URL รหัสผ่านของ role ของ app migration ตั้งให้ตอนสร้าง role และตั้งใหม่เมื่อ app เข้าระบบด้วยรหัสนี้ไม่ได้แล้ว ถ้าตั้ง ต้องตรงกับใน DATABASE_URL
MIGRATE_ON_START เปิด app อัปเดตฐานข้อมูลตอนเริ่มทำงาน ยกเว้นตั้งเป็น false Helm chart ตั้ง false เพราะมี migration job ทำให้แล้ว

ที่เก็บไฟล์

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
STORAGE_DRIVER local local (ไฟล์อยู่ใน DATA_DIR/media) หรือ s3
S3_ENDPOINT ว่าง endpoint ของ S3 สำหรับ MinIO, Cloudflare R2, Wasabi ฯลฯ ถ้าว่าง ใช้ AWS
S3_REGION auto region
S3_BUCKET radio ชื่อ bucket
S3_ACCESS_KEY_ID ว่าง access key
S3_SECRET_ACCESS_KEY ว่าง secret key
S3_FORCE_PATH_STYLE true ใช้ URL แบบ path-style ค่าอื่นที่ไม่ใช่ true คือปิด

อีเมล

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
SMTP_URL ว่าง เช่น smtp://user:pass@smtp.example.th:587 ถ้าว่าง อีเมลถูกเขียนลง DATA_DIR/mail-outbox
MAIL_FROM Online Radio <no-reply@localhost> ผู้ส่ง ใน .env ต้องใส่เครื่องหมายคำพูด

การออกอากาศ

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
ICECAST_HOST 127.0.0.1 โฮสต์ที่ Liquidsoap และ app ใช้ติดต่อ Icecast
ICECAST_PORT 8000 พอร์ต Icecast
ICECAST_SOURCE_PASSWORD hackme-source รหัสผ่านที่ Liquidsoap ใช้ส่งสตรีม ต้องเปลี่ยน
ICECAST_ADMIN_PASSWORD hackme-admin รหัสผ่านผู้ดูแล Icecast ต้องเปลี่ยน
ICECAST_PUBLIC_URL http://localhost:8000 URL ที่ผู้ฟังใช้: BASE_URL/stream เมื่อใช้ Docker Compose หรือโดเมนสตรีม relay หรือ CDN เป็นค่าเริ่มต้นเท่านั้น (ดูโดเมนและที่อยู่)
ICECAST_TRUSTED_PROXIES ว่าง IP ของ proxy หน้า Icecast (ตรงตัว คั่นด้วยจุลภาค) ที่เชื่อ X-Forwarded-For ได้ Docker Compose ตั้งเป็น IP ของ Caddy 172.28.0.10
LIQUIDSOAP_BIN liquidsoap โปรแกรม Liquidsoap (ชื่อใน PATH หรือพาธเต็ม)
LIQUIDSOAP_CALLBACK_URL http://127.0.0.1:3000 URL ที่ Liquidsoap และ Icecast ใช้เรียก app
LIQUIDSOAP_CONTROL_HOST 127.0.0.1 โฮสต์ที่ app ใช้ติดต่อพอร์ตควบคุม Liquidsoap ของแต่ละสถานี (worker) ใส่ {shard} ได้
LIQUIDSOAP_HARBOR_HOST 127.0.0.1 โฮสต์ที่ app ส่งเสียงจากห้องจัดรายการบนเว็บไป (worker) ใส่ {shard} ได้
LIQUIDSOAP_UID / LIQUIDSOAP_GID ว่าง ใช้เฉพาะเมื่อ worker ทำงานด้วย root: user id และ group id ที่ Liquidsoap ใช้ ถ้าว่าง ใช้ผู้ใช้ระบบ liquidsoap หรือ nobody ไม่มีผลเมื่อ worker ไม่ใช่ root
LISTEN_TOKEN_TTL 300 จำนวนวินาทีที่ลิงก์สำหรับฟังจากเครื่องเล่น (/s/<สถานี>/listen.json) ใช้เริ่มฟังได้ (10 ถึง 86400) ถ้าฟังอยู่แล้ว เสียงไม่ถูกตัดเมื่อลิงก์หมดอายุ
HARBOR_PORT_START 8100 พอร์ตแรกสำหรับเสียงสดจากดีเจสถานีใหม่ได้พอร์ตถัดไป
CONTROL_PORT_START 9100 พอร์ตควบคุมภายในพอร์ตแรก สถานีใหม่ได้พอร์ตถัดไป
HARBOR_PUBLIC_HOST localhost ชื่อโฮสต์ที่ดีเจกรอกใน BUTT หรือ OBS ใส่ {shard} ได้
FFMPEG_BIN ffmpeg โปรแกรม FFmpeg
FFPROBE_BIN ffprobe โปรแกรม FFprobe
ENCODE_ON_DEMAND true เข้ารหัสสตรีมประหยัดเน็ตและ HLS เฉพาะเมื่อมีคนฟัง low = เฉพาะสตรีมประหยัดเน็ต false = เข้ารหัสตลอด
ENCODE_IDLE_MINUTES 5 นาทีที่สตรีมแบบตามความต้องการยังทำงานต่อหลังผู้ฟังคนสุดท้ายออก
ICECAST_FALLBACK true ทุก mount ของสถานีมีไฟล์เสียงสำรองของตัวเองไว้เล่นขณะระบบออกอากาศเริ่มใหม่ (/fallback/<slug>.mp3, <slug>-low.aac จาก DATA_DIR/ops/icecast-fallback เปลี่ยนไฟล์ของสถานีเพื่อใช้เสียงของตัวเองได้) false = ปิด
ICECAST_WEBROOT /usr/share/icecast2/web web root ของ Icecast ใน icecast.xml ที่สร้างขึ้น โฟลเดอร์ fallback ข้างในต้องชี้ไปที่ DATA_DIR/ops/icecast-fallback (อิมเมจ Docker ทำไว้แล้ว)
ICECAST_STANDBY false (Docker Compose เป็น true เสมอ) ทุก mount ของสถานีใช้ relay ของตัวเอง (/<slug>.standby) จากสตรีมรอสัญญาณที่อิมเมจ Icecast ป้อนตามเวลาจริง แทนไฟล์ด้านบน (ซึ่ง Icecast ส่งเร็วกว่าเวลาจริงหลายเท่า)
ICECAST_STANDBY_MOUNT /rp-standby ชื่อสตรีมรอสัญญาณ (และ <ชื่อ>-low สำหรับ mount ประหยัดเน็ต)
ICECAST_CONFIG_FILE ว่าง ถ้าตั้งไว้ worker (shard 0) เขียน icecast.xml ที่นี่และเขียนใหม่เมื่อสถานีเปลี่ยน อิมเมจ Icecast ของ Docker อ่านค่าใหม่เอง Docker Compose ตั้งให้แล้ว
ICECAST_LOG_DIR /var/log/icecast2 โฟลเดอร์ log ของ Icecast ในไฟล์นั้น

การขยายระบบและ relay

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
ICECAST_RELAY_PASSWORD ว่าง รหัสผ่านที่ edge relay ใช้ดึงสตรีมจาก Icecast หลัก relay ไม่ถูกนับเป็นผู้ฟัง
ICECAST_RELAYS ว่าง URL ของ edge relay (คั่นด้วยจุลภาค) ระบบอ่านสถานะเพื่อกระทบยอดจำนวนผู้ฟัง
WORKER_SHARDS 1 จำนวน worker สถานีถูกกระจายตามรหัสสถานี ตั้งค่าเดียวกันทั้งที่ app และ worker ทุกตัว
WORKER_SHARD_INDEX จากชื่อโฮสต์ ถ้าไม่มีเป็น 0 หมายเลขของ worker นี้ (0 ถึง N−1) worker 0 รันงานตามเวลาด้วย (ผังรายการ การแจ้งเตือน สถิติ)
HOSTNAME ระบบตั้งให้ ถ้าไม่ได้ตั้ง WORKER_SHARD_INDEX ระบบใช้ -<ตัวเลข> ท้ายชื่อโฮสต์ (เช่น pod …-worker-2 ใน Kubernetes) เป็นหมายเลข shard
WORKER_NO_ENGINE ปิด true = worker ทำงานเบื้องหลังและงานตามเวลา แต่ไม่เปิด Liquidsoap

ความสามารถเสริม

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
GEOIP_DB ว่าง พาธไฟล์ .mmdb ของประเทศ เปิดสถิติตามประเทศและการจำกัดพื้นที่
TRANSCRIBE_COMMAND ว่าง คำสั่งถอดเสียงในเครื่อง {in} = ไฟล์เสียง {out} = พาธผลลัพธ์ คำสั่งต้องเขียนไฟล์ {out}.vtt
TRANSCRIBE_URL ว่าง endpoint ถอดเสียงแบบ OpenAI (ใช้เมื่อ TRANSCRIBE_COMMAND ว่าง)
TRANSCRIBE_API_KEY ว่าง Bearer โทเคนสำหรับ TRANSCRIBE_URL
TRANSCRIBE_MODEL whisper-1 ชื่อโมเดลที่ส่งไป TRANSCRIBE_URL
RTC_ICE_SERVERS [{"urls":"stun:stun.l.google.com:19302"}] เซิร์ฟเวอร์ STUN/TURN เป็น JSON สำหรับแขกรับเชิญระยะไกลและสายผู้ฟัง ถ้า JSON ผิด ระบบใช้ค่าเริ่มต้น
WEBHOOK_ALLOW_HTTP false true = ยอมให้เว็บฮุกปลายทางเป็น http://
WEBHOOK_ALLOW_PRIVATE false true = ยอมให้เว็บฮุกปลายทางอยู่ในเครือข่ายภายในหรือเครือข่ายสงวน
EXTERNAL_OUTPUT_ALLOW_PRIVATE false true = ยอมให้สถานีส่ง สัญญาณออกภายนอก ไปยังที่อยู่ในเครือข่ายภายใน loopback หรือเครือข่ายสงวน (เช่น relay ใน LAN ขององค์กร) ปกติจะปฏิเสธเหมือนเว็บฮุก
RTMP_FONT_FILE อัตโนมัติ ฟอนต์ที่ใช้เขียนชื่อสถานีและชื่อเพลงบนภาพวิดีโอ RTMP ต้องแสดงภาษาไทยได้ ค่าเริ่มต้นคือฟอนต์แรกที่พบในเครื่อง: Noto Sans Thai, Loma (fonts-tlwg-loma), FreeSans หรือ DejaVu Sans
RTMP_CA_FILE ชุดของระบบ ไฟล์ใบรับรอง CA ที่ FFmpeg ใช้ตรวจใบรับรองของเซิร์ฟเวอร์ rtmps:// (ค่าเริ่มต้น /etc/ssl/certs/ca-certificates.crt หรือชุดแรกที่พบ) เซิร์ฟเวอร์ที่ใบรับรองไม่น่าเชื่อถือจะถูกปฏิเสธ ส่วนปลายทาง Icecast แบบ TLS นั้น Liquidsoap ตรวจกับใบรับรองของระบบ
LINE_API_URL https://api.line.me ที่อยู่ Messaging API ของ LINE เปลี่ยนเฉพาะตอนทดสอบ
TELEGRAM_API_URL https://api.telegram.org ที่อยู่ Bot API ของ Telegram เปลี่ยนเฉพาะตอนทดสอบ

การชำระเงินและใบอนุญาต

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
OMISE_SECRET_KEY ว่าง secret key ของ Omise (Opn Payments) พร้อมเพย์ QR พร้อมตรวจสลิปใช้ได้โดยไม่ต้องตั้ง
OMISE_API_URL https://api.omise.co ที่อยู่ API ของ Omise เปลี่ยนเฉพาะตอนทดสอบ
ETAX_P12_PATH ว่าง e-Tax Invoice: ที่อยู่ไฟล์ PKCS#12 (.p12/.pfx) ที่มีใบรับรองสำหรับลงลายมือชื่อและกุญแจส่วนตัว ถ้าว่าง เอกสารจะไม่ถูกลงลายมือชื่อ ดูหัวข้อ "e-Tax Invoice" ด้านล่าง
ETAX_P12_PASSWORD ว่าง รหัสผ่านของไฟล์ PKCS#12 ไม่แสดงในหน้าผู้ดูแลหรือในบันทึกระบบ
ETAX_P12_PASSWORD_FILE ว่าง ใช้แทน ETAX_P12_PASSWORD: ไฟล์ที่เก็บรหัสผ่าน (secret ของ Docker หรือ Kubernetes)
ETAX_FONT_DIR ว่าง โฟลเดอร์ไฟล์ฟอนต์ Noto Sans Thai (.woff) สำหรับ PDF ของ e-Tax ถ้าว่าง ใช้ชุดที่ติดตั้งมากับแอป
LICENSE_ENFORCE false true = รุ่นติดตั้งเองที่ต้องใช้คีย์ใบอนุญาต
UPDATE_CHECK_URL ว่าง URL ที่ระบบใช้ถามรุ่นล่าสุด ถ้าว่าง ไม่ตรวจอัปเดต

โทรศัพท์และเสียงสังเคราะห์

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
TTS_COMMAND ว่าง คำสั่งแปลงข้อความเป็นเสียง {text} = ไฟล์ข้อความ {out} = ไฟล์ WAV ที่ต้องเขียน ใช้กับเสียงเมนูโทรศัพท์และประกาศฉุกเฉินที่มาเป็นข้อความ
IVR_AGI_PORT ว่าง (ปิด) เมื่อตั้งค่า worker 0 เปิดเซิร์ฟเวอร์ FastAGI ที่พอร์ตนี้ และเขียนไฟล์ของ Asterisk ทุกนาที ใช้ 4573
IVR_AGI_HOST 0.0.0.0 ที่อยู่ที่เซิร์ฟเวอร์ FastAGI ฟัง
IVR_AGI_PUBLIC_HOST worker โฮสต์ที่ Asterisk ใช้ติดต่อเซิร์ฟเวอร์ FastAGI
IVR_SOUNDS_DIR DATA_DIR/ivr-sounds โฟลเดอร์เสียงเมนูโทรศัพท์
IVR_ASTERISK_CONF_DIR DATA_DIR/asterisk โฟลเดอร์ที่ worker เขียน extensions_radio.conf และ musiconhold_radio.conf

การติดตามระบบและการแจ้งเตือน

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
METRICS_TOKEN ว่าง โทเคน Bearer สำหรับอ่านค่าวัดแบบ Prometheus (GET /metrics ของ app และพอร์ตค่าวัดของ worker) ว่าง = ปิด (ตอบ 404) ควรเป็นอักขระสุ่ม 32 ตัวขึ้นไป
METRICS_ALLOW_IPS ว่าง ไม่บังคับ: ให้เฉพาะ IP หรือช่วง CIDR เหล่านี้อ่านค่าวัด (คั่นด้วยจุลภาค เช่น 10.0.0.0/8,192.168.1.5) app ใช้ IP ของผู้เรียกหลังผ่าน TRUST_PROXY
WORKER_METRICS_PORT ว่าง (Docker Compose: 9464) พอร์ตที่ worker แต่ละตัวให้บริการ GET /metrics เมื่อใช้ Docker Compose shard ที่ N ใช้ 9464 + N ว่าง = ไม่เปิด
WORKER_METRICS_HOST 0.0.0.0 ที่อยู่ที่พอร์ตค่าวัดของ worker รอรับ
ALERT_WEBHOOK_URL ว่าง ส่งทุกเหตุการณ์แจ้งเตือน (เปิด แจ้งซ้ำ คลี่คลาย) ไปที่นี่เป็น JSON ด้วย
ALERT_WEBHOOK_SECRET ว่าง ลงลายมือชื่อคำขอเหล่านั้น: x-radio-signature: sha256=… จาก timestamp.body แบบเดียวกับเว็บฮุกของสถานี
ALERT_WEBHOOK_ALLOW_PRIVATE ปิด true = ยอมให้ ALERT_WEBHOOK_URL ชี้ไปที่อยู่ภายในหรือ loopback (ปกติจะปฏิเสธ เหมือนเว็บฮุกของสถานี)
ALERT_RENOTIFY_HOURS 4 แจ้งซ้ำทุก N ชั่วโมงถ้าเหตุยังไม่คลี่คลาย
ALERT_DISK_PERCENT 85 แจ้งเมื่อดิสก์ที่เก็บ DATA_DIR ใช้เกินร้อยละนี้
ALERT_CERT_DAYS 30 แจ้งเมื่อใบรับรองลงนาม e-Tax จะหมดอายุภายในจำนวนวันนี้
ALERT_TICK_FAILURES 3 แจ้งเมื่องานตามรอบของ worker ล้มเหลวติดกันครบจำนวนนี้
ALERT_RESTARTS_10M 3 แจ้งเมื่อระบบออกอากาศของสถานีเริ่มใหม่เกินจำนวนนี้ใน 10 นาที
ALERT_ENGINE_STOPPED_MINUTES 2 แจ้งเมื่อระบบออกอากาศของสถานีที่เปิดใช้งานไม่ทำงานนานเท่านี้ (นาที)
ALERT_EXTERNAL_OUTPUT_MINUTES 5 แจ้งผู้ดูแลสถานีเมื่อปลายทางถ่ายทอดไปช่องทางอื่น (SHOUTcast, Icecast, RTMP) ที่เปิดอยู่เชื่อมต่อไม่ได้นานเท่านี้ (นาที)
ALERT_MEMORY_PERCENT, ALERT_MEMORY_MINUTES 90, 5 แจ้งเตือนเมื่อบริการใช้หน่วยความจำเกินสัดส่วนนี้ของขีดจำกัดนานเท่านี้นาที
ALERT_CPU_PERCENT, ALERT_CPU_MINUTES 90, 10 แจ้งเตือนเมื่อบริการใช้ CPU เกินสัดส่วนนี้นานเท่านี้นาที
ALERT_DISK_FREE_PERCENT 10 แจ้งเตือนเมื่อพื้นที่ว่างของระบบไฟล์เหลือน้อยกว่านี้
ALERT_SERVICE_STALE_MINUTES 3 แจ้งเตือนเมื่อบริการ (app, shard ของ worker) ไม่รายงานนานเท่านี้และไม่มีตัวใหม่มาแทน
ALERT_RESTARTS_30M 3 แจ้งเตือนเมื่อบริการเริ่มใหม่เกินจำนวนนี้ใน 30 นาที
ALERT_BACKUP_HOURS 36 แจ้งเตือนเมื่อการสำรองข้อมูลที่สำเร็จครั้งล่าสุด (DATA_DIR/ops/backup-status.json) เก่ากว่านี้ หรือครั้งล่าสุดล้มเหลว
RESOURCE_HEARTBEAT true false หยุดการรายงานทรัพยากรของบริการ (ทุก 30 วินาที) สำหรับหน้า "ทรัพยากรเซิร์ฟเวอร์"
INSTANCE_NAME ชื่อเครื่อง ชื่อของโปรเซสนี้ในหน้านั้น Docker Compose ตั้งเป็น app, worker-0, worker-1, ...
APP_VERSION เวอร์ชันของแพ็กเกจ เวอร์ชันที่แสดงในหน้านั้น อิมเมจตั้งให้เอง
LOG_LEVEL info ระดับ log ของ app และ worker: debug info warn หรือ error
WORKER_LOG_FORMAT JSON text = ให้ worker เขียน log เป็นข้อความธรรมดาแบบเดิม

การดีบัก

ตัวแปร ค่าเริ่มต้น ใช้ทำอะไร
DEBUG_IVR ปิด true = บันทึก log ทุกขั้นของสายโทรศัพท์
DEBUG_ICECAST_AUTH ปิด true = บันทึก log การตรวจสิทธิ์ผู้ฟังของ Icecast ทุกครั้ง (ซ่อนรหัสผ่าน)
LICENSE_PUBLIC_KEY ว่าง ใช้ในการทดสอบอัตโนมัติเท่านั้น ไม่มีผลถ้า NODE_ENV ไม่ใช่ test

กุญแจสาธารณะของใบอนุญาตที่ใช้งานจริงไม่ใช่ตัวแปรสภาพแวดล้อม แต่เป็นค่าคงที่ VENDOR_PUBLIC_KEY ใน src/lib/license.ts

ตั้งค่าความสามารถเสริม

ถอดเสียงเป็นข้อความ

เลือกอย่างใดอย่างหนึ่ง

  • คำสั่งในเครื่อง เช่น whisper.cpp: TRANSCRIBE_COMMAND="whisper-cli -m /models/ggml-large-v3.bin -l th -ovtt -of {out} -f {in}" ตรวจตัวเลือกให้ตรงกับรุ่นของ whisper.cpp ที่ใช้ ระบบแยกคำสั่งด้วยช่องว่าง พาธจึงห้ามมีช่องว่าง
  • บริการที่รองรับ API แบบ OpenAI: TRANSCRIBE_URL=https://.../v1/audio/transcriptions พร้อม TRANSCRIBE_API_KEY และ TRANSCRIBE_MODEL

ชั่วโมงถอดเสียงต่อเดือนกำหนดในแพ็กเกจ ถ้าใช้ Docker คำสั่งในเครื่องต้องมีอยู่ใน image

GeoIP และ IP จริงของผู้ฟัง

  1. ดาวน์โหลดฐานข้อมูลประเทศ (.mmdb): MaxMind GeoLite2-Country (สมัครบัญชีฟรี) หรือ DB-IP Lite
  2. ตั้ง GEOIP_DB เป็นพาธของไฟล์ ถ้าใช้ Docker ให้วางไฟล์ใน volume ข้อมูล เช่น docker compose cp GeoLite2-Country.mmdb app:/data/GeoLite2-Country.mmdb แล้วตั้ง GEOIP_DB=/data/GeoLite2-Country.mmdb ถ้าใช้ Helm ใช้ geoip.secretName
  3. restart app และ worker

ระบบเก็บแค่รหัสประเทศ ไม่เก็บ IP

ถ้ามี proxy อยู่หน้า Icecast ให้ตั้ง ICECAST_TRUSTED_PROXIES เป็น IP ของ proxy (Docker Compose ตั้ง IP ของ Caddy ให้) ไม่อย่างนั้นผู้ฟังทุกคนจะดูเหมือนมาจาก IP เดียว โควตาต่อ IP และการจำกัดพื้นที่จะผิด

Edge relay

relay คือ Icecast เพิ่มเติมที่ดึงสตรีมจาก Icecast หลัก ผู้ฟังทุกคนยังต้องผ่านการตรวจสิทธิ์จากแพลตฟอร์ม โควตา การจำกัดพื้นที่ และสถิติจึงยังถูกต้อง

  1. ตั้ง ICECAST_RELAY_PASSWORD บนแพลตฟอร์ม สร้าง icecast.xml ของเครื่องหลักใหม่ แล้ว restart Icecast

  2. บนเครื่อง relay แต่ละเครื่อง (ต้องมีสำเนา .env ของแพลตฟอร์มและต่อฐานข้อมูลได้)

    node --env-file=.env dist/src/cli.js icecast-relay-config --hostname edge1.radio.example.th --master-host <ที่อยู่เครื่องหลัก> --master-port 8000 --out /etc/icecast2/icecast.xml --user icecast2 --group icecast --watch --pid-file /run/icecast2/icecast2.pid
    

    --watch เขียนไฟล์ใหม่เมื่อสถานีเปลี่ยน แล้วสั่ง Icecast โหลดใหม่ relay ต้องเข้าถึงพอร์ต 8000 ของเครื่องหลักได้

  3. เพิ่ม relay แต่ละเครื่อง (เช่น http://edge1.radio.example.th:8000) ใน ICECAST_RELAYS บนแพลตฟอร์ม

  4. ชี้ ICECAST_PUBLIC_URL และ DNS ของสตรีมไปที่ relay หรือ load balancer ของ relay

ถ้าใช้ Helm ตั้ง relays.enabled: true

ถ้าผู้ฟังมาก ยังเปิด HLS ในแพ็กเกจ แล้วตั้งลิงก์ HLS ผ่าน CDN ในหน้าตั้งค่าสถานีได้ด้วย

แบ่ง worker เป็นหลาย shard

ถ้ามีสถานีมาก ให้รัน worker หลายตัว

  1. ตั้ง WORKER_SHARDS=N ที่ app และ worker ทุกตัว
  2. ให้ worker แต่ละตัวมี WORKER_SHARD_INDEX ของตัวเอง (0 ถึง N−1) Kubernetes ใช้ชื่อ pod แทน
  3. ใส่ {shard} ใน LIQUIDSOAP_CONTROL_HOST, LIQUIDSOAP_HARBOR_HOST และ HARBOR_PUBLIC_HOST เช่น HARBOR_PUBLIC_HOST=dj{shard}.radio.example.th
  4. worker ทุกตัวและ app ต้องใช้ DATA_DIR ร่วมกัน (worker เขียนไฟล์ HLS และไฟล์ส่งออก app เป็นผู้ให้บริการ)

worker 0 รันงานตามเวลาด้วย ต้องเปิดไว้เสมอ

ถ้าใช้ Docker Compose ให้รัน sudo radioctl scale --shards N แทน คำสั่งนี้ทำทุกข้อให้ ทุก shard ใช้เครือข่ายเดียวกัน จึงไม่ต้องใช้ที่อยู่แบบ {shard} และดีเจใช้ที่อยู่เดียว ดูคู่มือดูแลระบบ

แขกรับเชิญระยะไกลและ TURN

แขกรับเชิญระยะไกลและสายผู้ฟังใช้ WebRTC จำนวนแขกกำหนดในแพ็กเกจ ค่าเริ่มต้นใช้ STUN สาธารณะ ถ้าผู้โทรอยู่หลัง NAT ที่เข้มงวด ให้ติดตั้ง TURN (เช่น coturn) แล้วเพิ่มเข้าไป

RTC_ICE_SERVERS='[{"urls":"stun:stun.l.google.com:19302"},{"urls":"turn:turn.radio.example.th:3478","username":"radio","credential":"SECRET"}]'

proxy ต้องให้ WebSocket ผ่านได้

เว็บฮุก

ปลายทางเว็บฮุกต้องเป็น https:// ปลายทางที่อยู่ในเครือข่ายภายในหรือเครือข่ายสงวนจะถูกปฏิเสธตอนเชื่อมต่อ ถ้าติดตั้งในองค์กรและต้องส่งเข้าระบบภายใน

  • WEBHOOK_ALLOW_PRIVATE=true ยอมให้ส่งเข้าเครือข่ายภายใน
  • WEBHOOK_ALLOW_HTTP=true ยอมให้ใช้ http://

ถ่ายทอดไปช่องทางอื่น

สถานีส่งรายการไปยัง SHOUTcast, Icecast ของที่อื่น และบริการ RTMP (YouTube Live, Facebook Live) ได้จากหน้า ถ่ายทอดไปช่องทางอื่น ผู้ดูแลระบบกำหนดจำนวนตามแพ็กเกจ ไม่ต้องตั้งค่าอะไรบนเซิร์ฟเวอร์ แต่ควรรู้ว่า

  • CPU: ปลายทาง SHOUTcast และ Icecast เป็นตัวเข้ารหัสเสียงเพิ่มใน Liquidsoap ของสถานี (ใช้ CPU ราวเท่าสตรีมหลักของสถานีต่อปลายทาง AAC ใช้มากกว่าเล็กน้อย) ปลายทาง RTMP แต่ละรายการเป็นโปรเซส FFmpeg หนึ่งตัวบน worker ซึ่งดึงสตรีมของสถานีจาก Icecast ในเครื่อง (ไม่นับเป็นผู้ฟัง) แล้วเข้ารหัสภาพนิ่ง H.264 1280×720 2 เฟรมต่อวินาที กับเสียง AAC 128 kbps วัดได้ราว 3–5% ของ CPU หนึ่งคอร์และหน่วยความจำราว 150 MB ต่อปลายทาง เตรียม worker ให้พอกับจำนวนปลายทาง RTMP ที่แพ็กเกจอนุญาต
  • แพ็กเกจ: FFmpeg ที่มี libx264, libfreetype, libharfbuzz (แพ็กเกจ ffmpeg ของ Ubuntu มีครบ) และฟอนต์ภาษาไทย (fonts-tlwg-loma หรือ fonts-noto-core) image ของ Docker มีให้แล้ว RTMPS ต้องใช้ FFmpeg ที่รองรับ TLS (GnuTLS หรือ OpenSSL) ส่วน Icecast แบบ TLS ต้องใช้ Liquidsoap ที่รองรับ SSL
  • เครือข่าย: ต้องเชื่อมต่อขาออกไปยังปลายทางได้ (ดู "เปิดไฟร์วอลล์") ปลายทางที่เป็นที่อยู่ภายในหรือ loopback จะถูกปฏิเสธ เว้นแต่ตั้ง EXTERNAL_OUTPUT_ALLOW_PRIVATE=true
  • การแจ้งเตือน: ปลายทางที่เปิดอยู่แต่เชื่อมต่อไม่ได้นาน ALERT_EXTERNAL_OUTPUT_MINUTES นาที (ค่าเริ่มต้น 5) จะแจ้งผู้ดูแลสถานี
  • รหัสผ่านและ Stream key เก็บในฐานข้อมูล (ตาราง external_output_secrets มี row-level security) เขียนลงเฉพาะไฟล์ตั้งค่าของระบบออกอากาศ (station.json ที่ผู้ใช้ของ engine อ่านได้คนเดียว) หรือส่งให้ FFmpeg ไม่แสดงในหน้าเว็บและไม่ถูกส่งออก ถ้าเซิร์ฟเวอร์มีผู้ใช้อื่นร่วมใช้ บรรทัดคำสั่งของ FFmpeg (ที่มี Stream key ของ RTMP) จะเห็นได้ในรายการโปรเซส

ข้อความจาก LINE และ Telegram

ไม่ต้องตั้งค่าบนเซิร์ฟเวอร์ แต่ละสถานีใส่คีย์ของตนในหน้า ช่องทางติดต่อดีเจ เงื่อนไขมีดังนี้

  • BASE_URL ต้องเป็น https:// ที่มีใบรับรองที่เบราว์เซอร์เชื่อถือ (แบบโดเมน หรือใบรับรอง IP ของ Let's Encrypt) และ LINE กับ Telegram เข้าถึงได้จากอินเทอร์เน็ต ใช้ไม่ได้กับแบบ ip-http หรือใบรับรองของ Caddy เอง ที่อยู่เว็บฮุกมีรูปแบบ BASE_URL/hooks/line/<สถานี>/<คีย์> และ BASE_URL/hooks/telegram/<สถานี>/<คีย์>
  • reverse proxy ต้องส่ง /hooks/ ไปที่แอปโดยไม่แก้เนื้อหา request (LINE ตรวจลายเซ็นจากเนื้อหาเดิม)
  • เซิร์ฟเวอร์ต้องออกอินเทอร์เน็ตไปที่ api.line.me และ api.telegram.org ได้ (ตอบกลับอัตโนมัติ ดึงชื่อผู้ส่ง และตั้งเว็บฮุก)
  • คีย์ถูกเก็บในตาราง station_secrets (มี row-level security แยกสถานี) ไม่อยู่ในไฟล์ส่งออกสถานี สำรองข้อมูลด้วย pg_dump ตามปกติจะรวมตารางนี้ด้วย ให้เก็บไฟล์สำรองอย่างปลอดภัย

การเงินและ Omise

ระบบการเงินปิดไว้เป็นค่าเริ่มต้น เปิดหรือปิดได้ที่ ศูนย์ควบคุมแพลตฟอร์ม → การเงินและใบแจ้งหนี้ การชำระด้วยพร้อมเพย์ QR และแนบสลิปใช้ได้โดยไม่ต้องมีผู้ให้บริการชำระเงิน

ถ้าจะรับบัตรผ่าน Omise

  1. ตั้ง OMISE_SECRET_KEY แล้ว restart
  2. ใน Omise Dashboard ตั้งเว็บฮุก URL เป็น https://<โดเมนของคุณ>/billing/omise/webhook

e-Tax Invoice

ระบบออกใบเสร็จรับเงิน/ใบกำกับภาษีแต่ละฉบับเป็นใบกำกับภาษีอิเล็กทรอนิกส์ได้ คือไฟล์ XML ตามมาตรฐาน ขมธอ. 3-2560 (เวอร์ชัน 2.0) ของ สพธอ. ที่ลงลายมือชื่อดิจิทัลแบบ XAdES-BES และไฟล์ PDF/A-3 ที่มี XML แนบอยู่ภายใน ปิดไว้เป็นค่าเริ่มต้น ผู้ดูแลระบบเปิดได้ที่ ศูนย์ควบคุมแพลตฟอร์ม → การเงินและใบแจ้งหนี้ → e-Tax ใช้ได้เฉพาะเมื่อผู้ขายจดทะเบียนภาษีมูลค่าเพิ่ม

การลงลายมือชื่อต้องใช้ใบรับรองอิเล็กทรอนิกส์ของบริษัท จากผู้ให้บริการออกใบรับรองที่อยู่ภายใต้ NRCA ของประเทศไทย ส่งออกเป็นไฟล์ PKCS#12 พร้อมกุญแจส่วนตัว รองรับเฉพาะกุญแจ RSA

  1. คัดลอกไฟล์ไปไว้บนเซิร์ฟเวอร์ในที่ที่ผู้ใช้ของแอปอ่านได้คนเดียว เช่น /etc/radio/etax.p12 (chmod 600 เจ้าของ radio)
  2. ตั้ง ETAX_P12_PATH เป็นที่อยู่ไฟล์นั้น และตั้ง ETAX_P12_PASSWORD (หรือ ETAX_P12_PASSWORD_FILE) เป็นรหัสผ่าน ถ้าใช้ Docker Compose ให้ mount ไฟล์เข้า container app แล้วเพิ่มตัวแปรใน .env ถ้าใช้ Helm ให้ mount จาก Secret แล้วเพิ่มตัวแปรด้วย extraEnv
  3. restart แอปและ worker
  4. ที่ ศูนย์ควบคุมแพลตฟอร์ม → การเงินและใบแจ้งหนี้ → e-Tax ตรวจสถานะใบรับรอง แล้วกด ทดสอบลงลายมือชื่อ

ข้อควรทราบ

  • กรมสรรพากรแนะนำให้เก็บกุญแจใน USB โทเคนหรือ HSM (PKCS#11) รุ่นนี้อ่านได้เฉพาะไฟล์ PKCS#12 จึงต้องดูแลไฟล์และรหัสผ่านเหมือน secret อื่น และต่ออายุใบรับรองก่อนหมดอายุ (หน้าตั้งค่าแสดงจำนวนวันที่เหลือ)
  • ระบบไม่ได้นำส่งข้อมูลเข้าระบบ e-Tax Invoice & e-Receipt ของกรมสรรพากร ถ้าได้รับอนุมัติให้ใช้ระบบนั้น ให้นำส่งไฟล์ XML ที่ลงลายมือชื่อแล้วผ่านช่องทางที่ได้รับอนุมัติภายในวันที่ 15 ของเดือนถัดไป
  • e-Tax Invoice by Email (Time Stamp): สำหรับผู้จดทะเบียน VAT ที่มีรายได้ไม่เกิน 30 ล้านบาทต่อปี หลังได้รับอนุมัติจากกรมสรรพากร (แบบ ก.อ.01 ที่ rd.go.th) ระบบส่งไฟล์ PDF/A-3 ทางอีเมลถึงผู้ซื้อ พร้อมสำเนาถึงที่อยู่ที่กรอกในหน้าตั้งค่า e-Tax การส่งอีเมลต้องตั้ง SMTP_URL ถ้าไม่ได้ตั้ง อีเมลจะถูกเขียนเป็นไฟล์ .eml ใน DATA_DIR/mail-outbox
  • ไฟล์ XML ถูกตรวจตามข้อกำหนดของมาตรฐาน แต่ไม่ได้รวมไฟล์ XSD ทางการของ สพธอ. มาด้วย ก่อนใช้งานจริงให้ตรวจไฟล์ตัวอย่างด้วยเครื่องมือของ สพธอ.

คีย์ใบอนุญาตสำหรับรุ่นติดตั้งเอง

สำหรับรุ่นที่ขายให้ลูกค้าติดตั้งเอง ขั้นที่ 1–3 ทำโดย SAINT Technology

  1. สร้างคู่กุญแจครั้งเดียว

    node tools/license-issuer/issue.mjs keygen --out ~/.saint-license
    
  2. เก็บกุญแจส่วนตัว (private.pem) ไว้นอกระบบและสำรองไว้ ห้ามแนบไปกับโปรแกรม

  3. วางกุญแจสาธารณะที่แสดงออกมาใน VENDOR_PUBLIC_KEY ของ src/lib/license.ts แล้ว build รุ่นที่จะส่งมอบ

  4. ออกคีย์ให้ลูกค้าแต่ละราย

    node tools/license-issuer/issue.mjs issue --key ~/.saint-license/private.pem --licensee "ชื่อลูกค้า" --stations 10 --listeners 2000 --expires 2027-12-31 --updates 2027-12-31 [--domain radio.example.th] [--features emergency,ivr,relays,billing]
    
  5. ลูกค้าตั้ง LICENSE_ENFORCE=true แล้ววางคีย์ที่ ศูนย์ควบคุมแพลตฟอร์ม → ใบอนุญาต

node tools/license-issuer/issue.mjs show <คีย์> ใช้ถอดดูข้อมูลในคีย์ ใบอนุญาตจำกัดสิ่งที่เพิ่มได้ (สถานี ผู้ฟัง) แต่ไม่ตัดการออกอากาศที่กำลังดำเนินอยู่

แปลงข้อความเป็นเสียง

ตั้ง TTS_COMMAND เป็นโปรแกรม TTS ภาษาไทยที่อ่านไฟล์ข้อความ {text} แล้วเขียนไฟล์ WAV {out} ใช้กับเสียงเมนูโทรศัพท์ และประกาศฉุกเฉินที่มาเป็นข้อความอย่างเดียว

ฟังทางโทรศัพท์ (Asterisk)

ถ้าใช้ Docker Compose ดู "เพิ่มเติม: ฟังทางโทรศัพท์" ด้านบน worker เขียนเมนูโทรศัพท์และคลาส Music-On-Hold ลง DATA_DIR/asterisk ทุกนาที container ของ Asterisk ดึงไฟล์เหล่านี้เข้าไปและโหลดใหม่ทุกนาที

ถ้าไม่ใช้ Docker

  1. ติดตั้ง Asterisk และตั้ง SIP trunk (ดู deploy/asterisk/pjsip_trunk.example.conf) สายที่โทรเข้าต้องเข้า context radio-ivr
  2. แบบให้ worker ทำให้: ตั้ง IVR_AGI_PORT=4573, IVR_AGI_PUBLIC_HOST=127.0.0.1 แล้วเพิ่ม #include <IVR_ASTERISK_CONF_DIR>/extensions_radio.conf ใน extensions.conf และ #include <IVR_ASTERISK_CONF_DIR>/musiconhold_radio.conf ใน musiconhold.conf จากนั้นให้ Asterisk โหลดใหม่เป็นระยะ (asterisk -rx "dialplan reload" และ asterisk -rx "moh reload") สถานีใหม่จึงจะปรากฏ
  3. แบบทำเอง: radio asterisk-config --out /etc/asterisk --agi-host 127.0.0.1 เพิ่มบรรทัด #include ทั้งสอง สั่ง core reload แล้วรัน radio ivr-agi เป็น service
  4. สร้างเสียงประกาศของเมนู: radio ivr-prompts ใช้ TTS_COMMAND หรืออัดเสียงเป็นไฟล์ WAV 8 kHz ใส่ใน IVR_SOUNDS_DIR เอง

ถ้าทำได้ ให้เปิด 5060/udp และ 10000–10100/udp เฉพาะกับผู้ให้บริการ SIP

ประกาศฉุกเฉินแบบ CAP

  1. ศูนย์ควบคุมแพลตฟอร์ม → ประกาศฉุกเฉิน สร้างลิงก์ลับ
  2. ส่งลิงก์ (https://<โดเมนของคุณ>/emergency/cap/<token>) ให้หน่วยงาน หน่วยงานส่ง CAP 1.2 XML มาที่ลิงก์นี้ด้วย POST
  3. ตั้ง TTS_COMMAND ถ้าประกาศอาจมาเป็นข้อความโดยไม่มีเสียง

รายการตรวจความปลอดภัย

  • เปลี่ยนค่าเริ่มต้นและค่า change-me ทุกตัว SESSION_SECRET และ IP_HASH_SALT เป็นข้อความสุ่มยาว ๆ
  • .env อ่านได้เฉพาะผู้ใช้ของ service (chmod 600)
  • BASE_URL เป็น https:// (แบบโดเมนหรือ ip-https) ใช้ ip-http เฉพาะในเครือข่ายภายในที่ไว้ใจได้
  • DATABASE_URL ใช้ role ที่ไม่ใช่ superuser และต่างจาก DATABASE_ADMIN_URL (migrate จะไม่ยอมทำงานถ้าเป็นผู้ใช้เดียวกัน หรือถ้า role ของ app เป็น superuser มีสิทธิ์ BYPASSRLS หรือเป็นเจ้าของตาราง)
  • พาสคีย์ของทีมงาน: เพิ่มพาสคีย์ของคุณเองก่อน แล้วเลือก "บังคับทีมงานทุกคน … ใช้พาสคีย์เป็นการยืนยันขั้นที่ 2" ในส่วนนโยบายของแพลตฟอร์ม ที่หน้าความปลอดภัยของบัญชี (/account/security แสดงเฉพาะผู้ดูแลระบบ)
  • เงื่อนไขการใช้งาน: เผยแพร่ที่ ศูนย์ควบคุมแพลตฟอร์ม → เงื่อนไขและนโยบาย เลือก "เปลี่ยนในสาระสำคัญ" เพื่อให้ทีมงานยอมรับฉบับใหม่
  • LOGIN_RATE_LIMIT เหมาะกับเครือข่ายของคุณ
  • ไฟร์วอลล์: เปิดเฉพาะ 80, 443, พอร์ตดีเจ (8100 ขึ้นไป) และถ้าใช้โทรศัพท์ 5060/udp กับ 10000–10100/udp ปิด 3000, 5432, 8000, 9100 ขึ้นไป และ 4573
  • proxy กัน /internal/ แล้ว
  • เว็บฮุกส่งได้เฉพาะปลายทาง https:// สาธารณะ เว้นแต่จำเป็นต้องใช้ในเครือข่ายภายใน
  • สำรองข้อมูลทุกวัน และเคยทดสอบกู้คืนแล้ว
  • อัปเดต: ติดตั้งรุ่นใหม่ของแพลตฟอร์มและอัปเดตความปลอดภัยของ Ubuntu สม่ำเสมอ

การแยกข้อมูลสถานีในฐานข้อมูล

row-level security แยกข้อมูลของแต่ละสถานีออกจากกัน app บอกฐานข้อมูลว่าธุรกรรมนี้เป็นของสถานีไหน และลงลายมือชื่อด้วยรหัสลับ (DB_SCOPE_SECRET หรือรหัสที่สร้างจาก SESSION_SECRET) รหัสลับนี้เก็บในตารางที่ role ของ app อ่านไม่ได้ แม้มี SQL injection ที่รันด้วย role ของ app ก็ใช้ set_config() ขยายสิทธิ์ตัวเองไม่ได้ ถ้าไม่มีลายมือชื่อที่ถูกต้องของธุรกรรมนั้นจะไม่เห็นข้อมูลของสถานีใดเลย ใช้กับตารางของสถานีทุกตาราง รวมถึงการแจ้งเตือน การเตือนโควตา บันทึกการตรวจสอบ (audit log) และรายงานปัญหาการเข้าถึง

  • migrate เก็บรหัสลับไว้ และเก็บใหม่เมื่อรหัสเปลี่ยน หลังเปลี่ยน DB_SCOPE_SECRET หรือ SESSION_SECRET ให้รัน migrate ก่อนหรือพร้อมกับการเริ่ม app และ worker ใหม่ ถ้ารหัสในฐานข้อมูลไม่ตรง app จะหยุดตั้งแต่เริ่มทำงานพร้อมข้อความบอกสาเหตุ ถ้าไม่มีการตรวจนี้ ทุกหน้าจะว่างเปล่าโดยไม่มีข้อความเตือน
  • Kubernetes: migration job สร้างรหัสลับจาก Secret เดียวกับ pod ของ app ถ้าตั้ง DB_SCOPE_SECRET ต้องให้ทั้ง migration job, app และ worker

Content-Security-Policy และ reverse proxy

ทุกหน้าส่ง Content-Security-Policy แบบเข้มงวด สคริปต์รันได้เฉพาะไฟล์ของแพลตฟอร์มเอง ไม่มีโค้ดในหน้า สไตล์มาจากไฟล์ของแพลตฟอร์มและบล็อกสไตล์เล็ก ๆ หนึ่งบล็อกที่มี nonce ซึ่งเปลี่ยนทุกครั้งที่ส่งหน้า รูปภาพมาจากที่อยู่ https:// ใดก็ได้ เสียงและการเชื่อมต่อมาจาก URL สาธารณะของ Icecast (ICECAST_PUBLIC_URL) ที่อยู่ HLS CDN ของสถานี และ endpoint ของ S3 ได้ ฟอร์มส่งได้เฉพาะไปที่แพลตฟอร์มเอง (BASE_URL) ทุกหน้าแสดงในกรอบ (frame) ได้เฉพาะในแพลตฟอร์มเอง ส่วนเครื่องเล่นแบบฝัง ทุกเว็บฝังได้ หรือเฉพาะเว็บที่สถานีระบุไว้ในหน้าเชื่อมต่อบริการอื่น

  • อย่าให้ proxy หรือ CDN เพิ่มหรือแทนที่ header Content-Security-Policy ให้ส่ง header ของ app ผ่านไปตามเดิม proxy ที่แก้ HTML (แทรกสคริปต์วิเคราะห์หรือแชต หรือย่อโค้ดในหน้า) จะทำให้หน้าเสียภายใต้นโยบายนี้
  • อย่าแคชหน้า HTML ที่ CDN เพราะ nonce เปลี่ยนทุกครั้ง ไฟล์ใน /assets/ แคชได้
  • ถ้าเบราว์เซอร์บล็อกอะไร รายงานจะส่งไปที่ /csp-report และบันทึกใน log ของ app เป็น CSP violation (ไม่เกิน 60 รายการต่อนาที) อย่าปิด /csp-report ที่ proxy

สำรองและกู้คืนข้อมูล

สำรอง 3 อย่าง

  1. ฐานข้อมูล (ทุกวัน)
  2. ไฟล์: DATA_DIR ถ้าใช้ STORAGE_DRIVER=local (ไฟล์เสียงอยู่ใน DATA_DIR/media) หรือเปิด versioning ของ bucket ถ้าใช้ S3 ใน DATA_DIR ยังมีไฟล์ส่งออกและเสียงเมนูโทรศัพท์ด้วย
  3. ไฟล์ .env (หรือ Helm values/Secret) ถ้า SESSION_SECRET ไม่ตรงเดิม ทุกคนจะหลุดจากระบบ และต้องตั้งค่า Icecast ใหม่

ติดตั้งโดยตรง (ไม่รองรับ)

สำรอง

pg_dump -Fc -d "postgres://postgres:ADMIN-PASSWORD@127.0.0.1:5432/radio" -f radio-$(date +%F).dump
sudo tar czf radio-data-$(date +%F).tgz -C /var/lib/radio .

กู้คืน

sudo systemctl stop radio-app radio-worker
sudo -u radio node --env-file=.env dist/src/cli.js migrate
pg_restore --clean --if-exists -d "postgres://postgres:ADMIN-PASSWORD@127.0.0.1:5432/radio" radio-2026-09-26.dump
sudo -u radio node --env-file=.env dist/src/cli.js migrate
sudo tar xzf radio-data-2026-09-26.tgz -C /var/lib/radio
sudo systemctl start radio-app radio-worker

บนเครื่องใหม่ migrate ครั้งแรกสร้าง role ของ app ไว้ก่อนกู้คืน ครั้งที่สองให้สิทธิ์ (grant) ซ้ำอีกรอบ

Docker Compose

บริการ backup สำรองฐานข้อมูล ไฟล์ทั้งหมด และ .env ทุกคืนแบบเข้ารหัส ไปที่ /var/backups/radio และ S3 (ถ้าตั้งไว้) เก็บ 7 ชุดรายวัน 4 ชุดรายสัปดาห์ และ 6 ชุดรายเดือน สำรองทันทีด้วย sudo radioctl backup กู้คืนด้วย sudo radioctl restore ค่าตั้ง การกู้คืนบนเครื่องใหม่ และการซ้อมกู้คืนอยู่ในคู่มือดูแลระบบ

ย้ายสถานีเดียว

  • ส่งออก: สถานี → เชื่อมต่อบริการอื่น → ส่งออกสถานี → เริ่มส่งออก เมื่อพร้อมแล้วดาวน์โหลดไฟล์ .tar.gz

  • นำเข้าบนแพลตฟอร์มใดก็ได้ เข้าองค์กรที่มีอยู่แล้ว

    radio station-import station-export.tar.gz --org <org-slug> --slug <slug-ใหม่> [--name "ชื่อใหม่"]
    

    ถ้าใช้ Docker คำสั่ง sudo radio station-import station-export.tar.gz --org … คัดลอกไฟล์เข้า container ให้เอง

ทดสอบการกู้คืน

ทำอย่างน้อยทุก 2–3 เดือน บนเครื่องหรือฐานข้อมูลแยก

  1. กู้คืนฐานข้อมูลและไฟล์ชุดล่าสุด
  2. เปิด app และ worker ตรวจ /health
  3. เข้าสู่ระบบ เปิดสถานี ฟังสตรีม และเล่นไฟล์จากคลังเสียง
  4. จดเวลาที่ใช้ทั้งหมด

อัปเกรด

สำรองข้อมูลก่อนเสมอ แล้วทำดังนี้

Docker Compose

sudo radioctl update --version <รุ่นใหม่>

คำสั่งนี้สำรองข้อมูล ดึงอิมเมจใหม่ เริ่มเว็บแอปใหม่ก่อน (migration) แล้วจึงเริ่มระบบออกอากาศใหม่ทีละ shard ระหว่างนั้นผู้ฟังได้ยินเสียงรอสัญญาณ ย้อนกลับด้วย radioctl rollback รายละเอียดในคู่มือดูแลระบบ ถ้ารุ่นใหม่เปลี่ยนไฟล์ในโฟลเดอร์ติดตั้งด้วย (เช่น docker-compose.yml) ให้แตก radio-<version>.tar.gz ทับ /opt/radio ก่อน (หรือ git pull ถ้าเป็นโค้ดที่ clone มา) หรือรัน scripts/install.sh อีกครั้ง ถ้าไม่มีอินเทอร์เน็ต: sudo radioctl update --offline radio-images-<version>-<arch>.tar.gz

ติดตั้งโดยตรง (ไม่รองรับ)

cd /opt/radio
# ดึงรุ่นใหม่ (เช่น git pull)
sudo -u radio npm ci
sudo -u radio npm run build
sudo -u radio node --env-file=.env dist/src/cli.js migrate
sudo systemctl restart radio-app radio-worker

Helm: push image ใหม่ แล้ว helm upgrade radio deploy/helm/radio -f my-values.yaml --set image.tag=<tag ใหม่> migration job ทำงานก่อน (ชื่อออบเจกต์คงเดิม ดู Kubernetes)

app อัปเดตฐานข้อมูลตอนเริ่มทำงาน ยกเว้นตั้ง MIGRATE_ON_START=false การรัน migrate เองก่อน ช่วยให้เห็นข้อผิดพลาดก่อน restart

การ restart worker ทำให้ Liquidsoap ของทุกสถานีเริ่มใหม่ สถานีจะหยุดออกอากาศครู่หนึ่ง (ถ้าใช้ Docker Compose ผู้ฟังได้ยินเสียงรอสัญญาณและไม่หลุดการเชื่อมต่อ) ควรอัปเกรดช่วงที่มีผู้ฟังน้อย และอ่านบันทึกประจำรุ่นเพื่อดูค่าตั้งใหม่

ติดตามสถานะระบบ

Endpoint ใครใช้ได้ ข้อมูล
/health ทุกคน ตรวจว่ายังทำงาน ตอบ {"ok":true} เมื่อ app ต่อฐานข้อมูลได้ Docker และ Kubernetes ใช้เป็น probe
/status สาธารณะ หน้าสถานะ: เว็บไซต์ ฐานข้อมูล การออกอากาศ แต่ละสถานี และความพร้อมใช้งาน 30 วันของแต่ละสถานี
/status.json สาธารณะ ข้อมูลเดียวกันเป็น JSON (เปิด CORS) สำหรับระบบเฝ้าระวังภายนอก
/platform/health ผู้ดูแลระบบ หน้าสุขภาพระบบ: ระบบออกอากาศ เสียงเงียบ ผู้ฟัง งานเบื้องหลัง พื้นที่ดิสก์
/platform/health.json ผู้ดูแลระบบ (ที่เข้าสู่ระบบแล้ว) ข้อมูลเดียวกันเป็น JSON
/platform/alerts ผู้ดูแลระบบ การแจ้งเตือนระบบที่เปิดอยู่และย้อนหลัง (ดูด้านล่าง)
/metrics Prometheus (Bearer METRICS_TOKEN) ค่าวัดของ app สำหรับ Prometheus ส่วน worker แต่ละตัวให้บริการที่ WORKER_METRICS_PORT ปิดอยู่ถ้า METRICS_TOKEN ว่าง

ที่หน้าสุขภาพระบบ (ศูนย์ควบคุมแพลตฟอร์ม → สุขภาพระบบ) ตั้งได้ว่า

  • เป้าหมายความพร้อมใช้งานต่อเดือน
  • หยุดออกอากาศนานกี่นาทีจึงส่งอีเมลแจ้งผู้ดูแลสถานี
  • จะแสดงความพร้อมใช้งานและเหตุขัดข้องในหน้า /status สาธารณะหรือไม่

ดู log

รูปแบบการติดตั้ง คำสั่ง
Docker Compose sudo radioctl logs app --follow (หรือ worker, icecast, caddy, …)
ติดตั้งโดยตรง (ไม่รองรับ) journalctl -u radio-app -f, journalctl -u radio-worker -f, journalctl -u icecast2 และ /var/log/icecast2/
Kubernetes kubectl logs deploy/<release>-saint-radio-app, kubectl logs <release>-saint-radio-worker-0

ค่าวัดสำหรับ Prometheus

  1. ตั้ง METRICS_TOKEN (อักขระสุ่ม 32 ตัวขึ้นไป) ให้ทั้ง app และ worker และตั้ง WORKER_METRICS_PORT ให้ worker (Docker Compose ตั้ง 9464 ให้แล้ว shard ที่ N ใช้ 9464 + N) แล้วเริ่มทั้งสองใหม่
  2. ตรวจด้วย curl -H "Authorization: Bearer $METRICS_TOKEN" http://127.0.0.1:3000/metrics บน app และ http://127.0.0.1:9464/metrics บน worker
  3. ตั้ง Prometheus ให้ดึงค่าพร้อมโทเคนตัวอย่างที่ใช้ได้จริงอยู่ที่ deploy/monitoring/prometheus.yml (สอง job คือ radio-app และ radio-worker อ่านโทเคนจากไฟล์) ให้ดึงจาก app โดยตรง ไม่ผ่าน reverse proxy สาธารณะ หรือปิด /metrics ที่ proxy แล้วอาศัยโทเคนกับ METRICS_ALLOW_IPS
  4. โหลดกฎแจ้งเตือน deploy/monitoring/alerts.yml (rule_files ในไฟล์ตัวอย่าง) ตรวจไวยากรณ์ด้วย promtool check rules deploy/monitoring/alerts.yml

ค่าที่วัด

โปรเซส ค่าวัด
App (/metrics) radio_http_request_duration_seconds (แยกตามรูปแบบเส้นทาง method และกลุ่มรหัสสถานะ) radio_http_requests_in_flight radio_websocket_connections radio_db_pool_connections (ทั้งหมด ว่าง รอคิว) radio_db_query_duration_seconds radio_mail_failures_total radio_webhook_deliveries_last_hour (แยกตามสถานะ) และค่ามาตรฐาน process_* / nodejs_*
Worker (WORKER_METRICS_PORT) รายสถานี: radio_station_listeners radio_station_engine_state radio_station_silence_seconds radio_station_on_air_source radio_station_live_dj_connected radio_station_engine_restarts_10m งานของ worker: radio_worker_tick_duration_seconds radio_worker_tick_failures_total radio_worker_tick_consecutive_failures ผลการตรวจ: radio_icecast_up radio_data_dir_disk_used_ratio radio_db_scope_ok radio_etax_certificate_days_left radio_alerts_open และค่าของฐานข้อมูลกับโปรเซส

ถ้าแบ่ง worker หลาย shard แต่ละตัวรายงานเฉพาะสถานีของตัวเอง ส่วนการตรวจระดับแพลตฟอร์ม (Icecast ดิสก์ ใบรับรอง การแจ้งเตือน) ทำที่ worker shard 0

Docker Compose: เพิ่ม METRICS_TOKEN ใน .env แล้วรัน sudo radioctl up รัน Prometheus ในเครือข่าย Compose ชื่อ radio_default ด้วยไฟล์ตัวอย่าง เป้าหมายคือ app:3000 และ engine-net:9464 (worker ใช้เครือข่ายของบริการ engine-net ร่วมกัน worker จึงไม่มีที่อยู่ของตัวเอง ถ้ามีหลาย shard เพิ่ม engine-net:9465, …)

Kubernetes (Helm): ตั้ง secrets.metricsToken และ monitoring.enabled=true แล้ว pod จะมี annotation prometheus.io/scrape|port|path (monitoring.podAnnotations) worker เปิดพอร์ตชื่อ metrics และถ้าใช้ Prometheus Operator ตั้ง monitoring.serviceMonitor.enabled=true จะได้ ServiceMonitor ที่ส่งโทเคนจาก Secret ของ chart (เพิ่ม monitoring.serviceMonitor.labels ให้ Prometheus ของคุณเลือกมัน) ส่วน alerts.yml ให้ใส่ใน PrometheusRule (ย้ายรายการ groups: ไปไว้ใต้ spec:)

แดชบอร์ด Grafana

Grafana → Dashboards → New → Import → อัปโหลด deploy/monitoring/grafana/saint-radio-dashboard.json → เลือกแหล่งข้อมูล Prometheus แดชบอร์ดแสดงการแจ้งเตือนที่เปิดอยู่ ผู้ฟัง ช่วงเงียบ สถานะและการเริ่มใหม่ของระบบออกอากาศรายสถานี (เลือกสถานีจากเมนู Station) อัตราคำขอ เวลาตอบ และข้อผิดพลาด pool และเวลาคิวรีของฐานข้อมูล เวลาและความล้มเหลวของงาน worker, Icecast, ดิสก์, ใบรับรอง e-Tax และหน่วยความจำกับ CPU ของโปรเซส

การแจ้งเตือนจากแพลตฟอร์มเอง

ไม่มี Prometheus ก็ได้ worker ตรวจเงื่อนไขเดียวกันทุกนาทีและแจ้งผู้เกี่ยวข้องเอง

การแจ้งเตือน แจ้งใคร
สถานีเงียบนานเกินเกณฑ์ (ค่าเริ่มต้น 2 นาที แต่ละสถานีเปลี่ยนเองได้) ผู้ดูแลสถานี
ระบบออกอากาศของสถานีเริ่มใหม่เกิน 3 ครั้งใน 10 นาที ผู้ดูแลสถานี
ระบบออกอากาศของสถานีไม่ทำงานนาน 2 นาที ผู้ดูแลสถานี
Icecast หรือ relay ไม่ตอบ ผู้ดูแลระบบ
งานของ worker ล้มเหลว 3 ครั้งติดกัน ผู้ดูแลระบบ
ฐานข้อมูลไม่ยอมรับขอบเขตข้อมูลที่ลงนามของ worker ผู้ดูแลระบบ
ดิสก์ของ DATA_DIR ใช้เกิน 85% ผู้ดูแลระบบ
ใบรับรองลงนาม e-Tax จะหมดอายุภายใน 30 วัน ผู้ดูแลระบบ

แต่ละเหตุแจ้งครั้งเดียว (การแจ้งเตือนในระบบ อีเมลตามภาษาของแต่ละคน และการแจ้งเตือนบนเบราว์เซอร์สำหรับคนที่อนุญาตไว้) แจ้งซ้ำทุก ALERT_RENOTIFY_HOURS ชั่วโมงถ้ายังไม่คลี่คลาย และแจ้งอีกครั้งเมื่อคลี่คลาย ถ้าตั้ง ALERT_WEBHOOK_URL ไว้ เหตุการณ์เดียวกันจะถูกส่งเป็น JSON (event คือ alert.opened alert.reminder หรือ alert.resolved และ alert มีชนิด ความรุนแรง สถานี เวลา และสรุปภาษาอังกฤษกับภาษาไทย) ดูรายการที่เปิดอยู่และย้อนหลังได้ที่ ศูนย์ควบคุมแพลตฟอร์ม → การแจ้งเตือนระบบ (/platform/alerts) และของแต่ละสถานีที่ จัดการสถานี → การแจ้งเตือน

Log และรหัสอ้างอิงคำขอ (request id)

  • app และ worker เขียน log เป็น JSON บรรทัดละหนึ่งรายการ ทุกบรรทัดมี component (web worker supervisor alerts …) บรรทัดของ app มี request_id ด้วย
  • ทุกคำตอบมี header X-Request-Id ถ้า proxy หรือ load balancer ส่ง X-Request-Id ที่สั้นและปลอดภัยมา ระบบใช้ค่านั้น ไม่เช่นนั้นสร้างใหม่ หน้าข้อผิดพลาดแสดงค่านี้เป็น "รหัสอ้างอิงสำหรับแจ้งผู้ดูแล" ค้นหาค่านี้ใน log ได้ทันที
  • header Authorization, cookie, รหัสผ่าน, โทเคนและค่าลับใน URL ถูกบันทึกเป็น [redacted]
  • อ่าน log ให้ง่ายขึ้นตอนดีบัก: journalctl -u radio-app -o cat | npx pino-pretty

คำสั่งผู้ดูแล

เมื่อใช้ Docker radio เป็นคำสั่งที่ตัวติดตั้งวางไว้ใน /usr/local/bin: sudo radio <คำสั่ง> รัน docker compose exec app node dist/src/cli.js <คำสั่ง> ใน container ของเว็บแอปของการติดตั้งนี้ (ไฟล์ที่ส่งให้ station-import ถูกคัดลอกเข้า container ก่อน) บน NAS ใช้ sudo bash scripts/radio … ในโฟลเดอร์ติดตั้ง Kubernetes: kubectl exec deploy/<release>-saint-radio-app -- node dist/src/cli.js … ติดตั้งโดยตรง: node --env-file=.env dist/src/cli.js … ในโฟลเดอร์โค้ด

คำสั่ง ใช้ทำอะไร
radio migrate อัปเดตฐานข้อมูล สร้าง role ของ app และเพิ่มแพ็กเกจเริ่มต้น
radio create-admin <email> [name] สร้างผู้ใช้ หรือตั้งผู้ใช้ที่มีอยู่ให้เป็นผู้ดูแลระบบ
radio icecast-config [--out file] [--user icecast2 --group icecast] [--log-dir dir] เขียน icecast.xml ของแพลตฟอร์มนี้ ถ้าไม่ใส่ --out จะแสดงออกหน้าจอ
radio icecast-relay-config --hostname <host> [--master-host h] [--master-port 8000] [--port 8000] [--out file] [--user u --group g] [--log-dir dir] [--watch --pid <pid> | --pid-file <file>] เขียน icecast.xml ของ edge relay --watch อัปเดตไฟล์ให้ตลอด
radio station-import <export.tar.gz> --org <org-slug> --slug <new-slug> [--name "Name"] นำเข้าสถานีที่ส่งออกไว้เข้าองค์กร
radio station-script <slug> แสดงสคริปต์ Liquidsoap ที่ระบบสร้างให้สถานี
radio asterisk-config --out <dir> --agi-host <host> [--agi-port 4573] [--icecast <url>] เขียนไฟล์เมนูโทรศัพท์และ Music-On-Hold ของ Asterisk
radio ivr-prompts สร้างเสียงประกาศของเมนูโทรศัพท์ (ต้องมี TTS_COMMAND) หรือบอกว่าขาดไฟล์ไหน
radio ivr-agi รันเซิร์ฟเวอร์ FastAGI สำหรับ Asterisk (ปกติ worker ทำให้)

เครื่องมือใบอนุญาต (เฉพาะ SAINT Technology): node tools/license-issuer/issue.mjs keygen | issue | show

แก้ปัญหา

ปัญหา ตรวจอะไร
ไม่ได้รับอีเมลรหัสเข้าสู่ระบบ ตั้ง SMTP_URL แล้วหรือยัง ถ้ายัง อีเมลเป็นไฟล์อยู่ใน DATA_DIR/mail-outbox ดู log ของ app ว่ามีข้อผิดพลาดการส่งอีเมลหรือไม่ หน้าติดตั้งมีช่องตรวจ SMTP บอกว่าตั้งแล้วหรือยัง
สถานีเงียบ สถานี → ภาพรวม ดู "ระบบออกอากาศ" ถ้าขึ้นว่าหยุด ให้ดู log ของ worker มี liquidsoap ใน PATH หรือตั้ง LIQUIDSOAP_BIN แล้วหรือยัง ใช้ radio station-script <slug> ดูสคริปต์ พอร์ต harbor และพอร์ตควบคุมของสถานีว่างหรือไม่ worker ติดต่อ Icecast ได้หรือไม่ (ICECAST_HOST, ICECAST_PORT, รหัสผ่าน source)
ผู้ฟังเข้าไม่ได้ สถานีอาจเต็ม: ดูจำนวนผู้ฟังพร้อมกันในแพ็กเกจ Icecast ต้องเรียกลิงก์ตรวจสิทธิ์ของ app ที่ LIQUIDSOAP_CALLBACK_URL ได้ ถ้าเปลี่ยน SESSION_SECRET หรือ URL นี้ ต้องสร้าง icecast.xml ใหม่ ตั้ง DEBUG_ICECAST_AUTH=true เพื่อดู log การตรวจแต่ละครั้ง
Icecast ไม่ยอมเริ่ม Docker: sudo radioctl logs icecast worker (worker เป็นผู้เขียนค่าตั้งของ Icecast) ติดตั้งโดยตรง: ดูภาคผนวก และดู /var/log/icecast2/error.log
ค่าตั้งผิดหรือถูกตัด ค่าใน .env ที่มีช่องว่าง < > หรือ JSON ไม่ได้ใส่เครื่องหมายคำพูด ใส่แบบ MAIL_FROM="Online Radio <no-reply@radio.example.th>"
app ต่อฐานข้อมูลไม่ได้ DB_APP_PASSWORD ต้องตรงกับรหัสผ่านใน DATABASE_URL รัน migrate (โดยตั้ง DATABASE_ADMIN_URL) ระบบจะตั้งรหัสผ่านของ role ของ app ให้ใหม่เมื่อรหัสเดิมใช้ไม่ได้แล้ว
ดีเจต่อด้วย BUTT/OBS ไม่ได้ เปิดพอร์ตของสถานี (8100 ขึ้นไป ดูได้ที่หน้าการออกอากาศสดของสถานี) บนไฟร์วอลล์และส่งต่อจากเราเตอร์แล้วหรือยัง HARBOR_PUBLIC_HOST ชี้ไปที่ IP สาธารณะของเซิร์ฟเวอร์หรือไม่ ถ้าใช้ Docker พอร์ตอยู่ในช่วง HARBOR_PORT_START–HARBOR_PORT_END หรือไม่ (radioctl doctor ตรวจให้)
ห้องจัดรายการบนเว็บหรือแขกรับเชิญต่อไม่ได้ แบบ ip-http เบราว์เซอร์ไม่ให้ใช้ไมโครโฟนและ WebRTC ต้องใช้ HTTPS นอกนั้น proxy กั้น WebSocket ให้ส่งต่อ header Upgrade และ Connection และปิด buffering ถ้าแขกอยู่หลัง NAT ที่เข้มงวด เพิ่ม TURN ใน RTC_ICE_SERVERS
ผู้ฟังทุกคนมี IP เดียวกัน ตั้ง ICECAST_TRUSTED_PROXIES เป็น IP ของ proxy แล้วสร้าง icecast.xml ใหม่
ข้อความ LINE หรือ Telegram ไม่เข้าห้องจัดรายการ BASE_URL ต้องเป็น https ที่เข้าถึงได้จากอินเทอร์เน็ต proxy ต้องส่ง /hooks/ ถึงแอป กด ตั้งเว็บฮุก … ให้อัตโนมัติ ใหม่หลังเปลี่ยนโดเมน ถ้า log มีสถานะ 401 จาก /hooks/line/ แปลว่า Channel secret ไม่ตรงกับ channel
เว็บฮุกถูกปฏิเสธ ปลายทางเป็น http:// หรืออยู่ในเครือข่ายภายใน ดูหัวข้อเว็บฮุก: WEBHOOK_ALLOW_HTTP, WEBHOOK_ALLOW_PRIVATE
ไม่ได้ใบรับรองของโดเมนสถานี โดเมนต้องชี้มาที่เครื่องนี้ และแพลตฟอร์มต้องรู้จัก (โดเมนย่อยของสถานี โดเมนของสถานีเอง หรือชื่อในหน้าโดเมนและที่อยู่) sudo radioctl logs caddy แสดงคำตอบของ Let's Encrypt
ทุกที่อยู่แสดงหน้าติดตั้งว่างหลังย้ายหรือเปลี่ยนชื่อโฟลเดอร์ ข้อมูลอยู่ในวอลุ่มที่ตั้งชื่อตามโฟลเดอร์เดิม: รัน sudo bash scripts/radioctl migrate-name (ดู "การติดตั้งที่ทำก่อนใช้ชื่อ radio")

ภาคผนวก: ติดตั้งโดยตรงโดยไม่ใช้ Docker (ไม่แนะนำ ไม่รองรับ)

ไม่แนะนำ ไม่รองรับ (unsupported) การติดตั้งที่รองรับมีแบบเดียวคือ Docker (ด้านบน) ซึ่งผ่านการทดสอบ อัปเดตด้วย radioctl และสำรองข้อมูลอัตโนมัติ ใช้ภาคผนวกนี้เฉพาะเมื่อรัน Docker ไม่ได้ และต้องดูแลแพ็กเกจของระบบ การอัปเดต และการสำรองข้อมูลเอง ขั้น build, migration, หน้าติดตั้ง, อัปโหลดไฟล์ และเล่นเพลงอัตโนมัติเคยทดสอบครบบน Ubuntu 24.04 ส่วน systemd การโหลดค่าตั้ง Icecast ใหม่ และ Caddy ด้านล่างเป็นตัวอย่างมาตรฐานที่ไม่ได้รันในการทดสอบ

ตัวอย่างใช้ค่าต่อไปนี้

  • โค้ดอยู่ที่ /opt/radio
  • ข้อมูลอยู่ที่ /var/lib/radio
  • ผู้ใช้ระบบชื่อ radio ซึ่งรัน Icecast ด้วย (Icecast จึงอ่านไฟล์ค่าตั้งที่ worker เขียนได้)
  • โดเมน radio.example.th สตรีมที่ https://radio.example.th/stream/<สถานี>

ตั้ง DNS เหมือนแบบโดเมน

1. ติดตั้งแพ็กเกจของระบบ

sudo apt update
sudo apt install -y postgresql ffmpeg liquidsoap icecast2 curl

แพ็กเกจ postgresql ของ Ubuntu 24.04 คือ PostgreSQL 16 ตอนติดตั้ง icecast2 อาจมีคำถามให้ตอบ ตอบอะไรก็ได้ เพราะขั้นที่ 7 จะเขียนไฟล์ตั้งค่าใหม่ทั้งหมด

2. ติดตั้ง Node.js 22

แพ็กเกจ nodejs ของ Ubuntu 24.04 เก่าเกินไป ให้ติดตั้ง Node.js 22 จาก NodeSource (หรือใช้ไฟล์ tarball ทางการจาก nodejs.org)

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt install -y nodejs
node --version

node --version ต้องแสดง v22 ขึ้นไป ดูตำแหน่งของ node ด้วย command -v node ตัวอย่าง systemd ด้านล่างใช้ /usr/bin/node

3. สร้างผู้ใช้และ build โปรแกรม

sudo useradd --system --create-home --home-dir /var/lib/radio radio
# Icecast อ่านเสียงสำรองของสถานีจากโฟลเดอร์ข้อมูล
sudo chmod 755 /var/lib/radio
sudo mkdir -p /opt/radio
# คัดลอกหรือ clone ซอร์สโค้ดมาไว้ที่ /opt/radio
sudo chown -R radio:radio /opt/radio
cd /opt/radio
sudo -u radio npm ci
sudo -u radio npm run build

ผลการ build อยู่ในโฟลเดอร์ dist/

4. สร้างฐานข้อมูล

ตั้งรหัสผ่านให้บัญชีผู้ดูแลของ PostgreSQL แล้วสร้างฐานข้อมูล

sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'ADMIN-PASSWORD'"
sudo -u postgres createdb radio

ไม่ต้องสร้าง role ของ app เอง migration สร้างให้ (ขั้นที่ 6)

5. เขียนไฟล์ตั้งค่า

sudo -u radio cp .env.example .env
sudo chmod 600 .env
sudo -u radio nano .env

.env.example เขียนไว้สำหรับ Docker Compose ถ้าติดตั้งโดยตรง ให้แก้ค่าเหล่านี้

DATABASE_URL=postgres://radio_app:APP-PASSWORD@127.0.0.1:5432/radio
DATABASE_ADMIN_URL=postgres://postgres:ADMIN-PASSWORD@127.0.0.1:5432/radio
DB_APP_PASSWORD=APP-PASSWORD
DATA_DIR=/var/lib/radio
HOST=127.0.0.1
BASE_URL=https://radio.example.th
ICECAST_PUBLIC_URL=https://radio.example.th/stream
HARBOR_PUBLIC_HOST=radio.example.th
ICECAST_HOST=127.0.0.1
LIQUIDSOAP_CALLBACK_URL=http://127.0.0.1:3000
ICECAST_TRUSTED_PROXIES=127.0.0.1
ICECAST_CONFIG_FILE=/var/lib/radio/icecast/icecast.xml
ICECAST_LOG_DIR=/var/log/icecast2
ICECAST_STANDBY=false

ข้อควรรู้

  • DATABASE_ADMIN_URL ใช้บัญชี postgres จากขั้นที่ 4 ใช้เฉพาะตอน migration
  • DATABASE_URL ระบุ role ของ app (radio_app) ถ้าตั้ง DB_APP_PASSWORD ต้องตรงกับรหัสผ่านใน DATABASE_URL ถ้าเว้นว่าง ระบบใช้รหัสผ่านจาก DATABASE_URL
  • role ของ app ต้องไม่เป็น superuser เพราะ superuser ข้าม row-level security ได้ ข้อมูลแต่ละสถานีจะไม่ถูกแยกกันอีก migrate จะไม่ยอมทำงานถ้า role ของ app เป็น superuser มีสิทธิ์ BYPASSRLS เป็นเจ้าของตาราง หรือเป็นผู้ใช้เดียวกับใน DATABASE_ADMIN_URL
  • ตั้ง BASE_DOMAIN (เฉพาะเมื่อใช้โดเมนย่อยของสถานี), SESSION_SECRET, IP_HASH_SALT, รหัสผ่าน Icecast และค่าอีเมล เหมือน "ติดตั้งเอง"
  • ICECAST_STANDBY=false: สตรีมรอสัญญาณตามเวลาจริงต้องใช้อิมเมจ Icecast ของ Docker สถานีจะใช้ไฟล์สำรองของตัวเองแทน
  • ค่าที่มีช่องว่าง < > หรือ JSON ต้องใส่เครื่องหมายคำพูดไว้ เหมือนใน .env.example

6. อัปเดตฐานข้อมูล (migration)

sudo -u radio node --env-file=.env dist/src/cli.js migrate

คำสั่งนี้สร้างตาราง สร้าง role ของ app ตาม DATABASE_URL (หรือตั้งรหัสผ่านให้ใหม่ถ้าไม่ตรงแล้ว) และเพิ่มแพ็กเกจเริ่มต้น ถ้ารัน migrate พร้อมกันหลายตัว ตัวที่ตามมาจะรอตัวแรกเสร็จก่อน (npm run migrate เป็นทางลัดตอนพัฒนา ทำเฉพาะ migration ไม่เพิ่มแพ็กเกจเริ่มต้น แต่หน้าติดตั้งจะเพิ่มให้ถ้ายังไม่มี)

7. ตั้งค่า Icecast

worker (shard 0) เขียนค่าตั้งของ Icecast ลง ICECAST_CONFIG_FILE และเขียนใหม่ทุกครั้งที่สถานีหรือค่าตั้งเปลี่ยน (สถานีใหม่ เปลี่ยนรหัสผ่าน SESSION_SECRET หรือ ICECAST_TRUSTED_PROXIES) Icecast รันเป็นผู้ใช้ radio จึงอ่านไฟล์นั้นได้ (ไฟล์มีรหัสผ่าน อ่านได้เฉพาะเจ้าของ) และ systemd path unit สั่งโหลด Icecast ใหม่ทุกครั้งที่ไฟล์เปลี่ยน ผู้ฟังไม่หลุด

sudo install -d -o radio -g radio -m 750 /var/lib/radio/icecast
sudo chown -R radio:radio /var/log/icecast2
# เสียงสำรองของสถานี (worker เป็นผู้เขียน) คือโฟลเดอร์ /fallback ของ Icecast
sudo install -d -o radio -g radio /var/lib/radio/ops/icecast-fallback
sudo ln -sfn /var/lib/radio/ops/icecast-fallback /usr/share/icecast2/web/fallback
# ไฟล์ค่าตั้งแรก ก่อน worker เริ่มทำงาน
sudo -u radio node --env-file=.env dist/src/cli.js icecast-config --out /var/lib/radio/icecast/icecast.xml

แก้ /etc/default/icecast2:

CONFIGFILE="/var/lib/radio/icecast/icecast.xml"
USERID=radio
GROUPID=radio

ให้โหลด Icecast ใหม่เมื่อ worker เขียนไฟล์ใหม่ /etc/systemd/system/radio-icecast-reload.path:

[Path]
PathChanged=/var/lib/radio/icecast/icecast.xml

[Install]
WantedBy=multi-user.target

และ /etc/systemd/system/radio-icecast-reload.service:

[Service]
Type=oneshot
ExecStart=/bin/systemctl reload icecast2
sudo systemctl daemon-reload
sudo systemctl enable --now radio-icecast-reload.path
sudo systemctl restart icecast2
sudo systemctl status icecast2

8. สร้าง systemd service

/etc/systemd/system/radio-app.service:

[Unit]
Description=Online Radio web app
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=radio
Group=radio
WorkingDirectory=/opt/radio
EnvironmentFile=/opt/radio/.env
ExecStart=/usr/bin/node dist/src/index.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

/etc/systemd/system/radio-worker.service:

[Unit]
Description=Online Radio streaming worker
After=network-online.target postgresql.service icecast2.service radio-app.service
Wants=network-online.target

[Service]
Type=simple
User=radio
Group=radio
WorkingDirectory=/opt/radio
EnvironmentFile=/opt/radio/.env
ExecStart=/usr/bin/node dist/src/worker.js
Restart=always
RestartSec=5
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target

ใช้ ExecStart=/usr/bin/node --env-file=/opt/radio/.env dist/src/index.js แทน EnvironmentFile= ก็ได้ เลือกอย่างใดอย่างหนึ่ง

WorkingDirectory ต้องเป็นโฟลเดอร์โค้ด เพราะ app อ่าน public/, docs/ และ src/db/migrations/ จากที่นั่น

เปิด service

sudo systemctl daemon-reload
sudo systemctl enable --now radio-app radio-worker
curl http://127.0.0.1:3000/health

/health ตอบ {"ok":true} เมื่อ app ต่อฐานข้อมูลได้

ระบบออกอากาศไม่ทำงานด้วยสิทธิ์ root

worker เปิดโปรแกรม Liquidsoap หนึ่งโปรแกรมต่อหนึ่งสถานี และ Liquidsoap ทำงานด้วยผู้ใช้ธรรมดาเสมอ

  • ถ้า worker ทำงานด้วยผู้ใช้ธรรมดา (ผู้ใช้ radio ข้างบน หรือผู้ใช้ 10001 ใน Docker และ Helm) Liquidsoap ใช้ผู้ใช้เดียวกัน ไม่ต้องตั้งค่าอะไร
  • ถ้า worker ทำงานด้วย root (คอนเทนเนอร์สำหรับพัฒนาบางแบบ) worker จะเปิด Liquidsoap ด้วย LIQUIDSOAP_UID / LIQUIDSOAP_GID ถ้าไม่ได้ตั้ง จะใช้ผู้ใช้ระบบ liquidsoap ที่แพ็กเกจ Debian/Ubuntu สร้างไว้ หรือ nobody ถ้าไม่มี worker มอบไฟล์ตั้งค่าของสถานี โฟลเดอร์บันทึกรายการ และโฟลเดอร์ HLS ให้ผู้ใช้นั้น ส่วนโฟลเดอร์ไฟล์เสียงขอเพียงอ่านได้ (สิทธิ์ตั้งต้นใช้ได้แล้ว)

โฟลเดอร์ของแต่ละสถานี (DATA_DIR/stations/<สถานี>/) มี station.liq ซึ่งเป็นสคริปต์ระบบออกอากาศชุดเดียวกันทุกสถานี และ station.json ซึ่งเป็นค่าตั้งของสถานีนั้น (ชื่อ ที่อยู่ พอร์ต รหัสผ่าน) สคริปต์อ่านค่าตั้งเป็นข้อมูล ข้อความที่สถานีพิมพ์จึงไม่มีทางกลายเป็นคำสั่งของระบบออกอากาศ station.json มีรหัสผ่านสตรีมอยู่ จึงอ่านได้เฉพาะผู้ใช้ของระบบออกอากาศ คำสั่ง node --env-file=.env dist/src/cli.js station-script <สถานี> แสดงไฟล์นี้

9. ตั้ง reverse proxy (Caddy)

ติดตั้ง Caddy 2.10 ขึ้นไป (จาก repository ของ Caddy แพ็กเกจของ Ubuntu เก่ากว่านั้น) แล้วเขียน /etc/caddy/Caddyfile โดยอิงจาก deploy/caddy/domain.Caddyfile และ deploy/caddy/routes.caddy แต่เปลี่ยนเป็นที่อยู่ในเครื่อง

{
	email admin@radio.example.th
	on_demand_tls {
		ask http://127.0.0.1:3000/internal/tls-check
	}
}

https:// {
	tls {
		on_demand
	}
	encode zstd gzip

	handle_path /stream/* {
		reverse_proxy 127.0.0.1:8000 {
			flush_interval -1
		}
	}

	@internal path /internal/*
	handle @internal {
		respond 404
	}

	handle {
		reverse_proxy 127.0.0.1:3000 {
			flush_interval -1
		}
	}
}

http:// {
	redir https://{host}{uri} permanent
}
sudo systemctl reload caddy

ไฟล์นี้ทำอะไรบ้าง

  • ออกใบรับรองเมื่อมีคนเรียกใช้ และออกเฉพาะโดเมนที่แพลตฟอร์มรู้จัก (ที่อยู่หลัก โดเมนย่อยของสถานี โดเมนของสถานีเอง ชื่อในหน้าโดเมนและที่อยู่)
  • /stream/<สถานี> ส่งไป Icecast โดยตัด /stream ออก (ICECAST_PUBLIC_URL=https://radio.example.th/stream)
  • กัน /internal/* จากภายนอก Icecast และ Liquidsoap เรียกเส้นทางนี้ตรงที่ 127.0.0.1
  • flush_interval -1 ส่งเสียงและข้อมูลสดทันทีโดยไม่พักไว้
  • proxy ต้องให้ WebSocket ผ่านได้ห้องจัดรายการบนเว็บส่งเสียงสดผ่าน WebSocket และแขกรับเชิญระยะไกลก็เชื่อมต่อผ่าน WebSocket Caddy รองรับให้อัตโนมัติ

ถ้าใช้ proxy อื่น (nginx, HAProxy) ต้อง: ส่ง /stream/ ไป Icecast โดยตัดคำนำหน้าออก, ส่งต่อ header Upgrade และ Connection, ปิดการพักข้อมูล (buffering), ตั้ง read timeout ให้ยาว (1 ชั่วโมงขึ้นไป), ยอมให้อัปโหลดไฟล์ใหญ่, ส่ง X-Forwarded-For และกัน /internal/

10. เปิดไฟร์วอลล์

sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 8100:8199/tcp

เปิดตั้งแต่ 8100 ขึ้นไปให้พอกับจำนวนสถานี ปิด 3000, 5432, 8000 และ 9100 ขึ้นไปไว้

ถ้าสถานีใช้ ถ่ายทอดไปช่องทางอื่น เซิร์ฟเวอร์ต้องเชื่อมต่อ ออก ไปยังปลายทางได้ด้วย ปกติคือ TCP 80, 443, 8000–8999 (SHOUTcast ใช้พอร์ต + 1 ด้วย), 1935 (RTMP) และ 443 (RTMPS: YouTube, Facebook) ufw อนุญาตการเชื่อมต่อขาออกอยู่แล้ว ให้ตรวจกฎขาออกของผู้ให้บริการหรือไฟร์วอลล์ขององค์กร

11. เปิดหน้าติดตั้ง

เปิด https://radio.example.th/install แล้วทำตามหัวข้อ "เริ่มใช้งานครั้งแรก"

การอัปเดต การสำรองข้อมูล และ log ของการติดตั้งโดยตรงอยู่ในส่วนที่มีคำว่า "(ไม่รองรับ)" ของหัวข้อ "สำรองและกู้คืนข้อมูล" "อัปเกรด" และ "ติดตามสถานะระบบ" ด้านบน

นำเข้าจากคลาวด์

สถานีนำเข้าไฟล์เสียงจากลิงก์แชร์ ตัวเลือกไฟล์ Dropbox / OneDrive / Google Drive และโฟลเดอร์ที่ซิงก์ได้ (คู่มือสถานี: "นำเข้าจากคลาวด์") คีย์ของแอปสำหรับตัวเลือกไฟล์และการซิงก์โฟลเดอร์ Dropbox / OneDrive ใส่หลังติดตั้งที่ ผู้ดูแลระบบ → บริการคลาวด์ (คู่มือผู้ดูแลระบบ: "บริการคลาวด์") Redirect URI ที่ต้องลงทะเบียนคือที่อยู่แพลตฟอร์มต่อด้วย /cloud/oauth/callback ไม่ต้องตั้งค่าอะไรก่อนเริ่มใช้ ค่าที่ตั้งเพิ่มได้ใน .env (app และ worker ต้องเห็นค่าเดียวกัน)

ค่า ค่าเริ่มต้น ความหมาย
CLOUD_SECRET_KEY (สร้างจาก SESSION_SECRET) คีย์เข้ารหัสข้อมูลลับของการนำเข้าจากคลาวด์ (refresh โทเคนรหัสผ่านแอป WebDAV คีย์ S3 และ secret ของแอป) ควรตั้งเป็นข้อความสุ่มยาว ๆ เพื่อให้การเปลี่ยน SESSION_SECRET ภายหลังไม่ทำให้การเชื่อมต่อที่เก็บไว้อ่านไม่ได้ ถ้าเปลี่ยนค่านี้ สถานีต้องเชื่อมต่อใหม่และต้องใส่คีย์ของแอปอีกครั้ง
CLOUD_IMPORT_ALLOW_PRIVATE false true ให้การนำเข้าและการซิงก์เข้าถึงที่อยู่ในเครือข่ายภายในได้ (เช่น Nextcloud หรือ MinIO ในเครือข่ายของสถานี) แพลตฟอร์มที่ใช้ร่วมกันหลายองค์กรควรปิดไว้
CLOUD_IMPORT_MAX_PENDING 200 จำนวนไฟล์ที่สถานีหนึ่งรอนำเข้าหรือกำลังดาวน์โหลดพร้อมกันได้
ALERT_CLOUD_SYNC_FAILURES 3 จำนวนครั้งที่ซิงก์โฟลเดอร์ล้มเหลวติดกันก่อนแจ้งเตือนสถานี

worker เป็นผู้ดาวน์โหลดไฟล์ จึงต้องออกอินเทอร์เน็ตทาง HTTPS ไปยังผู้ให้บริการ (Google, Dropbox, Microsoft, Box และลิงก์ของสถานี) ได้ และต้องมีพื้นที่ว่างใน DATA_DIR/tmp พอสำหรับไฟล์ใหญ่ที่สุดที่นำเข้า (สูงสุด 2 GB)