Add multi-step radio wizard with Shoutcast/Icecast/AzureCast support and multi-language translations

- New 5-step RadioWizardController with session-based wizard flow
- Enhanced RadioStreamService with Shoutcast/Icecast/ AzureCast auto-detection
- Connection test functionality for stream, now-playing, and listeners
- Wizard views for all 5 steps with step indicator navigation
- All 21 language files updated with wizard translation keys (NL/EN + placeholders)
- Wizard link added to existing radio setup page
- Routes registered under /admin/radio/wizard/*
This commit is contained in:
root
2026-05-24 13:12:57 +02:00
parent 8c49a1138c
commit 5476dce882
31 changed files with 4298 additions and 43 deletions
+425 -3
View File
@@ -16,10 +16,19 @@ class RadioStreamService
}
try {
return Http::timeout(2)
$response = Http::timeout(2)
->withOptions(['verify' => false])
->head($streamUrl)
->successful();
->head($streamUrl);
if ($response->successful()) {
return true;
}
$response = Http::timeout(2)
->withOptions(['verify' => false])
->get($streamUrl);
return $response->successful();
} catch (\Exception) {
return false;
}
@@ -105,6 +114,355 @@ class RadioStreamService
return $url;
}
public function detectStreamType(string $streamUrl): array
{
if (empty($streamUrl)) {
return ['type' => 'unknown', 'detected' => false];
}
$parsed = parse_url($streamUrl);
if (! $parsed) {
return ['type' => 'unknown', 'detected' => false];
}
$scheme = $parsed['scheme'] ?? 'https';
$host = $parsed['host'] ?? '';
$baseUrl = $scheme . '://' . $host;
// Try AzureCast first
$azureCast = $this->tryAzureCast($baseUrl);
if ($azureCast['detected']) {
return $azureCast;
}
// Try Icecast
$icecast = $this->tryIcecast($baseUrl);
if ($icecast['detected']) {
return $icecast;
}
// Try Shoutcast
$shoutcast = $this->tryShoutcast($baseUrl, $streamUrl);
if ($shoutcast['detected']) {
return $shoutcast;
}
return ['type' => 'unknown', 'detected' => false, 'base_url' => $baseUrl];
}
public function tryAzureCast(string $baseUrl): array
{
try {
$response = Http::timeout(3)->get(rtrim($baseUrl, '/') . '/api/nowplaying');
if ($response->successful()) {
$data = $response->json();
if (is_array($data) && (isset($data[0]['station']) || isset($data['station']))) {
$stationId = $data[0]['station']['id'] ?? $data['station']['id'] ?? 1;
return [
'type' => 'azurecast',
'detected' => true,
'base_url' => $baseUrl,
'station_id' => $stationId,
'now_playing_api' => rtrim($baseUrl, '/') . '/api/nowplaying/' . $stationId,
'listeners_api' => rtrim($baseUrl, '/') . '/api/nowplaying/' . $stationId,
];
}
}
} catch (\Exception) {
// Not AzureCast
}
return ['detected' => false];
}
public function tryIcecast(string $baseUrl): array
{
try {
$response = Http::timeout(3)->get(rtrim($baseUrl, '/') . '/status-json.xsl');
if ($response->successful()) {
$data = $response->json();
if (isset($data['icestats'])) {
$source = $data['icestats']['source'] ?? [];
$listeners = 0;
$song = null;
$artist = null;
if (is_array($source)) {
if (isset($source['listeners'])) {
$listeners = (int) $source['listeners'];
$title = $source['title'] ?? '';
} else {
$firstSource = $source[0] ?? null;
if ($firstSource) {
$listeners = (int) ($firstSource['listeners'] ?? 0);
$title = $firstSource['title'] ?? '';
}
}
}
$title = $title ?? '';
if (str_contains($title, ' - ')) {
$parts = explode(' - ', $title, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
} else {
$song = $title ?: null;
}
return [
'type' => 'icecast',
'detected' => true,
'base_url' => $baseUrl,
'now_playing_api' => rtrim($baseUrl, '/') . '/status-json.xsl',
'listeners_api' => rtrim($baseUrl, '/') . '/status-json.xsl',
'listeners' => $listeners,
'song' => $song,
'artist' => $artist,
];
}
}
} catch (\Exception) {
// Not Icecast
}
return ['detected' => false];
}
public function tryShoutcast(string $baseUrl, string $streamUrl): array
{
// Try SHOUTcast stats endpoint
$statsUrls = [
rtrim($baseUrl, '/') . '/stats?json=1',
rtrim($baseUrl, '/') . '/7.html',
];
foreach ($statsUrls as $statsUrl) {
try {
$response = Http::timeout(3)->get($statsUrl);
if ($response->successful()) {
$body = $response->body();
if (str_contains($statsUrl, 'json=1')) {
$data = json_decode($body, true);
if ($data && isset($data['streamstatus'])) {
$song = $data['songtitle'] ?? null;
$artist = null;
if ($song && str_contains($song, ' - ')) {
$parts = explode(' - ', $song, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
}
return [
'type' => 'shoutcast',
'detected' => true,
'base_url' => $baseUrl,
'now_playing_api' => rtrim($baseUrl, '/') . '/stats?json=1',
'listeners_api' => rtrim($baseUrl, '/') . '/stats?json=1',
'listeners' => (int) ($data['currentlisteners'] ?? 0),
'song' => $song,
'artist' => $artist,
'stream_url' => $data['streamurl'] ?? $streamUrl,
];
}
}
if (str_contains($body, ',')) {
$parts = explode(',', $body);
if (count($parts) >= 7) {
$listeners = (int) trim($parts[2]);
return [
'type' => 'shoutcast',
'detected' => true,
'base_url' => $baseUrl,
'now_playing_api' => rtrim($baseUrl, '/') . '/7.html',
'listeners_api' => rtrim($baseUrl, '/') . '/7.html',
'listeners' => $listeners,
'stream_url' => $streamUrl,
];
}
}
}
} catch (\Exception) {
continue;
}
}
return ['detected' => false];
}
public function testStreamConnection(string $streamUrl, ?string $nowPlayingApiUrl = null, ?string $listenersApiUrl = null): array
{
$results = [
'stream' => ['status' => 'untested', 'message' => ''],
'now_playing' => ['status' => 'untested', 'message' => ''],
'listeners' => ['status' => 'untested', 'message' => ''],
'stream_type' => 'unknown',
'stream_info' => null,
];
if (empty($streamUrl)) {
$results['stream'] = ['status' => 'error', 'message' => 'Geen stream URL opgegeven'];
return $results;
}
// Test stream URL
try {
$response = Http::timeout(5)
->withOptions(['verify' => false])
->head($streamUrl);
if ($response->successful()) {
$contentType = $response->header('Content-Type');
$results['stream'] = [
'status' => 'success',
'message' => 'Stream bereikbaar!',
'content_type' => $contentType,
'http_code' => $response->status(),
];
} else {
$results['stream'] = [
'status' => 'warning',
'message' => 'Stream reageert met status ' . $response->status(),
'http_code' => $response->status(),
];
}
} catch (\Exception $e) {
$results['stream'] = [
'status' => 'error',
'message' => 'Kon geen verbinding maken: ' . $e->getMessage(),
];
}
// Detect stream type
$detected = $this->detectStreamType($streamUrl);
$results['stream_type'] = $detected['type'];
$results['stream_info'] = $detected;
// Test now-playing API
$npUrl = $nowPlayingApiUrl ?: ($detected['now_playing_api'] ?? null);
if ($npUrl) {
try {
$npResponse = Http::timeout(5)->get($npUrl);
if ($npResponse->successful()) {
$npData = $npResponse->json();
$song = null;
$artist = null;
if ($detected['type'] === 'azurecast') {
$np = $npData['now_playing'] ?? [];
$song = $np['song']['title'] ?? null;
$artist = $np['song']['artist'] ?? null;
} elseif ($detected['type'] === 'icecast') {
$source = $npData['icestats']['source'] ?? [];
if (is_array($source)) {
if (isset($source['title'])) {
$title = $source['title'];
} else {
$title = $source[0]['title'] ?? null;
}
if ($title && str_contains($title, ' - ')) {
$parts = explode(' - ', $title, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
} else {
$song = $title;
}
}
} elseif ($detected['type'] === 'shoutcast') {
$song = $npData['songtitle'] ?? null;
if ($song && str_contains($song, ' - ')) {
$parts = explode(' - ', $song, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
}
} else {
$song = $npData['song'] ?? $npData['title'] ?? null;
$artist = $npData['artist'] ?? null;
}
$results['now_playing'] = [
'status' => 'success',
'message' => 'Nu afspelen informatie beschikbaar',
'song' => $song,
'artist' => $artist,
'api_url' => $npUrl,
];
} else {
$results['now_playing'] = [
'status' => 'warning',
'message' => 'API reageert met status ' . $npResponse->status(),
'api_url' => $npUrl,
];
}
} catch (\Exception $e) {
$results['now_playing'] = [
'status' => 'error',
'message' => 'Kon now-playing API niet bereiken: ' . $e->getMessage(),
'api_url' => $npUrl,
];
}
} else {
$results['now_playing'] = [
'status' => 'skipped',
'message' => 'Geen now-playing API geconfigureerd',
];
}
// Test listeners API
$listenersUrl = $listenersApiUrl ?: ($detected['listeners_api'] ?? null);
if ($listenersUrl) {
try {
$listenersResponse = Http::timeout(5)->get($listenersUrl);
if ($listenersResponse->successful()) {
$listenersData = $listenersResponse->json();
$count = 0;
if ($detected['type'] === 'azurecast') {
$count = $listenersData['listeners']['total'] ?? $listenersData['listeners']['current'] ?? 0;
} elseif ($detected['type'] === 'icecast') {
$source = $listenersData['icestats']['source'] ?? [];
if (is_array($source)) {
$count = (int) ($source['listeners'] ?? $source[0]['listeners'] ?? 0);
}
} elseif ($detected['type'] === 'shoutcast') {
$count = (int) ($listenersData['currentlisteners'] ?? 0);
} else {
$count = $listenersData['listeners'] ?? $listenersData['count'] ?? $listenersData['total'] ?? 0;
}
$results['listeners'] = [
'status' => 'success',
'message' => 'Luisteraars informatie beschikbaar',
'count' => (int) $count,
'api_url' => $listenersUrl,
];
} else {
$results['listeners'] = [
'status' => 'warning',
'message' => 'Listeners API reageert met status ' . $listenersResponse->status(),
'api_url' => $listenersUrl,
];
}
} catch (\Exception $e) {
$results['listeners'] = [
'status' => 'error',
'message' => 'Kon listeners API niet bereiken: ' . $e->getMessage(),
'api_url' => $listenersUrl,
];
}
} else {
$results['listeners'] = [
'status' => 'skipped',
'message' => 'Geen listeners API geconfigureerd',
];
}
return $results;
}
public function detectAzureCast(): array
{
$baseUrl = $this->getSetting(RadioSettings::AzureCastBaseUrl);
@@ -171,6 +529,70 @@ class RadioStreamService
return $scheme . '://' . $host . '/api/nowplaying/' . $stationId;
}
public function getNowPlayingFromShoutcast(string $baseUrl): ?array
{
try {
$response = Http::timeout(3)->get(rtrim($baseUrl, '/') . '/stats?json=1');
if ($response->successful()) {
$data = $response->json();
$song = $data['songtitle'] ?? null;
$artist = null;
if ($song && str_contains($song, ' - ')) {
$parts = explode(' - ', $song, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
}
return [
'song' => $song,
'artist' => $artist,
'raw' => $data['songtitle'] ?? null,
];
}
} catch (\Exception) {
// Silent fail
}
return null;
}
public function getNowPlayingFromIcecast(string $baseUrl): ?array
{
try {
$response = Http::timeout(3)->get(rtrim($baseUrl, '/') . '/status-json.xsl');
if ($response->successful()) {
$data = $response->json();
$icestats = $data['icestats'] ?? [];
$source = $icestats['source'] ?? [];
if (is_array($source)) {
$title = $source['title'] ?? $source[0]['title'] ?? null;
$song = null;
$artist = null;
if ($title && str_contains($title, ' - ')) {
$parts = explode(' - ', $title, 2);
$artist = trim($parts[0]);
$song = trim($parts[1]);
} else {
$song = $title;
}
return [
'song' => $song,
'artist' => $artist,
'raw' => $title,
];
}
}
} catch (\Exception) {
// Silent fail
}
return null;
}
private function getSetting(RadioSettings $setting, mixed $default = null): mixed
{
return Cache::remember("setting_{$setting->value}", 60, function () use ($setting, $default): mixed {