Platforma hostingu radia
bez przerw w sygnale
Agent Node.js prowadzi wiele stacji naraz: miksuje dźwięk, obsługuje DJ-ów na żywo, emituje reklamy i raportuje odtworzenia. Jedno połączenie ze słuchaczem trwa cały czas — utwory, zapowiedzi, wejścia DJ-a i bloki reklamowe wchodzą wewnątrz tego strumienia.
linii kodu
28 modułów, zero zależności natywnych
endpointy REST
całość sterowalna z zewnątrz
zerwań przy przejściach
zmierzone w testach E2E
Jak dźwięk płynie przez system
To jest rdzeń całej platformy. Każde źródło dekodowane jest do surowego PCM 16-bit, trafia do miksera, a stamtąd do enkoderów. Enkoder i połączenie ze słuchaczami żyją bez przerwy — dlatego zmiana utworu, wejście DJ-a czy dżingiel nie zrywają odtwarzania.
Dwa tryby wejścia na antenę
| Tryb | Co robi | Kiedy używać |
|---|---|---|
duckdomyślny |
Muzyka gra dalej, ściszona pod głosem. Po zejściu wraca do pełnej głośności — ten sam utwór, bez przeskoku. Przejścia głośności płynne. | Prowadzenie programu, wejścia między utworami, komentarz na podkładzie |
replace |
Głos całkowicie zastępuje muzykę (crossfade), po zejściu startuje kolejny utwór. | Długie audycje, transmisje, gdy podkład przeszkadza |
Ustawiane w autopilotConfig.djMode, głośność podkładu w autopilotConfig.djDuck (domyślnie 0.15).
Co za co odpowiada
Każdy moduł ma jedną odpowiedzialność. Strzałka = kto kogo woła.
autopilotEngine.js 1273
Serce systemu. Decyduje co gra teraz: czyta ramówkę, generuje rotacje z regułami no-repeat, wstrzykuje bloki reklamowe, obsługuje wejścia DJ-a, prowadzi kolejkę.
audioPipeline.js 701
Miksuje PCM i utrzymuje enkodery. Tu żyje crossfade, ducking, archiwum i wszystkie wyjścia (SHOUTcast, Icecast, RTMP).
processManager.js 377
Cykl życia sc_serv: start, stop, wykrywanie osieroconych procesów, checklista diagnostyczna dla panelu.
musicLibrary.js 366
Baza SQLite (node:sqlite, bez kompilacji): metadane, punkty cięcia, wyrównanie głośności, log odtworzeń dla ZAiKS i reklamodawców.
Gdzie co leży
Gdzie system trzyma stan
Wszystko per stacja, w jednym katalogu. Brak centralnej bazy — stację można przenieść na inny serwer kopiując folder.
Tabele w library.db
| Tabela | Zawiera | Kto pisze |
|---|---|---|
tracks |
ścieżka, artysta, tytuł, album, rok, gatunek, ocena, tagi, długość, cue_start / cue_end / mix_point / gain_db, dayparting, daty grywalności, licznik odtworzeń | skan biblioteki, edycja w panelu |
play_log |
plik, artysta, tytuł, czas emisji, długość, źródło + campaign_id, campaign_name, client, block_time dla reklam | silnik przy każdym odtworzeniu |
better-sqlite3 wymagał Visual Studio na Windows i blokował instalację.Co dzieje się od kliknięcia „Start"
| # | Krok | Szczegóły |
|---|---|---|
| 1 | Walidacja | Sprawdzenie configu i binarki. Hasła admin ≠ source (inaczej DNAS odmawia startu). |
| 2 | Sprzątanie | Zabicie osieroconych sc_serv z poprzedniej sesji, które trzymają porty. |
| 3 | Start sc_serv | Spawn procesu, zapis PID, czekanie do 6 s aż realnie wstanie. |
| 4 | Pipeline | Uruchomienie enkoderów i połączenie źródeł z serwerem nadawania. |
| 5 | Port DJ | Nasłuch na porcie stacji + 2 dla zewnętrznych prowadzących. |
| 6 | Ramówka | Timer sprawdzający eventy co 20 s (z losowym przesunięciem 0–15 s, żeby 100 stacji nie przełączało się w tej samej sekundzie). |
| 7 | Pierwszy utwór | Wypełnienie kolejki wg ramówki i start odtwarzania. |
Samonaprawa
Watchdog
Co 10 s sprawdza czy sc_serv żyje. Padnięty — restartuje i notuje w logu.
Sieroty
Na Windows sc_serv.exe przeżywa zamknięcie agenta i blokuje port. Przy starcie agent je znajduje po PID-ach i ubija.
Binarki
Brak sc_serv w katalogu roboczym — agent kopiuje właściwą wersję (system + architektura) i dokłada cacert.pem.
Reconnect
Zerwane połączenie z serwerem nadawania odtwarzane z narastającym opóźnieniem (2 s → 30 s), bez restartu enkodera.
Wszystkie endpointy
Kompletna specyfikacja: 102 endpointy z ciałami żądań i odpowiedzi. Rozwiń pozycję, żeby zobaczyć szczegóły. Filtr niżej szuka po ścieżce i opisie.
Autoryzacja
Authorization: Bearer <token>
| Rola | Skąd | Zakres |
|---|---|---|
master | AGENT_MASTER_TOKEN w .env | wszystkie stacje na agencie |
owner | zwracany przy POST /stations | jedna stacja, pełne prawa |
editor | GET /stations/:id/tokens | obsługa codzienna — bez konfiguracji, kasowania i tokenów |
viewer | GET /stations/:id/tokens | tylko GET |
Tokeny są bezstanowe (HMAC z AGENT_SECRET). Unieważnienie wszystkich naraz = zmiana sekretu i restart agenta.
Odpowiedzi i błędy
{ "success": true, "...": "dane" }
{ "success": false, "error": "opis co poszło nie tak" }
| Kod | Znaczenie |
|---|---|
400 | złe dane wejściowe — error mówi które |
401 | brak lub zły token |
403 | token poprawny, rola bez uprawnień |
404 | stacja lub zasób nie istnieje |
409 | konflikt stanu (np. już działa) |
500 | błąd serwera — szczegóły w logu agenta |
GET/stationslista stacji na agencie
{ "success": true, "stations": [
{ "stationId": "radio1", "enabled": true, "running": true, "port": 8000 }
]}POST/stationsutworzenie stacji
Żądanie
{
"stationId": "radio1",
"scServConfig": {
"port": 8000,
"adminPassword": "haslo-admina",
"sourcePassword": "haslo-source",
"streamTitle": "Radio Przykład",
"maxListeners": 100,
"publicServer": "never",
"authhash": ""
},
"autopilotConfig": {
"bitrate": 128, "crossfade": 2, "xfadeThreshold": 10,
"shuffle": true, "djMode": "duck", "djDuck": 0.15, "djFade": 1.5,
"serverType": "shoutcast", "diskQuotaMB": 5000,
"archive": { "enabled": false, "keepDays": 14 },
"outputs": []
}
}
Odpowiedź
{ "success": true, "stationId": "radio1", "token": "…" }
adminPassword i sourcePassword — endpoint zwraca wtedy 400.
Zapisz zwrócony token: to dostęp owner do tej stacji.Pola scServConfig
| Pole | Typ | Domyślnie | Opis |
|---|---|---|---|
port | int | 8000 | port dla słuchaczy; port DJ = port + 2 |
adminPassword | string | — | panel admina DNAS |
sourcePassword | string | — | logowanie źródeł |
streamTitle | string | '' | nazwa stacji (nagłówek ICY) |
maxListeners | int | 100 | sloty — ilu słuchaczy naraz |
publicServer | enum | never | never / always — katalog SHOUTcast |
authhash | string | — | klucz z shoutcast.com dla katalogu |
autodumpSourceTime | int | 30 | s bez danych → rozłączenie źródła |
Pola autopilotConfig
| Pole | Typ | Domyślnie | Opis |
|---|---|---|---|
bitrate | int | 128 | kbps wyjścia |
crossfade | float | 2 | sekundy nakładania utworów |
xfadeThreshold | int | 10 | pliki krótsze niż X s bez crossfade (dżingle) |
shuffle | bool | true | losowa kolejność |
djMode | enum | duck | duck = muzyka gra pod głosem; replace = zastępuje |
djDuck | float | 0.15 | głośność muzyki pod głosem DJ-a |
djFade | float | 1.5 | s przejścia przy wejściu i zejściu |
djPort | int | port+2 | port dla zewnętrznych DJ-ów |
normalize | bool | false | loudnorm w locie (gdy brak skanu biblioteki) |
recordDj | bool | false | nagrywanie audycji DJ-ów |
archive | obiekt | null | { enabled, bitrate, keepDays } |
outputs | tablica | — | wyjścia — patrz grupa 10 |
GET/stations/:id/statuskonfiguracja i stan procesów
{ "success": true, "stationId": "radio1",
"processes": { "scServ": { "running": true, "pid": 16212 },
"autopilot": { "running": true } },
"meta": { "scServConfig": { … }, "autopilotConfig": { … } },
"publicStreamBase": "https://domena/s" }
publicStreamBase ustawia setup-ssl.sh — panel buduje z tego link po HTTPS.
PUT/stations/:id/configzmiana konfiguracji
{ "scServConfig": { … }, "autopilotConfig": { … } }
Oba pola opcjonalne. Zmiany w scServConfig wymagają restartu stacji.
Walidacja haseł jak przy tworzeniu.
tylko owner / master
DELETE/stations/:idusunięcie stacji
Usuwa konfigurację. Pliki mediów zostają na dysku.
tylko owner / master
POST/stations/:id/starturuchomienie: sc_serv + autopilot
{ "success": true, "scServ": { "pid": 16212 }, "autopilot": { "running": true } }
Sekwencja: sprzątnięcie osieroconych procesów → spawn sc_serv → czekanie do 6 s na gotowość → start pipeline'u i autopilota.
POST/stations/:id/stopzatrzymanie stacji
Słuchacze tracą połączenie.
POST/stations/:id/restartrestart — wdraża zmiany konfiguracji
POST/stations/:id/sc_serv/startstart samego serwera nadawania
POST/stations/:id/sc_serv/stopstop samego serwera nadawania
POST/stations/:id/sc_serv/restartrestart samego serwera nadawania
GET/stations/:id/diagdiagnostyka: dlaczego stacja nie startuje
{ "success": true, "diag": {
"ok": false, "platform": "win32", "arch": "x64", "port": 8000,
"checks": [
{ "name": "Binarka sc_serv", "ok": true, "detail": "C:\\…\\bin\\sc_serv.exe" },
{ "name": "Plik konfiguracji", "ok": true, "detail": "…/sc_serv.conf" },
{ "name": "Osierocony sc_serv", "ok": false,
"detail": "pid 16212 z poprzedniej sesji trzyma port",
"hint": "Restart agenta sprzątnie go automatycznie" }
]
}}
Każdy nieudany punkt ma hint z konkretną instrukcją naprawy.
POST/stations/:id/clear-lockodblokowanie zaciętego startu
GET/stations/:id/logs/:typelogi
:type = sc_serv | sc_w3c | sc_serv_process
GET/stations/:id/tokenstokeny ról
{ "success": true, "tokens": {
"owner": "…", "editor": "…", "viewer": "…"
}}
tylko owner / master
GET/stations/:id/exporteksport konfiguracji stacji do JSON
GET/stations/:id/autopilot/statuspełny stan anteny
{ "success": true, "autopilot": {
"running": true,
"currentTrack": "/…/media/utwor.mp3",
"trackElapsedSec": 47,
"trackDurationSec": 213,
"queue": ["/…/next1.mp3", "/…/next2.mp3"],
"queueLength": 24,
"activePlaylist": "main",
"currentEvent": { "name": "Poranek", "type": "block" },
"liveDj": { "name": "maciek", "priority": 5, "since": "2026-07-23T08:12:00Z" },
"overlayActive": false,
"manualRelay": null,
"pipeline": { "outputs": [
{ "name": "out1-mp3-128k", "type": "shoutcast",
"connected": true, "bytesOut": 8412300 }
]}
}}
queue zawiera 10 najbliższych pozycji — używane przez voice tracking do wyboru miejsca wstawki.
POST/stations/:id/autopilot/starturuchomienie samego autopilota
POST/stations/:id/autopilot/stopzatrzymanie autopilota (serwer nadal działa)
POST/stations/:id/autopilot/nextprzeskoczenie utworu z crossfade
POST/stations/:id/autopilot/reloadprzeładowanie playlist z dysku
GET/stations/:id/autopilot/queuepodgląd kolejki
PUT/stations/:id/autopilot/configzmiana ustawień autopilota bez restartu sc_serv
POST/stations/:id/autopilot/queue/pushdodanie do kolejki
{ "tracks": ["/pełna/ścieżka.mp3"], "next": true }
next: true — zaraz po bieżącym utworze. false — na koniec kolejki.
POST/stations/:id/autopilot/queue/insertwstawienie na pozycji
{ "tracks": ["/…/voicetrack.webm"], "index": 2 }
{ "success": true, "index": 2, "queueLength": 25 }
index: 0 = zaraz po bieżącym utworze. Podstawa voice trackingu.
POST/stations/:id/autopilot/relaynadawanie z sieci lub YouTube
{ "url": "https://www.youtube.com/watch?v=…" }
{ "url": "http://inne-radio:8000/stream" }
Linki YouTube / SoundCloud / Twitch agent rozwiązuje przez yt-dlp
do bezpośredniego strumienia audio. Działa też z transmisjami na żywo.
Zwykły film po zakończeniu oddaje antenę playliście.
DELETE/stations/:id/autopilot/relaypowrót do playlisty
GET/stations/:id/autopilot/youtube/checkczy yt-dlp jest zainstalowany
{ "success": true, "ok": true, "version": "2026.07.01" }
{ "success": true, "ok": false }GET/stations/:id/autopilot/scheduleodczyt ramówki
PUT/stations/:id/autopilot/schedulezapis ramówki
{
"defaultPlaylist": "main",
"events": [ … ],
"rotations": { … }
}
defaultPlaylist przyjmuje nazwę playlisty albo rotation:nazwa.
Pasmo czasowe
{ "name": "Poranek", "playlist": "poranna",
"startTime": "06:00", "endTime": "10:00",
"days": [1,2,3,4,5], "priority": 1 }
days: 0 = niedziela … 6 = sobota; brak pola = codziennie.
Okna przez północ (22:00–02:00) działają.
Przy nakładaniu wygrywa wyższy priority.
Relay o czasie
{ "type": "relay", "name": "Wiadomości", "url": "http://…:8000/;",
"startTime": "18:00", "endTime": "18:30", "days": [1,2,3,4,5] }
Spot punktowy
{ "type": "spot", "name": "Hejnał", "file": "/…/hejnal.mp3",
"startTime": "12:00", "mode": "queue" }
mode: queue = po bieżącym utworze, now = natychmiast z fade.
Co godzinę
{ "type": "spot", "name": "Dżingiel", "file": "/…/jingiel.mp3",
"hourly": true, "minute": 0, "mode": "queue" }
Rotacje
"rotations": {
"standard": {
"template": [
{ "category": "muzyka", "count": 3,
"filters": { "genre": "rock", "yearFrom": 2000, "yearTo": 2020,
"ratingMin": 3, "durMin": 120, "durMax": 360,
"tagsAny": "hit,lato" } },
{ "category": "jingle", "count": 1, "ignoreRules": true }
],
"rules": {
"trackNoRepeatMin": 240,
"artistSepMin": 60,
"titleSepMin": 120,
"albumSepMin": 45,
"sameTimeWindowMin": 60
},
"relatedArtists": { "Anna Nowak": ["Ania N.", "A. Nowak"] },
"cycles": 12
}
}
| Reguła | Działanie |
|---|---|
trackNoRepeatMin | ten sam plik nie wraca przez X minut |
artistSepMin | separacja artysty — rozpoznaje feat. i aliasy z relatedArtists |
titleSepMin | separacja tytułu — ignoruje dopiski typu (Remix) |
albumSepMin | separacja albumu (0 = wyłączona) |
sameTimeWindowMin | nie graj tego, co wczoraj o tej porze |
Przy braku materiału spełniającego reguły generator schodzi łagodnie — wybiera utwór najdawniej grany zamiast przerywać.
POST/stations/:id/autopilot/schedule/eventdodanie pojedynczego eventu
DELETE/stations/:id/autopilot/schedulewyczyszczenie ramówki
GET/stations/:id/playlistslista playlist
GET/stations/:id/playlists/:namezawartość playlisty
{ "success": true, "tracks": ["/…/a.mp3", "/…/b.mp3"] }PUT/stations/:id/playlists/:namezapis całej playlisty
{ "tracks": ["/…/a.mp3", "/…/b.mp3"] }
Zapisuje całą listę — to również sposób na zmianę kolejności.
Format na dysku: playlists/<name>.lst, jedna ścieżka na linię.
DELETE/stations/:id/playlists/:nameusunięcie playlisty
POST/stations/:id/playlists/:name/tracksdopisanie jednego utworu
{ "path": "/…/c.mp3" }GET/stations/:id/playlists/calendar/xmleksport ramówki do XML (zgodność z narzędziami zewnętrznymi)
PUT/stations/:id/playlists/calendar/xmlimport ramówki z XML
GET/stations/:id/fileslista plików
Parametr ?type=media
POST/stations/:id/filesupload plików
multipart/form-data, pole files — wiele plików naraz.
Parametr ?type=media.
Dozwolone: .mp3 .aac .ogg .flac .wav .m4a .opus .webm
{ "success": true, "files": [
{ "name": "utwor.mp3", "size": 5242880,
"relativePath": "media/utwor.mp3",
"fullPath": "/opt/scscript/stations/station_radio1/media/utwor.mp3" }
]}
fullPath wstawiasz wprost do playlist, eventów,
kampanii reklamowych i padów cart wall.Limit wielkości: MAX_FILE_SIZE_MB (domyślnie 300).
DELETE/stations/:id/filesusunięcie pliku
?type=media&name=x.mp3
GET/stations/:id/files/downloadpobranie pliku
?name=x.mp3
PUT/stations/:id/files/playlist/:namezapis playlisty z poziomu menedżera plików
POST/stations/:id/library/scanuruchomienie skanu
{ "deep": true, "force": false }
Skan deep analizuje dźwięk jednym przebiegiem ffmpeg i zapisuje:
| Pole | Co to |
|---|---|
cue_start / cue_end | punkty przycięcia ciszy na końcach utworu |
mix_point | moment rozpoczęcia crossfade |
gain_db | korekta do −16 LUFS — koniec skakania głośności |
Silnik używa tych danych automatycznie przy odtwarzaniu.
GET/stations/:id/library/scanpostęp skanu
{ "success": true, "scan": { "running": true, "done": 142, "total": 380, "errors": 0 } }GET/stations/:id/libraryprzeglądanie biblioteki
Parametry: ?q= (szukajka) &limit=&offset=
{ "success": true, "total": 380, "rows": [ { "file": "…", "artist": "…", "title": "…", "gain_db": -2.3, … } ] }GET/stations/:id/library/trackdane jednego utworu
?file=/pełna/ścieżka.mp3
PUT/stations/:id/library/trackedycja utworu
{ "file": "/…/utwor.mp3",
"artist": "…", "title": "…", "album": "…", "year": 2019,
"genre": "pop", "rating": 4, "tags": "hit,lato",
"cue_start": 1.9, "cue_end": 210.4, "mix_point": 205.0, "gain_db": -2.3,
"enabled": true,
"daypart_from": 6, "daypart_to": 22,
"date_from": "2026-12-01", "date_to": "2026-12-31" }
| Pole | Działanie |
|---|---|
rating | 0–5; rotacje mogą filtrować ratingMin |
daypart_from/to | utwór grany tylko w tych godzinach |
date_from/to | np. utwory świąteczne tylko w grudniu |
enabled | false = wyłączony z rotacji bez kasowania pliku |
GET/stations/:id/reportsraport odtworzeń (ZAiKS / OZZ)
Parametry: ?from=2026-07-01&to=2026-07-31&type=list|playcount&format=json|csv
type=list — każde odtworzenie z godziną. playcount — zliczenia per utwór.
CSV otwiera się w Excelu z polskimi znakami.
GET/stations/:id/djslista kont DJ
{ "success": true, "djs": [ { "name": "maciek", "priority": 5, "enabled": true } ] }POST/stations/:id/djsnowe konto DJ
{ "name": "maciek", "password": "tajne123", "priority": 5, "enabled": true }
Jak łączy się DJ (BUTT / Mixxx)
| Ustawienie | Wartość |
|---|---|
| Typ serwera | SHOUTcast |
| Adres | host agenta |
| Port | port stacji + 2 (stacja 8000 → 8002) |
| Hasło | nazwa:hasło — z dwukropkiem |
Wyższy priority wypycha niższego z anteny.
PUT/stations/:id/djs/:namezmiana hasła, priorytetu lub aktywności
{ "password": "…", "priority": 9, "enabled": false }DELETE/stations/:id/djs/:nameusunięcie konta
POST/stations/:id/djs/kickzrzucenie DJ-a z anteny
Autopilot wraca natychmiast.
Wszystko poniżej gra na muzyce (overlay z duckingiem), nie zamiast niej.
POST/stations/:id/autopilot/announcezapowiedź TTS
{ "text": "Słuchasz Radia Przykład", "duck": 0.25, "gain": 1.0 }
duck — głośność podkładu pod głosem (0–1). Silnik: piper
(lepszy głos, wymaga PIPER_MODEL) z fallbackiem na espeak-ng.
Jednocześnie może grać tylko jedna zapowiedź — kolejna dostaje 400.
POST/stations/:id/autopilot/saytimepodanie godziny
{ "duck": 0.25 }
{ "success": true, "text": "Jest godzina dwudziesta pierwsza trzydzieści" }
Poprawna polska odmiana godzin.
POST/stations/:id/autopilot/weatherzapowiedź pogody
{ "temp": 21, "desc": "słonecznie", "duck": 0.3 }
Odmienia stopnie: stopień / stopnie / stopni.
GET/stations/:id/autopilot/cartskonfiguracja cart wall
PUT/stations/:id/autopilot/cartszapis padów
{ "carts": [
{ "label": "Dżingiel", "file": "/…/jingiel.mp3", "duck": 0.3, "gain": 1.0 }
]}
Maksymalnie 12 padów. W panelu pod klawiszami 1–9.
POST/stations/:id/autopilot/cartzagranie pada natychmiast
{ "file": "/…/jingiel.mp3", "duck": 0.3 }GET/stations/:id/adskonfiguracja reklam i lista kampanii
PUT/stations/:id/adsbloki i elementy stałe
{ "blocks": ["08:00", "12:00", "16:30"],
"intro": "/…/ads/intro.mp3",
"outro": "/…/ads/outro.mp3",
"separator": "/…/ads/sep.mp3",
"maxBlockSec": 180 }POST/stations/:id/ads/campaignsnowa kampania
{ "name": "Promocja lato", "client": "Pizzeria Roma",
"file": "/…/ads/spot.mp3",
"type": "gastronomia",
"priority": 3,
"dateFrom": "2026-07-01", "dateTo": "2026-07-31",
"enabled": true,
"grid": { "08:00": 1, "12:00": 2 } }
| Pole | Działanie |
|---|---|
priority | 1 = początek bloku … 9 = koniec |
type | spoty tego samego typu nie lecą pod rząd |
dateFrom/To | okres emisji — poza nim kampania milczy |
grid | ile emisji w którym bloku |
PUT/stations/:id/ads/campaigns/:cidedycja kampanii
DELETE/stations/:id/ads/campaigns/:cidusunięcie kampanii
PUT/stations/:id/ads/gridprzypisanie kampanii do bloku
{ "id": "a3f9…", "blockTime": "12:00", "count": 2 }count: 0 usuwa z bloku.
GET/stations/:id/ads/previewmedia plan na dziś
{ "success": true, "plan": [ { "blockTime": "12:00", "spots": 3, "items": 5, "warnings": [] } ] }GET/stations/:id/ads/validatekontrola konfiguracji
Wykrywa: brakujące pliki, wygasłe kampanie, kampanie nieprzypisane do żadnego bloku, dwa spoty tego samego typu pod rząd.
POST/stations/:id/ads/testwypuszczenie bloku natychmiast
{ "blockTime": "12:00" }GET/stations/:id/ads/reportraport emisji — dowód dla reklamodawcy
Parametry: ?from=&to=&groupBy=campaign|list&campaignId=&format=json|csv
groupBy=campaign — podsumowanie: ile emisji, łączny czas, pierwsza i ostatnia.
groupBy=list&format=csv — dowód emisji: data, godzina, klient, kampania, blok, długość każdej emisji.
GET/stations/:id/requests/config-publicczy życzenia są włączone bez tokenu
{ "success": true, "enabled": true }GET/stations/:id/requests/searchwyszukiwanie utworu bez tokenu
?q=zima — min. 2 znaki
{ "success": true, "results": [
{ "file": "/…/zima.mp3", "artist": "Tomek", "title": "Zima", "duration": 214 }
]}
Zwraca tylko utwory enabled, maksymalnie 15.
POST/stations/:id/requestszgłoszenie życzenia bez tokenu
{ "file": "/…/zima.mp3", "name": "Ania", "message": "Pozdro dla ekipy" }
{ "success": true, "status": "pending" }
{ "success": true, "status": "queued" } ← przy autoApprove
Anty-spam: limit zgłoszeń na IP na godzinę + blokada duplikatów w kolejce.
GET/stations/:id/requestslista oczekujących (moderacja)
{ "success": true, "pending": [ { "id": "…", "name": "Ania", "file": "…", "message": "…" } ], "stats": { … } }PUT/stations/:id/requests/configustawienia życzeń
{ "enabled": true, "autoApprove": false, "maxPerIpPerHour": 3, "playNext": false }POST/stations/:id/requests/:rid/approveakceptacja → do kolejki anteny
POST/stations/:id/requests/:rid/rejectodrzucenie życzenia
Jedna stacja może nadawać w kilku formatach naraz — pole autopilotConfig.outputs:
"outputs": [
{ "format": "mp3", "bitrate": 128, "streamid": 1 },
{ "type": "icecast", "format": "opus", "bitrate": 96,
"mount": "/radio.opus", "host": "127.0.0.1", "port": 8000,
"username": "source", "password": "hasło" },
{ "type": "rtmp", "format": "aac", "bitrate": 128,
"url": "rtmp://a.rtmp.youtube.com/live2/KLUCZ",
"image": "/…/logo.png" }
]
| Typ | Kodeki | Uwagi |
|---|---|---|
shoutcastdomyślny | mp3, aac | streamid = numer streamu w DNAS |
icecast | mp3, aac, ogg, opus, flac | wymaga mount i hasła source |
rtmp | aac + obraz H.264 | YouTube Live, Twitch |
ogg/opus/flac działają
tylko z Icecastem — DNAS ich nie przyjmie, agent sam wymusza wtedy type: icecast.
opus wymaga 48 kHz, enkoder resampluje automatycznie. flac jest bezstratny,
bitrate nie ma zastosowania. RTMP restartuje się sam po zerwaniu.GET/stations/:id/nowplayingco gra teraz bez tokenu
{ "success": true, "nowPlaying": { "currentSong": "Artysta — Tytuł", "listeners": 42, "online": true } }GET/stations/:id/nowplaying/historyhistoria odtworzeń bez tokenu
?limit=20
GET/stations/:id/nowplaying/streamstrumień zdarzeń (SSE) bez tokenu
Server-Sent Events — do widgetu aktualizowanego na żywo.
GET/stations/:id/nowplaying/listen.plsplaylista dla odtwarzaczy bez tokenu
GET/stations/:id/nowplaying/mediaokładka, tekst, teledysk bez tokenu
{ "success": true, "media": {
"track": { "artist": "…", "title": "…" },
"cover": "https://… albo /stations/…/nowplaying/cover?f=…",
"lyrics": { "plain": "…", "synced": "[00:12.30]linia…", "source": "lrclib" },
"video": { "url": "https://…preview.m4v", "poster": "https://…" }
}}
Źródła po kolei: grafika i tekst osadzone w pliku → iTunes Search API (okładka 600×600, oficjalny 30-sekundowy teledysk) → LRCLIB (tekst, także zsynchronizowany). Wyniki cachowane 30 dni per plik.
GET/stations/:id/nowplaying/coverokładka z cache bez tokenu
?f=nazwa.mp3 → JPEG
GET/stations/:id/archivelista nagrań anteny
{ "success": true, "files": [
{ "name": "2026-07-23_14.mp3", "size": 57802240, "mtime": 1753280000000 }
]}
Włączenie: autopilotConfig.archive = { "enabled": true, "keepDays": 14 }.
Pliki godzinowe, starsze niż keepDays kasują się same.
GET/stations/:id/archive/:namepobranie nagrania
DELETE/stations/:id/archive/:nameusunięcie nagrania
Ustawienia całego serwera, nie stacji. Wymagają tokenu master.
Zapisywane w agent-settings.json — wartość z panelu wygrywa nad .env.
GET/settingsodczyt ustawień agenta
{ "success": true, "settings": {
"proxy": {
"publicPanelUrl": "https://radio.domena.pl",
"publicStreamBase": "https://radio.domena.pl/s"
},
"webhook": {
"enabled": true,
"url": "https://scscript.pl/api/agent-events",
"secret": "••••••••",
"events": { "adPlayed": true, "djOnOff": true, "stationDown": true,
"nowPlaying": false, "requestNew": false },
"lastOk": "2026-07-23T12:00:03Z", "lastError": null
}
}}
Sekret nigdy nie wraca w jawnej postaci — tylko informacja, że jest ustawiony.
PUT/settingszapis ustawień
{ "proxy": { "publicPanelUrl": "…", "publicStreamBase": "…" },
"webhook": { "enabled": true, "url": "…", "secret": "…",
"events": { "adPlayed": true, … } } }
Pola opcjonalne — zapisujesz tylko to, co zmieniasz. Pominięty albo zamaskowany
(••••••••) sekret zostawia istniejący bez zmian.
publicStreamBase trafia natychmiast do GET /stations/:id/status
— panel i apka zaczynają budować linki https zamiast http://host:port.
POST/settings/webhook/testtestowe uderzenie do SCScripta
{ "success": true, "status": 200, "ms": 33 }
{ "success": false, "error": "HTTP 404 Not Found" }
Wysyła pakiet event: "test" z pełnym podpisem — dobre do sprawdzenia,
czy SCScript poprawnie weryfikuje HMAC.
GET/settings/ssl/checkdiagnostyka HTTPS
{ "success": true, "ok": false,
"checks": [
{ "name": "Panel po HTTPS", "ok": false, "detail": "połączenie po http",
"hint": "Bez HTTPS nie zadziała mikrofon w apce mobilnej…" },
{ "name": "Za reverse proxy", "ok": false, "detail": "brak nagłówków proxy",
"hint": "Dodaj proxy_set_header X-Forwarded-Proto $scheme;" },
{ "name": "Publiczny adres streamów", "ok": true, "detail": "https://radio.domena.pl/s" },
{ "name": "Certyfikat Let's Encrypt", "ok": false, "detail": "brak w /etc/letsencrypt/live" }
],
"command": "sudo bash deploy/setup-ssl.sh radio.domena.pl twoj@email.pl" }
/s/PORT/, WebSocket),
pobiera certyfikat i sam uzupełnia publicStreamBase.Wykrywa też niespójność: panel po HTTPS + streamy po HTTP = przeglądarka zablokuje odtwarzanie (mixed content).
Pakiety wysyłane do SCScripta
Każdy jako POST z nagłówkiem podpisu:
X-SCScript-Signature: HMAC-SHA256(secret, surowe-ciało-żądania)
Po stronie SCScripta policz to samo i porównaj — to odrzuca podszywanie się.
{ "event": "adPlayed", "agentId": "radio-srv-1",
"at": "2026-07-23T12:00:03.412Z", "data": { … } }
| event | data zawiera | Do czego w SCScripcie |
|---|---|---|
adPlayed | stationId, campaignId, campaignName, client, blockTime, file, duration, playedAt | rozliczenia z reklamodawcami — dowód każdej emisji |
djOnOff | stationId, dj, state | statystyki prowadzących, podgląd kto na antenie |
stationDown | stationId, what | monitoring, SLA, alerty |
nowPlaying | stationId, artist, title, listeners | widget na stronie — uwaga, ~17 pakietów/h na stację |
requestNew | stationId, file, name, message | moderacja życzeń z poziomu SCScripta |
lastError widocznego w panelu.GET/stations/:id/shoutcast/statusstatus DNAS: słuchacze, bitrate, uptime
GET/stations/:id/shoutcast/listenerslista podłączonych słuchaczy
POST/stations/:id/shoutcast/kicksrcrozłączenie źródła
POST/stations/:id/shoutcast/kickdstrozłączenie słuchacza
GET/healthstan agenta
{ "success": true, "version": "3.12.0", "uptime": 84213 }bez tokenu
GET/cluster/statusstan węzłów (praca wielowęzłowa)
WebSocket — nadawanie na żywo
ws(s)://HOST/live/:stationId?token=<token>&name=Studio
- Połącz się z tokenem stacji albo master
- Wysyłaj binarne pakiety
audio/webm;codecs=opus(np.MediaRecorderco 250 ms) - Serwer odpowiada
{"type":"onair"}gdy wejdziesz na antenę - Zamknięcie socketu = zejście, autopilot wraca
Obowiązuje ten sam djMode co dla DJ-ów — domyślnie ducking.
localhost). Do nadawania z telefonu postaw SSL
przez deploy/setup-ssl.sh.Pełny cykl — od zera do nadawania
A=http://serwer:3500
H="Authorization: Bearer $MASTER_TOKEN"
CT="Content-Type: application/json"
# 1. Stacja (hasła MUSZĄ się różnić)
curl -X POST $A/stations -H "$H" -H "$CT" -d '{
"stationId": "radio1",
"scServConfig": { "port": 8000, "adminPassword": "adm-1",
"sourcePassword": "src-2", "streamTitle": "Radio Przykład",
"maxListeners": 100 } }'
# → zapisz zwrócony token
# 2. Muzyka
curl -X POST "$A/stations/radio1/files?type=media" -H "$H" \
-F "files=@utwor1.mp3" -F "files=@utwor2.mp3"
# 3. Playlista (fullPath z odpowiedzi wyżej)
curl -X PUT $A/stations/radio1/playlists/main -H "$H" -H "$CT" -d '{
"tracks": ["/opt/scscript/stations/station_radio1/media/utwor1.mp3",
"/opt/scscript/stations/station_radio1/media/utwor2.mp3"] }'
# 4. Ramówka — dżingiel co godzinę
curl -X PUT $A/stations/radio1/autopilot/schedule -H "$H" -H "$CT" -d '{
"defaultPlaylist": "main",
"events": [{ "type": "spot", "name": "Dżingiel", "hourly": true, "minute": 0,
"file": "/opt/scscript/stations/station_radio1/media/utwor1.mp3",
"mode": "queue" }],
"rotations": {} }'
# 5. Start
curl -X POST $A/stations/radio1/start -H "$H"
# 6. Kontrola — sprawdź connected i bytesOut
curl $A/stations/radio1/autopilot/status -H "$H"
# Stream dla słuchaczy: http://serwer:8000/;
Zmienne środowiskowe
| Zmienna | Domyślnie | Opis |
|---|---|---|
AGENT_PORT | 3500 | port panelu i API |
AGENT_MASTER_TOKEN | — | token pełnego dostępu |
AGENT_SECRET | — | klucz HMAC do tokenów stacji i ról |
STATIONS_BASE_DIR | /opt/scscript/stations | katalog danych stacji |
BINARIES_DIR | bin | gdzie leży sc_serv |
PUBLIC_STREAM_BASE | — | np. https://domena/s — ustawia setup-ssl.sh |
MAX_FILE_SIZE_MB | 300 | limit uploadu |
LOG_LEVEL | info | error / warn / info / debug |
WATCHDOG_INTERVAL | 10000 | ms między kontrolami procesów |
NOW_PLAYING_INTERVAL | 5000 | ms między odpytaniami DNAS |
PIPER_BIN / PIPER_MODEL | — | lepszy głos TTS |
YTDLP_BIN | yt-dlp | ścieżka do yt-dlp |
ITUNES_BASE / LRCLIB_BASE | oficjalne | podmiana źródeł okładek i tekstów |
ALLOWED_ORIGINS | — | CORS dla zewnętrznych panelów |
/nowplaying, /requests) są rejestrowane przed chronionymi —
inaczej middleware autoryzacji odrzuciłby żądanie zanim dotrze do handlera.SCScript nad flotą agentów
Agent jest w pełni sterowalny przez REST — panel www to tylko klient tego samego API. Panel nadrzędny trzyma klientów i płatności, a operacje techniczne wykonuje żądaniami do agentów.
| Zdarzenie w SCScript | Wywołanie do agenta |
|---|---|
| Klient kupuje stację | POST /stations → zapisz zwrócony token przy koncie klienta |
| Panel klienta | Ten sam agent, ale token owner (dostęp tylko do jego stacji) |
| Klient dodaje DJ-a | POST /stations/:id/djs — skutek natychmiastowy |
| Brak płatności | POST /stations/:id/stop |
| Wznowienie | POST /stations/:id/start |
| Rezygnacja | DELETE /stations/:id |
| Monitoring floty | GET /stations + status każdej stacji cyklicznie |
master nigdy nie trafia do przeglądarki klienta.
Backend SCScript woła agenta, klient rozmawia tylko z SCScript. Ruch między nimi po HTTPS albo w sieci prywatnej.Uruchomienie na serwerze
Linux (produkcja)
sudo bash deploy/install.sh
# Node 22 z NodeSource (repo Debiana daje 20 — za stare dla node:sqlite),
# ffmpeg, espeak-ng, usługa systemd startująca po restarcie serwera
sudo bash deploy/setup-ssl.sh radio.domena.pl admin@domena.pl
# nginx + Let's Encrypt:
# https://domena/ panel
# https://domena/s/8000/; stream stacji
# https://domena/app/ apka PWA (mikrofon wymaga HTTPS)
Windows (testy, mniejsze instalacje)
deploy\install-windows.bat :: ffmpeg, tokeny, sc_serv.exe wg architektury
deploy\start-windows.bat :: doinstalowuje npm i uruchamia agenta
Wymagania
Node 22+
node:sqlite istnieje dopiero od 22. Instalator wymusza właściwą wersję.
ffmpeg
Dekodowanie, enkodery, analiza głośności, archiwum, RTMP.
espeak-ng
Zapowiedzi TTS. Opcjonalnie piper z modelem polskim dla lepszego głosu.
yt-dlp
Opcjonalnie — nadawanie z linków YouTube.
Icecast
Opcjonalnie — jeśli chcesz wyjścia OGG/Opus/FLAC.
Porty
3500 (panel) + port każdej stacji i port+2 dla DJ-ów.
Pułapki, które już kosztowały czas
Każda z tych rzeczy zatrzymała system w praktyce i została naprawiona u źródła. Zapisane, żeby nie wróciły.
| Objaw | Przyczyna i naprawa |
|---|---|
sc_serv nie startuje, w logu You must specify different passwords |
DNAS odmawia startu, gdy adminpassword = password. Panel blokuje to przy zakładaniu, a generator poprawia automatycznie. |
Invalid item ... streamtitle |
W DNAS 2.6 nie ma pól streamtitle/genre/description — metadane idą nagłówkami ICY od źródła. Usunięte z generatora. |
| Port zajęty po restarcie agenta (Windows) | sc_serv.exe nie ginie razem z rodzicem. Agent przy starcie znajduje sieroty po PID-ach i ubija (taskkill /T). |
npm install pada na Visual Studio |
better-sqlite3 wymagał kompilacji. Zastąpiony wbudowanym node:sqlite — zero zależności natywnych. |
| Cisza mimo działającego serwera | Pusta playlista. Panel pokazuje wprost „PLAYLISTA PUSTA" zamiast myślnika. |
ECONNREFUSED w logu przy starcie |
Autopilot wstawał przed sc_serv. Kolejność odwrócona, z czekaniem na gotowość. |
| Wejście DJ-a przerywało utwór | Było switchTo + playNext po zejściu. Jest ducking: muzyka gra pod głosem i wraca ta sama. |
| Opus nie łączył się z Icecastem | libopus przyjmuje tylko 48 kHz. Enkoder wymusza resampling. |
| Widget „teraz gramy" zwracał 401 | Trasy publiczne montowane po chronionych. Kolejność poprawiona. |
Diagnostyka w panelu
Przycisk 🔍 Diagnoza na zakładce Antena uruchamia checklistę: binarka, prawa do pliku, konfiguracja, port, osierocone procesy, zacięte blokady startu. Każdy punkt ma podpowiedź naprawy. Zakładka Baza wiedzy zawiera przeszukiwalne artykuły dla operatora.