resolveEvent($token); if (! $event) { return $this->notFound(); } $settings = $this->liveShowSettings($event); $settingsVersion = $this->settingsVersion($settings); $limit = $this->resolveLimit($request); $photos = $this->baseLiveShowQuery($event, $settings) ->orderByDesc('live_approved_at') ->orderByDesc('id') ->limit($limit) ->get() ->reverse() ->values(); $cursor = $this->buildCursor($photos->last()); return response()->json([ 'event' => [ 'id' => $event->id, 'slug' => $event->slug, 'name' => $event->name, 'default_locale' => $event->default_locale, ], 'settings' => $settings, 'settings_version' => $settingsVersion, 'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)), 'cursor' => $cursor, ]); } public function updates(Request $request, string $token): JsonResponse { $event = $this->resolveEvent($token); if (! $event) { return $this->notFound(); } $cursor = $this->parseCursor($request); if ($cursor === null) { return response()->json([ 'error' => 'invalid_cursor', ], Response::HTTP_UNPROCESSABLE_ENTITY); } $settings = $this->liveShowSettings($event); $settingsVersion = $this->settingsVersion($settings); $limit = $this->resolveLimit($request); $query = $this->baseLiveShowQuery($event, $settings); $this->applyCursor($query, $cursor); $photos = $query ->orderBy('live_approved_at') ->orderBy('id') ->limit($limit) ->get(); $nextCursor = $this->buildCursor($photos->last()); $requestedSettingsVersion = (string) $request->query('settings_version', ''); $includeSettings = $requestedSettingsVersion === '' || $requestedSettingsVersion !== $settingsVersion; return response()->json([ 'settings' => $includeSettings ? $settings : null, 'settings_version' => $settingsVersion, 'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)), 'cursor' => $nextCursor ?? $cursor, ]); } public function stream(Request $request, string $token): Response { $event = $this->resolveEvent($token); if (! $event) { return $this->notFound(); } $cursor = $this->parseCursor($request); if ($cursor === null) { return response()->json([ 'error' => 'invalid_cursor', ], Response::HTTP_UNPROCESSABLE_ENTITY); } $settings = $this->liveShowSettings($event); $settingsVersion = $this->settingsVersion($settings); $requestedSettingsVersion = (string) $request->query('settings_version', ''); $lastSettingsCheck = CarbonImmutable::now(); $lastPingAt = CarbonImmutable::now(); $startedAt = CarbonImmutable::now(); return response()->eventStream(function () use ($event, $cursor, $settings, $settingsVersion, $requestedSettingsVersion, $startedAt, $lastPingAt, $lastSettingsCheck) { $lastApprovedAt = $cursor['approved_at']; $lastId = $cursor['id']; $currentSettingsVersion = $requestedSettingsVersion !== '' ? $requestedSettingsVersion : $settingsVersion; $currentSettings = $settings; while (CarbonImmutable::now()->diffInSeconds($startedAt) < self::STREAM_MAX_SECONDS) { if (connection_aborted()) { break; } $now = CarbonImmutable::now(); if ($now->diffInSeconds($lastSettingsCheck) >= self::STREAM_PING_SECONDS) { $event->refresh(); $currentSettings = $this->liveShowSettings($event); $newSettingsVersion = $this->settingsVersion($currentSettings); if ($newSettingsVersion !== $currentSettingsVersion) { $currentSettingsVersion = $newSettingsVersion; yield new StreamedEvent( event: 'settings.updated', data: [ 'settings' => $currentSettings, 'settings_version' => $currentSettingsVersion, ] ); } $lastSettingsCheck = $now; } $query = $this->baseLiveShowQuery($event, $currentSettings); $this->applyCursor($query, [ 'approved_at' => $lastApprovedAt, 'id' => $lastId, ]); $updates = $query ->orderBy('live_approved_at') ->orderBy('id') ->limit(self::MAX_LIMIT) ->get(); foreach ($updates as $photo) { $payload = $this->serializePhoto($photo); $lastApprovedAt = $photo->live_approved_at; $lastId = $photo->id; yield new StreamedEvent( event: 'photo.approved', data: [ 'photo' => $payload, 'cursor' => $this->buildCursor($photo), ] ); } if ($now->diffInSeconds($lastPingAt) >= self::STREAM_PING_SECONDS) { $lastPingAt = $now; yield new StreamedEvent( event: 'ping', data: [ 'time' => $now->toAtomString(), ] ); } sleep(self::STREAM_TICK_SECONDS); } }, Response::HTTP_OK, [ 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no', ]); } private function resolveEvent(string $token): ?Event { if ($token === '') { return null; } return Event::query() ->where('live_show_token', $token) ->first(); } private function liveShowSettings(Event $event): array { $settings = is_array($event->settings) ? $event->settings : []; $liveShow = Arr::get($settings, 'live_show', []); return array_merge([ 'retention_window_hours' => self::DEFAULT_RETENTION_HOURS, 'moderation_mode' => 'manual', 'playback_mode' => 'newest_first', 'pace_mode' => 'auto', 'fixed_interval_seconds' => 8, 'layout_mode' => 'single', 'effect_preset' => 'film_cut', 'effect_intensity' => 70, 'background_mode' => 'blur_last', ], is_array($liveShow) ? $liveShow : []); } private function settingsVersion(array $settings): string { return sha1(json_encode($settings)); } private function resolveLimit(Request $request): int { $limit = (int) $request->query('limit', self::DEFAULT_LIMIT); if ($limit <= 0) { return self::DEFAULT_LIMIT; } return min($limit, self::MAX_LIMIT); } private function baseLiveShowQuery(Event $event, array $settings): Builder { $query = Photo::query() ->where('event_id', $event->id) ->where('live_status', PhotoLiveStatus::APPROVED->value) ->where('status', 'approved') ->whereNotNull('live_approved_at'); $retention = (int) ($settings['retention_window_hours'] ?? self::DEFAULT_RETENTION_HOURS); if ($retention > 0) { $query->where('live_approved_at', '>=', now()->subHours($retention)); } return $query; } private function applyCursor(Builder $query, array $cursor): void { $afterAt = $cursor['approved_at']; $afterId = $cursor['id']; if (! $afterAt) { return; } if ($afterId <= 0) { $query->where('live_approved_at', '>', $afterAt); return; } $query->where(function (Builder $inner) use ($afterAt, $afterId) { $inner->where('live_approved_at', '>', $afterAt) ->orWhere(function (Builder $tie) use ($afterAt, $afterId) { $tie->where('live_approved_at', $afterAt) ->where('id', '>', $afterId); }); }); } private function serializePhoto(Photo $photo): array { return [ 'id' => $photo->id, 'full_url' => $photo->file_path, 'thumb_url' => $photo->thumbnail_path, 'approved_at' => $photo->live_approved_at?->toAtomString(), 'width' => $photo->width, 'height' => $photo->height, 'is_featured' => (bool) $photo->is_featured, 'live_priority' => (int) ($photo->live_priority ?? 0), ]; } private function buildCursor(?Photo $photo): ?array { if (! $photo || ! $photo->live_approved_at) { return null; } return [ 'approved_at' => $photo->live_approved_at, 'id' => $photo->id, ]; } private function parseCursor(Request $request): ?array { $afterAt = trim((string) $request->query('after_approved_at', '')); $afterId = (int) $request->query('after_id', 0); if ($afterAt === '' && $afterId === 0) { return [ 'approved_at' => null, 'id' => 0, ]; } try { $approvedAt = CarbonImmutable::parse($afterAt); } catch (\Throwable) { return null; } return [ 'approved_at' => $approvedAt, 'id' => max(0, $afterId), ]; } private function notFound(): JsonResponse { return response()->json([ 'error' => 'live_show_not_found', ], Response::HTTP_NOT_FOUND); } }