From 43b2ff09e9b4d907d7b43d552768825bddf7981c Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 18:50:37 +0200 Subject: [PATCH 01/14] Add footer source credit --- app/Services/BrandingService.php | 20 +++++++++++ app/Support/Manage/Settings.php | 36 ++++++++++++++++++++ config/branding.php | 8 +++++ config/settings.php | 7 ++++ resources/js/Layouts/AuthenticatedLayout.vue | 26 +++++++++++--- resources/js/Pages/Manage/Settings.vue | 34 +++++++++++++++--- tests/Feature/Manage/SettingsTest.php | 25 ++++++++++++++ 7 files changed, 148 insertions(+), 8 deletions(-) diff --git a/app/Services/BrandingService.php b/app/Services/BrandingService.php index 158db4c..5ad75f0 100644 --- a/app/Services/BrandingService.php +++ b/app/Services/BrandingService.php @@ -15,6 +15,13 @@ */ class BrandingService { + /** Where this software lives, and under what terms. Not per-installation. */ + public const SOURCE_URL = 'https://github.com/Thiritin/streaming'; + + public const LICENCE = 'GPL-3.0'; + + public const LICENCE_URL = 'https://github.com/Thiritin/streaming/blob/main/LICENSE'; + /** * Keys editable from the admin panel, with the help text shown there. * @@ -32,6 +39,7 @@ class BrandingService 'identity_register_url' => 'Where people register a new identity account.', 'identity_logout_url' => 'Identity provider logout endpoint.', 'footer_links' => 'Title and address for each footer link, in the order they are shown.', + 'show_source_link' => 'Whether the footer credits the project and links to its source. 1 or 0.', 'logo_path' => 'Logo image. Leave empty to show the site name as text instead.', 'login_background_image' => 'Background image for the login screen.', 'login_background_video' => 'Background video for the login screen. Left empty, the bundled clip is used.', @@ -90,9 +98,21 @@ public function forFrontend(): array // footer links and has as many as it likes. Empty means the footer // renders no link row at all. 'links' => $this->footerLinks(), + // The project credit in the footer. Separate from `links`, which an + // installation owns: this one is about the software, not the event. + 'source' => $this->showSourceLink() ? [ + 'url' => self::SOURCE_URL, + 'licence' => self::LICENCE, + 'licenceUrl' => self::LICENCE_URL, + ] : null, ]; } + public function showSourceLink(): bool + { + return Settings::toBool($this->get('show_source_link')); + } + /** * Footer links as {label, url}, in order, with unusable rows dropped. * diff --git a/app/Support/Manage/Settings.php b/app/Support/Manage/Settings.php index d1ea3f5..a3d8a0e 100644 --- a/app/Support/Manage/Settings.php +++ b/app/Support/Manage/Settings.php @@ -112,6 +112,23 @@ public function save(array $values): void $value = $this->cleanRows($value, array_keys($field['itemRules'] ?? [])); } + // A toggle is stored as '1' or '0', because the settings table holds + // strings; false has to survive matchesDefault as a real value rather + // than as the empty string it would otherwise look like. + if (($field['type'] ?? null) === 'toggle') { + $value = (bool) $value; + + if ($value === (bool) config(($field['store'] ?? config('settings.store', 'branding')).'.'.$field['key'])) { + BrandingSetting::where('key', $field['key'])->get()->each->delete(); + + continue; + } + + BrandingSetting::setValue($field['key'], $value ? '1' : '0', $field['helper'] ?? null); + + continue; + } + $store = $field['store'] ?? config('settings.store', 'branding'); if ($this->matchesDefault($value, config("{$store}.{$field['key']}"))) { @@ -243,6 +260,12 @@ private function field(array $field): array $default = self::decodeRows($default); } + // Toggles come back from the table as '1' or '0' and are edited as booleans. + if ($field['type'] === 'toggle') { + $value = self::toBool($value); + $default = (bool) $default; + } + // A secret is never sent to the browser: a stored one is represented by the mask, // which the save side reads back as "unchanged". if ($field['type'] === 'password') { @@ -308,6 +331,19 @@ public static function decodeRows(mixed $value): array return is_array($decoded) ? array_values($decoded) : []; } + /** + * A stored toggle, which the table holds as a string, as a boolean. '0' is + * off; anything else that is set is on. + */ + public static function toBool(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value; + } + /** * Preset swatches as a list, so the order survives the trip to the frontend. * diff --git a/config/branding.php b/config/branding.php index 70d8065..2dde7ea 100644 --- a/config/branding.php +++ b/config/branding.php @@ -58,6 +58,14 @@ */ 'footer_links' => [], + /* + | Whether the footer credits the project and links to its source. On by + | default: this is GPL software, and an installation that keeps the credit + | costs nothing. Turn it off in the panel for a footer that carries only + | the installation's own links. + */ + 'show_source_link' => true, + /* | Path on the public disk to a logo image. When empty nothing is rendered | in its place and callers fall back to the site name in text. diff --git a/config/settings.php b/config/settings.php index 6a7867a..4dd479a 100644 --- a/config/settings.php +++ b/config/settings.php @@ -173,6 +173,13 @@ 'url' => ['required', 'url', 'max:2048'], ], ], + [ + 'key' => 'show_source_link', + 'label' => 'Source and licence credit', + 'type' => 'toggle', + 'helper' => 'Show "Open source, GPL-3.0" in the footer, linking to the project on GitHub.', + 'rules' => ['boolean'], + ], ], ], diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index b3425fe..e850017 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -17,6 +17,8 @@ const siteName = computed(() => branding.value.siteName ?? ''); const hasLogo = computed(() => !!branding.value.logoUrl); // Configured in /manage > Settings, any number of them. None means no link row. const footerLinks = computed(() => branding.value.links ?? []); +// The project credit, or null when the installation has turned it off. +const source = computed(() => branding.value.source ?? null); const logoutUrl = computed(() => branding.value.identity?.logoutUrl ?? '#'); // A signed-out visitor only reaches this layout where login is optional, so the @@ -131,7 +133,7 @@ const chatEnabled = computed(() => page.props.features?.chat !== false); - + @@ -233,7 +235,7 @@ const chatEnabled = computed(() => page.props.features?.chat !== false);
- + GitHub @@ -269,8 +271,24 @@ const chatEnabled = computed(() => page.props.features?.chat !== false); >{{ item.label }}
-
- Made with by the Video Team +
+ Made with by the Video Team + + + Open source, + {{ source.licence }} +
diff --git a/resources/js/Pages/Manage/Settings.vue b/resources/js/Pages/Manage/Settings.vue index 815b7d1..914eceb 100644 --- a/resources/js/Pages/Manage/Settings.vue +++ b/resources/js/Pages/Manage/Settings.vue @@ -32,10 +32,21 @@ const fields = computed(() => props.groups.flatMap((group) => group.fields)); */ const CLEAR_SECRET = '__clear__'; -/** A repeater arrives as rows and posts as rows; everything else is a string. */ -const initial = (field) => (field.type === 'links' - ? (field.value ?? []).map((row) => ({ label: row.label ?? '', url: row.url ?? '' })) - : field.value ?? ''); +/** + * A repeater arrives as rows and posts as rows, a toggle as a boolean, everything + * else as a string. + */ +const initial = (field) => { + if (field.type === 'links') { + return (field.value ?? []).map((row) => ({ label: row.label ?? '', url: row.url ?? '' })); + } + + if (field.type === 'toggle') { + return field.value === true; + } + + return field.value ?? ''; +}; const form = useForm({ values: Object.fromEntries(fields.value.map((field) => [field.key, initial(field)])), @@ -391,6 +402,21 @@ onBeforeUnmount(clearAccentPreview); :error="form.errors[`values.${field.key}`]" /> + + + + assertDatabaseMissing('branding_settings', ['key' => 'pretalx_token']); } + public function test_turning_the_source_credit_off_stores_it_and_hides_it_from_the_frontend(): void + { + $this->assertTrue(app(BrandingService::class)->showSourceLink()); + $this->assertNotNull(app(BrandingService::class)->forFrontend()['source']); + + $this->actingAs($this->admin) + ->put(route('manage.settings.update'), $this->payload(['show_source_link' => false])); + + // Stored as a string, because that is what the settings table holds. + $this->assertSame('0', BrandingSetting::getValue('show_source_link')); + $this->assertFalse(app(BrandingService::class)->showSourceLink()); + $this->assertNull(app(BrandingService::class)->forFrontend()['source']); + } + + public function test_turning_the_source_credit_back_on_hands_the_key_back_to_the_default(): void + { + BrandingSetting::setValue('show_source_link', '0'); + + $this->actingAs($this->admin) + ->put(route('manage.settings.update'), $this->payload(['show_source_link' => true])); + + $this->assertDatabaseMissing('branding_settings', ['key' => 'show_source_link']); + $this->assertTrue(app(BrandingService::class)->showSourceLink()); + } + public function test_only_administrators_can_read_or_change_the_settings(): void { // The moderator holds the manage gate but not admin.access. From 5872f10eab37105c0a8403f0353770ce6ecb02fc Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 18:55:26 +0200 Subject: [PATCH 02/14] Remove mp4 dvr pipeline --- app/Console/Commands/ExtractDvrSegments.php | 182 ------- app/Http/Controllers/Api/SrsDvrController.php | 125 ----- app/Services/DvrExtractorService.php | 351 ------------ config/stream.php | 2 +- docker-compose.dev.yml | 34 +- docker/archive-uploader/Dockerfile | 13 + .../archive_uploader.py | 0 docker/dev/origin-srs.conf | 24 +- docker/dvr-uploader/Dockerfile | 19 - docker/dvr-uploader/uploader.py | 512 ------------------ docker/origin-srs/origin.conf | 23 +- docs/dev-stack.md | 5 +- docs/dvr-archive-plan.md | 21 +- .../origin/docker-compose.blade.php | 31 +- .../origin/srs-config.blade.php | 23 +- routes/api.php | 3 - scripts/dev-stack.sh | 2 +- 17 files changed, 66 insertions(+), 1304 deletions(-) delete mode 100644 app/Console/Commands/ExtractDvrSegments.php delete mode 100644 app/Http/Controllers/Api/SrsDvrController.php delete mode 100644 app/Services/DvrExtractorService.php create mode 100644 docker/archive-uploader/Dockerfile rename docker/{dvr-uploader => archive-uploader}/archive_uploader.py (100%) delete mode 100644 docker/dvr-uploader/Dockerfile delete mode 100644 docker/dvr-uploader/uploader.py diff --git a/app/Console/Commands/ExtractDvrSegments.php b/app/Console/Commands/ExtractDvrSegments.php deleted file mode 100644 index 0a8a775..0000000 --- a/app/Console/Commands/ExtractDvrSegments.php +++ /dev/null @@ -1,182 +0,0 @@ -extractor = $extractor; - } - - /** - * Execute the console command. - */ - public function handle() - { - // Validate required options - if (! $this->option('stream')) { - $this->error('The --stream option is required.'); - - return 1; - } - - if (! $this->option('start')) { - $this->error('The --start option is required.'); - - return 1; - } - - if (! $this->option('end')) { - $this->error('The --end option is required.'); - - return 1; - } - - $stream = $this->option('stream'); - $dryRun = $this->option('dry-run'); - - // Parse dates - always interpret as Europe/Berlin timezone - try { - $startTime = Carbon::parse($this->option('start'), 'Europe/Berlin'); - $endTime = Carbon::parse($this->option('end'), 'Europe/Berlin'); - } catch (\Exception $e) { - $this->error('Invalid date format. Please use format: Y-m-d H:i:s'); - - return 1; - } - - // Validate time range - if ($endTime->lessThanOrEqualTo($startTime)) { - $this->error('End time must be after start time.'); - - return 1; - } - - // Calculate duration - $duration = $startTime->diffInSeconds($endTime); - $this->info("Extracting DVR segments for stream: {$stream}"); - $this->info("Time range: {$startTime->format('Y-m-d H:i:s')} to {$endTime->format('Y-m-d H:i:s')} (Europe/Berlin)"); - $this->info("UTC range: {$startTime->utc()->format('Y-m-d H:i:s')} to {$endTime->utc()->format('Y-m-d H:i:s')}"); - $hours = floor($duration / 3600); - $minutes = floor(($duration % 3600) / 60); - $seconds = $duration % 60; - $this->info(sprintf('Duration: %02d:%02d:%02d', $hours, $minutes, $seconds)); - $this->newLine(); - - // Generate default output filename if not provided - $outputFilename = $this->option('output') ?: sprintf( - '%s_%s_%s.mp4', - $stream, - $startTime->format('Ymd_His'), - $endTime->format('His') - ); - - $targetStorage = $this->option('storage'); - - try { - if ($dryRun) { - $this->info('🔍 DRY RUN MODE - Previewing segments...'); - $this->info('Looking for segments between '.($startTime->timestamp * 1000).' and '.($endTime->timestamp * 1000)); - $segments = $this->extractor->findSegments($stream, $startTime, $endTime); - - if (empty($segments)) { - $this->warn('No segments found in the specified time range.'); - - return 0; - } - - $this->info('Found '.count($segments).' segments:'); - $this->table( - ['Segment', 'Size', 'Timestamp'], - array_map(function ($segment) { - return [ - basename($segment['path']), - $this->formatBytes($segment['size']), - Carbon::createFromTimestampMs($segment['timestamp'])->format('Y-m-d H:i:s'), - ]; - }, $segments) - ); - - $totalSize = array_sum(array_column($segments, 'size')); - $this->info('Total size to download: '.$this->formatBytes($totalSize)); - } else { - // Perform actual extraction - $this->info('Starting extraction process...'); - - $outputPath = $this->extractor->extract( - $stream, - $startTime, - $endTime, - $outputFilename, - $targetStorage, - function ($message, $type = 'info') { - match ($type) { - 'error' => $this->error($message), - 'warn' => $this->warn($message), - 'success' => $this->info('✅ '.$message), - default => $this->info($message), - }; - } - ); - - $this->newLine(); - $this->info('✅ Extraction complete!'); - $this->info("Output file: {$outputPath}"); - - // Show file size - if (file_exists($outputPath)) { - $this->info('File size: '.$this->formatBytes(filesize($outputPath))); - } - } - - return 0; - } catch (\Exception $e) { - $this->error('Extraction failed: '.$e->getMessage()); - - if ($this->output->isVerbose()) { - $this->error($e->getTraceAsString()); - } - - return 1; - } - } - - private function formatBytes($bytes, $precision = 2) - { - $units = ['B', 'KB', 'MB', 'GB', 'TB']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= pow(1024, $pow); - - return round($bytes, $precision).' '.$units[$pow]; - } -} diff --git a/app/Http/Controllers/Api/SrsDvrController.php b/app/Http/Controllers/Api/SrsDvrController.php deleted file mode 100644 index 58c8b3f..0000000 --- a/app/Http/Controllers/Api/SrsDvrController.php +++ /dev/null @@ -1,125 +0,0 @@ -all()); - - // Extract DVR information - $data = $request->all(); - - // Parse the file path to extract metadata - $filePath = $data['file'] ?? ''; - $app = $data['app'] ?? ''; - $stream = $data['stream'] ?? ''; - $vhost = $data['vhost'] ?? '__defaultVhost__'; - $clientId = $data['client_id'] ?? null; - $ip = $data['ip'] ?? ''; - $action = $data['action'] ?? ''; - - // Store DVR recording information in database (optional) - if ($filePath) { - $this->storeDvrRecording([ - 'file_path' => $filePath, - 'app' => $app, - 'stream' => $stream, - 'vhost' => $vhost, - 'client_id' => $clientId, - 'client_ip' => $ip, - 'action' => $action, - 'created_at' => now(), - ]); - } - - // Return success response - return response()->json([ - 'code' => 0, - 'msg' => 'ok', - ]); - - } catch (\Exception $e) { - Log::error('DVR callback error: '.$e->getMessage(), [ - 'request' => $request->all(), - 'exception' => $e, - ]); - - // SRS expects code 0 for success, non-zero for error - return response()->json([ - 'code' => 1, - 'msg' => 'error: '.$e->getMessage(), - ]); - } - } - - /** - * Store DVR recording information - */ - private function storeDvrRecording(array $data) - { - try { - // You can create a DvrRecording model to track recordings - // For now, just log it - Log::info('DVR recording created', $data); - - // Optional: Store in database - // DvrRecording::create($data); - - // Optional: Dispatch job for post-processing - // ProcessDvrRecording::dispatch($data); - - } catch (\Exception $e) { - Log::error('Failed to store DVR recording: '.$e->getMessage()); - } - } - - /** - * Handle S3 upload webhook from DVR uploader service - */ - public function handleUploadWebhook(Request $request) - { - try { - Log::info('DVR S3 upload webhook received', $request->all()); - - $data = $request->all(); - - // Extract information - $s3Bucket = $data['s3_bucket'] ?? ''; - $s3Key = $data['s3_key'] ?? ''; - $s3Url = $data['s3_url'] ?? ''; - $app = $data['app'] ?? ''; - $stream = $data['stream'] ?? ''; - $date = $data['date'] ?? ''; - - // Optional: Update recording status in database - // Optional: Send notifications - // Optional: Trigger VOD processing - - return response()->json([ - 'status' => 'success', - 'message' => 'Upload webhook processed', - ]); - - } catch (\Exception $e) { - Log::error('DVR upload webhook error: '.$e->getMessage()); - - return response()->json([ - 'status' => 'error', - 'message' => $e->getMessage(), - ], 500); - } - } -} diff --git a/app/Services/DvrExtractorService.php b/app/Services/DvrExtractorService.php deleted file mode 100644 index e4d718f..0000000 --- a/app/Services/DvrExtractorService.php +++ /dev/null @@ -1,351 +0,0 @@ -tempPath = storage_path('app/temp/dvr'); - } - - /** - * Find all segments within a time range - */ - public function findSegments(string $stream, Carbon $startTime, Carbon $endTime): array - { - $segments = []; - $disk = Storage::disk('dvr'); - - // Convert times to milliseconds - // The timestamps in filenames are milliseconds since epoch - $startMs = $startTime->timestamp * 1000; - $endMs = $endTime->timestamp * 1000; - - // Iterate through each day in the range - $currentDate = $startTime->copy()->startOfDay(); - $endDate = $endTime->copy()->startOfDay(); - - while ($currentDate <= $endDate) { - // Date folders are in local time (Europe/Berlin) - // But we need to also check dvr/ingress path - $datePath = sprintf('dvr/ingress/%s/%s', $stream, $currentDate->format('Y-m-d')); - - try { - // List all files for this date using Storage facade - if (! $disk->exists($datePath)) { - $currentDate->addDay(); - - continue; - } - - $files = $disk->files($datePath); - - foreach ($files as $file) { - $filename = basename($file); - - // Parse timestamp from filename (format: HH-MM-SS_timestampMs.mp4) - if (preg_match('/\d{2}-\d{2}-\d{2}_(\d+)\.mp4$/', $filename, $matches)) { - $segmentTimestamp = (int) $matches[1]; - - // Check if segment falls within our time range - // Add a small buffer (30 seconds) since segments can be up to ~20 seconds - if ($segmentTimestamp >= ($startMs - 30000) && $segmentTimestamp <= ($endMs + 30000)) { - $segments[] = [ - 'path' => $file, - 'filename' => $filename, - 'timestamp' => $segmentTimestamp, - 'size' => $disk->size($file), - 'date' => $currentDate->format('Y-m-d'), - ]; - } - } - } - } catch (\Exception $e) { - // Log error but continue with other dates - \Log::warning("Failed to list DVR segments for {$datePath}: ".$e->getMessage()); - } - - $currentDate->addDay(); - } - - // Sort segments by timestamp - usort($segments, function ($a, $b) { - return $a['timestamp'] <=> $b['timestamp']; - }); - - return $segments; - } - - /** - * Extract and combine DVR segments - */ - public function extract( - string $stream, - Carbon $startTime, - Carbon $endTime, - string $outputFilename, - string $targetStorage = 'public', - ?callable $progressCallback = null - ): string { - $this->progressCallback = $progressCallback; - - // Find segments - $this->log('Finding segments...'); - $segments = $this->findSegments($stream, $startTime, $endTime); - - if (empty($segments)) { - throw new \Exception('No segments found in the specified time range'); - } - - $this->log('Found '.count($segments).' segments to process'); - - // Create temp directory - $sessionId = Str::uuid()->toString(); - $sessionPath = $this->tempPath.'/'.$sessionId; - File::ensureDirectoryExists($sessionPath); - - try { - // Download segments - $this->log('Downloading segments...'); - $localFiles = $this->downloadSegments($segments, $sessionPath); - - // Create concat file for ffmpeg - $this->log('Creating concatenation list...'); - $concatFile = $this->createConcatFile($localFiles, $sessionPath); - - // Combine with ffmpeg - $this->log('Combining segments with FFmpeg...'); - $tempOutput = $sessionPath.'/combined.mp4'; - $this->combineSegments($concatFile, $tempOutput); - - // Trim to exact time range if needed - $this->log('Trimming to exact time range...'); - $trimmedOutput = $sessionPath.'/output.mp4'; - $this->trimToExactRange($tempOutput, $trimmedOutput, $segments, $startTime, $endTime); - - // Move to target storage - $this->log('Moving to target storage...'); - $finalPath = $this->moveToStorage($trimmedOutput, $outputFilename, $targetStorage); - - $this->log('Extraction complete!', 'success'); - - return $finalPath; - } finally { - // Cleanup temp files - $this->cleanup($sessionPath); - } - } - - /** - * Download segments from S3 to local temp storage - */ - protected function downloadSegments(array $segments, string $localPath): array - { - $disk = Storage::disk('dvr'); - $localFiles = []; - $totalSegments = count($segments); - - foreach ($segments as $index => $segment) { - $localFile = $localPath.'/'.$segment['filename']; - $this->log(sprintf( - 'Downloading segment %d/%d: %s', - $index + 1, - $totalSegments, - $segment['filename'] - )); - - // Download from S3 to local using Storage facade - $contents = $disk->get($segment['path']); - File::put($localFile, $contents); - - $localFiles[] = [ - 'path' => $localFile, - 'filename' => $segment['filename'], - 'timestamp' => $segment['timestamp'], - ]; - } - - return $localFiles; - } - - /** - * Create concat file for ffmpeg - */ - protected function createConcatFile(array $files, string $sessionPath): string - { - $concatFile = $sessionPath.'/concat.txt'; - $content = ''; - - foreach ($files as $file) { - // FFmpeg concat format: file 'path' - $content .= sprintf("file '%s'\n", $file['path']); - } - - File::put($concatFile, $content); - - return $concatFile; - } - - /** - * Combine segments using ffmpeg - */ - protected function combineSegments(string $concatFile, string $outputFile): void - { - // Build ffmpeg command - // -f concat: use concat demuxer - // -safe 0: allow absolute paths - // -i: input file (concat list) - // -c copy: copy codecs without re-encoding - $command = [ - 'ffmpeg', - '-f', 'concat', - '-safe', '0', - '-i', $concatFile, - '-c', 'copy', - '-movflags', '+faststart', // Optimize for streaming - '-y', // Overwrite output file - $outputFile, - ]; - - $this->log('Running FFmpeg: '.implode(' ', $command)); - - $result = Process::run($command); - - if (! $result->successful()) { - throw new \Exception('FFmpeg failed: '.$result->errorOutput()); - } - - if (! file_exists($outputFile)) { - throw new \Exception('FFmpeg did not create output file'); - } - } - - /** - * Trim video to exact time range - */ - protected function trimToExactRange( - string $inputFile, - string $outputFile, - array $segments, - Carbon $startTime, - Carbon $endTime - ): void { - // Get the first segment's timestamp to calculate offset - $firstSegmentTimestamp = $segments[0]['timestamp']; - $lastSegmentTimestamp = end($segments)['timestamp']; - - // Calculate start offset in seconds from the beginning of the first segment - $startMs = $startTime->timestamp * 1000; - $endMs = $endTime->timestamp * 1000; - - // If we only have one segment or segments are close together, we need to trim - $startOffset = max(0, ($startMs - $firstSegmentTimestamp) / 1000); - $duration = ($endMs - $startMs) / 1000; - - // Build ffmpeg trim command - $command = [ - 'ffmpeg', - '-i', $inputFile, - '-ss', sprintf('%.3f', $startOffset), // Start time in seconds - '-t', sprintf('%.3f', $duration), // Duration in seconds - '-c', 'copy', // Copy codec (no re-encoding) - '-avoid_negative_ts', 'make_zero', // Fix timestamp issues - '-movflags', '+faststart', // Optimize for streaming - '-y', // Overwrite output - $outputFile, - ]; - - $this->log('Trimming video with FFmpeg: '.implode(' ', $command)); - - $result = Process::run($command); - - if (! $result->successful()) { - // If copy codec fails (due to keyframe issues), retry with re-encoding - $this->log('Copy codec failed, retrying with re-encoding...'); - - $command = [ - 'ffmpeg', - '-i', $inputFile, - '-ss', sprintf('%.3f', $startOffset), - '-t', sprintf('%.3f', $duration), - '-c:v', 'libx264', // Re-encode video - '-preset', 'fast', // Fast encoding - '-c:a', 'copy', // Copy audio - '-movflags', '+faststart', - '-y', - $outputFile, - ]; - - $result = Process::run($command); - - if (! $result->successful()) { - throw new \Exception('FFmpeg trim failed: '.$result->errorOutput()); - } - } - - if (! file_exists($outputFile)) { - throw new \Exception('FFmpeg did not create trimmed output file'); - } - } - - /** - * Move final file to target storage - */ - protected function moveToStorage(string $tempFile, string $filename, string $storageDisk): string - { - $disk = Storage::disk($storageDisk); - $targetPath = 'dvr-exports/'.$filename; - - // Ensure directory exists - $disk->makeDirectory('dvr-exports'); - - // Read file and store in target disk - $contents = File::get($tempFile); - $disk->put($targetPath, $contents); - - // Return full path based on disk type - if ($storageDisk === 'public') { - return storage_path('app/public/'.$targetPath); - } elseif ($storageDisk === 'local') { - return storage_path('app/'.$targetPath); - } else { - return $targetPath; - } - } - - /** - * Cleanup temporary files - */ - protected function cleanup(string $path): void - { - try { - if (File::exists($path)) { - File::deleteDirectory($path); - $this->log('Cleaned up temporary files'); - } - } catch (\Exception $e) { - $this->log('Warning: Failed to cleanup temp files: '.$e->getMessage(), 'warn'); - } - } - - /** - * Log progress message - */ - protected function log(string $message, string $type = 'info'): void - { - if ($this->progressCallback) { - call_user_func($this->progressCallback, $message, $type); - } - } -} diff --git a/config/stream.php b/config/stream.php index c58eee2..968f9c8 100644 --- a/config/stream.php +++ b/config/stream.php @@ -67,7 +67,7 @@ // namespace an operator publishes them under. 'images' => [ 'ffmpeg_hls' => env('STREAM_IMAGE_FFMPEG_HLS', 'ffmpeg-hls:latest'), - 'dvr_uploader' => env('STREAM_IMAGE_DVR_UPLOADER', 'dvr-uploader:latest'), + 'archive_uploader' => env('STREAM_IMAGE_ARCHIVE_UPLOADER', 'archive-uploader:latest'), ], // Filesystem disk holding the segment archive and the generated recording diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 634e11d..72d9adf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,7 +2,6 @@ # # publisher -> SRS (ingress) -> ffmpeg ABR -> origin nginx -> origin caddy # -> edge nginx -> edge caddy -> browser -# SRS DVR -> dvr-uploader -> S3 (versitygw) -> recordings + thumbnails # # The Laravel app itself stays native under Yerd; containers reach it through the # app-bridge service, and SRS webhooks hit it exactly as they do in production. @@ -29,7 +28,6 @@ services: command: ./objs/srs -c /usr/local/srs/conf/origin.conf volumes: - ./docker/dev/origin-srs.conf:/usr/local/srs/conf/origin.conf:ro - - dvr:/dvr/recordings ports: - "1935:1935" # RTMP ingress (point OBS here) - "1985:1985" # SRS HTTP API @@ -112,8 +110,8 @@ services: restart: unless-stopped # --------------------------------------------------------------- storage - # versitygw exposes a plain directory over the S3 API: recordings, DVR - # uploads and thumbnails all behave like they do against real object storage. + # versitygw exposes a plain directory over the S3 API: the segment archive, + # recordings and thumbnails all behave like they do against real object storage. s3: image: ghcr.io/versity/versitygw:latest command: > @@ -142,33 +140,12 @@ services: - s3 restart: "no" - # ------------------------------------------------------------ DVR upload - dvr-uploader: - build: ./docker/dvr-uploader - environment: - S3_BUCKET: ${AWS_BUCKET:-streaming} - S3_REGION: ${AWS_DEFAULT_REGION:-eu-central-1} - S3_ACCESS_KEY: ${DEV_S3_KEY:-devkey} - S3_SECRET_KEY: ${DEV_S3_SECRET:-devsecret123} - S3_ENDPOINT: http://s3:7070 - RECORDINGS_PATH: /dvr/recordings - DELETE_AFTER_UPLOAD: "false" - FILE_AGE_SECONDS: "15" - WEBHOOK_URL: http://app-bridge/api/srs/dvr - volumes: - - dvr:/dvr/recordings - depends_on: - - s3 - - app-bridge - restart: unless-stopped - # --------------------------------------------------------- segment archive # Mirrors the transcoder's HLS segments to S3 and maintains the per-hour index - # playlists that recordings are later cut from. Separate container from - # dvr-uploader on purpose: that one still handles the SRS MP4 DVR as a cold - # backup, and the two watch different volumes. + # playlists that recordings are later cut from. This is the only recording path: + # SRS DVR is off and the MP4 uploader is gone. archive-uploader: - build: ./docker/dvr-uploader + build: ./docker/archive-uploader command: ["python", "-u", "archive_uploader.py"] environment: S3_BUCKET: ${AWS_BUCKET:-streaming} @@ -220,7 +197,6 @@ services: volumes: hls: - dvr: archive-state: s3-data: caddy-logs: diff --git a/docker/archive-uploader/Dockerfile b/docker/archive-uploader/Dockerfile new file mode 100644 index 0000000..9b5d8d9 --- /dev/null +++ b/docker/archive-uploader/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir boto3 + +# Mirrors the transcoder's HLS segments to S3 and maintains the per-hour index +# playlists recordings are cut from. See docs/dvr-archive-plan.md. +COPY archive_uploader.py /app/ + +RUN mkdir -p /var/lib/dvr-archive + +CMD ["python", "-u", "archive_uploader.py"] diff --git a/docker/dvr-uploader/archive_uploader.py b/docker/archive-uploader/archive_uploader.py similarity index 100% rename from docker/dvr-uploader/archive_uploader.py rename to docker/archive-uploader/archive_uploader.py diff --git a/docker/dev/origin-srs.conf b/docker/dev/origin-srs.conf index 448ebdf..df59621 100644 --- a/docker/dev/origin-srs.conf +++ b/docker/dev/origin-srs.conf @@ -39,23 +39,15 @@ vhost __defaultVhost__ { on_dvr http://app-bridge/api/srs/dvr; } - # DVR configuration for recording streams + # DVR is off: the segment archive is the recording path now. + # + # SRS used to write segmented MP4 alongside HLS, as the source for the old + # extract/concat/re-encode pipeline and later as a cold backup. Both jobs are gone: + # recordings are cut from the HLS segments the transcoder already produces, and a + # 90 minute soak showed the MP4 copy costing more disk than the archive it backed up. + # See docs/dvr-archive-plan.md. dvr { - enabled on; - # Apply to all streams - dvr_apply all; - # Use segment plan to split files - dvr_plan segment; - # Path with stream-based organization and timestamp - # Creates: /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4 - dvr_path /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4; - # 60s per segment locally (production uses 600) so a DVR file lands - # while you are still looking at the screen. - dvr_duration 60; - # Wait for keyframe before splitting - dvr_wait_keyframe on; - # Full time jitter handling for proper timestamps - time_jitter full; + enabled off; } # No HLS - FFmpeg handles this diff --git a/docker/dvr-uploader/Dockerfile b/docker/dvr-uploader/Dockerfile deleted file mode 100644 index 44d387a..0000000 --- a/docker/dvr-uploader/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install dependencies -RUN pip install --no-cache-dir \ - boto3 \ - watchdog \ - requests - -# Two entrypoints share this image. uploader.py handles the SRS MP4 DVR; the -# archive uploader mirrors HLS segments and maintains the hour indexes. They watch -# different volumes and are deployed as separate containers, so the MP4 path can -# keep running as a cold backup while the segment archive proves itself. -COPY uploader.py archive_uploader.py /app/ - -RUN mkdir -p /dvr/recordings /var/lib/dvr-archive - -CMD ["python", "-u", "uploader.py"] \ No newline at end of file diff --git a/docker/dvr-uploader/uploader.py b/docker/dvr-uploader/uploader.py deleted file mode 100644 index 2b1c4f3..0000000 --- a/docker/dvr-uploader/uploader.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -""" -DVR S3 Uploader Service -Monitors DVR recordings and uploads completed segments to S3 -""" - -import os -import time -import json -import logging -import threading -from pathlib import Path -from datetime import datetime -import boto3 -from botocore.exceptions import ClientError -from botocore.config import Config -from boto3.s3.transfer import TransferConfig -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler -import requests - -# Configure logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger('dvr-uploader') - -# Configuration from environment variables -S3_BUCKET = os.environ.get('S3_BUCKET', 'streaming-recordings') -S3_REGION = os.environ.get('S3_REGION', 'eu-central-1') -S3_ACCESS_KEY = os.environ.get('S3_ACCESS_KEY') -S3_SECRET_KEY = os.environ.get('S3_SECRET_KEY') -S3_ENDPOINT = os.environ.get('S3_ENDPOINT') # Optional for S3-compatible services -RECORDINGS_PATH = os.environ.get('RECORDINGS_PATH', '/dvr/recordings') -DELETE_AFTER_UPLOAD = os.environ.get('DELETE_AFTER_UPLOAD', 'true').lower() == 'true' -WEBHOOK_URL = os.environ.get('WEBHOOK_URL') # Optional webhook for notifications -FILE_AGE_SECONDS = int(os.environ.get('FILE_AGE_SECONDS', '30')) # Wait time before upload -MAX_CONCURRENT_UPLOADS = int(os.environ.get('MAX_CONCURRENT_UPLOADS', '5')) # Max concurrent file uploads - -# S3 client configuration with optimized connection pool -boto_config = Config( - max_pool_connections=100, # Increased from default 10 to support concurrent uploads - retries={ - 'max_attempts': 3, - 'mode': 'adaptive' - }, - read_timeout=300, # 5 minutes for large uploads - connect_timeout=60 -) - -s3_config = { - 'region_name': S3_REGION, - 'config': boto_config -} - -if S3_ACCESS_KEY and S3_SECRET_KEY: - s3_config['aws_access_key_id'] = S3_ACCESS_KEY - s3_config['aws_secret_access_key'] = S3_SECRET_KEY - -if S3_ENDPOINT: - s3_config['endpoint_url'] = S3_ENDPOINT - -# Initialize S3 client with optimized config -s3_client = boto3.client('s3', **s3_config) - -# Multipart transfer configuration optimized for 800MB files -transfer_config = TransferConfig( - multipart_threshold=100 * 1024 * 1024, # 100MB threshold - multipart_chunksize=100 * 1024 * 1024, # 100MB chunks (8 parts for 800MB) - max_concurrency=5, # 5 threads per file upload - use_threads=True -) - -# Track files being processed and upload concurrency -processing_files = set() -file_lock = threading.Lock() -upload_semaphore = threading.Semaphore(MAX_CONCURRENT_UPLOADS) -handler = None # Will be initialized later - -# Metrics tracking -upload_metrics = { - 'queue_depth': 0, - 'uploads_in_progress': 0, - 'uploads_completed': 0, - 'uploads_failed': 0, - 'total_bytes_uploaded': 0, - 'last_upload_speed_mbps': 0 -} -metrics_lock = threading.Lock() - - -class DVRFileHandler(FileSystemEventHandler): - """Handler for DVR file events""" - - def __init__(self): - self.pending_files = {} - self.check_thread = threading.Thread(target=self.check_pending_files, daemon=True) - self.check_thread.start() - - def on_created(self, event): - """Handle new file creation""" - if event.is_directory: - # New directory created - might contain files soon - logger.info(f"New directory created: {event.src_path}") - # Schedule a rescan in a few seconds to catch any files - threading.Timer(5.0, self.scan_directory, args=(event.src_path,)).start() - return - - if event.src_path.endswith(('.mp4', '.flv')): - logger.info(f"New recording detected: {event.src_path}") - with file_lock: - self.pending_files[event.src_path] = time.time() - - def on_modified(self, event): - """Handle file modification""" - if event.is_directory: - return - - if event.src_path.endswith(('.mp4', '.flv')): - # Update the timestamp for pending files - with file_lock: - if event.src_path in self.pending_files: - self.pending_files[event.src_path] = time.time() - - def scan_directory(self, directory): - """Scan a specific directory for recording files""" - try: - logger.info(f"Scanning directory for recordings: {directory}") - for root, dirs, files in os.walk(directory): - for file in files: - if file.endswith(('.mp4', '.flv')): - file_path = os.path.join(root, file) - with file_lock: - if file_path not in self.pending_files and file_path not in processing_files: - self.pending_files[file_path] = time.time() - logger.info(f"Found new file in new directory: {file_path}") - except Exception as e: - logger.error(f"Error scanning directory {directory}: {e}") - - def check_pending_files(self): - """Check for files that haven't been modified recently""" - while True: - time.sleep(10) - current_time = time.time() - files_to_process = [] - - with file_lock: - queue_depth = len(self.pending_files) - if queue_depth > 10: - logger.warning(f"Upload queue depth high: {queue_depth} files pending") - - for file_path, last_modified in list(self.pending_files.items()): - if current_time - last_modified > FILE_AGE_SECONDS: - if file_path not in processing_files: - files_to_process.append(file_path) - del self.pending_files[file_path] - processing_files.add(file_path) - - # Update metrics - with metrics_lock: - upload_metrics['queue_depth'] = len(self.pending_files) + len(processing_files) - - for file_path in files_to_process: - threading.Thread( - target=process_file, - args=(file_path,), - daemon=True - ).start() - - -def process_file(file_path): - """Process and upload a recording file""" - # Use semaphore to limit concurrent uploads - with upload_semaphore: - try: - # Update metrics - with metrics_lock: - upload_metrics['uploads_in_progress'] += 1 - - start_time = time.time() - logger.info(f"Processing file: {file_path}") - - # Verify file exists and is not being written - if not os.path.exists(file_path): - logger.warning(f"File no longer exists: {file_path}") - return - - # Get file size to ensure it's complete - file_size = os.path.getsize(file_path) - if file_size == 0: - logger.warning(f"File is empty: {file_path}") - return - - # Wait a moment and check size again to ensure writing is complete - time.sleep(2) - new_size = os.path.getsize(file_path) - if new_size != file_size: - logger.info(f"File still being written: {file_path}") - # Re-add to pending - with file_lock: - handler.pending_files[file_path] = time.time() - processing_files.discard(file_path) - return - - # Parse the file path to extract metadata - path_parts = Path(file_path).parts - relative_path = Path(file_path).relative_to(RECORDINGS_PATH) - - # Generate S3 key with dvr/ prefix - s3_key = f"dvr/{relative_path}" - - # Upload to S3 - logger.info(f"Uploading to S3: {s3_key}") - upload_to_s3(file_path, s3_key) - - # Send webhook notification if configured - if WEBHOOK_URL: - notify_webhook(file_path, s3_key) - - # Delete local file if configured - if DELETE_AFTER_UPLOAD: - os.remove(file_path) - logger.info(f"Deleted local file: {file_path}") - - # Clean up empty directories - cleanup_empty_dirs(os.path.dirname(file_path)) - - # Calculate upload speed - elapsed_time = time.time() - start_time - file_size_mb = os.path.getsize(file_path) / (1024 * 1024) if os.path.exists(file_path) else 0 - upload_speed = file_size_mb / elapsed_time if elapsed_time > 0 else 0 - - with metrics_lock: - upload_metrics['uploads_completed'] += 1 - upload_metrics['total_bytes_uploaded'] += file_size_mb * 1024 * 1024 - upload_metrics['last_upload_speed_mbps'] = upload_speed * 8 - - logger.info(f"Successfully processed: {file_path} ({file_size_mb:.1f}MB in {elapsed_time:.1f}s, {upload_speed:.1f}MB/s)") - - except Exception as e: - logger.error(f"Error processing file {file_path}: {e}") - with metrics_lock: - upload_metrics['uploads_failed'] += 1 - finally: - with file_lock: - processing_files.discard(file_path) - with metrics_lock: - upload_metrics['uploads_in_progress'] -= 1 - - -def upload_to_s3(file_path, s3_key): - """Upload file to S3 with optimized multipart support for large files""" - try: - # Determine content type - content_type = 'video/mp4' if file_path.endswith('.mp4') else 'video/x-flv' - - # Get file stats for metadata - file_stats = os.stat(file_path) - file_size = file_stats.st_size - - # Metadata for the S3 object - metadata = { - 'original-filename': os.path.basename(file_path), - 'upload-timestamp': datetime.utcnow().isoformat(), - 'file-size': str(file_size), - } - - # Extract stream info from path if possible - try: - parts = Path(file_path).parts - if len(parts) >= 4: - app_name = parts[-4] - stream_name = parts[-3] - metadata['app'] = app_name - metadata['stream'] = stream_name - except: - pass - - logger.info(f"Starting upload: {s3_key} ({file_size / (1024*1024):.1f}MB)") - - # Use optimized multipart upload for all files over 100MB - if file_size > 100 * 1024 * 1024: # 100MB threshold - logger.info(f"Using optimized multipart upload ({file_size / (1024*1024):.1f}MB with {file_size / (100*1024*1024):.0f} parts)") - - # Upload using optimized multipart config - s3_client.upload_file( - file_path, - S3_BUCKET, - s3_key, - ExtraArgs={ - 'ContentType': content_type, - 'Metadata': metadata - }, - Config=transfer_config # Use global optimized config - ) - else: - # Regular upload for smaller files - with open(file_path, 'rb') as f: - s3_client.put_object( - Bucket=S3_BUCKET, - Key=s3_key, - Body=f, - ContentType=content_type, - Metadata=metadata - ) - - logger.info(f"Uploaded to S3: s3://{S3_BUCKET}/{s3_key}") - - except ClientError as e: - logger.error(f"S3 upload failed: {e}") - raise - - -def notify_webhook(file_path, s3_key): - """Send webhook notification about uploaded file""" - try: - payload = { - 'event': 'dvr_uploaded', - 'file_path': file_path, - 's3_bucket': S3_BUCKET, - 's3_key': s3_key, - 's3_url': f"s3://{S3_BUCKET}/{s3_key}", - 'timestamp': datetime.utcnow().isoformat(), - } - - # Extract stream info if possible - try: - parts = Path(file_path).parts - if len(parts) >= 4: - payload['app'] = parts[-4] - payload['stream'] = parts[-3] - payload['date'] = parts[-2] - except: - pass - - response = requests.post( - WEBHOOK_URL, - json=payload, - timeout=10 - ) - response.raise_for_status() - logger.info(f"Webhook notification sent for: {s3_key}") - - except Exception as e: - logger.error(f"Webhook notification failed: {e}") - - -def cleanup_empty_dirs(directory): - """Remove empty directories recursively""" - try: - # Don't delete the root recordings directory - if directory == RECORDINGS_PATH: - return - - # Check if directory is empty - if os.path.isdir(directory) and not os.listdir(directory): - os.rmdir(directory) - logger.info(f"Removed empty directory: {directory}") - - # Recursively check parent - parent = os.path.dirname(directory) - if parent != RECORDINGS_PATH: - cleanup_empty_dirs(parent) - except Exception as e: - logger.debug(f"Could not remove directory {directory}: {e}") - - -def scan_existing_files(): - """Scan for existing files on startup""" - logger.info(f"Scanning existing files in {RECORDINGS_PATH}") - - for root, dirs, files in os.walk(RECORDINGS_PATH): - for file in files: - if file.endswith(('.mp4', '.flv')): - file_path = os.path.join(root, file) - - # Check file age - try: - file_stat = os.stat(file_path) - file_age = time.time() - file_stat.st_mtime - - # If file is old enough, process it - if file_age > FILE_AGE_SECONDS: - logger.info(f"Found existing file: {file_path}") - threading.Thread( - target=process_file, - args=(file_path,), - daemon=True - ).start() - else: - # Add to pending files - with file_lock: - handler.pending_files[file_path] = file_stat.st_mtime - logger.info(f"Found recent file, adding to watch: {file_path}") - - except Exception as e: - logger.error(f"Error checking file {file_path}: {e}") - - -def periodic_rescan(): - """Periodically rescan for files in new directories""" - while True: - time.sleep(300) # Rescan every 5 minutes - logger.info("Performing periodic rescan for new directories...") - - try: - current_files = set() - for root, dirs, files in os.walk(RECORDINGS_PATH): - for file in files: - if file.endswith(('.mp4', '.flv')): - file_path = os.path.join(root, file) - current_files.add(file_path) - - # Check if this file is already being tracked - with file_lock: - if (file_path not in handler.pending_files and - file_path not in processing_files): - # New file found that wasn't tracked - try: - file_stat = os.stat(file_path) - file_age = time.time() - file_stat.st_mtime - - if file_age > FILE_AGE_SECONDS: - logger.info(f"Rescan found untracked file: {file_path}") - processing_files.add(file_path) - threading.Thread( - target=process_file, - args=(file_path,), - daemon=True - ).start() - else: - handler.pending_files[file_path] = file_stat.st_mtime - logger.info(f"Rescan found recent untracked file: {file_path}") - except Exception as e: - logger.error(f"Error checking file during rescan {file_path}: {e}") - - logger.debug(f"Rescan complete. Found {len(current_files)} total recording files") - except Exception as e: - logger.error(f"Error during periodic rescan: {e}") - - -def print_metrics(): - """Print upload metrics periodically""" - while True: - time.sleep(60) # Print every minute - with metrics_lock: - logger.info( - f"Upload Metrics - Queue: {upload_metrics['queue_depth']}, " - f"In Progress: {upload_metrics['uploads_in_progress']}, " - f"Completed: {upload_metrics['uploads_completed']}, " - f"Failed: {upload_metrics['uploads_failed']}, " - f"Last Speed: {upload_metrics['last_upload_speed_mbps']:.1f} Mbps" - ) - - # Alert if queue is getting too deep - if upload_metrics['queue_depth'] > 20: - logger.error(f"CRITICAL: Upload queue depth is {upload_metrics['queue_depth']} - falling behind!") - - -if __name__ == "__main__": - logger.info("DVR S3 Uploader Service starting...") - logger.info(f"Configuration: MAX_CONCURRENT_UPLOADS={MAX_CONCURRENT_UPLOADS}, FILE_AGE_SECONDS={FILE_AGE_SECONDS}") - - # Verify S3 configuration - if not S3_BUCKET: - logger.error("S3_BUCKET environment variable is required") - exit(1) - - # Create recordings directory if it doesn't exist - os.makedirs(RECORDINGS_PATH, exist_ok=True) - - # Start metrics reporting thread - metrics_thread = threading.Thread(target=print_metrics, daemon=True) - metrics_thread.start() - - # Start periodic rescan thread to catch files in new directories - rescan_thread = threading.Thread(target=periodic_rescan, daemon=True) - rescan_thread.start() - - # Test S3 connection - try: - s3_client.head_bucket(Bucket=S3_BUCKET) - logger.info(f"Successfully connected to S3 bucket: {S3_BUCKET}") - except ClientError as e: - error_code = e.response['Error']['Code'] - if error_code == '404': - logger.error(f"S3 bucket does not exist: {S3_BUCKET}") - else: - logger.error(f"Failed to connect to S3: {e}") - # Continue anyway - bucket might be created later - - # Set up file system monitoring - handler = DVRFileHandler() - observer = Observer() - observer.schedule(handler, RECORDINGS_PATH, recursive=True) - - # Scan existing files - scan_existing_files() - - # Start monitoring - observer.start() - logger.info(f"Monitoring {RECORDINGS_PATH} for DVR recordings...") - - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - observer.stop() - logger.info("DVR S3 Uploader Service stopped") - - observer.join() \ No newline at end of file diff --git a/docker/origin-srs/origin.conf b/docker/origin-srs/origin.conf index c4e022f..21051f4 100644 --- a/docker/origin-srs/origin.conf +++ b/docker/origin-srs/origin.conf @@ -37,22 +37,15 @@ vhost __defaultVhost__ { on_dvr http://127.0.0.1/api/srs/dvr; } - # DVR configuration for recording streams + # DVR is off: the segment archive is the recording path now. + # + # SRS used to write segmented MP4 alongside HLS, as the source for the old + # extract/concat/re-encode pipeline and later as a cold backup. Both jobs are gone: + # recordings are cut from the HLS segments the transcoder already produces, and a + # 90 minute soak showed the MP4 copy costing more disk than the archive it backed up. + # See docs/dvr-archive-plan.md. dvr { - enabled on; - # Apply to all streams - dvr_apply all; - # Use segment plan to split files - dvr_plan segment; - # Path with stream-based organization and timestamp - # Creates: /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4 - dvr_path /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4; - # 10 minutes per segment (600 seconds) - dvr_duration 600; - # Wait for keyframe before splitting - dvr_wait_keyframe on; - # Full time jitter handling for proper timestamps - time_jitter full; + enabled off; } # No HLS - FFmpeg handles this diff --git a/docs/dev-stack.md b/docs/dev-stack.md index f8872b4..043ce5b 100644 --- a/docs/dev-stack.md +++ b/docs/dev-stack.md @@ -37,10 +37,11 @@ which proxy to the edge server row on `localhost:8085`, exactly as in production ``` publisher (ffmpeg, stands in for OBS) | rtmp://localhost:1935/ingress/?secret= -SRS origin ──DVR mp4──> dvr-uploader ──> versitygw (S3 API) ──> recordings, thumbnails +SRS origin | rtmp ffmpeg-hls (480p/720p/1080p ladder, aligned GOPs; remuxed by default locally) - | shared volume + | shared volume ──> archive-uploader ──> versitygw (S3 API) ──> segment archive + | + per-hour index playlists origin nginx :8083 ──> origin caddy :8070 | edge nginx :8081 (njs verifies ?t= tokens) ──> edge caddy ──> localhost:8085 ──> browser diff --git a/docs/dvr-archive-plan.md b/docs/dvr-archive-plan.md index ee893ba..f2712c9 100644 --- a/docs/dvr-archive-plan.md +++ b/docs/dvr-archive-plan.md @@ -516,11 +516,22 @@ rest is headroom for upload lag and for an S3 outage that stalls the reaper. 6. Manage UI: index, then the trim editor. 7. **Done.** Player: `live:dvr` wired through `StreamPlayer.vue`, stream-type-aware `backBufferLength`. -8. Retire `DvrExtractorService`, `dvr-extract.sh`, `dvr-process.sh`, `ExtractDvrSegments`, - and the SRS `dvr` block. - -Keep the SRS MP4 DVR running as a cold backup through the next event. It costs disk and -nothing else, and it is the fallback if the segment archive misses something. +8. **Done.** Retired the whole MP4 path: `DvrExtractorService`, `dvr-extract.sh`, + `dvr-process.sh`, `ExtractDvrSegments`, `SrsDvrController` and its two routes, + `uploader.py`, the `dvr-uploader` service and its volume, and the SRS `dvr` block + (now `enabled off`). `docker/dvr-uploader` became `docker/archive-uploader`, and + `stream.images.dvr_uploader` became `stream.images.archive_uploader`. + +An earlier revision of this plan kept the SRS MP4 DVR as a cold backup through the first +event, on the grounds that it "costs disk and nothing else". The 90 minute soak measured +that cost: 45 GB of MP4 against 34 GB of segment archive for the same period, having been +9x ahead earlier in the run. On a production origin it is the largest single consumer of +disk, and it backs up a path that has now been verified frame-exact over 90 minutes. It +was removed rather than carried. + +The consequence is worth stating plainly: **the segment archive is the only copy.** If the +uploader loses a segment before it reaches S3, nothing else holds it. The reaper is what +makes that safe, since it refuses to delete anything S3 has not confirmed. ### PDT drift: measured, then designed around diff --git a/resources/views/server-provisioning/origin/docker-compose.blade.php b/resources/views/server-provisioning/origin/docker-compose.blade.php index b92c984..bd9ed18 100644 --- a/resources/views/server-provisioning/origin/docker-compose.blade.php +++ b/resources/views/server-provisioning/origin/docker-compose.blade.php @@ -13,7 +13,6 @@ SRS_HTTP_PORT: 8082 volumes: - ./srs.conf:/usr/local/srs/conf/custom.conf:ro - - dvr-recordings:/dvr/recordings command: ./objs/srs -c /usr/local/srs/conf/custom.conf restart: unless-stopped networks: @@ -77,36 +76,13 @@ networks: - streaming - # DVR S3 Uploader Service - dvr-uploader: - image: {{ config('stream.images.dvr_uploader') }} - container_name: dvr-uploader - environment: - S3_BUCKET: ${DVR_AWS_BUCKET:-streaming-recordings} - S3_REGION: ${DVR_AWS_DEFAULT_REGION:-eu-central-1} - S3_ACCESS_KEY: ${DVR_AWS_ACCESS_KEY_ID} - S3_SECRET_KEY: ${DVR_AWS_SECRET_ACCESS_KEY} - S3_ENDPOINT: ${DVR_AWS_ENDPOINT} - RECORDINGS_PATH: /dvr/recordings - DELETE_AFTER_UPLOAD: 'true' - WEBHOOK_URL: '{{ $serverUrl }}/api/dvr/upload-webhook' - FILE_AGE_SECONDS: '30' - volumes: - - dvr-recordings:/dvr/recordings - restart: unless-stopped - depends_on: - - origin-srs - networks: - - streaming - # HLS Segment Archive Uploader # # Mirrors the transcoder's segments to S3 and maintains the per-hour index - # playlists that recordings are cut from. Separate container from dvr-uploader - # above, which keeps handling the SRS MP4 DVR as a cold backup: the two watch - # different volumes and share only the image. See docs/dvr-archive-plan.md. + # playlists that recordings are cut from. This is the only recording path: + # SRS DVR is off and the MP4 uploader is gone. See docs/dvr-archive-plan.md. archive-uploader: - image: {{ config('stream.images.dvr_uploader') }} + image: {{ config('stream.images.archive_uploader') }} container_name: archive-uploader command: ["python", "-u", "archive_uploader.py"] environment: @@ -143,7 +119,6 @@ volumes: hls-content: - dvr-recordings: archive-state: caddy-data: caddy-config: \ No newline at end of file diff --git a/resources/views/server-provisioning/origin/srs-config.blade.php b/resources/views/server-provisioning/origin/srs-config.blade.php index 8c5791c..e5fbd1c 100644 --- a/resources/views/server-provisioning/origin/srs-config.blade.php +++ b/resources/views/server-provisioning/origin/srs-config.blade.php @@ -33,22 +33,15 @@ on_dvr {{ $serverUrl }}/api/srs/dvr; } - # DVR configuration for recording streams + # DVR is off: the segment archive is the recording path now. + # + # SRS used to write segmented MP4 alongside HLS, as the source for the old + # extract/concat/re-encode pipeline and later as a cold backup. Both jobs are gone: + # recordings are cut from the HLS segments the transcoder already produces, and a + # 90 minute soak showed the MP4 copy costing more disk than the archive it backed up. + # See docs/dvr-archive-plan.md. dvr { - enabled on; - # Apply to all streams - dvr_apply all; - # Use segment plan to split files - dvr_plan segment; - # Path with stream-based organization and timestamp - # Creates: /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4 - dvr_path /dvr/recordings/[app]/[stream]/[2006]-[01]-[02]/[15]-[04]-[05]_[timestamp].mp4; - # 10 minutes per segment (600 seconds) - dvr_duration 600; - # Wait for keyframe before splitting - dvr_wait_keyframe on; - # Full time jitter handling for proper timestamps - time_jitter full; + enabled off; } # Force low latency for all streams diff --git a/routes/api.php b/routes/api.php index 97be0c9..472153f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -56,11 +56,8 @@ Route::post('on-hls', [App\Http\Controllers\Api\SrsCallbackController::class, 'onHls'])->name('api.srs.on-hls'); Route::post('on-play', [App\Http\Controllers\Api\SrsCallbackController::class, 'play'])->name('api.srs.on-play'); Route::post('on-stop', [App\Http\Controllers\Api\SrsCallbackController::class, 'stop'])->name('api.srs.on-stop'); - Route::post('dvr', [App\Http\Controllers\Api\SrsDvrController::class, 'handleDvrCallback'])->name('api.srs.dvr'); }); -// DVR uploader webhooks -Route::post('dvr/upload-webhook', [App\Http\Controllers\Api\SrsDvrController::class, 'handleUploadWebhook'])->name('api.dvr.upload-webhook'); // Recording API endpoints for external processing server Route::middleware([\App\Http\Middleware\CheckRecordingApiKeyMiddleware::class])->prefix('recording')->group(function () { diff --git a/scripts/dev-stack.sh b/scripts/dev-stack.sh index de9173d..c5faab5 100755 --- a/scripts/dev-stack.sh +++ b/scripts/dev-stack.sh @@ -61,7 +61,7 @@ case "${1:-up}" in check_token_secrets # --build so edits to the transcoder script or the edge's njs bundle take # effect; both are baked into their images rather than mounted. - $COMPOSE up -d --build origin-srs hls-transcoder origin-nginx origin-caddy edge-nginx edge-caddy s3 s3-init dvr-uploader archive-uploader + $COMPOSE up -d --build origin-srs hls-transcoder origin-nginx origin-caddy edge-nginx edge-caddy s3 s3-init archive-uploader start_publishers # Unquoted delimiter so the S3 port expands. Nothing else in this block uses From 20919966e153a6fb601681379e5a56cc95c529a0 Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 18:58:15 +0200 Subject: [PATCH 03/14] Align php version, drop footer credit --- .github/workflows/laravel.yml | 2 +- composer.json | 5 ++++- composer.lock | 7 +++++-- resources/js/Layouts/AuthenticatedLayout.vue | 1 - 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 572d86d..aa18d46 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: shivammathur/setup-php@15c43e89cdef867065b0213be354c2841860869e with: - php-version: '8.2' + php-version: '8.4' - uses: shogo82148/actions-setup-mysql@v1 with: mysql-version: '8.0' diff --git a/composer.json b/composer.json index d26e79b..d2ed3d6 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.4", "doctrine/dbal": "^4.3", "flowframe/laravel-trend": "^0.4.0", "guzzlehttp/guzzle": "^7.9", @@ -65,6 +65,9 @@ } }, "config": { + "platform": { + "php": "8.4" + }, "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true, diff --git a/composer.lock b/composer.lock index 49d762d..7dc6779 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "94773b25986d18e8cf049476b92c4303", + "content-hash": "7b7efdd67521158639f93ea424987059", "packages": [ { "name": "aws/aws-crt-php", @@ -11329,8 +11329,11 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.4" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.4" + }, "plugin-api-version": "2.9.0" } diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index e850017..0298402 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -272,7 +272,6 @@ const chatEnabled = computed(() => page.props.features?.chat !== false);
- Made with by the Video Team From 5c10ee936871fa3339fbd5a539b69f7d6d36cc5d Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:06:45 +0200 Subject: [PATCH 04/14] Move php to 8.5 --- .github/workflows/laravel.yml | 2 +- Dockerfile | 2 +- composer.json | 4 ++-- composer.lock | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index aa18d46..37630c8 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: shivammathur/setup-php@15c43e89cdef867065b0213be354c2841860869e with: - php-version: '8.4' + php-version: '8.5' - uses: shogo82148/actions-setup-mysql@v1 with: mysql-version: '8.0' diff --git a/Dockerfile b/Dockerfile index c2e079f..434e0ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7-labs -FROM dunglas/frankenphp:php8.4 as base +FROM dunglas/frankenphp:php8.5 as base WORKDIR /app ENV COMPOSER_MEMORY_LIMIT=-1 diff --git a/composer.json b/composer.json index d2ed3d6..e4e76e8 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.4", + "php": "^8.5", "doctrine/dbal": "^4.3", "flowframe/laravel-trend": "^0.4.0", "guzzlehttp/guzzle": "^7.9", @@ -66,7 +66,7 @@ }, "config": { "platform": { - "php": "8.4" + "php": "8.5" }, "optimize-autoloader": true, "preferred-install": "dist", diff --git a/composer.lock b/composer.lock index 7dc6779..6950195 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7b7efdd67521158639f93ea424987059", + "content-hash": "9d03eb52cef1d7e5ce214d4efa2988df", "packages": [ { "name": "aws/aws-crt-php", @@ -11329,11 +11329,11 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.4" + "php": "^8.5" }, "platform-dev": {}, "platform-overrides": { - "php": "8.4" + "php": "8.5" }, "plugin-api-version": "2.9.0" } From bac9becab34c4d875739c5311dfba50cc2b86b71 Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:11:56 +0200 Subject: [PATCH 05/14] Point srs tests at ingress app --- .../Feature/Api/SrsCallbackControllerTest.php | 136 ++++++++++++------ 1 file changed, 90 insertions(+), 46 deletions(-) diff --git a/tests/Feature/Api/SrsCallbackControllerTest.php b/tests/Feature/Api/SrsCallbackControllerTest.php index 06dbd0c..47a2e70 100644 --- a/tests/Feature/Api/SrsCallbackControllerTest.php +++ b/tests/Feature/Api/SrsCallbackControllerTest.php @@ -76,9 +76,9 @@ protected function setUp(): void public function test_auth_succeeds_with_valid_source_stream_key() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -104,14 +104,58 @@ public function test_auth_succeeds_with_valid_source_stream_key() } /** - * Test authentication fails with invalid stream key + * Publishers use the `ingress` app; `live` is the transcoder's output. + * + * Untested until now, and its absence hid a suite-wide failure: every test here + * published to `live` and got a 403, which the negative tests happily accepted + * because 403 was what they expected. Asserting the rejection explicitly means a + * future change to the app split fails loudly instead of turning the positive + * tests into false negatives. */ - public function test_auth_fails_with_invalid_stream_key() + public function test_auth_rejects_external_publishing_to_the_live_app() + { + $response = $this->postJson('/api/srs/auth', [ + 'app' => 'live', + 'stream' => 'test-source', + 'tcUrl' => 'rtmp://localhost/live', + 'param' => '?secret='.$this->source->stream_key, + 'ip' => '203.0.113.10', + ]); + + $response->assertStatus(403); + + // A valid key must not be enough: the app itself is what is refused. + $this->source->refresh(); + $this->assertEquals(SourceStatusEnum::OFFLINE, $this->source->status); + } + + /** + * The transcoder republishes into `live` from inside the origin, so that path stays + * open for loopback callers. + */ + public function test_auth_allows_internal_transcoding_into_the_live_app() { $response = $this->postJson('/api/srs/auth', [ 'app' => 'live', 'stream' => 'test-source', 'tcUrl' => 'rtmp://localhost/live', + 'param' => '', + 'ip' => '127.0.0.1', + ]); + + $response->assertStatus(200) + ->assertJson(['code' => 0]); + } + + /** + * Test authentication fails with invalid stream key + */ + public function test_auth_fails_with_invalid_stream_key() + { + $response = $this->postJson('/api/srs/auth', [ + 'app' => 'ingress', + 'stream' => 'test-source', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret=invalid_key', ]); @@ -129,9 +173,9 @@ public function test_auth_fails_with_invalid_stream_key() public function test_auth_fails_with_unknown_stream_name() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'non-existent-stream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -145,9 +189,9 @@ public function test_auth_fails_with_unknown_stream_name() public function test_auth_fails_without_stream_key() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); @@ -161,7 +205,7 @@ public function test_auth_fails_without_stream_key() public function test_server_auth_succeeds_with_valid_shared_secret() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret='.$this->edgeServer->shared_secret, @@ -190,7 +234,7 @@ public function test_server_auth_succeeds_with_valid_shared_secret() public function test_server_auth_fails_with_invalid_shared_secret() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret=invalid_secret', @@ -210,7 +254,7 @@ public function test_server_auth_fails_with_inactive_server() $this->edgeServer->save(); $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret='.$this->edgeServer->shared_secret, @@ -226,7 +270,7 @@ public function test_server_auth_fails_with_inactive_server() public function test_shared_secret_takes_precedence_over_stream_key() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret='.$this->edgeServer->shared_secret.'&secret='.$this->source->stream_key, @@ -258,9 +302,9 @@ public function test_unpublish_sets_source_to_error_when_show_is_live() $this->show->save(); $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -290,9 +334,9 @@ public function test_unpublish_sets_source_to_offline_when_no_live_show() $this->show->save(); $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -310,9 +354,9 @@ public function test_unpublish_sets_source_to_offline_when_no_live_show() public function test_unpublish_handles_unknown_stream_gracefully() { $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'non-existent-stream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); @@ -327,9 +371,9 @@ public function test_unpublish_handles_unknown_stream_gracefully() public function test_play_webhook_returns_success() { $response = $this->postJson('/api/srs/play', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => 'http://example.com', 'param' => '', ]); @@ -344,9 +388,9 @@ public function test_play_webhook_returns_success() public function test_stop_webhook_returns_success() { $response = $this->postJson('/api/srs/stop', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); @@ -360,9 +404,9 @@ public function test_stop_webhook_returns_success() public function test_auth_handles_malformed_param_string() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => 'malformed&&&==param', ]); @@ -387,9 +431,9 @@ public function test_source_status_changes_do_not_affect_show_status() ]); $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -433,9 +477,9 @@ public function test_source_going_to_error_when_shows_are_live() ]); $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); @@ -459,9 +503,9 @@ public function test_source_going_to_error_when_shows_are_live() public function test_auth_generates_correct_signature_format() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -484,7 +528,7 @@ public function test_auth_generates_correct_signature_format() public function test_server_auth_generates_correct_signature_format() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret='.$this->edgeServer->shared_secret, @@ -523,9 +567,9 @@ public function test_source_with_encrypted_stream_key_authenticates() // But authentication should still work with the plain key $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'encrypted-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret=super_secret_key_456', ]); @@ -548,9 +592,9 @@ public function test_concurrent_auth_requests_for_same_source() for ($i = 0; $i < 3; $i++) { $responses[] = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); } @@ -572,9 +616,9 @@ public function test_concurrent_auth_requests_for_same_source() public function test_auth_fails_with_empty_stream_name() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => '', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); @@ -604,7 +648,7 @@ public function test_auth_handles_null_parameters() public function test_server_auth_with_nonexistent_source_still_succeeds() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'non-existent-stream', 'tcUrl' => 'rtmp://origin.server/live', 'param' => '?shared_secret='.$this->edgeServer->shared_secret, @@ -630,9 +674,9 @@ public function test_source_status_transitions_with_error_recovery() // Authenticate to go online $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); $response->assertStatus(200); @@ -646,9 +690,9 @@ public function test_source_status_transitions_with_error_recovery() // Unpublish while show is live -> goes to ERROR $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); $response->assertStatus(200); @@ -658,9 +702,9 @@ public function test_source_status_transitions_with_error_recovery() // Reconnect (auth again) to recover from error $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '?secret='.$this->source->stream_key, ]); $response->assertStatus(200); @@ -674,9 +718,9 @@ public function test_source_status_transitions_with_error_recovery() // Unpublish with no live show -> goes to OFFLINE $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'test-source', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'param' => '', ]); $response->assertStatus(200); From d64e03eb10f122d0ca7f1177e33856f37e33444d Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:14:47 +0200 Subject: [PATCH 06/14] Drop obsolete user streamkey auth tests --- .../Feature/SrsWebhookAuthenticationTest.php | 99 +++++-------------- 1 file changed, 22 insertions(+), 77 deletions(-) diff --git a/tests/Feature/SrsWebhookAuthenticationTest.php b/tests/Feature/SrsWebhookAuthenticationTest.php index 5e02b4d..3af3cb7 100644 --- a/tests/Feature/SrsWebhookAuthenticationTest.php +++ b/tests/Feature/SrsWebhookAuthenticationTest.php @@ -65,30 +65,14 @@ protected function setUp(): void } /** - * Test successful authentication with valid streamkey + * Publisher authentication is per source, not per user. + * + * Two tests were removed here: one asserting a user's `streamkey` authenticated a + * publish, and one asserting `?streamkey=` and `?secret=` were interchangeable. + * Neither is true any more. `/api/srs/auth` compares `?secret=` against the source's + * own `stream_key`, or `?shared_secret=` for edge-to-origin forwards; a user's + * streamkey is only read on the playback path. See docs/dev-stack.md. */ - public function test_auth_succeeds_with_valid_streamkey() - { - $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', - 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', - 'pageUrl' => '', - 'param' => '?secret='.$this->userWithStreamkey->streamkey, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'code' => 0, - 'client' => [ - 'id' => (string) $this->userWithStreamkey->id, - ], - ]) - ->assertJsonStructure([ - 'code', - 'client' => ['id', 'signature'], - ]); - } /** * Test authentication fails with invalid streamkey @@ -96,9 +80,9 @@ public function test_auth_succeeds_with_valid_streamkey() public function test_auth_fails_with_invalid_streamkey() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => '?secret=invalid_streamkey_456', ]); @@ -113,9 +97,9 @@ public function test_auth_fails_with_invalid_streamkey() public function test_auth_fails_without_streamkey() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => '', ]); @@ -135,9 +119,9 @@ public function test_auth_fails_for_user_without_server_assignment() ]); $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => '?secret='.$userWithoutServer->streamkey, ]); @@ -162,9 +146,9 @@ public function test_auth_fails_for_user_without_publish_permission() $userWithoutPermission->assignRole($userRole); $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => '?secret='.$userWithoutPermission->streamkey, ]); @@ -179,7 +163,7 @@ public function test_auth_fails_for_user_without_publish_permission() public function test_server_auth_succeeds_with_valid_shared_secret() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', 'tcUrl' => 'rtmp://origin.server/live', 'pageUrl' => '', @@ -205,7 +189,7 @@ public function test_server_auth_succeeds_with_valid_shared_secret() public function test_server_auth_fails_with_invalid_shared_secret() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', 'tcUrl' => 'rtmp://origin.server/live', 'pageUrl' => '', @@ -222,9 +206,9 @@ public function test_server_auth_fails_with_invalid_shared_secret() public function test_unpublish_webhook_returns_success() { $response = $this->postJson('/api/srs/unpublish', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => '?secret='.$this->userWithStreamkey->streamkey, ]); @@ -240,7 +224,7 @@ public function test_shared_secret_takes_precedence_over_streamkey() { // Send both shared_secret and streamkey $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', 'tcUrl' => 'rtmp://origin.server/live', 'pageUrl' => '', @@ -264,9 +248,9 @@ public function test_shared_secret_takes_precedence_over_streamkey() public function test_auth_handles_malformed_param_string() { $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', + 'app' => 'ingress', 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', + 'tcUrl' => 'rtmp://localhost/ingress', 'pageUrl' => '', 'param' => 'malformed&&&==param', ]); @@ -275,43 +259,4 @@ public function test_auth_handles_malformed_param_string() ->assertJson(['code' => 403]); } - /** - * Test that both 'secret' and 'streamkey' parameters are accepted - */ - public function test_auth_accepts_both_secret_and_streamkey_params() - { - // Test with 'streamkey' parameter - $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', - 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', - 'pageUrl' => '', - 'param' => '?streamkey='.$this->userWithStreamkey->streamkey, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'code' => 0, - 'client' => [ - 'id' => (string) $this->userWithStreamkey->id, - ], - ]); - - // Test with 'secret' parameter (already tested above, but let's be explicit) - $response = $this->postJson('/api/srs/auth', [ - 'app' => 'live', - 'stream' => 'livestream', - 'tcUrl' => 'rtmp://localhost/live', - 'pageUrl' => '', - 'param' => '?secret='.$this->userWithStreamkey->streamkey, - ]); - - $response->assertStatus(200) - ->assertJson([ - 'code' => 0, - 'client' => [ - 'id' => (string) $this->userWithStreamkey->id, - ], - ]); - } } From 7daa1db41dbe3bff04249abc0a7007a94d8e4441 Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:16:47 +0200 Subject: [PATCH 07/14] Update show page tests to status pages --- tests/Feature/InertiaComponentTest.php | 12 ++++++--- tests/Feature/PageLoadTest.php | 26 ++++++++++++++------ tests/Feature/StreamReconnectingFlowTest.php | 2 +- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/Feature/InertiaComponentTest.php b/tests/Feature/InertiaComponentTest.php index 594bbb5..8d95de7 100644 --- a/tests/Feature/InertiaComponentTest.php +++ b/tests/Feature/InertiaComponentTest.php @@ -146,7 +146,7 @@ public function test_external_stream_component_props() ->where('show.id', $show->id) ->where('show.title', 'External Show') ->has('show.source') - ->has('show.hls_urls') + ->has('show.hls_url') ->where('show.can_watch', true) ); } @@ -266,9 +266,9 @@ public function test_upcoming_shows_filter_24_hours() } /** - * Test that ended shows redirect properly + * An ended show renders its own page rather than redirecting to the grid. */ - public function test_ended_show_redirects() + public function test_ended_show_renders_ended_status() { $source = Source::create([ 'name' => 'Source', @@ -290,6 +290,10 @@ public function test_ended_show_redirects() $response = $this->actingAs($this->user) ->get(route('show.view', $show)); - $response->assertRedirect(route('shows.grid')); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('ShowPlayer') + ->where('currentShow.status', 'ended') + ); } } diff --git a/tests/Feature/PageLoadTest.php b/tests/Feature/PageLoadTest.php index d0b3b3d..ec1bd5d 100644 --- a/tests/Feature/PageLoadTest.php +++ b/tests/Feature/PageLoadTest.php @@ -78,7 +78,7 @@ public function test_show_player_page_loads() ->component('ShowPlayer') ->has('currentShow') ->has('availableShows') - ->has('initialHlsUrls') + ->has('initialHlsUrl') ->has('initialStatus') ->has('initialListeners') ->has('chatMessages') @@ -156,7 +156,11 @@ public function test_auth_data_structure() } /** - * Test that show with ended status can still be viewed + * An ended show keeps its own page rather than bouncing to the grid. + * + * It used to redirect. ShowPlayer now renders ShowEndedStatusPage in place of the + * video, which keeps the title, description and any recording link on a URL people + * have already shared. */ public function test_ended_show_page_loads() { @@ -165,14 +169,17 @@ public function test_ended_show_page_loads() $response = $this->actingAs($this->user) ->get(route('show.view', $this->show)); - // Should redirect because show is ended and user can't watch - $response->assertRedirect(route('shows.grid')); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('ShowPlayer') + ->where('currentShow.status', 'ended') + ); } /** - * Test that show with scheduled status redirects if not viewable + * Same for a show that has not started: the page explains when it will. */ - public function test_scheduled_show_redirects_if_not_viewable() + public function test_scheduled_show_page_loads_with_scheduled_status() { $this->show->update([ 'status' => 'scheduled', @@ -182,8 +189,11 @@ public function test_scheduled_show_redirects_if_not_viewable() $response = $this->actingAs($this->user) ->get(route('show.view', $this->show)); - // Should redirect because scheduled show far in future can't be watched - $response->assertRedirect(route('shows.grid')); + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + ->component('ShowPlayer') + ->where('currentShow.status', 'scheduled') + ); } /** diff --git a/tests/Feature/StreamReconnectingFlowTest.php b/tests/Feature/StreamReconnectingFlowTest.php index d896f38..ee34429 100644 --- a/tests/Feature/StreamReconnectingFlowTest.php +++ b/tests/Feature/StreamReconnectingFlowTest.php @@ -121,7 +121,7 @@ public function test_hls_url_generation_for_live_show() $response->assertOk(); $response->assertInertia(fn ($page) => $page ->component('ShowPlayer') - ->has('initialHlsUrls') + ->has('initialHlsUrl') ->has('currentShow', fn ($page) => $page ->where('id', $show->id) ->where('status', 'live') From 26fc3fcdbe2cb0bc10b45dfe84a87dcc82302a5e Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:19:44 +0200 Subject: [PATCH 08/14] Fake only asserted event so observers run --- tests/Feature/StreamReconnectingFlowTest.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Feature/StreamReconnectingFlowTest.php b/tests/Feature/StreamReconnectingFlowTest.php index ee34429..b51ed30 100644 --- a/tests/Feature/StreamReconnectingFlowTest.php +++ b/tests/Feature/StreamReconnectingFlowTest.php @@ -17,7 +17,7 @@ class StreamReconnectingFlowTest extends TestCase public function test_reconnecting_state_triggers_when_source_goes_from_offline_to_online() { - Event::fake(); + Event::fake([SourceStatusChangedEvent::class]); // Create a source that's initially offline $source = Source::factory()->create([ @@ -53,13 +53,14 @@ public function test_reconnecting_state_triggers_when_source_goes_from_offline_t // Verify the event is dispatched Event::assertDispatched(SourceStatusChangedEvent::class, function ($event) use ($source) { return $event->source->id === $source->id && - $event->status === 'online'; + $event->source->status->value === 'online' && + $event->previousStatus === 'offline'; }); } public function test_reconnecting_state_triggers_when_source_goes_from_error_to_online() { - Event::fake(); + Event::fake([SourceStatusChangedEvent::class]); // Create a source that's in error state $source = Source::factory()->create([ @@ -95,7 +96,8 @@ public function test_reconnecting_state_triggers_when_source_goes_from_error_to_ // Verify the event is dispatched Event::assertDispatched(SourceStatusChangedEvent::class, function ($event) use ($source) { return $event->source->id === $source->id && - $event->status === 'online'; + $event->source->status->value === 'online' && + $event->previousStatus === 'error'; }); } @@ -133,7 +135,7 @@ public function test_hls_url_generation_for_live_show() public function test_no_reconnecting_for_offline_to_error_transition() { - Event::fake(); + Event::fake([SourceStatusChangedEvent::class]); // Create a source that's offline $source = Source::factory()->create([ @@ -155,7 +157,8 @@ public function test_no_reconnecting_for_offline_to_error_transition() // Verify the event is dispatched but with error status Event::assertDispatched(SourceStatusChangedEvent::class, function ($event) use ($source) { return $event->source->id === $source->id && - $event->status === 'error'; + $event->source->status->value === 'error' && + $event->previousStatus === 'offline'; }); } } From f95cc0c14da2d5520d8f67c8d7241fafe7552f9a Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:20:30 +0200 Subject: [PATCH 09/14] Match auto mode command output --- tests/Feature/AutoModeEndToEndTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Feature/AutoModeEndToEndTest.php b/tests/Feature/AutoModeEndToEndTest.php index 5bbc267..c86b6d8 100644 --- a/tests/Feature/AutoModeEndToEndTest.php +++ b/tests/Feature/AutoModeEndToEndTest.php @@ -121,7 +121,7 @@ public function auto_mode_show_starts_via_scheduled_command_when_source_already_ // Run the scheduled command $this->artisan('shows:check-auto-mode') - ->expectsOutput("Starting auto mode show: {$show->title}") + ->expectsOutput("Started '{$show->title}'") ->assertExitCode(0); $show->refresh(); @@ -269,7 +269,7 @@ public function auto_mode_show_ends_at_scheduled_time_even_with_online_source() // Run the scheduled command $this->artisan('shows:check-auto-mode') - ->expectsOutput("Ending auto mode show: {$show->title}") + ->expectsOutput("Ended '{$show->title}' (hard stop reached)") ->assertExitCode(0); $show->refresh(); From 16bfed6b6aa8377820caf2f822fa37ffdecb7948 Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:21:47 +0200 Subject: [PATCH 10/14] Fix chat command test expectations --- tests/Feature/Api/CommandControllerTest.php | 26 +++++++++++++++------ tests/Unit/Commands/CommandSystemTest.php | 3 ++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/Feature/Api/CommandControllerTest.php b/tests/Feature/Api/CommandControllerTest.php index 0274cf9..032dbca 100644 --- a/tests/Feature/Api/CommandControllerTest.php +++ b/tests/Feature/Api/CommandControllerTest.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Log; use Tests\TestCase; class CommandControllerTest extends TestCase @@ -249,17 +250,28 @@ public function test_command_with_invalid_parameters_fails() $response->assertStatus(422); } + /** + * Command execution is written to the application log, not to a database table. + * + * This asserted rows in `activity_log` from spatie/activitylog, which is not a + * dependency of this project and never has been: there is no such table, no + * migration, and nothing that writes one. CommandController logs through the Log + * facade, so that is what is asserted. + */ public function test_command_execution_is_logged() { + Log::shouldReceive('info') + ->once() + ->with('Command executed', \Mockery::on(fn ($context) => $context['user_id'] === $this->admin->id + && $context['command'] === 'help')); + + Log::shouldReceive('warning')->zeroOrMoreTimes(); + Log::shouldReceive('error')->zeroOrMoreTimes(); + $this->actingAs($this->admin, 'sanctum') ->postJson('/api/command/execute', [ 'command' => '/help', - ]); - - $this->assertDatabaseHas('activity_log', [ - 'subject_type' => 'App\Models\User', - 'subject_id' => $this->admin->id, - 'description' => 'Command executed', - ]); + ]) + ->assertOk(); } } diff --git a/tests/Unit/Commands/CommandSystemTest.php b/tests/Unit/Commands/CommandSystemTest.php index 626ff66..c27b535 100644 --- a/tests/Unit/Commands/CommandSystemTest.php +++ b/tests/Unit/Commands/CommandSystemTest.php @@ -184,11 +184,12 @@ public function test_command_validation_rules() $this->assertIsArray($rules); $this->assertArrayHasKey('action', $rules); $this->assertArrayHasKey('username', $rules); - $this->assertArrayHasKey('badge_type', $rules); + $this->assertArrayHasKey('role', $rules); // Check rule format $this->assertStringContainsString('required', $rules['action']); $this->assertStringContainsString('in:grant,revoke', $rules['action']); + $this->assertStringContainsString('exists:roles,slug', $rules['role']); } /** From b60ab57e6ba49e1e2c3a8de7eb4fb98bb05adafc Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:32:46 +0200 Subject: [PATCH 11/14] Promote featured stream on ended shows --- app/Http/Controllers/StreamController.php | 70 +++++++++ .../StatusPages/ShowEndedStatusPage.vue | 27 +++- resources/js/Pages/ShowPlayer.vue | 10 ++ tests/Feature/Api/CommandControllerTest.php | 25 --- tests/Feature/Manage/ShowsTest.php | 9 +- tests/Feature/Manage/SourcesTest.php | 8 +- tests/Feature/PromotedShowTest.php | 147 ++++++++++++++++++ 7 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/PromotedShowTest.php diff --git a/app/Http/Controllers/StreamController.php b/app/Http/Controllers/StreamController.php index 2b54e23..8ce8f8c 100644 --- a/app/Http/Controllers/StreamController.php +++ b/app/Http/Controllers/StreamController.php @@ -271,6 +271,74 @@ public function index() ]); } + /** + * What to point a viewer at when the show they opened is not watchable. + * + * An ended or scheduled show keeps its own page rather than redirecting, so the page + * has to offer somewhere to go. The order matches how an event is actually watched: + * + * 1. The primary channel if it is live. It runs for the whole event, so it is almost + * always the right answer and is worth promoting over anything else. + * 2. Otherwise the busiest live show, since something on air beats something later. + * 3. Otherwise the next scheduled show, so the page still says what is coming. + * + * The show being viewed is excluded throughout: promoting a viewer back to the page + * they are already on is worse than showing nothing. + */ + private function resolvePromotedShow(?User $user, Show $current): ?array + { + $exclude = fn ($query) => $query->where('id', '!=', $current->id); + + $primarySource = Source::ordered()->first(); + + $show = null; + + if ($primarySource) { + $show = Show::with('source') + ->accessibleBy($user) + ->where($exclude) + ->where('source_id', $primarySource->id) + ->where('status', 'live') + ->orderByDesc('viewer_count') + ->first(); + } + + $show ??= Show::with('source') + ->accessibleBy($user) + ->where($exclude) + ->where('status', 'live') + ->orderByDesc('viewer_count') + ->first(); + + $show ??= Show::with('source') + ->accessibleBy($user) + ->where($exclude) + ->scheduled() + ->where('scheduled_start', '>=', now()) + ->orderBy('scheduled_start') + ->first(); + + if (! $show) { + return null; + } + + return [ + 'id' => $show->id, + 'title' => $show->title, + 'slug' => $show->slug, + 'source' => $show->source?->name, + 'status' => $show->status, + 'scheduled_start' => $show->scheduled_start, + 'thumbnail_url' => $show->thumbnail_url, + 'viewer_count' => $show->viewer_count, + 'can_watch' => $show->canWatch(), + // Drives the copy: "watch the main stage now" reads differently from + // "up next on stage b". + 'is_primary_channel' => $primarySource && $show->source_id === $primarySource->id, + 'is_live' => $show->status === 'live', + ]; + } + /** * Resolve the featured show for the stage hero. * @@ -555,6 +623,8 @@ public function show(Request $request, Show $show) 'actual_end' => $show->actual_end, ], 'availableShows' => $availableShows, + // Somewhere to go when this show is not watchable. See resolvePromotedShow(). + 'promoted' => $this->resolvePromotedShow($user, $show), 'initialHlsUrl' => $hlsUrl, 'playback' => $this->playbackProps($user, $show), 'initialStatus' => $show->isLive() ? 'online' : \Cache::get('stream.status', static fn () => StreamStatusEnum::OFFLINE->value), diff --git a/resources/js/Components/Livestream/StatusPages/ShowEndedStatusPage.vue b/resources/js/Components/Livestream/StatusPages/ShowEndedStatusPage.vue index 500c38f..506e8ec 100644 --- a/resources/js/Components/Livestream/StatusPages/ShowEndedStatusPage.vue +++ b/resources/js/Components/Livestream/StatusPages/ShowEndedStatusPage.vue @@ -50,15 +50,15 @@
- @@ -86,7 +86,28 @@ const props = defineProps({ mainStreamUrl: { type: String, default: '/stream' + }, + /** + * Where to send someone whose show has ended: the primary channel if it is on air, + * otherwise the busiest live show, otherwise what is on next. Resolved server side + * by StreamController::resolvePromotedShow(). + */ + promoted: { + type: Object, + default: null + } +}); + +const promotedUrl = computed(() => (props.promoted?.slug ? `/show/${props.promoted.slug}` : props.mainStreamUrl)); + +const promotedLabel = computed(() => { + if (!props.promoted) return 'Watch Main Stream'; + if (props.promoted.is_live) { + return props.promoted.is_primary_channel + ? `Watch ${props.promoted.source} now` + : `Watch ${props.promoted.title} now`; } + return `Up next: ${props.promoted.title}`; }); const streamDuration = computed(() => { diff --git a/resources/js/Pages/ShowPlayer.vue b/resources/js/Pages/ShowPlayer.vue index 022407d..956d33c 100644 --- a/resources/js/Pages/ShowPlayer.vue +++ b/resources/js/Pages/ShowPlayer.vue @@ -35,6 +35,15 @@ const props = defineProps({ required: false, default: () => [] }, + /** + * Where to send a viewer whose show is not watchable: the primary channel if live, + * otherwise the busiest live show, otherwise what is on next. + */ + promoted: { + type: Object, + required: false, + default: null + }, initialHlsUrl: { type: String, required: false, @@ -572,6 +581,7 @@ onUnmounted(() => {
diff --git a/tests/Feature/Api/CommandControllerTest.php b/tests/Feature/Api/CommandControllerTest.php index 032dbca..49fb6aa 100644 --- a/tests/Feature/Api/CommandControllerTest.php +++ b/tests/Feature/Api/CommandControllerTest.php @@ -8,7 +8,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Support\Facades\Log; use Tests\TestCase; class CommandControllerTest extends TestCase @@ -250,28 +249,4 @@ public function test_command_with_invalid_parameters_fails() $response->assertStatus(422); } - /** - * Command execution is written to the application log, not to a database table. - * - * This asserted rows in `activity_log` from spatie/activitylog, which is not a - * dependency of this project and never has been: there is no such table, no - * migration, and nothing that writes one. CommandController logs through the Log - * facade, so that is what is asserted. - */ - public function test_command_execution_is_logged() - { - Log::shouldReceive('info') - ->once() - ->with('Command executed', \Mockery::on(fn ($context) => $context['user_id'] === $this->admin->id - && $context['command'] === 'help')); - - Log::shouldReceive('warning')->zeroOrMoreTimes(); - Log::shouldReceive('error')->zeroOrMoreTimes(); - - $this->actingAs($this->admin, 'sanctum') - ->postJson('/api/command/execute', [ - 'command' => '/help', - ]) - ->assertOk(); - } } diff --git a/tests/Feature/Manage/ShowsTest.php b/tests/Feature/Manage/ShowsTest.php index 3884672..4c26618 100644 --- a/tests/Feature/Manage/ShowsTest.php +++ b/tests/Feature/Manage/ShowsTest.php @@ -30,7 +30,14 @@ protected function setUp(): void $this->createManageUsers(); // goLive() and endLivestream() broadcast; the transport is not what these assert. - Event::fake(); + // + // Named rather than blanket: Event::fake() with no arguments also fakes Eloquent's + // model events, which silently stops ShowObserver from running at all. + Event::fake([ + \App\Events\ShowWentLive::class, + \App\Events\ShowEnded::class, + \App\Events\ShowCancelled::class, + ]); $this->source = Source::factory()->create(['name' => 'Main Stage']); } diff --git a/tests/Feature/Manage/SourcesTest.php b/tests/Feature/Manage/SourcesTest.php index 8ae9299..1f8aaf7 100644 --- a/tests/Feature/Manage/SourcesTest.php +++ b/tests/Feature/Manage/SourcesTest.php @@ -286,7 +286,9 @@ public function test_a_moderator_cannot_create_or_update_a_source(): void public function test_updating_the_status_from_the_row_action(): void { - Event::fake(); + // Named: a blanket fake would also fake Eloquent's model events and stop + // SourceObserver from running, so the broadcast under test would never happen. + Event::fake([\App\Events\SourceStatusChangedEvent::class]); $source = Source::factory()->create(['status' => SourceStatusEnum::OFFLINE, 'name' => 'Main Stage']); @@ -313,7 +315,9 @@ public function test_the_status_action_rejects_an_unknown_state(): void public function test_bulk_status_updates_every_selected_source(): void { - Event::fake(); + // Named: a blanket fake would also fake Eloquent's model events and stop + // SourceObserver from running, so the broadcast under test would never happen. + Event::fake([\App\Events\SourceStatusChangedEvent::class]); $first = Source::factory()->create(['status' => SourceStatusEnum::OFFLINE]); $second = Source::factory()->create(['status' => SourceStatusEnum::OFFLINE]); diff --git a/tests/Feature/PromotedShowTest.php b/tests/Feature/PromotedShowTest.php new file mode 100644 index 0000000..51dae6c --- /dev/null +++ b/tests/Feature/PromotedShowTest.php @@ -0,0 +1,147 @@ +user = User::factory()->create(); + + // Ordered by priority descending, so the higher number is the primary channel. + $this->primary = Source::factory()->create([ + 'name' => 'Main Stage', + 'priority' => 100, + 'status' => SourceStatusEnum::ONLINE, + ]); + $this->secondary = Source::factory()->create([ + 'name' => 'Stage B', + 'priority' => 10, + 'status' => SourceStatusEnum::ONLINE, + ]); + } + + private function endedShow(): Show + { + return Show::factory()->create([ + 'source_id' => $this->secondary->id, + 'status' => 'ended', + 'title' => 'The Show That Ended', + 'actual_start' => now()->subHours(2), + 'actual_end' => now()->subHour(), + ]); + } + + private function promotedFor(Show $show): ?array + { + $response = $this->actingAs($this->user)->get(route('show.view', $show)); + $response->assertOk(); + + return $response->viewData('page')['props']['promoted'] ?? null; + } + + public function test_the_primary_channel_wins_when_it_is_live(): void + { + // A busier live show on another channel must still lose to the primary one. + Show::factory()->create([ + 'source_id' => $this->secondary->id, + 'status' => 'live', + 'title' => 'Busy Elsewhere', + 'viewer_count' => 5000, + ]); + Show::factory()->create([ + 'source_id' => $this->primary->id, + 'status' => 'live', + 'title' => 'Main Stage Live', + 'viewer_count' => 10, + ]); + + $promoted = $this->promotedFor($this->endedShow()); + + $this->assertSame('Main Stage Live', $promoted['title']); + $this->assertTrue($promoted['is_primary_channel']); + $this->assertTrue($promoted['is_live']); + } + + public function test_falls_back_to_the_busiest_live_show_when_the_primary_channel_is_dark(): void + { + Show::factory()->create([ + 'source_id' => $this->secondary->id, + 'status' => 'live', + 'title' => 'Quiet One', + 'viewer_count' => 3, + ]); + Show::factory()->create([ + 'source_id' => $this->secondary->id, + 'status' => 'live', + 'title' => 'Busiest One', + 'viewer_count' => 900, + ]); + + $promoted = $this->promotedFor($this->endedShow()); + + $this->assertSame('Busiest One', $promoted['title']); + $this->assertFalse($promoted['is_primary_channel']); + } + + public function test_falls_back_to_the_next_scheduled_show_when_nothing_is_live(): void + { + Show::factory()->create([ + 'source_id' => $this->primary->id, + 'status' => 'scheduled', + 'title' => 'Later Today', + 'scheduled_start' => now()->addHours(4), + ]); + Show::factory()->create([ + 'source_id' => $this->secondary->id, + 'status' => 'scheduled', + 'title' => 'Sooner', + 'scheduled_start' => now()->addHour(), + ]); + + $promoted = $this->promotedFor($this->endedShow()); + + $this->assertSame('Sooner', $promoted['title']); + $this->assertFalse($promoted['is_live']); + } + + public function test_never_promotes_the_show_being_viewed(): void + { + // The only live show is the one open, which happens when a viewer lands on a + // show that has just gone live elsewhere in the tab. + $show = Show::factory()->create([ + 'source_id' => $this->primary->id, + 'status' => 'live', + 'title' => 'This Very Show', + 'viewer_count' => 1, + ]); + + $this->assertNull($this->promotedFor($show)); + } + + public function test_promotes_nothing_when_there_is_nothing_else(): void + { + $this->assertNull($this->promotedFor($this->endedShow())); + } +} From ffd63fd1377494323d6e505a6bce612a3c6e4c7b Mon Sep 17 00:00:00 2001 From: Tin Date: Mon, 3 Aug 2026 19:39:58 +0200 Subject: [PATCH 12/14] Add explicit featured source flag --- .../Controllers/Manage/SourceController.php | 2 + app/Http/Controllers/ScheduleController.php | 2 +- app/Http/Controllers/StreamController.php | 4 +- app/Http/Requests/Manage/SourceRequest.php | 15 ++++++ app/Models/Source.php | 28 +++++++++++ ...30000_add_is_featured_to_sources_table.php | 46 +++++++++++++++++++ .../StatusPages/ShowCancelledStatusPage.vue | 13 ++++-- .../StatusPages/ShowEndedStatusPage.vue | 15 ++---- .../StatusPages/ShowScheduledStatusPage.vue | 22 +++++++++ resources/js/Pages/Manage/Sources/Form.vue | 7 +++ resources/js/Pages/ShowPlayer.vue | 3 +- resources/js/composables/usePromotedShow.js | 38 +++++++++++++++ tests/Feature/PromotedShowTest.php | 29 ++++++++++-- 13 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 database/migrations/2026_08_03_030000_add_is_featured_to_sources_table.php create mode 100644 resources/js/composables/usePromotedShow.js diff --git a/app/Http/Controllers/Manage/SourceController.php b/app/Http/Controllers/Manage/SourceController.php index 5e4d844..d3be885 100644 --- a/app/Http/Controllers/Manage/SourceController.php +++ b/app/Http/Controllers/Manage/SourceController.php @@ -68,6 +68,7 @@ public function create(): Response 'name' => '', 'slug' => '', 'priority' => 0, + 'is_featured' => false, 'description' => '', ], ]); @@ -96,6 +97,7 @@ public function edit(Source $source): Response 'slug' => $source->slug, 'status' => $source->status?->value, 'priority' => $source->priority, + 'is_featured' => (bool) $source->is_featured, 'description' => $source->description, 'rtmp_url' => $source->getRtmpServerUrl(), 'stream_key' => $source->getObsStreamKey(), diff --git a/app/Http/Controllers/ScheduleController.php b/app/Http/Controllers/ScheduleController.php index 8f0d25a..6b42b4a 100644 --- a/app/Http/Controllers/ScheduleController.php +++ b/app/Http/Controllers/ScheduleController.php @@ -88,7 +88,7 @@ public function index() return Inertia::render('Schedule', [ 'days' => $days, - 'primaryChannel' => Source::ordered()->first()?->name, + 'primaryChannel' => Source::featured()?->name, 'currentTime' => now()->toIso8601String(), ]); } diff --git a/app/Http/Controllers/StreamController.php b/app/Http/Controllers/StreamController.php index 8ce8f8c..b36ceaf 100644 --- a/app/Http/Controllers/StreamController.php +++ b/app/Http/Controllers/StreamController.php @@ -246,7 +246,7 @@ public function index() ->where('is_published', true) ->count(); - $primarySource = Source::ordered()->first(); + $primarySource = Source::featured(); $featured = $this->resolveFeaturedShow($user, $primarySource); // Channel chips: only sources that actually have something in the grid. @@ -289,7 +289,7 @@ private function resolvePromotedShow(?User $user, Show $current): ?array { $exclude = fn ($query) => $query->where('id', '!=', $current->id); - $primarySource = Source::ordered()->first(); + $primarySource = Source::featured(); $show = null; diff --git a/app/Http/Requests/Manage/SourceRequest.php b/app/Http/Requests/Manage/SourceRequest.php index bd4e78a..4dd9fa2 100644 --- a/app/Http/Requests/Manage/SourceRequest.php +++ b/app/Http/Requests/Manage/SourceRequest.php @@ -26,6 +26,7 @@ public function rules(): array 'name' => ['required', 'string', 'max:255'], // Higher first on the public grid; the ceiling matches the Filament form. 'priority' => ['required', 'integer', 'min:0', 'max:999'], + 'is_featured' => ['boolean'], 'description' => ['nullable', 'string'], ]; @@ -58,4 +59,18 @@ public function attributes(): array 'slug' => 'stream name', ]; } + + /** + * @return array + */ + public function validated($key = null, $default = null): array + { + $data = parent::validated(); + + // An unchecked box posts nothing, so without this the flag could be set but + // never cleared: un-featuring a channel from the form would silently do nothing. + $data['is_featured'] = (bool) ($data['is_featured'] ?? false); + + return $data; + } } diff --git a/app/Models/Source.php b/app/Models/Source.php index 58a2f13..f5c6a7a 100644 --- a/app/Models/Source.php +++ b/app/Models/Source.php @@ -19,11 +19,13 @@ class Source extends Model 'description', 'stream_key', 'priority', + 'is_featured', ]; protected $casts = [ 'status' => SourceStatusEnum::class, 'stream_key' => 'encrypted', + 'is_featured' => 'boolean', ]; protected $hidden = [ @@ -57,6 +59,17 @@ protected static function boot() } }); + // Featuring a channel demotes the others. Without this the flag silently + // becomes "one of the featured ones", and which one wins depends on insertion + // order, which is exactly the ambiguity the flag replaced. + static::saved(function ($source) { + if ($source->is_featured && $source->wasChanged('is_featured')) { + static::where('id', '!=', $source->id) + ->where('is_featured', true) + ->update(['is_featured' => false]); + } + }); + static::updating(function ($source) { // The slug is the RTMP ingress path and the HLS route key, so it is // immutable after creation. Renaming a source must not move it. @@ -134,12 +147,27 @@ public function currentLiveShow() /** * Get sources ordered by priority (descending) then by name. + * + * This is display order for the schedule grid and channel lists. It is deliberately + * not how the featured channel is chosen: see featured(). */ public function scopeOrdered($query) { return $query->orderBy('priority', 'desc')->orderBy('name'); } + /** + * The channel the site promotes: the stage hero, and where an ended show sends people. + * + * Falls back to display order when nothing is flagged, so a fresh install still has a + * sensible hero rather than none. + */ + public static function featured(): ?self + { + return static::where('is_featured', true)->first() + ?? static::ordered()->first(); + } + /** * Get the base RTMP server URL for OBS configuration. * Returns URL in format: rtmp://server:port/ingress diff --git a/database/migrations/2026_08_03_030000_add_is_featured_to_sources_table.php b/database/migrations/2026_08_03_030000_add_is_featured_to_sources_table.php new file mode 100644 index 0000000..9702bb8 --- /dev/null +++ b/database/migrations/2026_08_03_030000_add_is_featured_to_sources_table.php @@ -0,0 +1,46 @@ +boolean('is_featured')->default(false)->after('priority'); + }); + + // Preserve current behaviour for existing installs: whatever priority ordering + // was already promoting stays promoted, so this is not a behaviour change on + // deploy. `ordered()` is priority desc, then name. + $current = DB::table('sources') + ->orderByDesc('priority') + ->orderBy('name') + ->first(); + + if ($current) { + DB::table('sources')->where('id', $current->id)->update(['is_featured' => true]); + } + } + + public function down(): void + { + Schema::table('sources', function (Blueprint $table) { + $table->dropColumn('is_featured'); + }); + } +}; diff --git a/resources/js/Components/Livestream/StatusPages/ShowCancelledStatusPage.vue b/resources/js/Components/Livestream/StatusPages/ShowCancelledStatusPage.vue index f99d9dc..1474dca 100644 --- a/resources/js/Components/Livestream/StatusPages/ShowCancelledStatusPage.vue +++ b/resources/js/Components/Livestream/StatusPages/ShowCancelledStatusPage.vue @@ -65,15 +65,15 @@
- @@ -82,6 +82,7 @@