#6796 -- Revert to Centrifugo for high-performance NP updates.
This commit is contained in:
parent
68a555fe7e
commit
4285561eda
|
@ -1,6 +1,5 @@
|
||||||
# Frontend
|
# Frontend
|
||||||
node_modules
|
node_modules
|
||||||
/frontend/hpnp*
|
|
||||||
|
|
||||||
# Junk/cache files.
|
# Junk/cache files.
|
||||||
*Thumbs.db
|
*Thumbs.db
|
||||||
|
|
19
Dockerfile
19
Dockerfile
|
@ -10,6 +10,8 @@ RUN go install github.com/jwilder/dockerize@v0.6.1
|
||||||
|
|
||||||
RUN go install github.com/aptible/supercronic@v0.2.28
|
RUN go install github.com/aptible/supercronic@v0.2.28
|
||||||
|
|
||||||
|
RUN go install github.com/centrifugal/centrifugo/v5@v5.1.2
|
||||||
|
|
||||||
#
|
#
|
||||||
# MariaDB dependencies build step
|
# MariaDB dependencies build step
|
||||||
#
|
#
|
||||||
|
@ -35,6 +37,7 @@ ENV TZ="UTC"
|
||||||
# Add Go dependencies
|
# Add Go dependencies
|
||||||
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
|
COPY --from=go-dependencies /go/bin/dockerize /usr/local/bin
|
||||||
COPY --from=go-dependencies /go/bin/supercronic /usr/local/bin/supercronic
|
COPY --from=go-dependencies /go/bin/supercronic /usr/local/bin/supercronic
|
||||||
|
COPY --from=go-dependencies /go/bin/centrifugo /usr/local/bin/centrifugo
|
||||||
|
|
||||||
# Add MariaDB dependencies
|
# Add MariaDB dependencies
|
||||||
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
|
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
|
||||||
|
@ -161,18 +164,6 @@ ENV TZ="UTC" \
|
||||||
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
ENTRYPOINT ["tini", "--", "/usr/local/bin/my_init"]
|
||||||
CMD ["--no-main-command"]
|
CMD ["--no-main-command"]
|
||||||
|
|
||||||
#
|
|
||||||
# High-Performance Now Playing (HPNP) Build
|
|
||||||
#
|
|
||||||
FROM node:20-alpine AS hpnp
|
|
||||||
|
|
||||||
COPY --chown=node:node ./frontend /data
|
|
||||||
WORKDIR /data
|
|
||||||
USER node
|
|
||||||
|
|
||||||
RUN npm ci --include=dev \
|
|
||||||
&& npm run build-hpnp
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Final build (Just environment vars and squishing the FS)
|
# Final build (Just environment vars and squishing the FS)
|
||||||
#
|
#
|
||||||
|
@ -180,10 +171,6 @@ FROM ubuntu:jammy AS final
|
||||||
|
|
||||||
COPY --from=pre-final / /
|
COPY --from=pre-final / /
|
||||||
|
|
||||||
# Add HPNP from previous step
|
|
||||||
COPY --from=hpnp --chown=azuracast:azuracast /data/hpnp /usr/local/bin/hpnp
|
|
||||||
RUN chmod a+x /usr/local/bin/hpnp
|
|
||||||
|
|
||||||
USER azuracast
|
USER azuracast
|
||||||
|
|
||||||
WORKDIR /var/azuracast/www
|
WORKDIR /var/azuracast/www
|
||||||
|
|
|
@ -130,6 +130,7 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
|
||||||
App\Sync\Task\RotateLogsTask::class,
|
App\Sync\Task\RotateLogsTask::class,
|
||||||
App\Sync\Task\RunAnalyticsTask::class,
|
App\Sync\Task\RunAnalyticsTask::class,
|
||||||
App\Sync\Task\RunBackupTask::class,
|
App\Sync\Task\RunBackupTask::class,
|
||||||
|
App\Sync\Task\SendTimeOnSocketTask::class,
|
||||||
App\Sync\Task\UpdateGeoLiteTask::class,
|
App\Sync\Task\UpdateGeoLiteTask::class,
|
||||||
App\Sync\Task\UpdateStorageLocationSizesTask::class,
|
App\Sync\Task\UpdateStorageLocationSizesTask::class,
|
||||||
]);
|
]);
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -6,8 +6,7 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite",
|
"serve": "vite",
|
||||||
"generate-locales": "vue-gettext-extract",
|
"generate-locales": "vue-gettext-extract",
|
||||||
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client",
|
"generate-api": "swagger-typescript-api --path http://localhost/api/openapi.yml --output ./src/entities --name ApiInterfaces.ts --no-client"
|
||||||
"build-hpnp": "esbuild --bundle --target=node18 --platform=node ./src/hpnp/index.ts > ./hpnp.cjs && pkg --target=node18-linux --output ./hpnp ./hpnp.cjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.0.1",
|
"@codemirror/lang-css": "^6.0.1",
|
||||||
|
@ -20,28 +19,23 @@
|
||||||
"@fullcalendar/timegrid": "^6",
|
"@fullcalendar/timegrid": "^6",
|
||||||
"@fullcalendar/vue3": "^6",
|
"@fullcalendar/vue3": "^6",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@tinyhttp/app": "^2.2.1",
|
|
||||||
"@vuelidate/core": "^2.0.0",
|
"@vuelidate/core": "^2.0.0",
|
||||||
"@vuelidate/validators": "^2.0.0",
|
"@vuelidate/validators": "^2.0.0",
|
||||||
"@vuepic/vue-datepicker": "^7",
|
"@vuepic/vue-datepicker": "^7",
|
||||||
"@vueuse/core": "^10",
|
"@vueuse/core": "^10",
|
||||||
"axios": "^1",
|
"axios": "^1",
|
||||||
"better-sse": "^0.10.0",
|
|
||||||
"bootstrap": "^5.3.0",
|
"bootstrap": "^5.3.0",
|
||||||
"chart.js": "^4.2.1",
|
"chart.js": "^4.2.1",
|
||||||
"chartjs-adapter-luxon": "^1.1.0",
|
"chartjs-adapter-luxon": "^1.1.0",
|
||||||
"chartjs-plugin-zoom": "^2.0.0",
|
"chartjs-plugin-zoom": "^2.0.0",
|
||||||
"codemirror": "^6",
|
"codemirror": "^6",
|
||||||
"codemirror-lang-liquidsoap": "^0.2.5",
|
"codemirror-lang-liquidsoap": "^0.2.5",
|
||||||
"esbuild": "^0.19.8",
|
|
||||||
"hls.js": "^1.1.5",
|
"hls.js": "^1.1.5",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.7.1",
|
||||||
"leaflet-fullscreen": "^1.0.2",
|
"leaflet-fullscreen": "^1.0.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3",
|
"luxon": "^3",
|
||||||
"milliparsec": "^2.3.0",
|
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pkg": "^5.8.1",
|
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"roboto-fontface": "^0.10.0",
|
"roboto-fontface": "^0.10.0",
|
||||||
"sweetalert2": "11.4.8",
|
"sweetalert2": "11.4.8",
|
||||||
|
@ -67,6 +61,7 @@
|
||||||
"@vitejs/plugin-vue": "^4.2.3",
|
"@vitejs/plugin-vue": "^4.2.3",
|
||||||
"@vue/eslint-config-typescript": "^12",
|
"@vue/eslint-config-typescript": "^12",
|
||||||
"del": "^7",
|
"del": "^7",
|
||||||
|
"esbuild": "^0.19.9",
|
||||||
"eslint": "^8.45.0",
|
"eslint": "^8.45.0",
|
||||||
"eslint-plugin-vue": "^9.8.0",
|
"eslint-plugin-vue": "^9.8.0",
|
||||||
"glob": "^10.2.7",
|
"glob": "^10.2.7",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {ApiNowPlaying} from "~/entities/ApiInterfaces.ts";
|
||||||
import {getApiUrl} from "~/router.ts";
|
import {getApiUrl} from "~/router.ts";
|
||||||
import {useAxios} from "~/vendor/axios.ts";
|
import {useAxios} from "~/vendor/axios.ts";
|
||||||
import formatTime from "~/functions/formatTime.ts";
|
import formatTime from "~/functions/formatTime.ts";
|
||||||
|
import {has} from "lodash";
|
||||||
|
|
||||||
export const nowPlayingProps = {
|
export const nowPlayingProps = {
|
||||||
stationShortName: {
|
stationShortName: {
|
||||||
|
@ -40,7 +41,6 @@ interface SSEResponse {
|
||||||
|
|
||||||
export default function useNowPlaying(props) {
|
export default function useNowPlaying(props) {
|
||||||
const np: ShallowRef<ApiNowPlaying> = shallowRef(NowPlaying);
|
const np: ShallowRef<ApiNowPlaying> = shallowRef(NowPlaying);
|
||||||
const npUpdated: Ref<number> = ref(0);
|
|
||||||
|
|
||||||
const currentTime: Ref<number> = ref(Math.floor(Date.now() / 1000));
|
const currentTime: Ref<number> = ref(Math.floor(Date.now() / 1000));
|
||||||
const currentTrackDuration: Ref<number> = ref(0);
|
const currentTrackDuration: Ref<number> = ref(0);
|
||||||
|
@ -48,7 +48,6 @@ export default function useNowPlaying(props) {
|
||||||
|
|
||||||
const setNowPlaying = (np_new: ApiNowPlaying) => {
|
const setNowPlaying = (np_new: ApiNowPlaying) => {
|
||||||
np.value = np_new;
|
np.value = np_new;
|
||||||
npUpdated.value = currentTime.value;
|
|
||||||
|
|
||||||
currentTrackDuration.value = np_new.now_playing.duration ?? 0;
|
currentTrackDuration.value = np_new.now_playing.duration ?? 0;
|
||||||
|
|
||||||
|
@ -68,41 +67,59 @@ export default function useNowPlaying(props) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.useSse) {
|
const nowPlayingUri = props.useStatic
|
||||||
const sseUri = getApiUrl(`/live/nowplaying/${props.stationShortName}`);
|
? getApiUrl(`/nowplaying_static/${props.stationShortName}.json`)
|
||||||
|
: getApiUrl(`/nowplaying/${props.stationShortName}`);
|
||||||
|
|
||||||
const {data} = useEventSource(sseUri.value);
|
const timeUri = getApiUrl('/time');
|
||||||
|
const {axiosSilent} = useAxios();
|
||||||
|
|
||||||
|
const axiosNoCacheConfig = {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.useSse) {
|
||||||
|
const sseBaseUri = getApiUrl('/live/nowplaying/sse');
|
||||||
|
const sseUriParams = new URLSearchParams({
|
||||||
|
"cf_connect": JSON.stringify({
|
||||||
|
"subs": {
|
||||||
|
[`station:${props.stationShortName}`]: {},
|
||||||
|
"global:time": {},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const sseUri = sseBaseUri.value + '?' + sseUriParams.toString();
|
||||||
|
|
||||||
|
// Make an initial AJAX request before SSE takes over.
|
||||||
|
axiosSilent.get(nowPlayingUri.value, axiosNoCacheConfig).then((response) => {
|
||||||
|
setNowPlaying(response.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
axiosSilent.get(timeUri.value, axiosNoCacheConfig).then((response) => {
|
||||||
|
currentTime.value = response.data.timestamp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subsequent events come from SSE.
|
||||||
|
const {data} = useEventSource(sseUri);
|
||||||
watch(data, (dataRaw: string) => {
|
watch(data, (dataRaw: string) => {
|
||||||
const jsonData: SSEResponse = JSON.parse(dataRaw);
|
const jsonData: SSEResponse = JSON.parse(dataRaw);
|
||||||
|
const jsonDataNp = jsonData?.pub?.data ?? {};
|
||||||
|
|
||||||
if (jsonData.type === 'time') {
|
if (has(jsonDataNp, 'np')) {
|
||||||
currentTime.value = jsonData.payload.timestamp;
|
setTimeout(() => {
|
||||||
} else if (jsonData.type === 'nowplaying') {
|
setNowPlaying(jsonDataNp.np);
|
||||||
if (npUpdated.value === 0) {
|
}, 3000);
|
||||||
setNowPlaying(jsonData.payload.np);
|
} else if (has(jsonDataNp, 'time')) {
|
||||||
} else {
|
currentTime.value = jsonDataNp.time;
|
||||||
setTimeout(() => {
|
|
||||||
setNowPlaying(jsonData.payload.np);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const nowPlayingUri = props.useStatic
|
|
||||||
? getApiUrl(`/nowplaying_static/${props.stationShortName}`)
|
|
||||||
: getApiUrl(`/nowplaying/${props.stationShortName}`);
|
|
||||||
|
|
||||||
const timeUri = getApiUrl('/time');
|
|
||||||
|
|
||||||
const {axios} = useAxios();
|
|
||||||
const checkNowPlaying = () => {
|
const checkNowPlaying = () => {
|
||||||
axios.get(nowPlayingUri.value, {
|
axiosSilent.get(nowPlayingUri.value, axiosNoCacheConfig).then((response) => {
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0',
|
|
||||||
}
|
|
||||||
}).then((response) => {
|
|
||||||
setNowPlaying(response.data);
|
setNowPlaying(response.data);
|
||||||
|
|
||||||
setTimeout(checkNowPlaying, (!document.hidden) ? 15000 : 30000);
|
setTimeout(checkNowPlaying, (!document.hidden) ? 15000 : 30000);
|
||||||
|
@ -112,13 +129,7 @@ export default function useNowPlaying(props) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkTime = () => {
|
const checkTime = () => {
|
||||||
axios.get(timeUri.value, {
|
axiosSilent.get(timeUri.value, axiosNoCacheConfig).then((response) => {
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Pragma': 'no-cache',
|
|
||||||
'Expires': '0',
|
|
||||||
}
|
|
||||||
}).then((response) => {
|
|
||||||
currentTime.value = response.data.timestamp;
|
currentTime.value = response.data.timestamp;
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setTimeout(checkTime, (!document.hidden) ? 300000 : 600000);
|
setTimeout(checkTime, (!document.hidden) ? 300000 : 600000);
|
||||||
|
|
|
@ -1,136 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
import {ApiNowPlaying} from "~/entities/ApiInterfaces.ts";
|
|
||||||
import {Channel, createChannel, createSession, Session} from "better-sse";
|
|
||||||
import {App} from '@tinyhttp/app';
|
|
||||||
import {json} from "milliparsec";
|
|
||||||
|
|
||||||
const publicPort: number = 6050;
|
|
||||||
const internalPort: number = 6055;
|
|
||||||
|
|
||||||
interface NowPlayingSubmission {
|
|
||||||
station: string,
|
|
||||||
np: ApiNowPlaying,
|
|
||||||
triggers: string[] | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StationChannelState extends Record<string, unknown> {
|
|
||||||
timestamp: number,
|
|
||||||
lastMessage: NowPlayingSubmission
|
|
||||||
}
|
|
||||||
|
|
||||||
const unixTimestamp = (): number => Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const timeChannel = createChannel();
|
|
||||||
timeChannel.on("session-registered", (session: Session) => {
|
|
||||||
session.push({
|
|
||||||
type: 'time',
|
|
||||||
payload: {
|
|
||||||
timestamp: unixTimestamp()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const stationChannels: Map<string, Channel<StationChannelState>> = new Map();
|
|
||||||
|
|
||||||
// Routine time ping.
|
|
||||||
setInterval(() => {
|
|
||||||
console.debug('Sending time ping...');
|
|
||||||
timeChannel.broadcast({
|
|
||||||
type: 'time',
|
|
||||||
payload: {
|
|
||||||
timestamp: unixTimestamp()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
// If a station hasn't posted NP updates in a specified time, close its channel and garbage-collect its sessions.
|
|
||||||
setInterval(() => {
|
|
||||||
const threshold = unixTimestamp() - 120;
|
|
||||||
|
|
||||||
for (const [key, channel] of stationChannels) {
|
|
||||||
if (channel.state.timestamp < threshold) {
|
|
||||||
channel.activeSessions.forEach((session) => {
|
|
||||||
channel.deregister(session);
|
|
||||||
});
|
|
||||||
stationChannels.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const publicServer = new App();
|
|
||||||
|
|
||||||
publicServer.get('/:station', async (req, res) => {
|
|
||||||
res.set("Access-Control-Allow-Origin", "*");
|
|
||||||
res.set("X-Accel-Buffering", "no");
|
|
||||||
|
|
||||||
const stations: string[] = req.params.station.split(',');
|
|
||||||
|
|
||||||
let anyStationsFound: boolean = false;
|
|
||||||
for (const stationId of stations) {
|
|
||||||
if (stationChannels.has(stationId)) {
|
|
||||||
anyStationsFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anyStationsFound) {
|
|
||||||
return res.status(404).send('Station(s) Not Found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await createSession(req, res, {
|
|
||||||
retry: 5000,
|
|
||||||
keepAlive: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
timeChannel.register(session);
|
|
||||||
|
|
||||||
for (const stationId of stations) {
|
|
||||||
const stationChannel = stationChannels.get(stationId);
|
|
||||||
stationChannel!.register(session);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
publicServer.listen(publicPort, () => {
|
|
||||||
console.debug(`Public server listening on port ${publicPort}...`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const privateServer = new App();
|
|
||||||
|
|
||||||
privateServer.use(json());
|
|
||||||
|
|
||||||
privateServer.post('/', async (req, res) => {
|
|
||||||
const body: NowPlayingSubmission = req.body;
|
|
||||||
|
|
||||||
console.debug(
|
|
||||||
`NP Update received for channel ${body.station}.`
|
|
||||||
);
|
|
||||||
|
|
||||||
let channel: Channel<StationChannelState>;
|
|
||||||
if (stationChannels.has(body.station)) {
|
|
||||||
channel = stationChannels.get(body.station)!;
|
|
||||||
} else {
|
|
||||||
// Create a new channel if none exists.
|
|
||||||
channel = createChannel();
|
|
||||||
channel.on("session-registered", (session: Session) => {
|
|
||||||
session.push({
|
|
||||||
type: 'nowplaying',
|
|
||||||
payload: channel.state.lastMessage
|
|
||||||
});
|
|
||||||
});
|
|
||||||
stationChannels.set(body.station, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.state.timestamp = unixTimestamp();
|
|
||||||
channel.state.lastMessage = body;
|
|
||||||
channel.broadcast({
|
|
||||||
type: 'nowplaying',
|
|
||||||
payload: body
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send('OK');
|
|
||||||
});
|
|
||||||
|
|
||||||
privateServer.listen(internalPort, () => {
|
|
||||||
console.debug(`Internal server listening on port ${internalPort}...`);
|
|
||||||
});
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Container\EnvironmentAwareTrait;
|
||||||
|
use App\Entity\Station;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
final class Centrifugo
|
||||||
|
{
|
||||||
|
use EnvironmentAwareTrait;
|
||||||
|
|
||||||
|
public const GLOBAL_TIME_CHANNEL = 'global:time';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Client $client,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSupported(): bool
|
||||||
|
{
|
||||||
|
return $this->environment->isDocker() && !$this->environment->isTesting();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendTime(): void
|
||||||
|
{
|
||||||
|
$this->send([
|
||||||
|
'method' => 'publish',
|
||||||
|
'params' => [
|
||||||
|
'channel' => self::GLOBAL_TIME_CHANNEL,
|
||||||
|
'data' => [
|
||||||
|
'time' => time(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function publishToStation(Station $station, mixed $message, array $triggers): void
|
||||||
|
{
|
||||||
|
$this->send([
|
||||||
|
'method' => 'publish',
|
||||||
|
'params' => [
|
||||||
|
'channel' => $this->getChannelName($station),
|
||||||
|
'data' => [
|
||||||
|
'np' => $message,
|
||||||
|
'triggers' => $triggers,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function send(array $body): void
|
||||||
|
{
|
||||||
|
$this->client->post(
|
||||||
|
'http://localhost:6025/api',
|
||||||
|
[
|
||||||
|
'json' => $body,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChannelName(Station $station): string
|
||||||
|
{
|
||||||
|
return 'station:' . $station->getShortName();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,41 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Service;
|
|
||||||
|
|
||||||
use App\Container\EnvironmentAwareTrait;
|
|
||||||
use App\Entity\Station;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class for the High-Performance Now-Playing (HPNP) library.
|
|
||||||
*/
|
|
||||||
final class HpNp
|
|
||||||
{
|
|
||||||
use EnvironmentAwareTrait;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly Client $client,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isSupported(): bool
|
|
||||||
{
|
|
||||||
return $this->environment->isDocker() && !$this->environment->isTesting();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function publishToStation(Station $station, mixed $message, array $triggers = []): void
|
|
||||||
{
|
|
||||||
$this->client->post(
|
|
||||||
'http://localhost:6055',
|
|
||||||
[
|
|
||||||
'json' => [
|
|
||||||
'station' => $station->getShortName(),
|
|
||||||
'np' => $message,
|
|
||||||
'triggers' => $triggers,
|
|
||||||
],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -20,7 +20,7 @@ final class ServiceControl
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SupervisorInterface $supervisor,
|
private readonly SupervisorInterface $supervisor,
|
||||||
private readonly HpNp $hpNp
|
private readonly Centrifugo $centrifugo
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,12 +85,12 @@ final class ServiceControl
|
||||||
'php-worker' => __('PHP queue processing worker'),
|
'php-worker' => __('PHP queue processing worker'),
|
||||||
'redis' => __('Cache'),
|
'redis' => __('Cache'),
|
||||||
'sftpgo' => __('SFTP service'),
|
'sftpgo' => __('SFTP service'),
|
||||||
'hpnp' => __('High-Performance Now Playing updates'),
|
'centrifugo' => __('Live Now Playing updates'),
|
||||||
'vite' => __('Frontend Assets'),
|
'vite' => __('Frontend Assets'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!$this->hpNp->isSupported()) {
|
if (!$this->centrifugo->isSupported()) {
|
||||||
unset($services['hpnp']);
|
unset($services['centrifugo']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->environment->useLocalDatabase()) {
|
if (!$this->environment->useLocalDatabase()) {
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Sync\Task;
|
||||||
|
|
||||||
|
use App\Service\Centrifugo;
|
||||||
|
|
||||||
|
final class SendTimeOnSocketTask extends AbstractTask
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Centrifugo $centrifugo,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSchedulePattern(): string
|
||||||
|
{
|
||||||
|
return self::SCHEDULE_EVERY_MINUTE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function run(bool $force = false): void
|
||||||
|
{
|
||||||
|
if (!$this->centrifugo->isSupported()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->centrifugo->sendTime();
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,12 +5,12 @@ declare(strict_types=1);
|
||||||
namespace App\VueComponent;
|
namespace App\VueComponent;
|
||||||
|
|
||||||
use App\Http\ServerRequest;
|
use App\Http\ServerRequest;
|
||||||
use App\Service\HpNp;
|
use App\Service\Centrifugo;
|
||||||
|
|
||||||
final class NowPlayingComponent implements VueComponentInterface
|
final class NowPlayingComponent implements VueComponentInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly HpNp $hpNp
|
private readonly Centrifugo $centrifugo
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ final class NowPlayingComponent implements VueComponentInterface
|
||||||
return [
|
return [
|
||||||
'stationShortName' => $station->getShortName(),
|
'stationShortName' => $station->getShortName(),
|
||||||
'useStatic' => $customization->useStaticNowPlaying(),
|
'useStatic' => $customization->useStaticNowPlaying(),
|
||||||
'useSse' => $customization->useStaticNowPlaying() && $this->hpNp->isSupported(),
|
'useSse' => $customization->useStaticNowPlaying() && $this->centrifugo->isSupported(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ use App\Container\EnvironmentAwareTrait;
|
||||||
use App\Container\LoggerAwareTrait;
|
use App\Container\LoggerAwareTrait;
|
||||||
use App\Entity\Api\NowPlaying\NowPlaying;
|
use App\Entity\Api\NowPlaying\NowPlaying;
|
||||||
use App\Entity\Station;
|
use App\Entity\Station;
|
||||||
use App\Service\HpNp;
|
use App\Service\Centrifugo;
|
||||||
use Symfony\Component\Filesystem\Filesystem;
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
|
|
||||||
use const JSON_PRETTY_PRINT;
|
use const JSON_PRETTY_PRINT;
|
||||||
|
@ -21,7 +21,7 @@ final class LocalWebhookHandler
|
||||||
public const NAME = 'local';
|
public const NAME = 'local';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly HpNp $hpNp
|
private readonly Centrifugo $centrifugo
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,8 +69,8 @@ final class LocalWebhookHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
// Publish to websocket library
|
// Publish to websocket library
|
||||||
if ($this->hpNp->isSupported()) {
|
if ($this->centrifugo->isSupported()) {
|
||||||
$this->hpNp->publishToStation($station, $np, $triggers);
|
$this->centrifugo->publishToStation($station, $np, $triggers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +0,0 @@
|
||||||
[program:hpnp]
|
|
||||||
directory=/var/azuracast/www/frontend
|
|
||||||
command=bun --hot ./src/hpnp/index.ts
|
|
||||||
user=azuracast
|
|
||||||
priority=700
|
|
||||||
numprocs=1
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
|
|
||||||
stopasgroup=true
|
|
||||||
killasgroup=true
|
|
||||||
|
|
||||||
stdout_logfile=/var/azuracast/www_tmp/service_hpnp.log
|
|
||||||
stdout_logfile_maxbytes=5MB
|
|
||||||
stdout_logfile_backups=5
|
|
||||||
redirect_stderr=true
|
|
||||||
|
|
||||||
stdout_events_enabled = true
|
|
||||||
stderr_events_enabled = true
|
|
|
@ -1,8 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
set -x
|
|
||||||
|
|
||||||
curl -fsSL https://bun.sh/install | gosu azuracast bash
|
|
||||||
|
|
||||||
ln -s /var/azuracast/.bun/bin/bun /usr/local/bin/bun
|
|
||||||
ln -s /var/azuracast/.bun/bin/bunx /usr/local/bin/bunx
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
allow_anonymous_connect_without_token: true
|
||||||
|
api_insecure: true
|
||||||
|
admin: true
|
||||||
|
admin_insecure: true
|
||||||
|
port: 6020
|
||||||
|
internal_port: 6025
|
||||||
|
websocket_disable: true
|
||||||
|
uni_websocket: true
|
||||||
|
uni_sse: true
|
||||||
|
uni_http_stream: true
|
||||||
|
allowed_origins:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
namespaces:
|
||||||
|
- name: "station"
|
||||||
|
history_size: 1
|
||||||
|
history_ttl: "30s"
|
||||||
|
allow_subscribe_for_client: true
|
||||||
|
allow_subscribe_for_anonymous: true
|
||||||
|
allow_history_for_client: true
|
||||||
|
allow_history_for_anonymous: true
|
||||||
|
|
||||||
|
- name: "global"
|
||||||
|
history_size: 0
|
||||||
|
allow_subscribe_for_client: true
|
||||||
|
allow_subscribe_for_anonymous: true
|
||||||
|
allow_history_for_client: true
|
||||||
|
allow_history_for_anonymous: true
|
||||||
|
|
||||||
|
{{if isTrue .Env.ENABLE_REDIS }}
|
||||||
|
engine: "redis"
|
||||||
|
redis_address: "{{ .Env.REDIS_HOST }}:{{ default .Env.REDIS_PORT "6379" }}"
|
||||||
|
redis_db: 0
|
||||||
|
{{end}}
|
|
@ -6,8 +6,8 @@ upstream php-fpm-www {
|
||||||
server unix:/var/run/php-fpm-www.sock;
|
server unix:/var/run/php-fpm-www.sock;
|
||||||
}
|
}
|
||||||
|
|
||||||
upstream hpnp {
|
upstream centrifugo {
|
||||||
server 127.0.0.1:6050;
|
server 127.0.0.1:6020;
|
||||||
}
|
}
|
||||||
|
|
||||||
{{if eq .Env.APPLICATION_ENV "development"}}
|
{{if eq .Env.APPLICATION_ENV "development"}}
|
||||||
|
@ -95,10 +95,10 @@ server {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SSE Now Playing Updates
|
# Websocket/SSE Now Playing Updates
|
||||||
location ~ ^/api/live/nowplaying/(.*)$ {
|
location ~ ^/api/live/nowplaying/(\w+)$ {
|
||||||
include proxy_params;
|
include proxy_params;
|
||||||
proxy_pass http://hpnp/$1?$args;
|
proxy_pass http://centrifugo/connection/uni_$1?$args;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default clean URL routing
|
# Default clean URL routing
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[program:hpnp]
|
[program:centrifugo]
|
||||||
directory=/var/azuracast
|
directory=/var/azuracast/centrifugo
|
||||||
command=hpnp
|
command=centrifugo -c /var/azuracast/centrifugo/config.yaml
|
||||||
user=azuracast
|
user=azuracast
|
||||||
priority=700
|
priority=700
|
||||||
numprocs=1
|
numprocs=1
|
||||||
|
@ -10,7 +10,7 @@ autorestart=true
|
||||||
stopasgroup=true
|
stopasgroup=true
|
||||||
killasgroup=true
|
killasgroup=true
|
||||||
|
|
||||||
stdout_logfile=/var/azuracast/www_tmp/service_hpnp.log
|
stdout_logfile=/var/azuracast/www_tmp/service_centrifugo.log
|
||||||
stdout_logfile_maxbytes=5MB
|
stdout_logfile_maxbytes=5MB
|
||||||
stdout_logfile_backups=5
|
stdout_logfile_backups=5
|
||||||
redirect_stderr=true
|
redirect_stderr=true
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
set -x
|
||||||
|
|
||||||
|
mkdir -p /var/azuracast/centrifugo
|
||||||
|
cp /bd_build/web/centrifugo/config.yaml.tmpl /var/azuracast/centrifugo/config.yaml.tmpl
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
ENABLE_REDIS=${ENABLE_REDIS:-true}
|
||||||
|
export ENABLE_REDIS
|
||||||
|
|
||||||
|
dockerize -template "/var/azuracast/centrifugo/config.yaml.tmpl:/var/azuracast/centrifugo/config.yaml"
|
Loading…
Reference in New Issue