Files
fotospiel-app/app/Http/Controllers/Api/LiveShowController.php
2026-01-05 15:02:21 +01:00

349 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Enums\PhotoLiveStatus;
use App\Models\Event;
use App\Models\Photo;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\StreamedEvent;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Symfony\Component\HttpFoundation\Response;
class LiveShowController extends BaseController
{
private const DEFAULT_RETENTION_HOURS = 12;
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200;
private const STREAM_TICK_SECONDS = 2;
private const STREAM_MAX_SECONDS = 60;
private const STREAM_PING_SECONDS = 10;
public function state(Request $request, string $token): JsonResponse
{
$event = $this->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);
}
}