#6771 -- Update high-performance now playing system.

This commit is contained in:
Buster Neece 2023-12-06 10:26:31 -06:00
parent 38d9be3c65
commit 7ae8a48341
No known key found for this signature in database
21 changed files with 5590 additions and 6186 deletions

View File

@ -87,6 +87,8 @@ jobs:
path: vendor
key: ${{ runner.OS }}-build-${{ hashFiles('composer.lock') }}
- uses: oven-sh/setup-bun@v1
- name: Set console permissions and clear static assets.
run: |
rm -rf web/static/vite_dist
@ -100,7 +102,7 @@ jobs:
- name: Generate new translations from existing code.
run: |
cd frontend
npm ci --include=dev
bun install
cd ..
bin/console locale:generate

View File

@ -14,6 +14,10 @@ release channel, you can take advantage of these new features and fixes.
## Code Quality/Technical Changes
- We are switching our implementation of High-Performance Now Playing updates to a simple script that we directly build
and maintain. This will greatly simplify the process of using and updating our high-performance nowplaying data, and
will also offer other improvements like sending Now Playing updates immediately upon initial connection.
- MariaDB has been updated to 11.2. Databases will automatically be upgraded on the first boot after updating.
- If you upload media to a folder and that folder is set to auto-assign to a playlist, the media will *instantly* be a

View File

@ -10,8 +10,6 @@ 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/centrifugal/centrifugo/v5@v5.1.2
#
# MariaDB dependencies build step
#
@ -27,7 +25,6 @@ ENV TZ="UTC"
# Add Go dependencies
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/centrifugo /usr/local/bin/centrifugo
# Add MariaDB dependencies
COPY --from=mariadb /usr/local/bin/healthcheck.sh /usr/local/bin/db_healthcheck.sh
@ -103,7 +100,7 @@ RUN composer install --no-ansi --no-interaction
WORKDIR /var/azuracast/www/frontend
RUN npm ci --include=dev
RUN bun install
WORKDIR /var/azuracast/www
@ -115,7 +112,7 @@ EXPOSE 8000-8999
# Sensible default environment variables.
ENV TZ="UTC" \
LANG="en_US.UTF-8" \
PATH="${PATH}:/var/azuracast/servers/shoutcast2" \
PATH="${PATH}:/var/azuracast/storage/shoutcast2" \
DOCKER_IS_STANDALONE="true" \
APPLICATION_ENV="development" \
MYSQL_HOST="localhost" \
@ -163,6 +160,12 @@ COPY --chown=azuracast:azuracast . .
RUN composer dump-autoload --optimize --classmap-authoritative
WORKDIR /var/azuracast/www/frontend
RUN bun install --production
WORKDIR /var/azuracast/www
USER root
EXPOSE 80 443 2022
@ -171,7 +174,7 @@ EXPOSE 8000-8999
# Sensible default environment variables.
ENV TZ="UTC" \
LANG="en_US.UTF-8" \
PATH="${PATH}:/var/azuracast/servers/shoutcast2" \
PATH="${PATH}:/var/azuracast/storage/shoutcast2" \
DOCKER_IS_STANDALONE="true" \
APPLICATION_ENV="production" \
MYSQL_HOST="localhost" \

View File

@ -130,7 +130,6 @@ return static function (CallableEventDispatcherInterface $dispatcher) {
App\Sync\Task\RotateLogsTask::class,
App\Sync\Task\RunAnalyticsTask::class,
App\Sync\Task\RunBackupTask::class,
App\Sync\Task\SendTimeOnSocketTask::class,
App\Sync\Task\UpdateGeoLiteTask::class,
App\Sync\Task\UpdateStorageLocationSizesTask::class,
]);

File diff suppressed because it is too large Load Diff

