Add radio embed widget, SSE real-time, song history, moderation panel, and Auto DJ

- Embed widget: standalone iframe player with dark/light/transparent themes, copy-paste embed code admin page
- Real-time SSE: streaming now-playing/listeners/dj events, replaces polling in radio-player and embed
- Song history: auto-records song changes to radio_song_plays table, Filament resource to view
- DJ moderation: unified panel for shouts approval, song request queue, DJ applications
- Auto DJ: playlist management with round-robin playback when no DJ is live
- Refactored radio-player Alpine component to use EventSource API with auto-reconnect
This commit is contained in:
root
2026-05-24 14:07:32 +02:00
parent 5476dce882
commit 0c6c558a59
32 changed files with 2236 additions and 29 deletions
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Models\RadioAutoDjTrack;
use App\Services\Community\RadioScheduleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'radio:auto-dj')]
final class AutoDjPlayback extends Command
{
#[\Override]
protected $signature = 'radio:auto-dj {--force : Update now-playing even if track has not changed}';
#[\Override]
protected $description = 'Auto DJ fallback: updates now-playing from playlist when no DJ is live';
public function handle(RadioScheduleService $scheduleService): int
{
$enabled = $this->getSetting('radio_auto_dj_detection', '0');
if ($enabled !== '1' && ! $this->option('force')) {
$this->info('Auto DJ is uitgeschakeld. Gebruik --force om toch te draaien.');
return Command::SUCCESS;
}
$currentDjId = $this->getSetting('radio_current_dj_id', '');
$currentDj = $scheduleService->getCurrentDJ($currentDjId ?: null);
$track = RadioAutoDjTrack::getNextTrack();
if (! $track) {
$this->info('Geen Auto DJ tracks in de playlist.');
return Command::SUCCESS;
}
if ($currentDj !== null) {
Cache::forget('radio_auto_dj_active');
$this->info('DJ is live. Auto DJ niet actief.');
return Command::SUCCESS;
}
Cache::forever('radio_auto_dj_active', [
'title' => $track->title,
'artist' => $track->artist,
'is_auto_dj' => true,
]);
$track->markPlayed();
$this->info("Auto DJ speelt: {$track->title}" . ($track->artist ? " by {$track->artist}" : ''));
return Command::SUCCESS;
}
private function getSetting(string $key, string $default = ''): string
{
$setting = WebsiteSetting::where('key', $key)->first();
return $setting !== null && isset($setting->value) ? (string) $setting->value : $default;
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Models\RadioSongPlay;
use App\Services\Community\RadioStreamService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Console\Attribute\AsCommand;
#[AsCommand(name: 'radio:record-songs')]
final class RecordSongPlays extends Command
{
#[\Override]
protected $signature = 'radio:record-songs
{--daemon : Run continuously as a daemon}
{--interval=15 : Polling interval in seconds (daemon mode)}';
#[\Override]
protected $description = 'Poll the now-playing API and record song changes to radio_song_plays';
public function handle(RadioStreamService $streamService): int
{
if (! $this->option('daemon')) {
$this->recordOnce($streamService);
return Command::SUCCESS;
}
$interval = max(5, (int) $this->option('interval'));
$this->info("Starting daemon mode (interval: {$interval}s)...");
while (true) {
$this->recordOnce($streamService);
sleep($interval);
}
}
private function recordOnce(RadioStreamService $streamService): void
{
$enabled = Cache::remember('setting_radio_enabled', 60, function () {
return WebsiteSetting::where('key', 'radio_enabled')->first()?->value;
});
if ($enabled !== '1') {
return;
}
$apiUrl = $this->getNowPlayingApiUrl();
if (! $apiUrl) {
return;
}
$nowPlaying = $streamService->getNowPlaying($apiUrl);
if (! $nowPlaying || empty($nowPlaying['song'])) {
return;
}
$title = $nowPlaying['song'];
$artist = $nowPlaying['artist'] ?? null;
$recorded = RadioSongPlay::recordNowPlaying($title, $artist, [
'artwork_url' => $nowPlaying['artwork'] ?? null,
]);
if ($recorded) {
$this->info("Recorded: {$title}" . ($artist ? " by {$artist}" : ''));
}
}
private function getNowPlayingApiUrl(): ?string
{
$enabled = Cache::remember('setting_radio_now_playing_enabled', 60, function () {
return WebsiteSetting::where('key', 'radio_now_playing_enabled')->first()?->value;
});
if ($enabled !== '1') {
return null;
}
$apiUrl = Cache::remember('setting_radio_now_playing_api_url', 60, function () {
return WebsiteSetting::where('key', 'radio_now_playing_api_url')->first()?->value;
});
if ($apiUrl) {
return $apiUrl;
}
$azureCastUrl = Cache::remember('setting_radio_azurecast_base_url', 60, function () {
return WebsiteSetting::where('key', 'radio_azurecast_base_url')->first()?->value;
});
if ($azureCastUrl) {
$stationId = Cache::remember('setting_radio_azurecast_station_id', 60, function () {
return WebsiteSetting::where('key', 'radio_azurecast_station_id')->first()?->value ?? '1';
});
return rtrim($azureCastUrl, '/') . '/api/nowplaying/' . $stationId;
}
return null;
}
}
+2
View File
@@ -23,6 +23,8 @@ class Kernel extends ConsoleKernel
protected function schedule(Schedule $schedule): void
{
$schedule->command('radio:check-dj')->everyMinute()->withoutOverlapping();
$schedule->command('radio:record-songs')->everyFifteenSeconds()->withoutOverlapping();
$schedule->command('radio:auto-dj')->everyMinute()->withoutOverlapping();
$schedule->command('maintenance:check-scheduled')->everyMinute()->withoutOverlapping();
$schedule->command('monitor:emulator')->everyMinute()->withoutOverlapping();
$schedule->command('monitor:ddos')->everyFiveMinutes()->withoutOverlapping();
+12
View File
@@ -20,6 +20,12 @@ enum RadioSettings: string
case WidgetEnabled = 'radio_widget_enabled';
case WidgetShowGlobally = 'radio_widget_show_globally';
case WidgetPosition = 'radio_widget_position';
case EmbedEnabled = 'radio_embed_enabled';
case EmbedAllowedDomains = 'radio_embed_allowed_domains';
case EmbedTheme = 'radio_embed_theme';
case EmbedHeight = 'radio_embed_height';
case EmbedWidth = 'radio_embed_width';
case EmbedAutoPlay = 'radio_embed_auto_play';
public function label(): string
{
@@ -40,6 +46,12 @@ enum RadioSettings: string
self::WidgetEnabled => 'Widget Enabled',
self::WidgetShowGlobally => 'Widget Show Globally',
self::WidgetPosition => 'Widget Position',
self::EmbedEnabled => 'Embed Enabled',
self::EmbedAllowedDomains => 'Embed Allowed Domains',
self::EmbedTheme => 'Embed Theme',
self::EmbedHeight => 'Embed Height',
self::EmbedWidth => 'Embed Width',
self::EmbedAutoPlay => 'Embed Auto Play',
};
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Radio;
use App\Models\RadioApiKey;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class ApiKeys extends Page implements HasTable
{
use InteractsWithTable;
#[\Override]
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
#[\Override]
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
#[\Override]
protected static ?string $navigationLabel = 'API Sleutels';
#[\Override]
protected static ?string $title = 'Radio API Sleutels';
#[\Override]
protected string $view = 'filament.pages.radio.api-keys';
public ?string $newKey = null;
public function table(Table $table): Table
{
return $table
->query(RadioApiKey::query())
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('name')
->label('Naam')
->searchable(),
TextColumn::make('key')
->label('API Key')
->formatStateUsing(fn ($state) => substr($state, 0, 16) . '...')
->copyable()
->copyMessage('Gekopieerd!'),
TextColumn::make('rate_limit')
->label('Rate Limit')
->badge()
->color('info'),
IconColumn::make('is_active')
->label('Actief')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
TextColumn::make('last_used_at')
->label('Laatst gebruikt')
->dateTime('d-m-Y H:i')
->placeholder('Nooit'),
TextColumn::make('expires_at')
->label('Verloopt')
->dateTime('d-m-Y')
->placeholder('Nooit'),
TextColumn::make('created_at')
->label('Aangemaakt')
->dateTime('d-m-Y H:i'),
])
->recordActions(function ($record) {
return [
ActionGroup::make([
Action::make('toggle')
->label($record->is_active ? 'Deactiveren' : 'Activeren')
->icon($record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
->action(fn () => $this->toggleKey($record)),
Action::make('delete')
->label('Verwijderen')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn () => $record->delete()),
]),
];
})
->toolbarActions([
Action::make('create')
->label('Nieuwe API Sleutel')
->icon('heroicon-o-plus')
->color('primary')
->form([
TextInput::make('name')
->label('Naam')
->required()
->maxLength(255)
->placeholder('Bijv. Discord Bot, Mobiele App'),
TextInput::make('allowed_ips')
->label('Toegestane IP-adressen')
->placeholder('Leeg laten voor alle IPs (bijv. 1.2.3.4, 5.6.7.8)')
->helperText('Komma-gescheiden lijst van IP-adressen. Leeg = alle IPs toegestaan.'),
Select::make('rate_limit')
->label('Rate Limit (verzoeken per minuut)')
->options([
60 => '60/min',
100 => '100/min',
300 => '300/min',
500 => '500/min',
1000 => '1000/min',
])
->default(300),
Select::make('permissions')
->label('Permissies')
->multiple()
->options([
'*' => 'Alle permissies',
'now-playing' => 'Nu Afspelen',
'listeners' => 'Luisteraars',
'current-dj' => 'Huidige DJ',
'config' => 'Configuratie',
'shouts' => 'Shouts',
'points' => 'Punten',
'requests' => 'Song Requests',
])
->default(['*']),
Toggle::make('set_expiration')
->label('Vervaldatum instellen')
->live(),
TextInput::make('expires_at')
->label('Vervaldatum')
->type('date')
->visible(fn ($get) => $get('set_expiration')),
])
->action(function (array $data) {
$this->createKey($data);
}),
]);
}
private function createKey(array $data): void
{
$key = RadioApiKey::generate($data['name'], [
'allowed_ips' => $data['allowed_ips'] ?? null,
'permissions' => $data['permissions'] ?? ['*'],
'rate_limit' => $data['rate_limit'] ?? 300,
'expires_at' => ! empty($data['set_expiration']) && ! empty($data['expires_at'])
? $data['expires_at'] : null,
]);
$this->newKey = $key->key;
Notification::make()
->success()
->title('API Sleutel Aangemaakt!')
->body('Kopieer de sleutel nu - deze wordt niet meer getoond.')
->send();
}
private function toggleKey(RadioApiKey $key): void
{
$key->update(['is_active' => ! $key->is_active]);
Notification::make()
->success()
->title($key->is_active ? 'API Sleutel geactiveerd' : 'API Sleutel gedeactiveerd')
->send();
}
#[\Override]
protected function getHeaderActions(): array
{
return [];
}
public function getNewKey(): ?string
{
$key = $this->newKey;
$this->newKey = null;
return $key;
}
}
+207
View File
@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Radio;
use App\Models\RadioAutoDjTrack;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
final class AutoDjPlaylist extends Page implements HasTable
{
use InteractsWithTable;
#[\Override]
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-play-circle';
#[\Override]
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
#[\Override]
protected static ?string $navigationLabel = 'Auto DJ Playlist';
#[\Override]
protected static ?string $title = 'Auto DJ Playlist';
#[\Override]
protected string $view = 'filament.pages.radio.auto-dj-playlist';
public function table(Table $table): Table
{
return $table
->query(RadioAutoDjTrack::query())
->defaultSort('sort_order')
->columns([
TextColumn::make('sort_order')
->label('#')
->sortable()
->width(50),
TextColumn::make('title')
->label('Titel')
->searchable()
->sortable(),
TextColumn::make('artist')
->label('Artiest')
->searchable()
->sortable(),
TextColumn::make('duration')
->label('Duur')
->formatStateUsing(fn ($state) => $state ? gmdate('i:s', $state) : '-'),
TextColumn::make('play_count')
->label('Gespeeld')
->sortable(),
TextColumn::make('last_played_at')
->label('Laatst gespeeld')
->dateTime('d-m-Y H:i')
->placeholder('Nooit'),
IconColumn::make('is_active')
->label('Actief')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
])
->recordActions(function ($record) {
return [
ActionGroup::make([
Action::make('toggle_active')
->label($record->is_active ? 'Deactiveren' : 'Activeren')
->icon($record->is_active ? 'heroicon-o-pause' : 'heroicon-o-play')
->action(fn () => $this->toggleActive($record)),
Action::make('move_up')
->label('Omhoog')
->icon('heroicon-o-chevron-up')
->action(fn () => $this->moveUp($record)),
Action::make('move_down')
->label('Omlaag')
->icon('heroicon-o-chevron-down')
->action(fn () => $this->moveDown($record)),
Action::make('delete')
->label('Verwijderen')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->action(fn () => $record->delete()),
]),
];
})
->toolbarActions([
Action::make('create')
->label('Track Toevoegen')
->icon('heroicon-o-plus')
->color('primary')
->form([
TextInput::make('title')
->label('Titel')
->required()
->maxLength(255),
TextInput::make('artist')
->label('Artiest')
->maxLength(255),
TextInput::make('album')
->label('Album')
->maxLength(255),
TextInput::make('artwork_url')
->label('Afbeelding URL')
->url(),
TextInput::make('duration')
->label('Duur (seconden)')
->numeric()
->minValue(0),
Toggle::make('is_active')
->label('Actief')
->default(true),
])
->action(function (array $data) {
$this->createTrack($data);
}),
])
->poll('30s');
}
public function getStatusBadge(): string
{
$autoDj = cache('radio_auto_dj_active');
if ($autoDj) {
return 'Auto DJ is actief - speelt: ' . ($autoDj['title'] ?? 'onbekend');
}
return 'Auto DJ is niet actief';
}
private function createTrack(array $data): void
{
$maxOrder = RadioAutoDjTrack::max('sort_order') ?? 0;
RadioAutoDjTrack::create([
'title' => $data['title'],
'artist' => $data['artist'] ?? null,
'album' => $data['album'] ?? null,
'artwork_url' => $data['artwork_url'] ?? null,
'duration' => $data['duration'] ? (int) $data['duration'] : null,
'is_active' => $data['is_active'] ?? true,
'sort_order' => $maxOrder + 1,
]);
Notification::make()
->success()
->title('Track toegevoegd aan playlist')
->send();
}
private function toggleActive(RadioAutoDjTrack $track): void
{
$track->update(['is_active' => ! $track->is_active]);
Notification::make()
->success()
->title($track->is_active ? 'Track geactiveerd' : 'Track gedeactiveerd')
->send();
}
private function moveUp(RadioAutoDjTrack $track): void
{
$prev = RadioAutoDjTrack::where('sort_order', '<', $track->sort_order)
->orderBy('sort_order', 'desc')
->first();
if ($prev) {
$temp = $track->sort_order;
$track->update(['sort_order' => $prev->sort_order]);
$prev->update(['sort_order' => $temp]);
}
}
private function moveDown(RadioAutoDjTrack $track): void
{
$next = RadioAutoDjTrack::where('sort_order', '>', $track->sort_order)
->orderBy('sort_order')
->first();
if ($next) {
$temp = $track->sort_order;
$track->update(['sort_order' => $next->sort_order]);
$next->update(['sort_order' => $temp]);
}
}
#[\Override]
protected function getHeaderActions(): array
{
return [];
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Radio;
use App\Models\RadioApplication;
use App\Models\RadioShout;
use App\Models\RadioSongRequest;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
final class DjModeration extends Page
{
#[\Override]
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
#[\Override]
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
#[\Override]
protected static ?string $navigationLabel = 'Moderatie';
#[\Override]
protected static ?string $title = 'DJ Moderatie';
#[\Override]
protected string $view = 'filament.pages.radio.dj-moderation';
public function getShouts()
{
return RadioShout::with('user')->latest()->paginate(20);
}
public function getPendingShouts()
{
return RadioShout::with('user')->where('approved', false)->latest()->paginate(20);
}
public function getReportedShouts()
{
return RadioShout::with('user')->where('reported', true)->latest()->paginate(20);
}
public function getRequests()
{
return RadioSongRequest::with('user')->latest('submitted_at')->paginate(20);
}
public function getPendingRequests()
{
return RadioSongRequest::with('user')->where('is_approved', false)->where('is_played', false)->latest('submitted_at')->paginate(20);
}
public function getApplications()
{
return RadioApplication::with(['user', 'rank'])->latest()->paginate(20);
}
public function getPendingApplications()
{
return RadioApplication::with(['user', 'rank'])->where('status', 'pending')->latest()->paginate(20);
}
public function approveShout(int $id): void
{
$shout = RadioShout::findOrFail($id);
$shout->update([
'approved' => true,
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
Notification::make()->success()->title('Shout goedgekeurd')->send();
}
public function dismissShoutReport(int $id): void
{
$shout = RadioShout::findOrFail($id);
$shout->update(['reported' => false, 'reported_by' => null]);
Notification::make()->success()->title('Melding genegeerd')->send();
}
public function deleteShout(int $id): void
{
RadioShout::findOrFail($id)->delete();
Notification::make()->success()->title('Shout verwijderd')->send();
}
public function approveRequest(int $id): void
{
$request = RadioSongRequest::findOrFail($id);
$request->approve();
Notification::make()->success()->title('Verzoek goedgekeurd')->send();
}
public function markRequestPlayed(int $id): void
{
$request = RadioSongRequest::findOrFail($id);
$request->markAsPlayed();
Notification::make()->success()->title('Verzoek gemarkeerd als gespeeld')->send();
}
public function rejectRequest(int $id): void
{
$request = RadioSongRequest::findOrFail($id);
$request->update(['is_approved' => false]);
Notification::make()->success()->title('Verzoek afgewezen')->send();
}
public function approveApplication(int $id): void
{
$application = RadioApplication::findOrFail($id);
$application->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
]);
Notification::make()->success()->title('Aanmelding goedgekeurd')->send();
}
public function rejectApplication(int $id): void
{
$application = RadioApplication::findOrFail($id);
$application->update([
'status' => 'rejected',
'rejected_by' => auth()->id(),
'rejected_at' => now(),
]);
Notification::make()->success()->title('Aanmelding afgewezen')->send();
}
#[\Override]
protected function getHeaderActions(): array
{
return [];
}
}
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Radio;
use App\Models\Miscellaneous\WebsiteSetting;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
final class EmbedCode extends Page implements HasForms
{
use InteractsWithForms;
#[\Override]
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-code-bracket';
#[\Override]
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
#[\Override]
protected static ?string $navigationLabel = 'Embed Code';
#[\Override]
protected static ?string $title = 'Radio Embed Code';
#[\Override]
protected string $view = 'filament.pages.radio.embed-code';
public array $data = [];
public function mount(): void
{
$this->form->fill([
'embed_theme' => $this->getSetting('radio_embed_theme', 'dark'),
'embed_height' => $this->getSetting('radio_embed_height', '400'),
'embed_width' => $this->getSetting('radio_embed_width', '100%'),
'embed_auto_play' => $this->getSetting('radio_embed_auto_play', '0'),
]);
}
public function form(Schema $schema): Schema
{
return $schema
->components([
Section::make('Embed Configuratie')
->description('Pas het uiterlijk van de embed player aan')
->schema([
Select::make('embed_theme')
->label('Thema')
->options([
'dark' => 'Donker',
'light' => 'Licht',
'transparent' => 'Transparant',
])
->default('dark'),
TextInput::make('embed_height')
->label('Hoogte (px)')
->numeric()
->default(400),
TextInput::make('embed_width')
->label('Breedte')
->placeholder('100% of 400px')
->default('100%'),
Toggle::make('embed_auto_play')
->label('Auto-Play'),
]),
]);
}
public function getIframeCode(): string
{
$theme = $this->data['embed_theme'] ?? 'dark';
$height = $this->data['embed_height'] ?? '400';
$width = $this->data['embed_width'] ?? '100%';
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
$url = route('radio.embed', ['theme' => $theme]) . $autoPlay;
return '<iframe src="' . e($url) . '" width="' . e($width) . '" height="' . e($height) . '" frameborder="0" allow="autoplay" style="border-radius: 12px;"></iframe>';
}
public function getJsSnippet(): string
{
$theme = $this->data['embed_theme'] ?? 'dark';
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
$url = route('radio.embed', ['theme' => $theme]) . $autoPlay;
return '<div id="radio-embed"></div>
<script>
(function() {
var iframe = document.createElement(\'iframe\');
iframe.src = \'' . e($url) . '\';
iframe.width = \'100%\';
iframe.height = \'400\';
iframe.frameBorder = \'0\';
iframe.style.borderRadius = \'12px\';
iframe.allow = \'autoplay\';
document.getElementById(\'radio-embed\').appendChild(iframe);
})();
</script>';
}
public function getDirectUrl(): string
{
$theme = $this->data['embed_theme'] ?? 'dark';
$autoPlay = ($this->data['embed_auto_play'] ?? false) ? '&autoplay=1' : '';
return route('radio.embed', ['theme' => $theme]) . $autoPlay;
}
#[\Override]
protected function getHeaderActions(): array
{
return [];
}
private function getSetting(string $key, mixed $default = null): mixed
{
$setting = WebsiteSetting::where('key', $key)->first();
return $setting?->value ?? $default;
}
}
+38 -1
View File
@@ -83,6 +83,12 @@ final class RadioSettings extends Page implements HasForms
'radio_widget_enabled' => $this->getSettingBool('radio_widget_enabled'),
'radio_widget_show_globally' => $this->getSettingBool('radio_widget_show_globally'),
'radio_widget_position' => $this->getSetting('radio_widget_position', 'bottom-right'),
'radio_embed_enabled' => $this->getSettingBool('radio_embed_enabled'),
'radio_embed_allowed_domains' => $this->getSetting('radio_embed_allowed_domains', ''),
'radio_embed_theme' => $this->getSetting('radio_embed_theme', 'dark'),
'radio_embed_height' => (int) $this->getSetting('radio_embed_height', '400'),
'radio_embed_width' => $this->getSetting('radio_embed_width', '100%'),
'radio_embed_auto_play' => $this->getSettingBool('radio_embed_auto_play'),
'radio_shouts_enabled' => $this->getSettingBool('radio_shouts_enabled'),
'radio_shouts_max_length' => (int) $this->getSetting('radio_shouts_max_length', '280'),
'radio_shouts_cooldown' => (int) $this->getSetting('radio_shouts_cooldown', '30'),
@@ -626,6 +632,32 @@ final class RadioSettings extends Page implements HasForms
'top-left' => 'Links Boven',
])
->default('bottom-right'),
Toggle::make('radio_embed_enabled')
->label('Externe Embed Inschakelen')
->columnSpanFull()
->helperText('Sta toe dat de radio als iframe op externe sites wordt geplaatst.'),
TextInput::make('radio_embed_allowed_domains')
->label('Toegestane Domeinen')
->placeholder('voorbeeld.nl, anderedomein.com')
->helperText('Komma-gescheiden lijst. Leeg = alle domeinen toegestaan.'),
Select::make('radio_embed_theme')
->label('Embed Thema')
->options([
'dark' => 'Donker',
'light' => 'Licht',
'transparent' => 'Transparant',
])
->default('dark'),
TextInput::make('radio_embed_height')
->label('Embed Hoogte (px)')
->numeric()
->default(400),
TextInput::make('radio_embed_width')
->label('Embed Breedte')
->placeholder('100% of 400px')
->default('100%'),
Toggle::make('radio_embed_auto_play')
->label('Auto-Play in Embed'),
]),
Section::make('Offline Pagina')
->description('Instellingen voor wanneer de radio offline is')
@@ -890,6 +922,9 @@ final class RadioSettings extends Page implements HasForms
$this->saveSettings([
'radio_widget_enabled', 'radio_widget_show_globally',
'radio_widget_position',
'radio_embed_enabled', 'radio_embed_allowed_domains',
'radio_embed_theme', 'radio_embed_height',
'radio_embed_width', 'radio_embed_auto_play',
]);
}
@@ -1475,7 +1510,8 @@ final class RadioSettings extends Page implements HasForms
'radio_stats_show_weekly', 'radio_stats_show_monthly',
'radio_stats_show_top_djs', 'radio_stats_show_top_songs',
'radio_show_offline_message', 'radio_widget_enabled',
'radio_widget_show_globally', 'radio_applications_enabled',
'radio_widget_show_globally', 'radio_embed_enabled',
'radio_embed_auto_play', 'radio_applications_enabled',
'radio_applications_require_approval', 'radio_discord_enabled',
'radio_discord_dj_live', 'radio_discord_song_changes',
'radio_listener_alerts_enabled', 'radio_azurecast_use_proxy',
@@ -1488,6 +1524,7 @@ final class RadioSettings extends Page implements HasForms
'radio_dj_avatar_size', 'radio_dj_detection_interval',
'radio_shouts_max_length', 'radio_shouts_cooldown',
'radio_chat_width', 'radio_chat_height', 'radio_chat_messages_count',
'radio_embed_height',
'radio_request_max_per_user', 'radio_request_cooldown',
'radio_min_listeners_threshold', 'radio_listener_alert_threshold_high',
'radio_listener_alert_threshold_low', 'radio_applications_max_per_day',
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\RadioSongPlay\Pages;
use App\Filament\Resources\RadioSongPlay\RadioSongPlayResource;
use Filament\Resources\Pages\ManageRecords;
class ManageRadioSongPlays extends ManageRecords
{
#[\Override]
protected static string $resource = RadioSongPlayResource::class;
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\RadioSongPlay;
use App\Filament\Resources\RadioSongPlay\Pages\ManageRadioSongPlays;
use App\Models\RadioSongPlay;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class RadioSongPlayResource extends Resource
{
#[\Override]
protected static ?string $model = RadioSongPlay::class;
#[\Override]
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-musical-note';
#[\Override]
protected static string|\UnitEnum|null $navigationGroup = 'Radio';
#[\Override]
protected static ?string $slug = 'radio/song-plays';
#[\Override]
public static function getNavigationLabel(): string
{
return 'Song Geschiedenis';
}
#[\Override]
public static function getLabel(): string
{
return 'Song Play';
}
#[\Override]
public static function getPluralLabel(): string
{
return 'Song Geschiedenis';
}
#[\Override]
public static function table(Table $table): Table
{
return $table
->query(RadioSongPlay::query())
->defaultSort('played_at', 'desc')
->columns([
TextColumn::make('title')
->label('Titel')
->searchable()
->sortable(),
TextColumn::make('artist')
->label('Artiest')
->searchable()
->sortable(),
TextColumn::make('played_at')
->label('Gespeeld op')
->dateTime('d-m-Y H:i:s')
->sortable(),
TextColumn::make('duration')
->label('Duur')
->formatStateUsing(fn ($state) => $state ? gmdate('i:s', $state) : '-')
->sortable(),
])
->filters([
SelectFilter::make('artist')
->label('Artiest')
->options(fn () => RadioSongPlay::distinct()->whereNotNull('artist')->pluck('artist', 'artist')->toArray())
->searchable(),
])
->recordActions([
DeleteAction::make()
->label('Verwijderen'),
])
->bulkActions([
DeleteBulkAction::make()
->label('Selectie verwijderen'),
])
->poll('30s');
}
#[\Override]
public static function getPages(): array
{
return [
'index' => ManageRadioSongPlays::route('/'),
];
}
}
@@ -167,6 +167,18 @@ class RadioController extends Controller
public function nowPlaying(): JsonResponse
{
$autoDj = Cache::get('radio_auto_dj_active');
if ($autoDj !== null) {
return response()->json([
'enabled' => true,
'song' => $autoDj['title'],
'artist' => $autoDj['artist'] ?? null,
'title' => $autoDj['title'],
'is_auto_dj' => true,
]);
}
$nowPlaying = Cache::remember('radio_nowplaying', 10, function () {
$apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->streamService->getAzureCastApiUrl())
@@ -175,6 +187,8 @@ class RadioController extends Controller
return $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
});
$nowPlaying['is_auto_dj'] = false;
return response()->json($nowPlaying);
}
@@ -195,9 +209,13 @@ class RadioController extends Controller
{
$dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId));
$autoDj = Cache::get('radio_auto_dj_active');
return response()->json([
'dj' => $dj,
'is_live' => $dj !== null,
'is_auto_dj' => $autoDj !== null,
'auto_dj_song' => $autoDj['title'] ?? null,
]);
}
@@ -218,6 +236,8 @@ class RadioController extends Controller
$streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? '');
$azureCast = $this->streamService->detectAzureCast();
$autoDj = Cache::get('radio_auto_dj_active');
return response()->json([
'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false),
'stream_url' => $streamUrl,
@@ -231,6 +251,8 @@ class RadioController extends Controller
'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right',
'is_azurecast' => $azureCast['detected'],
'azurecast_detected' => $azureCast['detected'],
'is_auto_dj' => $autoDj !== null,
'auto_dj_song' => $autoDj['title'] ?? null,
]);
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Radio;
use App\Enums\RadioSettings;
use App\Http\Controllers\Controller;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Services\Community\RadioStreamService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class EmbedController extends Controller
{
public function __construct(
private readonly RadioStreamService $streamService,
) {}
public function show(Request $request): View
{
$theme = $request->query('theme', $this->getSetting(RadioSettings::EmbedTheme, 'dark'));
$settings = [
'streamUrl' => $this->streamService->formatStreamUrl($this->getSetting(RadioSettings::StreamUrl, '')),
'theme' => in_array($theme, ['dark', 'light', 'transparent']) ? $theme : 'dark',
'autoPlay' => $request->query('autoplay', $this->getSetting(RadioSettings::EmbedAutoPlay, '0')) === '1',
'primaryColor' => $this->getSetting('radio_player_color_primary', '#eeb425'),
'secondaryColor' => $this->getSetting('radio_player_color_secondary', '#1a1a2e'),
'textColor' => $this->getSetting('radio_player_color_text', '#ffffff'),
'accentColor' => $this->getSetting('radio_player_color_accent', '#eeb425'),
];
return view('radio.embed', compact('settings'));
}
public function config(): JsonResponse
{
return response()->json([
'enabled' => (bool) $this->getSetting(RadioSettings::EmbedEnabled, '0'),
'stream_url' => $this->streamService->formatStreamUrl($this->getSetting(RadioSettings::StreamUrl, '')),
'theme' => $this->getSetting(RadioSettings::EmbedTheme, 'dark'),
'auto_play' => (bool) $this->getSetting(RadioSettings::EmbedAutoPlay, '0'),
'primary_color' => $this->getSetting('radio_player_color_primary', '#eeb425'),
'secondary_color' => $this->getSetting('radio_player_color_secondary', '#1a1a2e'),
'text_color' => $this->getSetting('radio_player_color_text', '#ffffff'),
'accent_color' => $this->getSetting('radio_player_color_accent', '#eeb425'),
'height' => (int) $this->getSetting(RadioSettings::EmbedHeight, '400'),
'width' => $this->getSetting(RadioSettings::EmbedWidth, '100%'),
'allowed_domains' => $this->getSetting(RadioSettings::EmbedAllowedDomains, ''),
]);
}
private function getSetting(RadioSettings|string $key, mixed $default = null): mixed
{
$keyStr = $key instanceof RadioSettings ? $key->value : $key;
return Cache::remember("setting_{$keyStr}", 60, function () use ($keyStr, $default): mixed {
$websiteSetting = WebsiteSetting::where('key', $keyStr)->first();
return $websiteSetting?->value ?? $default;
});
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Radio;
use App\Enums\RadioSettings;
use App\Http\Controllers\Controller;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Services\Community\RadioScheduleService;
use App\Services\Community\RadioStreamService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SseController extends Controller
{
private const int SSE_KEEPALIVE = 15;
public function __construct(
private readonly RadioStreamService $streamService,
private readonly RadioScheduleService $scheduleService,
) {}
public function stream(Request $request): StreamedResponse
{
$channels = $request->query('channels', 'now-playing,listeners,dj');
$wanted = array_flip(array_map('trim', explode(',', $channels)));
$response = new StreamedResponse(function () use ($wanted): void {
$last = [
'now-playing' => null,
'listeners' => null,
'dj' => null,
];
$this->sendSseComment('connected');
while (! connection_aborted()) {
$sent = false;
if (isset($wanted['now-playing'])) {
$data = $this->getNowPlaying();
if ($data !== $last['now-playing']) {
$this->sendSseEvent('now-playing', $data);
$last['now-playing'] = $data;
$sent = true;
}
}
if (isset($wanted['listeners'])) {
$count = $this->getListeners();
if ($count !== $last['listeners']) {
$this->sendSseEvent('listeners', ['count' => $count]);
$last['listeners'] = $count;
$sent = true;
}
}
if (isset($wanted['dj'])) {
$dj = $this->getCurrentDj();
if ($dj !== $last['dj']) {
$this->sendSseEvent('dj', $dj);
$last['dj'] = $dj;
$sent = true;
}
}
if (! $sent) {
$this->sendSseComment('keepalive');
}
ob_flush();
flush();
sleep(5);
}
});
$response->headers->set('Content-Type', 'text/event-stream');
$response->headers->set('Cache-Control', 'no-cache');
$response->headers->set('Connection', 'keep-alive');
$response->headers->set('X-Accel-Buffering', 'no');
return $response;
}
private function sendSseEvent(string $event, mixed $data): void
{
echo "event: {$event}\n";
echo 'data: ' . json_encode($data) . "\n\n";
}
private function sendSseComment(string $comment): void
{
echo ": {$comment}\n\n";
}
private function getNowPlaying(): ?array
{
$autoDj = Cache::get('radio_auto_dj_active');
if ($autoDj !== null) {
return [
'enabled' => true,
'song' => $autoDj['title'],
'artist' => $autoDj['artist'] ?? null,
'title' => $autoDj['title'],
'is_auto_dj' => true,
];
}
$cached = Cache::get('radio_nowplaying');
if ($cached !== null) {
$cached['is_auto_dj'] = false;
return $cached;
}
$apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->getAzureCastApiUrl())
: null;
$result = $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
$result['is_auto_dj'] = false;
Cache::put('radio_nowplaying', $result, 10);
return $result;
}
private function getListeners(): int
{
return Cache::remember('radio_listeners', 30, function () {
$apiUrl = $this->getSetting(RadioSettings::ListenersEnabled)
? ($this->getSetting(RadioSettings::ListenersApiUrl) ?: $this->getAzureCastApiUrl())
: null;
return $apiUrl ? $this->streamService->getListenersCount($apiUrl) : 0;
});
}
private function getCurrentDj(): ?array
{
$autoDj = Cache::get('radio_auto_dj_active');
$dj = $this->scheduleService->getCurrentDJ(
$this->getSetting(RadioSettings::CurrentDjId)
);
if ($dj === null && $autoDj !== null) {
return [
'username' => 'Auto DJ',
'look' => null,
'show_name' => 'Auto DJ',
'is_auto_dj' => true,
'song' => $autoDj['title'] ?? null,
];
}
if ($dj !== null) {
$dj['is_auto_dj'] = false;
}
return $dj;
}
private function getAzureCastApiUrl(): ?string
{
$baseUrl = $this->getSetting(RadioSettings::AzureCastBaseUrl);
$stationId = (int) $this->getSetting(RadioSettings::AzureCastStationId, '1');
if (! $baseUrl) {
return null;
}
return rtrim($baseUrl, '/') . '/api/nowplaying/' . $stationId;
}
private function getSetting(RadioSettings|string $key, mixed $default = null): mixed
{
$keyStr = $key instanceof RadioSettings ? $key->value : $key;
return Cache::remember("setting_{$keyStr}", 60, function () use ($keyStr, $default): mixed {
$setting = WebsiteSetting::where('key', $keyStr)->first();
return $setting?->value ?? $default;
});
}
}
+2
View File
@@ -7,6 +7,7 @@ namespace App\Http;
use App\Http\Middleware\AdminSecurityMiddleware;
use App\Http\Middleware\ApiResponseCache;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\RadioApiKey;
use App\Http\Middleware\BannedMiddleware;
use App\Http\Middleware\DDoSTrackingMiddleware;
use App\Http\Middleware\EncryptCookies;
@@ -120,5 +121,6 @@ class Kernel extends HttpKernel
'force.staff.2fa' => ForceStaffTwoFactorMiddleware::class,
'ddos.track' => DDoSTrackingMiddleware::class,
'admin.security' => AdminSecurityMiddleware::class,
'radio.api' => RadioApiKey::class,
];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Models\RadioApiKey;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RadioApiKey
{
public function handle(Request $request, Closure $next, string $permission = '*'): Response
{
$key = $request->bearerToken() ?? $request->query('api_key');
if (empty($key)) {
return response()->json([
'error' => 'API key is verplicht. Gebruik Authorization: Bearer <key> of ?api_key=<key>',
], 401);
}
$apiKey = RadioApiKey::active()->where('key', $key)->first();
if (! $apiKey) {
return response()->json([
'error' => 'API key is ongeldig of verlopen',
], 401);
}
if (! $apiKey->isAllowedIp($request->ip())) {
return response()->json([
'error' => 'IP-adres niet toegestaan voor deze API key',
], 403);
}
if (! $apiKey->hasPermission($permission)) {
return response()->json([
'error' => 'Geen toestemming voor deze actie',
], 403);
}
$apiKey->touchLastUsed();
$request->merge(['radio_api_key_id' => $apiKey->id]);
return $next($request);
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class RadioApiKey extends Model
{
protected $fillable = [
'name',
'key',
'allowed_ips',
'permissions',
'rate_limit',
'expires_at',
'is_active',
];
protected function casts(): array
{
return [
'permissions' => 'array',
'is_active' => 'boolean',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public static function generate(string $name, array $options = []): self
{
return static::create([
'name' => $name,
'key' => 'rad_' . Str::random(48),
'allowed_ips' => $options['allowed_ips'] ?? null,
'permissions' => $options['permissions'] ?? ['*'],
'rate_limit' => $options['rate_limit'] ?? 300,
'expires_at' => $options['expires_at'] ?? null,
'is_active' => true,
]);
}
public function scopeActive($query)
{
return $query->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function hasPermission(string $permission): bool
{
$permissions = $this->permissions ?? ['*'];
return in_array('*', $permissions, true) || in_array($permission, $permissions, true);
}
public function isAllowedIp(?string $ip): bool
{
if (empty($this->allowed_ips)) {
return true;
}
$allowed = array_map('trim', explode(',', $this->allowed_ips));
return in_array($ip, $allowed, true);
}
public function touchLastUsed(): void
{
$this->update(['last_used_at' => now()]);
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack where($column, $operator = null, $value = null)
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack create($attributes = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack active()
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioAutoDjTrack orderBy($column, $direction = 'asc')
*
* @property string $title
* @property string|null $artist
* @property string|null $album
* @property string|null $artwork_url
* @property int|null $duration
* @property int $play_count
* @property \Carbon\Carbon|null $last_played_at
* @property bool $is_active
* @property int $sort_order
*/
class RadioAutoDjTrack extends Model
{
#[\Override]
protected $table = 'radio_auto_dj_playlist';
#[\Override]
protected $guarded = ['id', 'created_at', 'updated_at'];
#[\Override]
protected $casts = [
'duration' => 'integer',
'play_count' => 'integer',
'last_played_at' => 'datetime',
'is_active' => 'boolean',
'sort_order' => 'integer',
];
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('title');
}
public function markPlayed(): void
{
$this->increment('play_count');
$this->update(['last_played_at' => now()]);
}
public static function getNextTrack(): ?self
{
$last = self::active()->orderBy('last_played_at', 'asc')->orderBy('sort_order')->first();
if ($last) {
return $last;
}
return self::active()->ordered()->first();
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Services\Community\RadioScheduleService;
use App\Services\Community\RadioStreamService;
use Illuminate\Database\Eloquent\Model;
/**
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay where($column, $operator = null, $value = null)
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay create($attributes = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay orderBy($column, $direction = 'asc')
* @method static \Illuminate\Database\Eloquent\Builder<static>|RadioSongPlay latest($column = 'played_at')
*
* @property string $title
* @property string|null $artist
* @property string|null $album
* @property string|null $artwork_url
* @property int|null $duration
* @property \Carbon\Carbon $played_at
* @property int|null $dj_id
* @property array|null $metadata
*/
class RadioSongPlay extends Model
{
#[\Override]
protected $table = 'radio_song_plays';
#[\Override]
protected $guarded = ['id', 'created_at', 'updated_at'];
#[\Override]
protected $casts = [
'played_at' => 'datetime',
'metadata' => 'array',
'duration' => 'integer',
];
public static function recordNowPlaying(?string $title, ?string $artist = null, ?array $extra = null): ?self
{
if (! $title) {
return null;
}
$last = self::latest('played_at')->first();
if ($last && $last->title === $title && $last->artist === $artist) {
return null;
}
$djId = WebsiteSetting::where('key', 'radio_current_dj_id')->first()?->value;
return self::create([
'title' => $title,
'artist' => $artist,
'album' => $extra['album'] ?? null,
'artwork_url' => $extra['artwork_url'] ?? null,
'duration' => $extra['duration'] ?? null,
'played_at' => $extra['played_at'] ?? now(),
'dj_id' => $djId ? (int) $djId : null,
'metadata' => $extra['metadata'] ?? null,
]);
}
}
+7 -1
View File
@@ -69,6 +69,12 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(15)->by($request->ip()));
// Rate limit for radio endpoints (high traffic)
RateLimiter::for('radio', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()));
RateLimiter::for('radio', function (Request $request) {
$key = $request->get('radio_api_key_id')
?? $request->user()?->id
?? $request->ip();
return Limit::perMinute(120)->by((string) $key);
});
}
}