From 81e99933e48eb6eb20f640eb51f31b255b83db16 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 19 May 2026 19:16:59 +0200 Subject: [PATCH] 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 --- .../Fortify/Rules/PasswordValidationRules.php | 15 +- app/Enums/CurrencyTypes.php | 10 + app/Enums/RadioSettings.php | 45 ++ .../Controllers/Api/FurniEditorController.php | 286 ++++++----- .../Controllers/Community/RadioController.php | 481 ++++-------------- .../Controllers/User/ProfileController.php | 77 ++- app/Rules/Password.php | 180 ------- .../Community/RadioScheduleService.php | 69 +++ app/Services/Community/RadioStreamService.php | 204 ++++++++ 9 files changed, 636 insertions(+), 731 deletions(-) create mode 100755 app/Enums/RadioSettings.php delete mode 100755 app/Rules/Password.php create mode 100755 app/Services/Community/RadioScheduleService.php create mode 100755 app/Services/Community/RadioStreamService.php diff --git a/app/Actions/Fortify/Rules/PasswordValidationRules.php b/app/Actions/Fortify/Rules/PasswordValidationRules.php index e7e6fe8..bea57df 100755 --- a/app/Actions/Fortify/Rules/PasswordValidationRules.php +++ b/app/Actions/Fortify/Rules/PasswordValidationRules.php @@ -2,18 +2,25 @@ namespace App\Actions\Fortify\Rules; -use App\Rules\Password; +use Illuminate\Validation\Rules\Password; trait PasswordValidationRules { /** * Get the validation rules used to validate passwords. - */ - /** + * * @return array */ protected function passwordRules(): array { - return ['required', 'string', new Password, 'confirmed']; + return [ + 'required', + 'string', + Password::min(6) + ->mixedCase() + ->numbers() + ->symbols(), + 'confirmed', + ]; } } diff --git a/app/Enums/CurrencyTypes.php b/app/Enums/CurrencyTypes.php index 4bfd41a..00daa9a 100755 --- a/app/Enums/CurrencyTypes.php +++ b/app/Enums/CurrencyTypes.php @@ -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 { return match ($this->value) { diff --git a/app/Enums/RadioSettings.php b/app/Enums/RadioSettings.php new file mode 100755 index 0000000..6daa4cb --- /dev/null +++ b/app/Enums/RadioSettings.php @@ -0,0 +1,45 @@ + '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', + }; + } +} diff --git a/app/Http/Controllers/Api/FurniEditorController.php b/app/Http/Controllers/Api/FurniEditorController.php index 568d2ed..1d2b6e2 100755 --- a/app/Http/Controllers/Api/FurniEditorController.php +++ b/app/Http/Controllers/Api/FurniEditorController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; 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(); @@ -70,14 +127,12 @@ class FurniEditorController extends Controller 'total' => $total, 'page' => $page, ]); - } catch (\Exception) { - Log::error('[FurniEditor] Search failed', ['error' => 'Actie mislukt']); - - return response()->json(['error' => 'Zoeken mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Zoeken', $e); } } - public function detail(Request $request) + public function detail(Request $request): JsonResponse { $this->checkAdmin(); @@ -95,55 +150,22 @@ class FurniEditorController extends Controller $catalog = DB::table('catalog_items') ->whereRaw('FIND_IN_SET(?, item_ids)', [$id]) ->get() - ->map(fn ($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') ?? '' : '', - ]); + ->map(fn ($r) => $this->formatCatalogItemData($r)); return response()->json([ - '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, + 'item' => array_merge($this->formatItemData($item), [ 'description' => '', 'usageCount' => 0, - ], + ]), 'catalogItems' => $catalog, 'furniDataEntry' => null, ]); - } catch (\Exception) { - return response()->json(['error' => 'Kan item niet laden'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Item laden', $e); } } - public function update(Request $request) + public function update(Request $request): JsonResponse { $this->checkAdmin(); @@ -154,48 +176,7 @@ class FurniEditorController extends Controller } $data = $request->json()->all(); - $update = []; - - $map = [ - 'itemName' => ['item_name', 'string', 100], - 'publicName' => ['public_name', 'string', 100], - 'spriteId' => ['sprite_id', 'integer', 0], - 'width' => ['width', 'integer', 0], - 'length' => ['length', 'integer', 0], - 'stackHeight' => ['stack_height', 'integer', 0], - 'allowStack' => ['allow_stack', 'boolean', 0], - 'allowWalk' => ['allow_walk', 'boolean', 0], - 'allowSit' => ['allow_sit', 'boolean', 0], - 'allowLay' => ['allow_lay', 'boolean', 0], - 'allowGift' => ['allow_gift', 'boolean', 0], - 'allowTrade' => ['allow_trade', 'boolean', 0], - 'allowRecycle' => ['allow_recycle', 'boolean', 0], - 'allowMarketplaceSell' => ['allow_marketplace_sell', 'boolean', 0], - 'allowInventoryStack' => ['allow_inventory_stack', 'boolean', 0], - 'interactionType' => ['interaction_type', 'string', 50], - 'interactionModesCount' => ['interaction_modes_count', 'integer', 0], - 'vendingIds' => ['vending_ids', 'string', 255], - 'multiheight' => ['multiheight', 'string', 255], - 'customparams' => ['customparams', 'string', 255], - 'effectIdMale' => ['effect_id_male', 'integer', 0], - 'effectIdFemale' => ['effect_id_female', 'integer', 0], - 'clothingOnWalk' => ['clothing_on_walk', 'string', 255], - ]; - - foreach ($map as $key => [$col, $type, $maxLen]) { - if (! array_key_exists($key, $data)) { - continue; - } - $val = $data[$key]; - 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; - } + $update = $this->buildUpdateData($data); if ($update === []) { return response()->json(['error' => 'No fields to update'], 400); @@ -210,38 +191,19 @@ class FurniEditorController extends Controller ]); return response()->json(['success' => true]); - } catch (\Exception) { - return response()->json(['error' => 'Actie mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Actie', $e); } } - public function create(Request $request) + public function create(Request $request): JsonResponse { $this->checkAdmin(); try { $data = $request->json()->all(); - $id = DB::table('items_base')->insertGetId([ - 'sprite_id' => $data['spriteId'] ?? 0, - 'item_name' => $data['itemName'] ?? '', - 'public_name' => $data['publicName'] ?? '', - 'type' => $data['type'] ?? 's', - 'width' => $data['width'] ?? 1, - 'length' => $data['length'] ?? 1, - 'stack_height' => $data['stackHeight'] ?? 0, - 'allow_stack' => $data['allowStack'] ?? false, - 'allow_walk' => $data['allowWalk'] ?? false, - 'allow_sit' => $data['allowSit'] ?? false, - 'allow_lay' => $data['allowLay'] ?? false, - 'allow_gift' => $data['allowGift'] ?? true, - 'allow_trade' => $data['allowTrade'] ?? true, - 'allow_recycle' => $data['allowRecycle'] ?? false, - 'allow_marketplace_sell' => $data['allowMarketplaceSell'] ?? false, - 'allow_inventory_stack' => $data['allowInventoryStack'] ?? true, - 'interaction_type' => $data['interactionType'] ?? 'default', - 'interaction_modes_count' => $data['interactionModesCount'] ?? 1, - ]); + $id = DB::table('items_base')->insertGetId($this->buildInsertData($data)); Log::info('[Audit] FurniEditor create', [ 'user_id' => Auth::id(), @@ -249,12 +211,12 @@ class FurniEditorController extends Controller ]); return response()->json(['id' => $id]); - } catch (\Exception) { - return response()->json(['error' => 'Actie mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Actie', $e); } } - public function delete(Request $request) + public function delete(Request $request): JsonResponse { $this->checkAdmin(); @@ -272,12 +234,12 @@ class FurniEditorController extends Controller ]); return response()->json(['success' => true]); - } catch (\Exception) { - return response()->json(['error' => 'Actie mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Actie', $e); } } - public function interactions(Request $request) + public function interactions(Request $request): JsonResponse { $this->checkAdmin(); @@ -289,12 +251,12 @@ class FurniEditorController extends Controller ->pluck('interaction_type'); return response()->json(['interactions' => $rows]); - } catch (\Exception) { - return response()->json(['error' => 'Actie mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Actie', $e); } } - public function bySprite(Request $request) + public function bySprite(Request $request): JsonResponse { $this->checkAdmin(); @@ -310,8 +272,90 @@ class FurniEditorController extends Controller } return response()->json(['id' => $item->id]); - } catch (\Exception) { - return response()->json(['error' => 'Actie mislukt'], 500); + } catch (\Exception $e) { + return $this->handleApiError('Actie', $e); } } + + /** + * @param array $data + * @return array + */ + private function buildUpdateData(array $data): array + { + $map = [ + 'itemName' => ['item_name', 'string', 100], + 'publicName' => ['public_name', 'string', 100], + 'spriteId' => ['sprite_id', 'integer', 0], + 'width' => ['width', 'integer', 0], + 'length' => ['length', 'integer', 0], + 'stackHeight' => ['stack_height', 'integer', 0], + 'allowStack' => ['allow_stack', 'boolean', 0], + 'allowWalk' => ['allow_walk', 'boolean', 0], + 'allowSit' => ['allow_sit', 'boolean', 0], + 'allowLay' => ['allow_lay', 'boolean', 0], + 'allowGift' => ['allow_gift', 'boolean', 0], + 'allowTrade' => ['allow_trade', 'boolean', 0], + 'allowRecycle' => ['allow_recycle', 'boolean', 0], + 'allowMarketplaceSell' => ['allow_marketplace_sell', 'boolean', 0], + 'allowInventoryStack' => ['allow_inventory_stack', 'boolean', 0], + 'interactionType' => ['interaction_type', 'string', 50], + 'interactionModesCount' => ['interaction_modes_count', 'integer', 0], + 'vendingIds' => ['vending_ids', 'string', 255], + 'multiheight' => ['multiheight', 'string', 255], + 'customparams' => ['customparams', 'string', 255], + 'effectIdMale' => ['effect_id_male', 'integer', 0], + 'effectIdFemale' => ['effect_id_female', 'integer', 0], + 'clothingOnWalk' => ['clothing_on_walk', 'string', 255], + ]; + + $update = []; + + foreach ($map as $key => [$col, $type, $maxLen]) { + if (! array_key_exists($key, $data)) { + continue; + } + $update[$col] = $this->castValue($data[$key], $type, $maxLen); + } + + return $update; + } + + /** + * @param array $data + * @return array + */ + private function buildInsertData(array $data): array + { + return [ + 'sprite_id' => $data['spriteId'] ?? 0, + 'item_name' => $data['itemName'] ?? '', + 'public_name' => $data['publicName'] ?? '', + 'type' => $data['type'] ?? 's', + 'width' => $data['width'] ?? 1, + 'length' => $data['length'] ?? 1, + 'stack_height' => $data['stackHeight'] ?? 0, + 'allow_stack' => $data['allowStack'] ?? false, + 'allow_walk' => $data['allowWalk'] ?? false, + 'allow_sit' => $data['allowSit'] ?? false, + 'allow_lay' => $data['allowLay'] ?? false, + 'allow_gift' => $data['allowGift'] ?? true, + 'allow_trade' => $data['allowTrade'] ?? true, + 'allow_recycle' => $data['allowRecycle'] ?? false, + 'allow_marketplace_sell' => $data['allowMarketplaceSell'] ?? false, + 'allow_inventory_stack' => $data['allowInventoryStack'] ?? true, + 'interaction_type' => $data['interactionType'] ?? 'default', + 'interaction_modes_count' => $data['interactionModesCount'] ?? 1, + ]; + } + + private function castValue(mixed $value, string $type, int $maxLen = 0): mixed + { + return match ($type) { + 'string' => mb_substr(strip_tags((string) $value), 0, $maxLen), + 'integer' => (int) $value, + 'boolean' => (bool) $value, + default => $value, + }; + } } diff --git a/app/Http/Controllers/Community/RadioController.php b/app/Http/Controllers/Community/RadioController.php index 537b804..167e991 100755 --- a/app/Http/Controllers/Community/RadioController.php +++ b/app/Http/Controllers/Community/RadioController.php @@ -2,80 +2,41 @@ namespace App\Http\Controllers\Community; +use App\Enums\RadioSettings; use App\Http\Controllers\Controller; use App\Models\Miscellaneous\WebsiteSetting; use App\Models\RadioApplication; use App\Models\RadioBanner; use App\Models\RadioHistory; use App\Models\RadioRank; -use App\Models\RadioSchedule; 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\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\View\View; class RadioController extends Controller { - private ?bool $azureCastDetected = null; - - /** @var array */ - private array $settingsCache = []; - - /** - * Get multiple settings at once (batched query) - * - * @param array $keys - * - * @return array - */ - 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 __construct( + private readonly RadioStreamService $streamService, + private readonly RadioScheduleService $scheduleService, + ) {} public function index(): View { - // Batch load all radio settings at once $settings = $this->getSettings([ - 'radio_enabled', - 'radio_stream_url', - 'radio_current_dj_id', + RadioSettings::Enabled, + RadioSettings::StreamUrl, + RadioSettings::CurrentDjId, ]); - if (! (bool) ($settings['radio_enabled'] ?? false)) { + if (! (bool) ($settings[RadioSettings::Enabled->value] ?? false)) { return view('community.radio.disabled'); } - // Load all data with single queries each $ranks = Cache::remember('radio_ranks', 300, fn () => RadioRank::all()); $banners = Cache::remember('radio_banners_active', 60, fn () => RadioBanner::with('user:id,username,look') @@ -84,36 +45,32 @@ class RadioController extends Controller ->take(10) ->get()); - $todaySchedule = Cache::remember('radio_schedule_today', 60, fn () => RadioSchedule::with('user:id,username,look') - ->active() - ->today() - ->orderBy('start_time') - ->get()); + $todaySchedule = $this->scheduleService->getTodaySchedule(); + $currentDJ = $this->scheduleService->getCurrentDJ($settings[RadioSettings::CurrentDjId->value] ?? null); - $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'] ?? ''); - - // Cache stream status check (30 seconds) - $isOnline = Cache::remember('radio_stream_status', 30, fn () => $this->checkStreamOnline($streamUrl)); - - return view('community.radio.index', ['ranks' => $ranks, 'banners' => $banners, 'todaySchedule' => $todaySchedule, 'currentDJ' => $currentDJ, 'isOnline' => $isOnline, 'streamUrl' => $streamUrl]); + return view('community.radio.index', [ + 'ranks' => $ranks, + 'banners' => $banners, + 'todaySchedule' => $todaySchedule, + 'currentDJ' => $currentDJ, + 'isOnline' => $isOnline, + 'streamUrl' => $streamUrl, + ]); } public function rooster(): View { - $schedule = Cache::remember('radio_schedule_all', 300, fn () => RadioSchedule::with('user:id,username,look') - ->active() - ->ordered() - ->get() - ->groupBy('day')); + $schedule = $this->scheduleService->getFullSchedule(); return view('community.radio.rooster', ['schedule' => $schedule]); } 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')); } @@ -122,38 +79,33 @@ class RadioController extends Controller 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')); } $userId = auth()->id(); - $hasPendingApplication = $userId && RadioApplication::where('user_id', $userId) ->where('status', 'pending') ->exists(); $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 { - if (! (bool) $this->getSetting('radio_applications_enabled')) { + if (! $this->getSetting(RadioSettings::ApplicationsEnabled)) { return redirect()->route('radio.index')->with('error', __('radio.applications_closed')); } - /** @var int|string $userId */ $userId = auth()->id(); - $hasPendingApplication = RadioApplication::where('user_id', $userId) - ->where('status', 'pending') - ->exists(); - - if ($hasPendingApplication) { - return back()->withErrors([ - 'general' => __('radio.application_pending'), - ])->withInput(); + if (RadioApplication::where('user_id', $userId)->where('status', 'pending')->exists()) { + return back()->withErrors(['general' => __('radio.application_pending')])->withInput(); } $validated = $request->validate([ @@ -167,7 +119,6 @@ class RadioController extends Controller 'rank_id' => ['nullable', 'exists:radio_ranks,id'], ]); - // Check rank accepts applications (cached) if ($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 { - if (! (bool) $this->getSetting('radio_shouts_enabled')) { + if (! $this->getSetting(RadioSettings::ShoutsEnabled)) { return redirect()->route('radio.index')->with('error', __('radio.shouts_disabled')); } @@ -209,7 +160,6 @@ class RadioController extends Controller 'message' => $validated['message'], ]); - // Clear shouts cache Cache::forget('radio_shouts_recent'); return redirect()->route('radio.shouts')->with('success', __('radio.shout_sent')); @@ -217,23 +167,33 @@ class RadioController extends Controller public function nowPlaying(): JsonResponse { - // Cache now playing for 10 seconds to reduce API calls - $nowPlaying = Cache::remember('radio_nowplaying', 10, fn () => $this->getNowPlaying()); + $nowPlaying = Cache::remember('radio_nowplaying', 10, function () { + $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); } public function listeners(): JsonResponse { - // Cache listeners count for 30 seconds - $count = Cache::remember('radio_listeners', 30, fn () => $this->getListenersCount()); + $count = Cache::remember('radio_listeners', 30, function () { + $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]); } public function currentDJ(): JsonResponse { - $dj = $this->getCurrentDJFromSchedule(); + $dj = $this->scheduleService->getCurrentDJ($this->getSetting(RadioSettings::CurrentDjId)); return response()->json([ '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 { $settings = $this->getSettings([ - 'radio_enabled', - 'radio_stream_url', - 'radio_style', - 'radio_now_playing_enabled', - 'radio_listeners_enabled', - 'radio_show_current_dj', - 'radio_widget_enabled', - 'radio_widget_show_globally', - 'radio_widget_position', + RadioSettings::Enabled, + RadioSettings::StreamUrl, + RadioSettings::Style, + RadioSettings::NowPlayingEnabled, + RadioSettings::ListenersEnabled, + RadioSettings::ShowCurrentDj, + RadioSettings::WidgetEnabled, + RadioSettings::WidgetShowGlobally, + RadioSettings::WidgetPosition, ]); - $streamUrl = $this->formatStreamUrl($settings['radio_stream_url'] ?? ''); - $isAzurecast = $this->detectAzureCast(); + $streamUrl = $this->streamService->formatStreamUrl($settings[RadioSettings::StreamUrl->value] ?? ''); + $azureCast = $this->streamService->detectAzureCast(); return response()->json([ - 'enabled' => (bool) ($settings['radio_enabled'] ?? false), + 'enabled' => (bool) ($settings[RadioSettings::Enabled->value] ?? false), 'stream_url' => $streamUrl, - 'style' => $settings['radio_style'] ?? 'dark', - 'dj' => $this->getCurrentDJFromSchedule(), - 'now_playing_enabled' => (bool) ($settings['radio_now_playing_enabled'] ?? false), - 'listeners_enabled' => (bool) ($settings['radio_listeners_enabled'] ?? false), - 'show_current_dj' => (bool) ($settings['radio_show_current_dj'] ?? false), - 'widget_enabled' => (bool) ($settings['radio_widget_enabled'] ?? false), - 'widget_show_globally' => (bool) ($settings['radio_widget_show_globally'] ?? false), - 'widget_position' => $settings['radio_widget_position'] ?? 'bottom-right', - 'is_azurecast' => $isAzurecast, - 'azurecast_detected' => $isAzurecast, + 'style' => $settings[RadioSettings::Style->value] ?? 'dark', + 'dj' => $this->scheduleService->getCurrentDJ($settings[RadioSettings::CurrentDjId->value] ?? null), + 'now_playing_enabled' => (bool) ($settings[RadioSettings::NowPlayingEnabled->value] ?? false), + 'listeners_enabled' => (bool) ($settings[RadioSettings::ListenersEnabled->value] ?? false), + 'show_current_dj' => (bool) ($settings[RadioSettings::ShowCurrentDj->value] ?? false), + 'widget_enabled' => (bool) ($settings[RadioSettings::WidgetEnabled->value] ?? false), + 'widget_show_globally' => (bool) ($settings[RadioSettings::WidgetShowGlobally->value] ?? false), + 'widget_position' => $settings[RadioSettings::WidgetPosition->value] ?? 'bottom-right', + 'is_azurecast' => $azureCast['detected'], + 'azurecast_detected' => $azureCast['detected'], ]); } @@ -535,9 +238,7 @@ class RadioController extends Controller { $userId = auth()->id(); - $activeSession = RadioHistory::where('user_id', $userId) - ->whereNull('ended_at') - ->first(); + $activeSession = RadioHistory::where('user_id', $userId)->whereNull('ended_at')->first(); if ($activeSession) { return response()->json([ @@ -562,14 +263,10 @@ class RadioController extends Controller { $userId = auth()->id(); - $activeSession = RadioHistory::where('user_id', $userId) - ->whereNull('ended_at') - ->first(); + $activeSession = RadioHistory::where('user_id', $userId)->whereNull('ended_at')->first(); if (! $activeSession) { - return response()->json([ - 'error' => 'Geen actieve sessie gevonden', - ], 404); + return response()->json(['error' => 'Geen actieve sessie gevonden'], 404); } $activeSession->endSession(); @@ -582,11 +279,8 @@ class RadioController extends Controller public function getShouts(): JsonResponse { - if (! (bool) $this->getSetting('radio_shouts_enabled')) { - return response()->json([ - 'error' => 'Shouts zijn uitgeschakeld', - 'shouts' => [], - ], 403); + if (! $this->getSetting(RadioSettings::ShoutsEnabled)) { + return response()->json(['error' => 'Shouts zijn uitgeschakeld', 'shouts' => []], 403); } $shouts = Cache::remember('radio_shouts_recent', 30, fn () => RadioShout::with('user:id,username') @@ -605,4 +299,27 @@ class RadioController extends Controller 'total' => $shouts->count(), ]); } + + /** + * @param array $keys + * @return array + */ + 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; + }); + } } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index cc92e4b..844d826 100755 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -5,11 +5,12 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; use App\Models\Miscellaneous\WebsiteSetting; use App\Models\User; -use Carbon\Carbon; +use Illuminate\Contracts\View\View; +use Illuminate\Support\Carbon; class ProfileController extends Controller { - public function __invoke(User $user) + public function __invoke(User $user): View { $user->load([ 'friends.friend:id,username,look', @@ -19,13 +20,8 @@ class ProfileController extends Controller 'badges', ]); - $showStats = WebsiteSetting::where('key', 'profile_show_stats')->first()?->value ?? '1'; - $showOnline = 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(); + $showStats = (bool) (WebsiteSetting::where('key', 'profile_show_stats')->first()?->value ?? '1'); + $showOnline = (bool) (WebsiteSetting::where('key', 'profile_show_online_status')->first()?->value ?? '1'); return view('user.profile', [ 'user' => $user, @@ -36,56 +32,49 @@ class ProfileController extends Controller 'badges' => $user->badges->take(3), 'showStats' => $showStats, 'showOnline' => $showOnline, - 'accountAge' => $accountAge, - 'lastLogin' => $lastLogin, - 'totalFriends' => $totalFriends, - 'totalGuilds' => $totalGuilds, + 'accountAge' => $this->getAccountAge($user->account_created), + 'lastLogin' => $this->getLastLogin($user->last_login), + 'totalFriends' => $user->friends()->count(), + 'totalGuilds' => $user->guilds()->count(), ]); } private function getAccountAge(int $timestamp): string { $created = Carbon::createFromTimestamp($timestamp); - $now = Carbon::now(); - $days = $created->diffInDays($now); - if ($days < 7) { - return $days . ' day' . ($days !== 1 ? 's' : ''); - } 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->diffInYears() >= 1) { + return $created->diffInYears() . ' ' . str('year')->plural($created->diffInYears()); } + + 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 { $lastLogin = Carbon::createFromTimestamp($timestamp); - $now = Carbon::now(); - $diff = $now->diffInMinutes($lastLogin); + $diffInMinutes = $lastLogin->diffInMinutes(); - if ($diff < 1) { + if ($diffInMinutes < 1) { 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'; - } elseif ($diff < 10080) { - $days = floor($diff / 1440); - - return $days . ' day' . ($days !== 1 ? 's' : '') . ' ago'; - } else { - return $lastLogin->format('d M Y'); } + + if ($diffInMinutes < 60) { + return $lastLogin->diffForHumans(); + } + + if ($diffInMinutes < 10080) { + return $lastLogin->diffForHumans(); + } + + return $lastLogin->format('d M Y'); } } diff --git a/app/Rules/Password.php b/app/Rules/Password.php deleted file mode 100755 index 4bc10cd..0000000 --- a/app/Rules/Password.php +++ /dev/null @@ -1,180 +0,0 @@ -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; - } -} diff --git a/app/Services/Community/RadioScheduleService.php b/app/Services/Community/RadioScheduleService.php new file mode 100755 index 0000000..abe7979 --- /dev/null +++ b/app/Services/Community/RadioScheduleService.php @@ -0,0 +1,69 @@ + 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')); + } +} diff --git a/app/Services/Community/RadioStreamService.php b/app/Services/Community/RadioStreamService.php new file mode 100755 index 0000000..4031c2b --- /dev/null +++ b/app/Services/Community/RadioStreamService.php @@ -0,0 +1,204 @@ +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_*'); + } +}