You've already forked Atomcms-edit
5476dce882
- 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/*
627 lines
22 KiB
PHP
Executable File
627 lines
22 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Services\Community;
|
|
|
|
use App\Enums\RadioSettings;
|
|
use App\Models\Miscellaneous\WebsiteSetting;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
class RadioStreamService
|
|
{
|
|
public function checkOnline(string $streamUrl): bool
|
|
{
|
|
if ($streamUrl === '' || $streamUrl === '0') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$response = Http::timeout(2)
|
|
->withOptions(['verify' => false])
|
|
->head($streamUrl);
|
|
|
|
if ($response->successful()) {
|
|
return true;
|
|
}
|
|
|
|
$response = Http::timeout(2)
|
|
->withOptions(['verify' => false])
|
|
->get($streamUrl);
|
|
|
|
return $response->successful();
|
|
} catch (\Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public function getNowPlaying(?string $apiUrl): array
|
|
{
|
|
if (! $apiUrl) {
|
|
return ['enabled' => true, 'song' => null, 'artist' => null];
|
|
}
|
|
|
|
try {
|
|
$response = Http::timeout(5)->get($apiUrl);
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
if (isset($data['now_playing'])) {
|
|
$song = $data['now_playing']['song'] ?? $data['now_playing'];
|
|
|
|
return [
|
|
'enabled' => true,
|
|
'song' => $song['title'] ?? $song['text'] ?? null,
|
|
'artist' => $song['artist'] ?? null,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'enabled' => true,
|
|
'song' => $data['song'] ?? $data['title'] ?? $data['now_playing'] ?? null,
|
|
'artist' => $data['artist'] ?? null,
|
|
];
|
|
}
|
|
} catch (\Exception) {
|
|
// Silent fail
|
|
}
|
|
|
|
return ['enabled' => true, 'song' => null, 'artist' => null];
|
|
}
|
|
|
|
public function getListenersCount(?string $apiUrl): int
|
|
{
|
|
if (! $apiUrl) {
|
|
return 0;
|
|
}
|
|
|
|
try {
|
|
$response = Http::timeout(5)->get($apiUrl);
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
return $data['listeners']['total']
|
|
?? $data['listeners']['current']
|
|
?? $data['listeners']
|
|
?? $data['count']
|
|
?? $data['total']
|
|
?? 0;
|
|
}
|
|
} catch (\Exception) {
|
|
// Silent fail
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
public function formatStreamUrl(string $url): string
|
|
{
|
|
if ($url === '' || $url === '0') {
|
|
return $url;
|
|
}
|
|
|
|
$url = str_replace('http://', 'https://', $url);
|
|
|
|
if (preg_match('/^(https?:\/\/[^\/]+):(\d+)\/(.+)$/', $url, $matches)) {
|
|
$baseUrl = $matches[1];
|
|
$port = $matches[2];
|
|
$path = $matches[3];
|
|
|
|
if (in_array($port, ['8000', '8010', '8020', '8030', '8040', '8050'])) {
|
|
return $baseUrl . '/radio/' . $port . '/' . $path;
|
|
}
|
|
}
|
|
|
|
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);
|
|
if (! empty($baseUrl)) {
|
|
return ['detected' => true, 'base_url' => $baseUrl];
|
|
}
|
|
|
|
$streamUrl = $this->getSetting(RadioSettings::StreamUrl, '');
|
|
if (empty($streamUrl)) {
|
|
return ['detected' => false];
|
|
}
|
|
|
|
$parsed = parse_url((string) $streamUrl);
|
|
if (! $parsed) {
|
|
return ['detected' => false];
|
|
}
|
|
|
|
$scheme = $parsed['scheme'] ?? 'https';
|
|
$host = $parsed['host'] ?? '';
|
|
$testUrl = $scheme . '://' . $host . '/api/nowplaying';
|
|
|
|
try {
|
|
$response = Http::timeout(3)->get($testUrl);
|
|
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;
|
|
$detectedBaseUrl = $scheme . '://' . $host;
|
|
|
|
$this->autoConfigureAzureCast($detectedBaseUrl, $stationId);
|
|
|
|
return ['detected' => true, 'base_url' => $detectedBaseUrl, 'station_id' => $stationId];
|
|
}
|
|
}
|
|
} catch (\Exception) {
|
|
// Not AzureCast
|
|
}
|
|
|
|
return ['detected' => false];
|
|
}
|
|
|
|
public function getAzureCastApiUrl(): ?string
|
|
{
|
|
$baseUrl = $this->getSetting(RadioSettings::AzureCastBaseUrl);
|
|
$stationId = $this->getSetting(RadioSettings::AzureCastStationId, '1');
|
|
|
|
if (! empty($baseUrl)) {
|
|
return rtrim((string) $baseUrl, '/') . '/api/nowplaying/' . $stationId;
|
|
}
|
|
|
|
$streamUrl = $this->getSetting(RadioSettings::StreamUrl);
|
|
if (! $streamUrl) {
|
|
return null;
|
|
}
|
|
|
|
$parsed = parse_url((string) $streamUrl);
|
|
if (! $parsed) {
|
|
return null;
|
|
}
|
|
|
|
$scheme = $parsed['scheme'] ?? 'https';
|
|
$host = $parsed['host'] ?? '';
|
|
|
|
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 {
|
|
$websiteSetting = WebsiteSetting::where('key', $setting->value)->first();
|
|
|
|
return $websiteSetting?->value ?? $default;
|
|
});
|
|
}
|
|
|
|
private function autoConfigureAzureCast(string $baseUrl, int $stationId): void
|
|
{
|
|
$settings = [
|
|
RadioSettings::AzureCastBaseUrl->value => $baseUrl,
|
|
RadioSettings::AzureCastStationId->value => $stationId,
|
|
RadioSettings::NowPlayingEnabled->value => '1',
|
|
RadioSettings::ListenersEnabled->value => '1',
|
|
RadioSettings::ShowCurrentDj->value => '1',
|
|
RadioSettings::WidgetEnabled->value => '1',
|
|
RadioSettings::WidgetShowGlobally->value => '1',
|
|
];
|
|
|
|
foreach ($settings as $key => $value) {
|
|
WebsiteSetting::updateOrCreate(
|
|
['key' => $key],
|
|
['value' => $value, 'comment' => 'Auto-configured'],
|
|
);
|
|
}
|
|
|
|
Cache::forget('radio_settings_*');
|
|
}
|
|
}
|