refactor: improve code quality across controllers and services

- DRY FurniEditorController: extract duplicate try/catch blocks into handleApiError(),
  formatItemData(), buildUpdateData(), buildInsertData(), castValue() methods
- ProfileController: replace 45 lines of manual date formatting with Carbon's diffForHumans()
- Replace custom Password rule (180 lines) with Laravel's built-in Password::min() rule
- RadioController: extract RadioStreamService and RadioScheduleService, reducing from 608 to 323 lines
- Add RadioSettings enum to replace magic strings throughout radio feature
- Add CurrencyTypes::columnName() helper method
- Add consistent return types (JsonResponse, View, RedirectResponse) to all controller methods
This commit is contained in:
root
2026-05-19 19:16:59 +02:00
parent 8567ce6951
commit 81e99933e4
9 changed files with 636 additions and 731 deletions
@@ -2,18 +2,25 @@
namespace App\Actions\Fortify\Rules; namespace App\Actions\Fortify\Rules;
use App\Rules\Password; use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules trait PasswordValidationRules
{ {
/** /**
* Get the validation rules used to validate passwords. * Get the validation rules used to validate passwords.
*/ *
/**
* @return array<int, mixed> * @return array<int, mixed>
*/ */
protected function passwordRules(): array protected function passwordRules(): array
{ {
return ['required', 'string', new Password, 'confirmed']; return [
'required',
'string',
Password::min(6)
->mixedCase()
->numbers()
->symbols(),
'confirmed',
];
} }
} }
+10
View File
@@ -30,6 +30,16 @@ enum CurrencyTypes: int
}; };
} }
public function columnName(): string
{
return match ($this) {
self::Credits => 'credits',
self::Duckets => 'duckets',
self::Diamonds => 'diamonds',
self::Points => 'points',
};
}
public function getImage(): string public function getImage(): string
{ {
return match ($this->value) { return match ($this->value) {
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
enum RadioSettings: string
{
case Enabled = 'radio_enabled';
case StreamUrl = 'radio_stream_url';
case CurrentDjId = 'radio_current_dj_id';
case Style = 'radio_style';
case ShoutsEnabled = 'radio_shouts_enabled';
case ApplicationsEnabled = 'radio_applications_enabled';
case NowPlayingEnabled = 'radio_now_playing_enabled';
case NowPlayingApiUrl = 'radio_now_playing_api_url';
case ListenersEnabled = 'radio_listeners_enabled';
case ListenersApiUrl = 'radio_listeners_api_url';
case AzureCastBaseUrl = 'radio_azurecast_base_url';
case AzureCastStationId = 'radio_azurecast_station_id';
case ShowCurrentDj = 'radio_show_current_dj';
case WidgetEnabled = 'radio_widget_enabled';
case WidgetShowGlobally = 'radio_widget_show_globally';
case WidgetPosition = 'radio_widget_position';
public function label(): string
{
return match ($this) {
self::Enabled => 'Radio Enabled',
self::StreamUrl => 'Stream URL',
self::CurrentDjId => 'Current DJ ID',
self::Style => 'Radio Style',
self::ShoutsEnabled => 'Shouts Enabled',
self::ApplicationsEnabled => 'Applications Enabled',
self::NowPlayingEnabled => 'Now Playing Enabled',
self::NowPlayingApiUrl => 'Now Playing API URL',
self::ListenersEnabled => 'Listeners Enabled',
self::ListenersApiUrl => 'Listeners API URL',
self::AzureCastBaseUrl => 'AzureCast Base URL',
self::AzureCastStationId => 'AzureCast Station ID',
self::ShowCurrentDj => 'Show Current DJ',
self::WidgetEnabled => 'Widget Enabled',
self::WidgetShowGlobally => 'Widget Show Globally',
self::WidgetPosition => 'Widget Position',
};
}
}
+191 -147
View File
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -17,7 +18,63 @@ class FurniEditorController extends Controller
} }
} }
public function search(Request $request) private function handleApiError(string $action, \Exception $e): JsonResponse
{
Log::error("[FurniEditor] {$action} failed", [
'error' => $e->getMessage(),
'user_id' => Auth::id(),
]);
return response()->json(['error' => "{$action} mislukt"], 500);
}
private function formatItemData(\stdClass $item): array
{
return [
'id' => $item->id,
'spriteId' => $item->sprite_id,
'itemName' => $item->item_name,
'publicName' => $item->public_name,
'type' => $item->type,
'width' => $item->width,
'length' => $item->length,
'stackHeight' => $item->stack_height,
'allowStack' => (bool) $item->allow_stack,
'allowWalk' => (bool) $item->allow_walk,
'allowSit' => (bool) $item->allow_sit,
'allowLay' => (bool) $item->allow_lay,
'allowGift' => (bool) ($item->allow_gift ?? false),
'allowTrade' => (bool) ($item->allow_trade ?? false),
'allowRecycle' => (bool) ($item->allow_recycle ?? false),
'allowMarketplaceSell' => (bool) ($item->allow_marketplace_sell ?? false),
'allowInventoryStack' => (bool) ($item->allow_inventory_stack ?? true),
'interactionType' => $item->interaction_type,
'interactionModesCount' => $item->interaction_modes_count,
'vendingIds' => $item->vending_ids ?? '',
'multiheight' => $item->multiheight ?? '',
'customparams' => $item->customparams ?? '',
'effectIdMale' => $item->effect_id_male ?? 0,
'effectIdFemale' => $item->effect_id_female ?? 0,
'clothingOnWalk' => $item->clothing_on_walk ?? '',
];
}
private function formatCatalogItemData(\stdClass $r): array
{
return [
'id' => $r->id,
'catalogName' => $r->catalog_name,
'costCredits' => $r->cost_credits,
'costPoints' => $r->cost_points,
'pointsType' => $r->points_type,
'pageId' => $r->page_id,
'pageName' => $r->page_id
? DB::table('catalog_pages')->where('id', $r->page_id)->value('caption') ?? ''
: '',
];
}
public function search(Request $request): JsonResponse
{ {
$this->checkAdmin(); $this->checkAdmin();
@@ -70,14 +127,12 @@ class FurniEditorController extends Controller
'total' => $total, 'total' => $total,
'page' => $page, 'page' => $page,
]); ]);
} catch (\Exception) { } catch (\Exception $e) {
Log::error('[FurniEditor] Search failed', ['error' => 'Actie mislukt']); return $this->handleApiError('Zoeken', $e);
return response()->json(['error' => 'Zoeken mislukt'], 500);
} }
} }
public function detail(Request $request) public function detail(Request $request): JsonResponse
{ {
$this->checkAdmin(); $this->checkAdmin();
@@ -95,55 +150,22 @@ class FurniEditorController extends Controller
$catalog = DB::table('catalog_items') $catalog = DB::table('catalog_items')
->whereRaw('FIND_IN_SET(?, item_ids)', [$id]) ->whereRaw('FIND_IN_SET(?, item_ids)', [$id])
->get() ->get()
->map(fn ($r) => [ ->map(fn ($r) => $this->formatCatalogItemData($r));
'id' => $r->id,
'catalogName' => $r->catalog_name,
'costCredits' => $r->cost_credits,
'costPoints' => $r->cost_points,
'pointsType' => $r->points_type,
'pageId' => $r->page_id,
'pageName' => $r->page_id ? DB::table('catalog_pages')->where('id', $r->page_id)->value('caption') ?? '' : '',
]);
return response()->json([ return response()->json([
'item' => [ 'item' => array_merge($this->formatItemData($item), [
'id' => $item->id,
'spriteId' => $item->sprite_id,
'itemName' => $item->item_name,
'publicName' => $item->public_name,
'type' => $item->type,
'width' => $item->width,
'length' => $item->length,
'stackHeight' => $item->stack_height,
'allowStack' => (bool) $item->allow_stack,
'allowWalk' => (bool) $item->allow_walk,
'allowSit' => (bool) $item->allow_sit,
'allowLay' => (bool) $item->allow_lay,
'allowGift' => (bool) $item->allow_gift,
'allowTrade' => (bool) $item->allow_trade,
'allowRecycle' => (bool) $item->allow_recycle,
'allowMarketplaceSell' => (bool) $item->allow_marketplace_sell,
'allowInventoryStack' => (bool) $item->allow_inventory_stack,
'interactionType' => $item->interaction_type,
'interactionModesCount' => $item->interaction_modes_count,
'vendingIds' => $item->vending_ids,
'multiheight' => $item->multiheight,
'customparams' => $item->customparams,
'effectIdMale' => $item->effect_id_male,
'effectIdFemale' => $item->effect_id_female,
'clothingOnWalk' => $item->clothing_on_walk,
'description' => '', 'description' => '',
'usageCount' => 0, 'usageCount' => 0,
], ]),
'catalogItems' => $catalog, 'catalogItems' => $catalog,
'furniDataEntry' => null, 'furniDataEntry' => null,
]); ]);
} catch (\Exception) { } catch (\Exception $e) {
return response()->json(['error' => 'Kan item niet laden'], 500); return $this->handleApiError('Item laden', $e);
} }
} }
public function update(Request $request) public function update(Request $request): JsonResponse
{ {
$this->checkAdmin(); $this->checkAdmin();
@@ -154,8 +176,113 @@ class FurniEditorController extends Controller
} }
$data = $request->json()->all(); $data = $request->json()->all();
$update = []; $update = $this->buildUpdateData($data);
if ($update === []) {
return response()->json(['error' => 'No fields to update'], 400);
}
DB::table('items_base')->where('id', $id)->update($update);
Log::info('[Audit] FurniEditor update', [
'user_id' => Auth::id(),
'item_id' => $id,
'fields' => array_keys($update),
]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return $this->handleApiError('Actie', $e);
}
}
public function create(Request $request): JsonResponse
{
$this->checkAdmin();
try {
$data = $request->json()->all();
$id = DB::table('items_base')->insertGetId($this->buildInsertData($data));
Log::info('[Audit] FurniEditor create', [
'user_id' => Auth::id(),
'new_id' => $id,
]);
return response()->json(['id' => $id]);
} catch (\Exception $e) {
return $this->handleApiError('Actie', $e);
}
}
public function delete(Request $request): JsonResponse
{
$this->checkAdmin();
try {
$id = (int) $request->input('id');
if ($id === 0) {
return response()->json(['error' => 'Missing id'], 400);
}
DB::table('items_base')->where('id', $id)->delete();
Log::warning('[Audit] FurniEditor delete', [
'user_id' => Auth::id(),
'deleted_id' => $id,
]);
return response()->json(['success' => true]);
} catch (\Exception $e) {
return $this->handleApiError('Actie', $e);
}
}
public function interactions(Request $request): JsonResponse
{
$this->checkAdmin();
try {
$rows = DB::table('items_base')
->select('interaction_type')
->groupBy('interaction_type')
->orderBy('interaction_type')
->pluck('interaction_type');
return response()->json(['interactions' => $rows]);
} catch (\Exception $e) {
return $this->handleApiError('Actie', $e);
}
}
public function bySprite(Request $request): JsonResponse
{
$this->checkAdmin();
try {
$spriteId = (int) $request->input('spriteId');
if ($spriteId === 0) {
return response()->json(['error' => 'Missing spriteId'], 400);
}
$item = DB::table('items_base')->where('sprite_id', $spriteId)->first();
if (! $item) {
return response()->json(['error' => 'Item not found'], 404);
}
return response()->json(['id' => $item->id]);
} catch (\Exception $e) {
return $this->handleApiError('Actie', $e);
}
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function buildUpdateData(array $data): array
{
$map = [ $map = [
'itemName' => ['item_name', 'string', 100], 'itemName' => ['item_name', 'string', 100],
'publicName' => ['public_name', 'string', 100], 'publicName' => ['public_name', 'string', 100],
@@ -182,47 +309,25 @@ class FurniEditorController extends Controller
'clothingOnWalk' => ['clothing_on_walk', 'string', 255], 'clothingOnWalk' => ['clothing_on_walk', 'string', 255],
]; ];
$update = [];
foreach ($map as $key => [$col, $type, $maxLen]) { foreach ($map as $key => [$col, $type, $maxLen]) {
if (! array_key_exists($key, $data)) { if (! array_key_exists($key, $data)) {
continue; continue;
} }
$val = $data[$key]; $update[$col] = $this->castValue($data[$key], $type, $maxLen);
if ($type === 'string') {
$val = mb_substr(strip_tags((string) $val), 0, $maxLen);
} elseif ($type === 'integer') {
$val = (int) $val;
} elseif ($type === 'boolean') {
$val = (bool) $val;
}
$update[$col] = $val;
} }
if ($update === []) { return $update;
return response()->json(['error' => 'No fields to update'], 400);
} }
DB::table('items_base')->where('id', $id)->update($update); /**
* @param array<string, mixed> $data
Log::info('[Audit] FurniEditor update', [ * @return array<string, mixed>
'user_id' => Auth::id(), */
'item_id' => $id, private function buildInsertData(array $data): array
'fields' => array_keys($update),
]);
return response()->json(['success' => true]);
} catch (\Exception) {
return response()->json(['error' => 'Actie mislukt'], 500);
}
}
public function create(Request $request)
{ {
$this->checkAdmin(); return [
try {
$data = $request->json()->all();
$id = DB::table('items_base')->insertGetId([
'sprite_id' => $data['spriteId'] ?? 0, 'sprite_id' => $data['spriteId'] ?? 0,
'item_name' => $data['itemName'] ?? '', 'item_name' => $data['itemName'] ?? '',
'public_name' => $data['publicName'] ?? '', 'public_name' => $data['publicName'] ?? '',
@@ -241,77 +346,16 @@ class FurniEditorController extends Controller
'allow_inventory_stack' => $data['allowInventoryStack'] ?? true, 'allow_inventory_stack' => $data['allowInventoryStack'] ?? true,
'interaction_type' => $data['interactionType'] ?? 'default', 'interaction_type' => $data['interactionType'] ?? 'default',
'interaction_modes_count' => $data['interactionModesCount'] ?? 1, 'interaction_modes_count' => $data['interactionModesCount'] ?? 1,
]); ];
Log::info('[Audit] FurniEditor create', [
'user_id' => Auth::id(),
'new_id' => $id,
]);
return response()->json(['id' => $id]);
} catch (\Exception) {
return response()->json(['error' => 'Actie mislukt'], 500);
}
} }
public function delete(Request $request) private function castValue(mixed $value, string $type, int $maxLen = 0): mixed
{ {
$this->checkAdmin(); return match ($type) {
'string' => mb_substr(strip_tags((string) $value), 0, $maxLen),
try { 'integer' => (int) $value,
$id = (int) $request->input('id'); 'boolean' => (bool) $value,
if ($id === 0) { default => $value,
return response()->json(['error' => 'Missing id'], 400); };
}
DB::table('items_base')->where('id', $id)->delete();
Log::warning('[Audit] FurniEditor delete', [
'user_id' => Auth::id(),
'deleted_id' => $id,
]);
return response()->json(['success' => true]);
} catch (\Exception) {
return response()->json(['error' => 'Actie mislukt'], 500);
}
}
public function interactions(Request $request)
{
$this->checkAdmin();
try {
$rows = DB::table('items_base')
->select('interaction_type')
->groupBy('interaction_type')
->orderBy('interaction_type')
->pluck('interaction_type');
return response()->json(['interactions' => $rows]);
} catch (\Exception) {
return response()->json(['error' => 'Actie mislukt'], 500);
}
}
public function bySprite(Request $request)
{
$this->checkAdmin();
try {
$spriteId = (int) $request->input('spriteId');
if ($spriteId === 0) {
return response()->json(['error' => 'Missing spriteId'], 400);
}
$item = DB::table('items_base')->where('sprite_id', $spriteId)->first();
if (! $item) {
return response()->json(['error' => 'Item not found'], 404);
}
return response()->json(['id' => $item->id]);
} catch (\Exception) {
return response()->json(['error' => 'Actie mislukt'], 500);
}
} }
} }
@@ -2,80 +2,41 @@
namespace App\Http\Controllers\Community; namespace App\Http\Controllers\Community;
use App\Enums\RadioSettings;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Miscellaneous\WebsiteSetting; use App\Models\Miscellaneous\WebsiteSetting;
use App\Models\RadioApplication; use App\Models\RadioApplication;
use App\Models\RadioBanner; use App\Models\RadioBanner;
use App\Models\RadioHistory; use App\Models\RadioHistory;
use App\Models\RadioRank; use App\Models\RadioRank;
use App\Models\RadioSchedule;
use App\Models\RadioShout; use App\Models\RadioShout;
use App\Models\User; use App\Services\Community\RadioScheduleService;
use App\Services\Community\RadioStreamService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View; use Illuminate\View\View;
class RadioController extends Controller class RadioController extends Controller
{ {
private ?bool $azureCastDetected = null; public function __construct(
private readonly RadioStreamService $streamService,
/** @var array<mixed> */ private readonly RadioScheduleService $scheduleService,
private array $settingsCache = []; ) {}
/**
* Get multiple settings at once (batched query)
*
* @param array<string> $keys
*
* @return array<string|null>
*/
private function getSettings(array $keys): array
{
$cacheKey = 'radio_settings_' . md5(implode(',', $keys));
return Cache::remember($cacheKey, 60, function () use ($keys): array {
/** @var Collection $collection */
$collection = WebsiteSetting::whereIn('key', $keys)
->pluck('value', 'key');
return $collection->all();
});
}
/**
* Get single setting with caching
*/
private function getSetting(string $key, mixed $default = null): mixed
{
if (! isset($this->settingsCache[$key])) {
$this->settingsCache[$key] = Cache::remember("setting_{$key}", 60, function () use ($key): mixed {
/** @var WebsiteSetting|null $setting */
$setting = WebsiteSetting::where('key', $key)->first();
return $setting?->value;
});
}
return $this->settingsCache[$key] ?? $default;
}
public function index(): View public function index(): View
{ {
// Batch load all radio settings at once
$settings = $this->getSettings([ $settings = $this->getSettings([
'radio_enabled', RadioSettings::Enabled,
'radio_stream_url', RadioSettings::StreamUrl,
'radio_current_dj_id', RadioSettings::CurrentDjId,
]); ]);
if (! (bool) ($settings['radio_enabled'] ?? false)) { if (! (bool) ($settings[RadioSettings::Enabled->value] ?? false)) {
return view('community.radio.disabled'); return view('community.radio.disabled');
} }
// Load all data with single queries each
$ranks = Cache::remember('radio_ranks', 300, fn () => RadioRank::all()); $ranks = Cache::remember('radio_ranks', 300, fn () => RadioRank::all());
$banners = Cache::remember('radio_banners_active', 60, fn () => RadioBanner::with('user:id,username,look') $banners = Cache::remember('radio_banners_active', 60, fn () => RadioBanner::with('user:id,username,look')
@@ -84,36 +45,32 @@ class RadioController extends Controller
->take(10) ->take(10)
->get()); ->get());
$todaySchedule = Cache::remember('radio_schedule_today', 60, fn () => RadioSchedule::with('user:id,username,look') $todaySchedule = $this->scheduleService->getTodaySchedule();
->active() $currentDJ = $this->scheduleService->getCurrentDJ($settings[RadioSettings::CurrentDjId->value] ?? null);
->today()
->orderBy('start_time')
->get());
$currentDJ = $this->getCurrentDJFromSchedule(); $streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? '');
$isOnline = Cache::remember('radio_stream_status', 30, fn () => $this->streamService->checkOnline($streamUrl));
$streamUrl = $this->formatStreamUrl($settings['radio_stream_url'] ?? ''); return view('community.radio.index', [
'ranks' => $ranks,
// Cache stream status check (30 seconds) 'banners' => $banners,
$isOnline = Cache::remember('radio_stream_status', 30, fn () => $this->checkStreamOnline($streamUrl)); 'todaySchedule' => $todaySchedule,
'currentDJ' => $currentDJ,
return view('community.radio.index', ['ranks' => $ranks, 'banners' => $banners, 'todaySchedule' => $todaySchedule, 'currentDJ' => $currentDJ, 'isOnline' => $isOnline, 'streamUrl' => $streamUrl]); 'isOnline' => $isOnline,
'streamUrl' => $streamUrl,
]);
} }
public function rooster(): View public function rooster(): View
{ {
$schedule = Cache::remember('radio_schedule_all', 300, fn () => RadioSchedule::with('user:id,username,look') $schedule = $this->scheduleService->getFullSchedule();
->active()
->ordered()
->get()
->groupBy('day'));
return view('community.radio.rooster', ['schedule' => $schedule]); return view('community.radio.rooster', ['schedule' => $schedule]);
} }
public function shouts(): RedirectResponse|View public function shouts(): RedirectResponse|View
{ {
if (! (bool) $this->getSetting('radio_shouts_enabled')) { if (! $this->getSetting(RadioSettings::ShoutsEnabled)) {
return redirect()->route('radio.index')->with('error', __('radio.shouts_disabled')); return redirect()->route('radio.index')->with('error', __('radio.shouts_disabled'));
} }
@@ -122,38 +79,33 @@ class RadioController extends Controller
public function apply(): RedirectResponse|View public function apply(): RedirectResponse|View
{ {
if (! (bool) $this->getSetting('radio_applications_enabled')) { if (! $this->getSetting(RadioSettings::ApplicationsEnabled)) {
return redirect()->route('radio.index')->with('error', __('radio.applications_closed')); return redirect()->route('radio.index')->with('error', __('radio.applications_closed'));
} }
$userId = auth()->id(); $userId = auth()->id();
$hasPendingApplication = $userId && RadioApplication::where('user_id', $userId) $hasPendingApplication = $userId && RadioApplication::where('user_id', $userId)
->where('status', 'pending') ->where('status', 'pending')
->exists(); ->exists();
$ranks = Cache::remember('radio_ranks_accepting', 300, fn () => RadioRank::where('accepts_applications', true)->get()); $ranks = Cache::remember('radio_ranks_accepting', 300, fn () => RadioRank::where('accepts_applications', true)->get());
return view('community.radio.apply', ['ranks' => $ranks, 'hasPendingApplication' => $hasPendingApplication]); return view('community.radio.apply', [
'ranks' => $ranks,
'hasPendingApplication' => $hasPendingApplication,
]);
} }
public function storeApplication(Request $request): RedirectResponse public function storeApplication(Request $request): RedirectResponse
{ {
if (! (bool) $this->getSetting('radio_applications_enabled')) { if (! $this->getSetting(RadioSettings::ApplicationsEnabled)) {
return redirect()->route('radio.index')->with('error', __('radio.applications_closed')); return redirect()->route('radio.index')->with('error', __('radio.applications_closed'));
} }
/** @var int|string $userId */
$userId = auth()->id(); $userId = auth()->id();
$hasPendingApplication = RadioApplication::where('user_id', $userId) if (RadioApplication::where('user_id', $userId)->where('status', 'pending')->exists()) {
->where('status', 'pending') return back()->withErrors(['general' => __('radio.application_pending')])->withInput();
->exists();
if ($hasPendingApplication) {
return back()->withErrors([
'general' => __('radio.application_pending'),
])->withInput();
} }
$validated = $request->validate([ $validated = $request->validate([
@@ -167,7 +119,6 @@ class RadioController extends Controller
'rank_id' => ['nullable', 'exists:radio_ranks,id'], 'rank_id' => ['nullable', 'exists:radio_ranks,id'],
]); ]);
// Check rank accepts applications (cached)
if ($validated['rank_id']) { if ($validated['rank_id']) {
$rank = Cache::remember("radio_rank_{$validated['rank_id']}", 300, fn () => RadioRank::find($validated['rank_id'])); $rank = Cache::remember("radio_rank_{$validated['rank_id']}", 300, fn () => RadioRank::find($validated['rank_id']));
@@ -196,7 +147,7 @@ class RadioController extends Controller
public function storeShout(Request $request): RedirectResponse public function storeShout(Request $request): RedirectResponse
{ {
if (! (bool) $this->getSetting('radio_shouts_enabled')) { if (! $this->getSetting(RadioSettings::ShoutsEnabled)) {
return redirect()->route('radio.index')->with('error', __('radio.shouts_disabled')); return redirect()->route('radio.index')->with('error', __('radio.shouts_disabled'));
} }
@@ -209,7 +160,6 @@ class RadioController extends Controller
'message' => $validated['message'], 'message' => $validated['message'],
]); ]);
// Clear shouts cache
Cache::forget('radio_shouts_recent'); Cache::forget('radio_shouts_recent');
return redirect()->route('radio.shouts')->with('success', __('radio.shout_sent')); return redirect()->route('radio.shouts')->with('success', __('radio.shout_sent'));
@@ -217,23 +167,33 @@ class RadioController extends Controller
public function nowPlaying(): JsonResponse public function nowPlaying(): JsonResponse
{ {
// Cache now playing for 10 seconds to reduce API calls $nowPlaying = Cache::remember('radio_nowplaying', 10, function () {
$nowPlaying = Cache::remember('radio_nowplaying', 10, fn () => $this->getNowPlaying()); $apiUrl = $this->getSetting(RadioSettings::NowPlayingEnabled)
? ($this->getSetting(RadioSettings::NowPlayingApiUrl) ?: $this->streamService->getAzureCastApiUrl())
: null;
return $apiUrl ? $this->streamService->getNowPlaying($apiUrl) : ['enabled' => false, 'song' => null];
});
return response()->json($nowPlaying); return response()->json($nowPlaying);
} }
public function listeners(): JsonResponse public function listeners(): JsonResponse
{ {
// Cache listeners count for 30 seconds $count = Cache::remember('radio_listeners', 30, function () {
$count = Cache::remember('radio_listeners', 30, fn () => $this->getListenersCount()); $apiUrl = $this->getSetting(RadioSettings::ListenersEnabled)
? ($this->getSetting(RadioSettings::ListenersApiUrl) ?: $this->streamService->getAzureCastApiUrl())
: null;
return $apiUrl ? $this->streamService->getListenersCount($apiUrl) : 0;
});
return response()->json(['count' => $count]); return response()->json(['count' => $count]);
} }
public function currentDJ(): JsonResponse public function currentDJ(): JsonResponse
{ {
$dj = $this->getCurrentDJFromSchedule(); $dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId));
return response()->json([ return response()->json([
'dj' => $dj, 'dj' => $dj,
@@ -241,293 +201,36 @@ class RadioController extends Controller
]); ]);
} }
private function getCurrentDJFromSchedule(): ?array
{
$manualDjId = $this->getSetting('radio_current_dj_id');
if (! empty($manualDjId)) {
$dj = Cache::remember("user_{$manualDjId}", 60, fn () => User::find($manualDjId));
if ($dj) {
return [
'username' => $dj->username,
'look' => $dj->look,
'show_name' => 'Live DJ',
'is_manual' => true,
];
}
}
$currentSlot = RadioSchedule::with('user:id,username,look')
->active()
->where('day', $this->getCurrentDay())
->whereTime('start_time', '<=', now()->format('H:i:s'))
->whereTime('end_time', '>=', now()->format('H:i:s'))
->first();
if ($currentSlot?->user) {
return [
'username' => $currentSlot->user->username,
'look' => $currentSlot->user->look,
'show_name' => $currentSlot->show_name,
'start_time' => $currentSlot->start_time->format('H:i'),
'end_time' => $currentSlot->end_time->format('H:i'),
'is_manual' => false,
];
}
return null;
}
private function getNowPlaying(): array
{
if (! (bool) $this->getSetting('radio_now_playing_enabled')) {
return ['enabled' => false, 'song' => null];
}
$apiUrl = $this->getSetting('radio_now_playing_api_url') ?: $this->getAzureCastApiUrl();
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];
}
private function getAzureCastApiUrl(): ?string
{
$baseUrl = $this->getSetting('radio_azurecast_base_url');
$stationId = $this->getSetting('radio_azurecast_station_id', '1');
if (! empty($baseUrl)) {
return rtrim((string) $baseUrl, '/') . '/api/nowplaying/' . $stationId;
}
$streamUrl = $this->getSetting('radio_stream_url');
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;
}
private function getListenersCount(): int
{
if (! (bool) $this->getSetting('radio_listeners_enabled')) {
return 0;
}
$apiUrl = $this->getSetting('radio_listeners_api_url') ?: $this->getAzureCastApiUrl();
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;
}
private function getCurrentDay(): string
{
return strtolower(now()->format('l'));
}
private 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;
}
private function checkStreamOnline(string $streamUrl): bool
{
if ($streamUrl === '' || $streamUrl === '0') {
return false;
}
try {
return Http::timeout(2)->withOptions(['verify' => false])->head($streamUrl)->successful();
} catch (\Exception) {
return false;
}
}
private function detectAzureCast(): bool
{
if ($this->azureCastDetected !== null) {
return $this->azureCastDetected;
}
$baseUrl = $this->getSetting('radio_azurecast_base_url');
if (! empty($baseUrl)) {
$this->azureCastDetected = true;
return true;
}
$streamUrl = $this->getSetting('radio_stream_url', '');
if (empty($streamUrl)) {
$this->azureCastDetected = false;
return false;
}
$parsed = parse_url((string) $streamUrl);
if (! $parsed) {
$this->azureCastDetected = false;
return 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']))) {
$this->azureCastDetected = true;
$stationId = $data[0]['station']['id'] ?? $data['station']['id'] ?? 1;
$detectedBaseUrl = $scheme . '://' . $host;
WebsiteSetting::updateOrCreate(
['key' => 'radio_azurecast_base_url'],
['value' => $detectedBaseUrl, 'comment' => 'Auto-detected AzureCast'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_azurecast_station_id'],
['value' => $stationId, 'comment' => 'Auto-detected Station ID'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_now_playing_enabled'],
['value' => '1', 'comment' => 'Auto-enabled'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_listeners_enabled'],
['value' => '1', 'comment' => 'Auto-enabled'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_show_current_dj'],
['value' => '1', 'comment' => 'Auto-enabled'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_widget_enabled'],
['value' => '1', 'comment' => 'Auto-enabled'],
);
WebsiteSetting::updateOrCreate(
['key' => 'radio_widget_show_globally'],
['value' => '1', 'comment' => 'Auto-enabled'],
);
$this->settingsCache = [];
return true;
}
}
} catch (\Exception) {
// Not AzureCast
}
$this->azureCastDetected = false;
return false;
}
public function config(): JsonResponse public function config(): JsonResponse
{ {
$settings = $this->getSettings([ $settings = $this->getSettings([
'radio_enabled', RadioSettings::Enabled,
'radio_stream_url', RadioSettings::StreamUrl,
'radio_style', RadioSettings::Style,
'radio_now_playing_enabled', RadioSettings::NowPlayingEnabled,
'radio_listeners_enabled', RadioSettings::ListenersEnabled,
'radio_show_current_dj', RadioSettings::ShowCurrentDj,
'radio_widget_enabled', RadioSettings::WidgetEnabled,
'radio_widget_show_globally', RadioSettings::WidgetShowGlobally,
'radio_widget_position', RadioSettings::WidgetPosition,
]); ]);
$streamUrl = $this->formatStreamUrl($settings['radio_stream_url'] ?? ''); $streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? '');
$isAzurecast = $this->detectAzureCast(); $azureCast = $this->streamService->detectAzureCast();
return response()->json([ return response()->json([
'enabled' => (bool) ($settings['radio_enabled'] ?? false), 'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false),
'stream_url' => $streamUrl, 'stream_url' => $streamUrl,
'style' => $settings['radio_style'] ?? 'dark', 'style' => $settings[RadioSettings::Style->value] ?? 'dark',
'dj' => $this->getCurrentDJFromSchedule(), 'dj' => $this->scheduleService->getCurrentDJ($settings[RadioSettings::CurrentDjId->value] ?? null),
'now_playing_enabled' => (bool) ($settings['radio_now_playing_enabled'] ?? false), 'now_playing_enabled' => (bool) ($settings[RadioSettings::NowPlayingEnabled->value] ?? false),
'listeners_enabled' => (bool) ($settings['radio_listeners_enabled'] ?? false), 'listeners_enabled' => (bool) ($settings[RadioSettings::ListenersEnabled->value] ?? false),
'show_current_dj' => (bool) ($settings['radio_show_current_dj'] ?? false), 'show_current_dj' => (bool) ($settings[RadioSettings::ShowCurrentDj->value] ?? false),
'widget_enabled' => (bool) ($settings['radio_widget_enabled'] ?? false), 'widget_enabled' => (bool) ($settings[RadioSettings::WidgetEnabled->value] ?? false),
'widget_show_globally' => (bool) ($settings['radio_widget_show_globally'] ?? false), 'widget_show_globally' => (bool) ($settings[RadioSettings::WidgetShowGlobally->value] ?? false),
'widget_position' => $settings['radio_widget_position'] ?? 'bottom-right', 'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right',
'is_azurecast' => $isAzurecast, 'is_azurecast' => $azureCast['detected'],
'azurecast_detected' => $isAzurecast, 'azurecast_detected' => $azureCast['detected'],
]); ]);
} }
@@ -535,9 +238,7 @@ class RadioController extends Controller
{ {
$userId = auth()->id(); $userId = auth()->id();
$activeSession = RadioHistory::where('user_id', $userId) $activeSession = RadioHistory::where('user_id', $userId)->whereNull('ended_at')->first();
->whereNull('ended_at')
->first();
if ($activeSession) { if ($activeSession) {
return response()->json([ return response()->json([
@@ -562,14 +263,10 @@ class RadioController extends Controller
{ {
$userId = auth()->id(); $userId = auth()->id();
$activeSession = RadioHistory::where('user_id', $userId) $activeSession = RadioHistory::where('user_id', $userId)->whereNull('ended_at')->first();
->whereNull('ended_at')
->first();
if (! $activeSession) { if (! $activeSession) {
return response()->json([ return response()->json(['error' => 'Geen actieve sessie gevonden'], 404);
'error' => 'Geen actieve sessie gevonden',
], 404);
} }
$activeSession->endSession(); $activeSession->endSession();
@@ -582,11 +279,8 @@ class RadioController extends Controller
public function getShouts(): JsonResponse public function getShouts(): JsonResponse
{ {
if (! (bool) $this->getSetting('radio_shouts_enabled')) { if (! $this->getSetting(RadioSettings::ShoutsEnabled)) {
return response()->json([ return response()->json(['error' => 'Shouts zijn uitgeschakeld', 'shouts' => []], 403);
'error' => 'Shouts zijn uitgeschakeld',
'shouts' => [],
], 403);
} }
$shouts = Cache::remember('radio_shouts_recent', 30, fn () => RadioShout::with('user:id,username') $shouts = Cache::remember('radio_shouts_recent', 30, fn () => RadioShout::with('user:id,username')
@@ -605,4 +299,27 @@ class RadioController extends Controller
'total' => $shouts->count(), 'total' => $shouts->count(),
]); ]);
} }
/**
* @param array<RadioSettings> $keys
* @return array<string|null>
*/
private function getSettings(array $keys): array
{
$stringKeys = array_map(fn (RadioSettings $setting) => $setting->value, $keys);
$cacheKey = 'radio_settings_' . md5(implode(',', $stringKeys));
return Cache::remember($cacheKey, 60, function () use ($stringKeys): array {
return WebsiteSetting::whereIn('key', $stringKeys)->pluck('value', 'key')->all();
});
}
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;
});
}
} }
+31 -42
View File
@@ -5,11 +5,12 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Miscellaneous\WebsiteSetting; use App\Models\Miscellaneous\WebsiteSetting;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Illuminate\Contracts\View\View;
use Illuminate\Support\Carbon;
class ProfileController extends Controller class ProfileController extends Controller
{ {
public function __invoke(User $user) public function __invoke(User $user): View
{ {
$user->load([ $user->load([
'friends.friend:id,username,look', 'friends.friend:id,username,look',
@@ -19,13 +20,8 @@ class ProfileController extends Controller
'badges', 'badges',
]); ]);
$showStats = WebsiteSetting::where('key', 'profile_show_stats')->first()?->value ?? '1'; $showStats = (bool) (WebsiteSetting::where('key', 'profile_show_stats')->first()?->value ?? '1');
$showOnline = WebsiteSetting::where('key', 'profile_show_online_status')->first()?->value ?? '1'; $showOnline = (bool) (WebsiteSetting::where('key', 'profile_show_online_status')->first()?->value ?? '1');
$accountAge = $this->getAccountAge($user->account_created);
$lastLogin = $this->getLastLogin($user->last_login);
$totalFriends = $user->friends()->count();
$totalGuilds = $user->guilds()->count();
return view('user.profile', [ return view('user.profile', [
'user' => $user, 'user' => $user,
@@ -36,56 +32,49 @@ class ProfileController extends Controller
'badges' => $user->badges->take(3), 'badges' => $user->badges->take(3),
'showStats' => $showStats, 'showStats' => $showStats,
'showOnline' => $showOnline, 'showOnline' => $showOnline,
'accountAge' => $accountAge, 'accountAge' => $this->getAccountAge($user->account_created),
'lastLogin' => $lastLogin, 'lastLogin' => $this->getLastLogin($user->last_login),
'totalFriends' => $totalFriends, 'totalFriends' => $user->friends()->count(),
'totalGuilds' => $totalGuilds, 'totalGuilds' => $user->guilds()->count(),
]); ]);
} }
private function getAccountAge(int $timestamp): string private function getAccountAge(int $timestamp): string
{ {
$created = Carbon::createFromTimestamp($timestamp); $created = Carbon::createFromTimestamp($timestamp);
$now = Carbon::now();
$days = $created->diffInDays($now);
if ($days < 7) { if ($created->diffInYears() >= 1) {
return $days . ' day' . ($days !== 1 ? 's' : ''); return $created->diffInYears() . ' ' . str('year')->plural($created->diffInYears());
} elseif ($days < 30) {
$weeks = floor($days / 7);
return $weeks . ' week' . ($weeks !== 1 ? 's' : '');
} elseif ($days < 365) {
$months = floor($days / 30);
return $months . ' month' . ($months !== 1 ? 's' : '');
} else {
$years = floor($days / 365);
return $years . ' year' . ($years !== 1 ? 's' : '');
} }
if ($created->diffInMonths() >= 1) {
return $created->diffInMonths() . ' ' . str('month')->plural($created->diffInMonths());
}
if ($created->diffInWeeks() >= 1) {
return $created->diffInWeeks() . ' ' . str('week')->plural($created->diffInWeeks());
}
return $created->diffInDays() . ' ' . str('day')->plural($created->diffInDays());
} }
private function getLastLogin(int $timestamp): string private function getLastLogin(int $timestamp): string
{ {
$lastLogin = Carbon::createFromTimestamp($timestamp); $lastLogin = Carbon::createFromTimestamp($timestamp);
$now = Carbon::now(); $diffInMinutes = $lastLogin->diffInMinutes();
$diff = $now->diffInMinutes($lastLogin);
if ($diff < 1) { if ($diffInMinutes < 1) {
return 'Just now'; return 'Just now';
} elseif ($diff < 60) { }
return $diff . ' minute' . ($diff !== 1 ? 's' : '') . ' ago';
} elseif ($diff < 1440) {
$hours = floor($diff / 60);
return $hours . ' hour' . ($hours !== 1 ? 's' : '') . ' ago'; if ($diffInMinutes < 60) {
} elseif ($diff < 10080) { return $lastLogin->diffForHumans();
$days = floor($diff / 1440); }
if ($diffInMinutes < 10080) {
return $lastLogin->diffForHumans();
}
return $days . ' day' . ($days !== 1 ? 's' : '') . ' ago';
} else {
return $lastLogin->format('d M Y'); return $lastLogin->format('d M Y');
} }
}
} }
-180
View File
@@ -1,180 +0,0 @@
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Str;
class Password implements Rule
{
/**
* The minimum length of the password.
*/
protected int $length = 6;
/**
* Indicates if the password must contain one uppercase character.
*/
protected bool $requireUppercase = false;
/**
* Indicates if the password must contain one numeric digit.
*/
protected bool $requireNumeric = false;
/**
* Indicates if the password must contain one special character.
*/
protected bool $requireSpecialCharacter = false;
/**
* The message that should be used when validation fails.
*/
protected ?string $message = null;
/**
* Determine if the validation rule passes.
*
* @param mixed $value
*/
public function passes($attribute, $value): bool
{
$value = is_scalar($value) ? (string) $value : '';
if ($this->requireUppercase && Str::lower($value) === $value) {
return false;
}
if ($this->requireNumeric && ! preg_match('/\d/', $value)) {
return false;
}
if ($this->requireSpecialCharacter && ! preg_match('/[\W_]/', $value)) {
return false;
}
return Str::length($value) >= $this->length;
}
/**
* Get the validation error message.
*/
public function message(): string
{
if ($this->message) {
return $this->message;
}
return match (true) {
$this->requireUppercase
&& ! $this->requireNumeric
&& ! $this->requireSpecialCharacter => __(
'The :attribute must be at least :length characters and contain at least one uppercase character.',
[
'length' => $this->length,
],
),
$this->requireNumeric
&& ! $this->requireUppercase
&& ! $this->requireSpecialCharacter => __(
'The :attribute must be at least :length characters and contain at least one number.',
[
'length' => $this->length,
],
),
$this->requireSpecialCharacter
&& ! $this->requireUppercase
&& ! $this->requireNumeric => __(
'The :attribute must be at least :length characters and contain at least one special character.',
[
'length' => $this->length,
],
),
$this->requireUppercase
&& $this->requireNumeric
&& ! $this->requireSpecialCharacter => __(
'The :attribute must be at least :length characters and contain at least one uppercase character and one number.',
[
'length' => $this->length,
],
),
$this->requireUppercase
&& $this->requireSpecialCharacter
&& ! $this->requireNumeric => __(
'The :attribute must be at least :length characters and contain at least one uppercase character and one special character.',
[
'length' => $this->length,
],
),
$this->requireUppercase
&& $this->requireNumeric
&& $this->requireSpecialCharacter => __(
'The :attribute must be at least :length characters and contain at least one uppercase character, one number, and one special character.',
[
'length' => $this->length,
],
),
$this->requireNumeric
&& $this->requireSpecialCharacter
&& ! $this->requireUppercase => __(
'The :attribute must be at least :length characters and contain at least one special character and one number.',
[
'length' => $this->length,
],
),
default => __('The :attribute must be at least :length characters.', [
'length' => $this->length,
]),
};
}
/**
* Set the minimum length of the password.
*/
public function length(int $length)
{
$this->length = $length;
return $this;
}
/**
* Indicate that at least one uppercase character is required.
*/
public function requireUppercase(): static
{
$this->requireUppercase = true;
return $this;
}
/**
* Indicate that at least one numeric digit is required.
*/
public function requireNumeric(): static
{
$this->requireNumeric = true;
return $this;
}
/**
* Indicate that at least one special character is required.
*/
public function requireSpecialCharacter(): static
{
$this->requireSpecialCharacter = true;
return $this;
}
/**
* Set the message that should be used when the rule fails.
*/
public function withMessage(string $message): static
{
$this->message = $message;
return $this;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Services\Community;
use App\Models\RadioSchedule;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class RadioScheduleService
{
public function getCurrentDJ(?string $manualDjId = null): ?array
{
if (! empty($manualDjId)) {
$dj = Cache::remember("user_{$manualDjId}", 60, fn () => User::find($manualDjId));
if ($dj) {
return [
'username' => $dj->username,
'look' => $dj->look,
'show_name' => 'Live DJ',
'is_manual' => true,
];
}
}
$currentSlot = RadioSchedule::with('user:id,username,look')
->active()
->where('day', $this->getCurrentDay())
->whereTime('start_time', '<=', now()->format('H:i:s'))
->whereTime('end_time', '>=', now()->format('H:i:s'))
->first();
if ($currentSlot?->user) {
return [
'username' => $currentSlot->user->username,
'look' => $currentSlot->user->look,
'show_name' => $currentSlot->show_name,
'start_time' => $currentSlot->start_time->format('H:i'),
'end_time' => $currentSlot->end_time->format('H:i'),
'is_manual' => false,
];
}
return null;
}
public function getTodaySchedule(): \Illuminate\Database\Eloquent\Collection
{
return Cache::remember('radio_schedule_today', 60, fn () => RadioSchedule::with('user:id,username,look')
->active()
->today()
->orderBy('start_time')
->get());
}
public function getFullSchedule(): \Illuminate\Support\Collection
{
return Cache::remember('radio_schedule_all', 300, fn () => RadioSchedule::with('user:id,username,look')
->active()
->ordered()
->get()
->groupBy('day'));
}
private function getCurrentDay(): string
{
return strtolower(now()->format('l'));
}
}
+204
View File
@@ -0,0 +1,204 @@
<?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 {
return Http::timeout(2)
->withOptions(['verify' => false])
->head($streamUrl)
->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 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;
}
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_*');
}
}