5321
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"build": "vite build",
"serve": "vite",
"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",
"hpnp-dev": "bun --hot ./src/hpnp/index.ts",
"hpnp-prod": "bun ./src/hpnp/index.ts"
},
"dependencies": {
"@codemirror/lang-css": "^6.0.1",
@ -19,11 +21,13 @@
"@fullcalendar/timegrid": "^6",
"@fullcalendar/vue3": "^6",
"@popperjs/core": "^2.11.8",
"@tinyhttp/app": "^2.2.1",
"@vuelidate/core": "^2.0.0",
"@vuelidate/validators": "^2.0.0",
"@vuepic/vue-datepicker": "^7",
"@vueuse/core": "^10",
"axios": "^1",
"better-sse": "^0.10.0",
"bootstrap": "^5.3.0",
"chart.js": "^4.2.1",
"chartjs-adapter-luxon": "^1.1.0",
@ -35,6 +39,7 @@
"leaflet-fullscreen": "^1.0.2",
"lodash": "^4.17.21",
"luxon": "^3",
"milliparsec": "^2.3.0",
"nprogress": "^0.2.0",
"qrcode": "^1.5.3",
"roboto-fontface": "^0.10.0",

View File

@ -1,48 +1,50 @@
import NowPlaying from '~/entities/NowPlaying';
import {computed, onMounted, ref, shallowRef, watch} from "vue";
import {computed, onMounted, Ref, ref, ShallowRef, shallowRef, watch} from "vue";
import {useEventSource, useIntervalFn} from "@vueuse/core";
import {useAxios} from "~/vendor/axios";
import {has} from "lodash";
import formatTime from "~/functions/formatTime";
import {ApiNowPlaying} from "~/entities/ApiInterfaces.ts";
import {getApiUrl} from "~/router.ts";
import {useAxios} from "~/vendor/axios.ts";
import formatTime from "~/functions/formatTime.ts";
export const nowPlayingProps = {
nowPlayingUri: {
stationShortName: {
type: String,
required: true
required: true,
},
useStatic: {
type: Boolean,
required: false,
default: false,
},
useSse: {
type: Boolean,
required: false,
default: false
},
sseUri: {
type: String,
required: false,
default: null
},
initialNowPlaying: {
type: Object,
default() {
return NowPlaying;
}
},
timeUri: {
type: String,
required: true
},
};
interface NowPlayingSSETime {
timestamp: number
}
interface NowPlayingSSEResponse {
type: string,
payload: NowPlayingSSETime | ApiNowPlaying
}
export default function useNowPlaying(props) {
const np = shallowRef(props.initialNowPlaying);
const np: ShallowRef<ApiNowPlaying> = shallowRef(NowPlaying);
const npUpdated: Ref<number> = ref(0);
const currentTime = ref(Math.floor(Date.now() / 1000));
const currentTrackDuration = ref(0);
const currentTrackElapsed = ref(0);
const currentTime: Ref<number> = ref(Math.floor(Date.now() / 1000));
const currentTrackDuration: Ref<number> = ref(0);
const currentTrackElapsed: Ref<number> = ref(0);
const setNowPlaying = (np_new) => {
const setNowPlaying = (np_new: ApiNowPlaying) => {
np.value = np_new;
npUpdated.value = currentTime.value;
currentTrackDuration.value = np_new?.now_playing?.duration ?? 0;
currentTrackDuration.value = np_new.now_playing.duration ?? 0;
// Update the browser metadata for browsers that support it (i.e. Mobile Chrome)
if ('mediaSession' in navigator) {
@ -60,27 +62,35 @@ export default function useNowPlaying(props) {
}));
}
// Trigger initial NP set.
setNowPlaying(np.value);
if (props.useSse) {
const {data} = useEventSource(props.sseUri);
watch(data, (data_raw) => {
const json_data = JSON.parse(data_raw);
const json_data_np = json_data?.pub?.data ?? {};
const sseUri = getApiUrl(`/live/nowplaying/${props.stationShortName}`);
if (has(json_data_np, 'np')) {
setTimeout(() => {
setNowPlaying(json_data_np.np);
}, 3000);
} else if (has(json_data_np, 'time')) {
currentTime.value = json_data_np.time;
const {data} = useEventSource(sseUri.value);
watch(data, (dataRaw: string) => {
const jsonData: NowPlayingSSEResponse = JSON.parse(dataRaw);
if (jsonData.type === 'time') {
currentTime.value = jsonData.payload.timestamp;
} else if (jsonData.type === 'nowplaying') {
if (npUpdated.value === 0) {
setNowPlaying(jsonData.payload);
} else {
setTimeout(() => {
setNowPlaying(jsonData.payload);
}, 3000);
}
}
});
} else {
const nowPlayingUri = props.useStatic
? getApiUrl(`/nowplaying_static/${props.stationShortName}`)
: getApiUrl(`/nowplaying/${props.stationShortName}`);
const timeUri = getApiUrl('/time');
const {axios} = useAxios();
const checkNowPlaying = () => {
axios.get(props.nowPlayingUri, {
axios.get(nowPlayingUri.value, {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
@ -96,7 +106,7 @@ export default function useNowPlaying(props) {
};
const checkTime = () => {
axios.get(props.timeUri, {
axios.get(timeUri.value, {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
@ -110,8 +120,8 @@ export default function useNowPlaying(props) {
};
onMounted(() => {
setTimeout(checkTime, 5000);
setTimeout(checkNowPlaying, 5000);
checkTime();
checkNowPlaying();
});
}
@ -135,39 +145,30 @@ export default function useNowPlaying(props) {
});
const currentTrackPercent = computed(() => {
const $currentTrackElapsed = currentTrackElapsed.value;
const $currentTrackDuration = currentTrackDuration.value;
if (!$currentTrackDuration) {
if (!currentTrackDuration.value) {
return 0;
}
if ($currentTrackElapsed > $currentTrackDuration) {
if (currentTrackElapsed.value > currentTrackDuration.value) {
return 100;
}
return ($currentTrackElapsed / $currentTrackDuration) * 100;
return (currentTrackElapsed.value / currentTrackDuration.value) * 100;
});
const currentTrackDurationDisplay = computed(() => {
const $currentTrackDuration = currentTrackDuration.value;
return ($currentTrackDuration) ? formatTime($currentTrackDuration) : null;
return (currentTrackDuration.value) ? formatTime(currentTrackDuration.value) : null;
});
const currentTrackElapsedDisplay = computed(() => {
let $currentTrackElapsed = currentTrackElapsed.value;
const $currentTrackDuration = currentTrackDuration.value;
if (!$currentTrackDuration) {
if (!currentTrackDuration.value) {
return null;
}
if ($currentTrackElapsed > $currentTrackDuration) {
$currentTrackElapsed = $currentTrackDuration;
}
return formatTime($currentTrackElapsed);
return (currentTrackElapsed.value <= currentTrackDuration.value)
? formatTime(currentTrackElapsed.value)
: currentTrackDurationDisplay.value;
});
return {
np,
currentTime,

123
frontend/src/hpnp/index.ts Normal file
View File

@ -0,0 +1,123 @@
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 {
channel: string,
payload: ApiNowPlaying
}
interface StationChannelState extends Record<string, unknown> {
timestamp: number,
lastMessage: ApiNowPlaying
}
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) => {
const stationId: string = req.params.station;
if (!stationChannels.has(stationId)) {
res.status(404).send('Station Not Found');
}
const session = await createSession(req, res, {
retry: 5000,
headers: {
"Access-Control-Allow-Origin": "*",
"X-Accel-Buffering": "no",
}
});
timeChannel.register(session);
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.channel}.`
);
let channel: Channel<StationChannelState>;
if (stationChannels.has(body.channel)) {
channel = stationChannels.get(body.channel);
} 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.channel, channel);
}
channel.state.timestamp = unixTimestamp();
channel.state.lastMessage = body.payload;
channel.broadcast({
type: 'nowplaying',
payload: body.payload
});
return res.send('OK');
});
privateServer.listen(internalPort, () => {
console.debug(`Internal server listening on port ${internalPort}...`);
});

View File

@ -8,12 +8,13 @@ use App\Container\EnvironmentAwareTrait;
use App\Entity\Station;
use GuzzleHttp\Client;
final class Centrifugo
/**
* Utility class for the High-Performance Now-Playing (HPNP) library.
*/
final class HpNp
{
use EnvironmentAwareTrait;
public const GLOBAL_TIME_CHANNEL = 'global:time';
public function __construct(
private readonly Client $client,
) {
@ -24,38 +25,15 @@ final class Centrifugo
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): void
{
$this->send([
'method' => 'publish',
'params' => [
'channel' => $this->getChannelName($station),
'data' => [
'np' => $message,
],
],
]);
}
private function send(array $body): void
{
$this->client->post(
'http://localhost:6025/api',
'http://localhost:6055',
[
'json' => $body,
'json' => [
'channel' => $station->getShortName(),
'payload' => $message,
],
]
);
}
@ -76,9 +54,4 @@ final class Centrifugo
]
);
}
public function getChannelName(Station $station): string
{
return 'station:' . $station->getShortName();
}
}

View File

@ -20,7 +20,7 @@ final class ServiceControl
public function __construct(
private readonly SupervisorInterface $supervisor,
private readonly Centrifugo $centrifugo
private readonly HpNp $hpNp
) {
}
@ -85,12 +85,12 @@ final class ServiceControl
'php-worker' => __('PHP queue processing worker'),
'redis' => __('Cache'),
'sftpgo' => __('SFTP service'),
'centrifugo' => __('Live Now Playing updates'),
'hpnp' => __('High-Performance Now Playing updates'),
'vite' => __('Frontend Assets'),
];
if (!$this->centrifugo->isSupported()) {
unset($services['centrifugo']);
if (!$this->hpNp->isSupported()) {
unset($services['hpnp']);
}
if (!$this->environment->useLocalDatabase()) {

View File

@ -1,29 +0,0 @@
<?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();
}
}

View File

@ -4,29 +4,21 @@ declare(strict_types=1);
namespace App\VueComponent;
use App\Entity\ApiGenerator\NowPlayingApiGenerator;
use App\Http\ServerRequest;
use App\Service\Centrifugo;
use App\Service\HpNp;
final class NowPlayingComponent implements VueComponentInterface
{
public function __construct(
private readonly NowPlayingApiGenerator $npApiGenerator,
private readonly Centrifugo $centrifugo
private readonly HpNp $hpNp
) {
}
public function getProps(ServerRequest $request): array
{
$station = $request->getStation();
$baseUrl = $request->getRouter()->getBaseUrl();
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($baseUrl);
$customization = $request->getCustomization();
$station = $request->getStation();
$backendConfig = $station->getBackendConfig();
return [
@ -41,29 +33,12 @@ final class NowPlayingComponent implements VueComponentInterface
public function getDataProps(ServerRequest $request): array
{
$station = $request->getStation();
$baseUrl = $request->getRouter()->getBaseUrl();
$np = $this->npApiGenerator->currentOrEmpty($station);
$np->resolveUrls($baseUrl);
$customization = $request->getCustomization();
$router = $request->getRouter();
$props = [
'initialNowPlaying' => $np,
'nowPlayingUri' => $customization->useStaticNowPlaying()
? '/api/nowplaying_static/' . urlencode($station->getShortName()) . '.json'
: $router->named('api:nowplaying:index', ['station_id' => $station->getShortName()]),
'timeUri' => $router->named('api:index:time'),
'useSse' => false,
return [
'stationShortName' => $station->getShortName(),
'useStatic' => $customization->useStaticNowPlaying(),
'useSse' => $customization->useStaticNowPlaying() && $this->hpNp->isSupported(),
];
if ($customization->useStaticNowPlaying() && $this->centrifugo->isSupported()) {
$props['useSse'] = true;
$props['sseUri'] = $this->centrifugo->getSseUrl($station);
}
return $props;
}
}

View File

@ -8,7 +8,7 @@ use App\Container\EnvironmentAwareTrait;
use App\Container\LoggerAwareTrait;
use App\Entity\Api\NowPlaying\NowPlaying;
use App\Entity\Station;
use App\Service\Centrifugo;
use App\Service\HpNp;
use Symfony\Component\Filesystem\Filesystem;
use const JSON_PRETTY_PRINT;
@ -21,7 +21,7 @@ final class LocalWebhookHandler
public const NAME = 'local';
public function __construct(
private readonly Centrifugo $centrifugo
private readonly HpNp $hpNp
) {
}
@ -68,8 +68,8 @@ final class LocalWebhookHandler
);
// Publish to websocket library
if ($this->centrifugo->isSupported()) {
$this->centrifugo->publishToStation($station, $np);
if ($this->hpNp->isSupported()) {
$this->hpNp->publishToStation($station, $np);
}
}
}

View File

@ -7,3 +7,5 @@ apt-get -y autoremove
apt-get clean
rm -rf /var/lib/apt/lists/*
rm -rf /tmp/tmp*
chmod -R a+x /usr/local/bin

View File

@ -1,6 +1,6 @@
[program:centrifugo]
directory=/var/azuracast/centrifugo
command=centrifugo -c /var/azuracast/centrifugo/config.yaml
[program:hpnp]
directory=/var/azuracast/www/frontend
command=bun run hpnp-dev
user=azuracast
priority=700
numprocs=1
@ -10,7 +10,7 @@ autorestart=true
stopasgroup=true
killasgroup=true
stdout_logfile=/var/azuracast/www_tmp/service_centrifugo.log
stdout_logfile=/var/azuracast/www_tmp/service_hpnp.log
stdout_logfile_maxbytes=5MB
stdout_logfile_backups=5
redirect_stderr=true

View File

@ -1,34 +0,0 @@
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}}

View File

@ -6,8 +6,8 @@ upstream php-fpm-www {
server unix:/var/run/php-fpm-www.sock;
}
upstream centrifugo {
server 127.0.0.1:6020;
upstream hpnp {
server 127.0.0.1:6050;
}
{{if eq .Env.APPLICATION_ENV "development"}}
@ -95,10 +95,10 @@ server {
try_files $uri =404;
}
# Websocket/SSE Now Playing Updates
# SSE Now Playing Updates
location ~ ^/api/live/nowplaying/(\w+)$ {
include proxy_params;
proxy_pass http://centrifugo/connection/uni_$1?$args;
proxy_pass http://hpnp/$1?$args;
}
# Default clean URL routing

View File

@ -0,0 +1,19 @@
[program:hpnp]
directory=/var/azuracast/www/frontend
command=bun run hpnp-prod
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

View File

@ -0,0 +1,8 @@
#!/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

View File

@ -1,7 +0,0 @@
#!/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