diff --git a/.env.dvr-process.example b/.env.dvr-process.example deleted file mode 100644 index c2ab512..0000000 --- a/.env.dvr-process.example +++ /dev/null @@ -1,22 +0,0 @@ -# DVR Processing Script Configuration -# Copy this file to .env in the same directory as dvr-process.sh - -# API Configuration -API_BASE_URL=http://localhost:8000/api -RECORDING_API_KEY=your-recording-api-key-here - -# S3 Configuration -S3_ALIAS=dvr # MinIO/S3 alias configured with 'mc alias' -S3_BUCKET=recording # S3 bucket name -S3_BASE_PATH=on-demand # Base path within bucket - -# Event Configuration -EVENT_SLUG=ef29 # Event identifier (e.g., ef29, ef30) - -# Local Paths -TEMP_DIR=/tmp/dvr-processing # Temporary directory for processing -DVR_SOURCE_DIR=/var/dvr # Path to DVR storage directory - -# Optional: Override for specific environments -# API_BASE_URL=https://streaming.example.org/api -# DVR_SOURCE_DIR=/mnt/dvr-storage \ No newline at end of file diff --git a/.env.example b/.env.example index 05227ec..f25c575 100644 --- a/.env.example +++ b/.env.example @@ -145,4 +145,4 @@ CHAT_ALLOWED_DOMAINS= # Container images for the generated provisioning scripts, built from docker/. #STREAM_IMAGE_FFMPEG_HLS= -#STREAM_IMAGE_DVR_UPLOADER= +#STREAM_IMAGE_ARCHIVE_UPLOADER= diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 572d86d..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.2' + 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/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/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 2b54e23..5e7341e 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. @@ -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::featured(); + + $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,9 @@ 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(). + // Live shows skip this entirely: the player is working, so don't run promotion queries. + 'promoted' => $show->status === 'live' ? null : $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/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..92fc8f5 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,25 @@ 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. + // + // Wrapped in a transaction with row locking to prevent concurrent promotions from + // leaving no explicit featured source (race condition where both transactions see + // the other source as featured and demote it). + static::saved(function ($source) { + if ($source->is_featured && $source->wasChanged('is_featured')) { + \DB::transaction(function () use ($source) { + static::where('id', '!=', $source->id) + ->where('is_featured', true) + ->lockForUpdate() + ->get() + ->each(fn ($other) => $other->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 +155,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/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/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/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/composer.json b/composer.json index d26e79b..e4e76e8 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.2", + "php": "^8.5", "doctrine/dbal": "^4.3", "flowframe/laravel-trend": "^0.4.0", "guzzlehttp/guzzle": "^7.9", @@ -65,6 +65,9 @@ } }, "config": { + "platform": { + "php": "8.5" + }, "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true, diff --git a/composer.lock b/composer.lock index 49d762d..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": "94773b25986d18e8cf049476b92c4303", + "content-hash": "9d03eb52cef1d7e5ce214d4efa2988df", "packages": [ { "name": "aws/aws-crt-php", @@ -11329,8 +11329,11 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2" + "php": "^8.5" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.5" + }, "plugin-api-version": "2.9.0" } 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/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/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..6326b09 --- /dev/null +++ b/database/migrations/2026_08_03_030000_add_is_featured_to_sources_table.php @@ -0,0 +1,67 @@ +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]); + } + + // Add a database constraint for at-most-one featured source, backing up the + // application-level enforcement in Source::booted(). + if (DB::getDriverName() === 'mysql') { + DB::statement('ALTER TABLE sources ADD COLUMN featured_marker TINYINT GENERATED ALWAYS AS (IF(is_featured, 1, NULL)) STORED'); + DB::statement('CREATE UNIQUE INDEX sources_featured_unique ON sources (featured_marker)'); + } else { + DB::statement('CREATE UNIQUE INDEX sources_featured_unique ON sources (is_featured) WHERE is_featured'); + } + } + + public function down(): void + { + if (DB::getDriverName() === 'mysql') { + DB::statement('DROP INDEX sources_featured_unique ON sources'); + DB::statement('ALTER TABLE sources DROP COLUMN featured_marker'); + } else { + DB::statement('DROP INDEX sources_featured_unique'); + } + + Schema::table('sources', function (Blueprint $table) { + $table->dropColumn('is_featured'); + }); + } +}; 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/dvr-extract.sh b/dvr-extract.sh deleted file mode 100755 index 38112bd..0000000 --- a/dvr-extract.sh +++ /dev/null @@ -1,339 +0,0 @@ -#!/bin/bash - -# DVR Extraction Script - Downloads only segments within time range -# Usage: ./dvr-extract.sh [output] [s3_alias] - -set -e - -# Configuration -S3_ALIAS="${5:-dvr}" # MinIO/S3 alias -S3_BUCKET="recording/dvr" -CACHE_DIR="/tmp/dvr-cache" # Persistent cache for segments -TEMP_DIR="/tmp/dvr-extract-$$" -STREAM="$1" -START_TIME="$2" -END_TIME="$3" -OUTPUT="${4:-}" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Functions -log() { - echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" >&2 -} - -warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -# Validate arguments -if [ -z "$STREAM" ] || [ -z "$START_TIME" ] || [ -z "$END_TIME" ]; then - echo "Usage: $0 [output_file] [s3_alias]" - echo "Example: $0 summerboat \"2025-09-02 20:00:00\" \"2025-09-02 20:30:00\" output.mp4 dvr" - echo "" - echo "Time format: YYYY-MM-DD HH:MM:SS (in Europe/Berlin timezone)" - echo "Output file: Optional, defaults to stream_YYYYMMDD_HHMMSS_HHMMSS.mp4" - echo "S3 alias: Optional, defaults to 'dvr'" - exit 1 -fi - -# Check for required tools -for tool in ffmpeg date mc; do - if ! command -v "$tool" &> /dev/null; then - error "$tool is required but not installed" - exit 1 - fi -done - -# Convert times to epoch milliseconds (assuming Europe/Berlin timezone) -START_EPOCH_MS=$(TZ="Europe/Berlin" date -d "$START_TIME" +%s%3N 2>/dev/null || date -d "$START_TIME" +%s000) -END_EPOCH_MS=$(TZ="Europe/Berlin" date -d "$END_TIME" +%s%3N 2>/dev/null || date -d "$END_TIME" +%s000) - -if [ -z "$START_EPOCH_MS" ] || [ -z "$END_EPOCH_MS" ]; then - error "Invalid date format. Please use: YYYY-MM-DD HH:MM:SS" - exit 1 -fi - -if [ "$END_EPOCH_MS" -le "$START_EPOCH_MS" ]; then - error "End time must be after start time" - exit 1 -fi - -# Calculate duration in seconds -DURATION_SEC=$(( (END_EPOCH_MS - START_EPOCH_MS) / 1000 )) -HOURS=$(( DURATION_SEC / 3600 )) -MINUTES=$(( (DURATION_SEC % 3600) / 60 )) -SECONDS=$(( DURATION_SEC % 60 )) - -log "DVR Extraction for stream: $STREAM" -log "Time range: $START_TIME to $END_TIME (Europe/Berlin)" -log "Duration: $(printf "%02d:%02d:%02d" $HOURS $MINUTES $SECONDS)" -log "S3 Source: $S3_ALIAS/$S3_BUCKET/ingress/$STREAM/" - -# Generate output filename if not provided -if [ -z "$OUTPUT" ]; then - START_FILE=$(TZ="Europe/Berlin" date -d "$START_TIME" +%Y%m%d_%H%M%S 2>/dev/null || date -d "$START_TIME" +%Y%m%d_%H%M%S) - END_FILE=$(TZ="Europe/Berlin" date -d "$END_TIME" +%H%M%S 2>/dev/null || date -d "$END_TIME" +%H%M%S) - OUTPUT="${STREAM}_${START_FILE}_${END_FILE}.mp4" -fi - -# Create directories -mkdir -p "$TEMP_DIR" -mkdir -p "$CACHE_DIR/$STREAM" -trap "rm -rf $TEMP_DIR" EXIT - -# Find segments in S3 within time range -log "Finding segments in S3..." -SEGMENT_LIST="$TEMP_DIR/segments.txt" -CONCAT_LIST="$TEMP_DIR/concat.txt" -> "$SEGMENT_LIST" -> "$CONCAT_LIST" - -# Calculate date range -START_DATE=$(TZ="Europe/Berlin" date -d "$START_TIME" +%Y-%m-%d 2>/dev/null || date -d "$START_TIME" +%Y-%m-%d) -END_DATE=$(TZ="Europe/Berlin" date -d "$END_TIME" +%Y-%m-%d 2>/dev/null || date -d "$END_TIME" +%Y-%m-%d) - -# Function to find and list segments for a specific date -find_segments_for_date() { - local date_str="$1" - local s3_path="$S3_ALIAS/$S3_BUCKET/ingress/$STREAM/$date_str/" - - log "Checking S3 path: $s3_path" - - # List all mp4 files in the S3 path - local files=$(mc ls "$s3_path" 2>/dev/null | grep '\.mp4$' | awk '{print $NF}' || true) - - if [ -z "$files" ]; then - warning "No files found in: $s3_path" - return - fi - - local file_count=$(echo "$files" | wc -l) - log "Found $file_count mp4 files in $date_str" - - # Process each file - while IFS= read -r filename; do - if [ -z "$filename" ]; then - continue - fi - - # Extract timestamp from filename (format: HH-MM-SS_timestampMs.mp4) - if [[ "$filename" =~ ([0-9]{2}-[0-9]{2}-[0-9]{2})_([0-9]+)\.mp4$ ]]; then - timestamp_ms="${BASH_REMATCH[2]}" - - # Add 30-second buffer for segment overlap - if [ "$timestamp_ms" -ge $((START_EPOCH_MS - 30000)) ] && [ "$timestamp_ms" -le $((END_EPOCH_MS + 30000)) ]; then - echo "$timestamp_ms|$date_str|$filename" >> "$SEGMENT_LIST" - fi - fi - done <<< "$files" -} - -# Iterate through dates -current_date="$START_DATE" -while [ "$current_date" != "$(date -d "$END_DATE + 1 day" +%Y-%m-%d 2>/dev/null || echo "")" ]; do - find_segments_for_date "$current_date" - - # Move to next date - current_date=$(date -d "$current_date + 1 day" +%Y-%m-%d 2>/dev/null || break) - - # Prevent infinite loop - if [ "$current_date" \> "$(date -d "$END_DATE + 7 days" +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d)" ]; then - break - fi -done - -# Sort segments by timestamp -sort -t'|' -k1 -n "$SEGMENT_LIST" > "$SEGMENT_LIST.sorted" -mv "$SEGMENT_LIST.sorted" "$SEGMENT_LIST" - -# Check if we found any segments -SEGMENT_COUNT=$(wc -l < "$SEGMENT_LIST") -if [ "$SEGMENT_COUNT" -eq 0 ]; then - error "No segments found in the specified time range" - error "Searched in: $S3_ALIAS/$S3_BUCKET/ingress/$STREAM/" - error "Time range: $START_TIME to $END_TIME" - error "Epoch range: $START_EPOCH_MS to $END_EPOCH_MS" - - # List what's available for debugging - log "Available dates in S3:" - mc ls "$S3_ALIAS/$S3_BUCKET/ingress/$STREAM/" 2>/dev/null | head -10 || true - - exit 1 -fi - -log "Found $SEGMENT_COUNT segments to download" - -# Download only the segments we need -SEGMENT_NUM=0 -DOWNLOADED_COUNT=0 -FIRST_TIMESTAMP="" - -while IFS='|' read -r timestamp_ms date_str filename; do - SEGMENT_NUM=$((SEGMENT_NUM + 1)) - - if [ -z "$FIRST_TIMESTAMP" ]; then - FIRST_TIMESTAMP="$timestamp_ms" - fi - - # Use cache directory structure - cache_dir="$CACHE_DIR/$STREAM/$date_str" - mkdir -p "$cache_dir" - - cached_file="$cache_dir/$filename" - s3_file="$S3_ALIAS/$S3_BUCKET/ingress/$STREAM/$date_str/$filename" - - # Download if not cached or incomplete - if [ ! -f "$cached_file" ] || [ ! -s "$cached_file" ]; then - log "Downloading segment $SEGMENT_NUM/$SEGMENT_COUNT: $filename" - if ! mc cp "$s3_file" "$cached_file"; then - warning "Failed to download: $filename" - continue - fi - else - log "Using cached segment $SEGMENT_NUM/$SEGMENT_COUNT: $filename" - fi - - # Create numbered symlink for ffmpeg concat - target_file="$TEMP_DIR/segment_$(printf "%04d" $SEGMENT_NUM).mp4" - if cp "$cached_file" "$target_file"; then - echo "file '$target_file'" >> "$CONCAT_LIST" - DOWNLOADED_COUNT=$((DOWNLOADED_COUNT + 1)) - else - warning "Failed to copy cached file: $filename" - fi -done < "$SEGMENT_LIST" - -# Check if we have segments to concat -if [ "$DOWNLOADED_COUNT" -eq 0 ]; then - error "No segments were successfully downloaded" - exit 1 -fi - -log "Downloaded $DOWNLOADED_COUNT of $SEGMENT_COUNT segments successfully" - -# Combine segments using ffmpeg -log "Combining segments with ffmpeg..." -COMBINED_FILE="$TEMP_DIR/combined.mp4" - -# First try with codec copy (fastest) -if ! ffmpeg -f concat -safe 0 -i "$CONCAT_LIST" \ - -c copy -movflags +faststart \ - -loglevel error \ - -y "$COMBINED_FILE" 2>/dev/null; then - - warning "Direct concat failed, trying with re-encoding..." - - # If concat fails, try re-encoding to ensure compatibility - if ! ffmpeg -f concat -safe 0 -i "$CONCAT_LIST" \ - -c:v libx264 -preset fast -crf 23 \ - -c:a aac -b:a 192k \ - -movflags +faststart \ - -loglevel error \ - -y "$COMBINED_FILE"; then - - # Last resort: concatenate using filter_complex - warning "Re-encoding failed, trying filter_complex method..." - - # Build input list for filter_complex - INPUT_LIST="" - FILTER_COMPLEX="" - INDEX=0 - - while IFS= read -r line; do - if [[ "$line" =~ file\ \'(.+)\' ]]; then - INPUT_LIST="$INPUT_LIST -i '${BASH_REMATCH[1]}'" - FILTER_COMPLEX="${FILTER_COMPLEX}[${INDEX}:v][${INDEX}:a]" - INDEX=$((INDEX + 1)) - fi - done < "$CONCAT_LIST" - - if [ $INDEX -gt 0 ]; then - eval "ffmpeg $INPUT_LIST \ - -filter_complex \"${FILTER_COMPLEX}concat=n=${INDEX}:v=1:a=1[outv][outa]\" \ - -map '[outv]' -map '[outa]' \ - -c:v libx264 -preset fast -crf 23 \ - -c:a aac -b:a 192k \ - -movflags +faststart \ - -loglevel error \ - -y '$COMBINED_FILE'" - else - error "No valid input files found" - exit 1 - fi - fi -fi - -if [ ! -f "$COMBINED_FILE" ] || [ ! -s "$COMBINED_FILE" ]; then - error "Failed to combine segments" - exit 1 -fi - -# Calculate trim offsets if needed -if [ ! -z "$FIRST_TIMESTAMP" ]; then - START_OFFSET_MS=$((START_EPOCH_MS - FIRST_TIMESTAMP)) - if [ "$START_OFFSET_MS" -lt 0 ]; then - START_OFFSET_MS=0 - fi - START_OFFSET_SEC=$(echo "scale=3; $START_OFFSET_MS / 1000" | bc 2>/dev/null || echo $((START_OFFSET_MS / 1000))) -else - START_OFFSET_SEC=0 -fi - -# Trim to exact time range -log "Trimming to exact time range..." -log "Start offset: ${START_OFFSET_SEC}s, Duration: ${DURATION_SEC}s" - -# Try with codec copy first (faster) -ffmpeg -i "$COMBINED_FILE" \ - -ss "$START_OFFSET_SEC" \ - -t "$DURATION_SEC" \ - -c copy \ - -avoid_negative_ts make_zero \ - -movflags +faststart \ - -loglevel error \ - -y "$OUTPUT" - -# If copy codec fails or output is empty, retry with re-encoding -if [ ! -f "$OUTPUT" ] || [ ! -s "$OUTPUT" ]; then - warning "Copy codec failed, re-encoding video..." - - ffmpeg -i "$COMBINED_FILE" \ - -ss "$START_OFFSET_SEC" \ - -t "$DURATION_SEC" \ - -c:v libx264 -preset fast \ - -c:a copy \ - -movflags +faststart \ - -loglevel error \ - -y "$OUTPUT" - - if [ ! -f "$OUTPUT" ] || [ ! -s "$OUTPUT" ]; then - error "Failed to create output file" - exit 1 - fi -fi - -# Get file size -if [ -f "$OUTPUT" ]; then - FILE_SIZE=$(ls -lh "$OUTPUT" | awk '{print $5}') - log "✅ Extraction complete!" - log "Output file: $OUTPUT" - log "File size: $FILE_SIZE" -else - error "Output file was not created" - exit 1 -fi - -# Clean up old cache files (older than 7 days) -find "$CACHE_DIR" -type f -mtime +7 -delete 2>/dev/null || true - -# Cleanup is handled by trap -exit 0 \ No newline at end of file diff --git a/dvr-process.sh b/dvr-process.sh deleted file mode 100755 index 58e0fc9..0000000 --- a/dvr-process.sh +++ /dev/null @@ -1,405 +0,0 @@ -#!/bin/bash - -# DVR Recording Processing Script -# This script fetches shows to process, extracts recordings, converts to HLS, and uploads to S3 - -# Get the directory of this script -SCRIPT_DIR="$(dirname "$0")" - -# Load environment variables from .env file if it exists -if [ -f "$SCRIPT_DIR/.env" ]; then - # Export all variables from .env file - set -a - source "$SCRIPT_DIR/.env" - set +a - echo "Loaded configuration from .env file" -elif [ -f ".env" ]; then - # Try current directory as fallback - set -a - source .env - set +a - echo "Loaded configuration from .env file" -fi - -# Configuration (with defaults if not set in .env) -API_BASE_URL="${API_BASE_URL:-http://localhost/api}" -API_KEY="${RECORDING_API_KEY}" -S3_ALIAS="${S3_ALIAS:-eventwolf}" # mc alias for DVR S3 bucket -S3_BUCKET="${S3_BUCKET:-recording}" -S3_BASE_PATH="${S3_BASE_PATH:-on-demand}" -EVENT_SLUG="${EVENT_SLUG:-event}" # Event identifier, set per convention/year -TEMP_DIR="${TEMP_DIR:-/tmp/dvr-processing}" -DVR_SOURCE_DIR="${DVR_SOURCE_DIR:-/var/dvr}" # Directory containing DVR m3u8/ts files - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Functions -log() { - echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" >&2 -} - -warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -# Check required tools -check_requirements() { - local missing_tools=() - - for tool in ffmpeg mc curl jq; do - if ! command -v "$tool" &> /dev/null; then - missing_tools+=("$tool") - fi - done - - if [ ${#missing_tools[@]} -gt 0 ]; then - error "Missing required tools: ${missing_tools[*]}" - error "Please install them before running this script" - exit 1 - fi - - if [ -z "$API_KEY" ]; then - error "RECORDING_API_KEY environment variable is not set" - error "Please set it in your .env file or export it:" - error " export RECORDING_API_KEY='your-api-key-here'" - exit 1 - fi - - if [ ! -f "$SCRIPT_DIR/dvr-convert.sh" ]; then - error "dvr-convert.sh not found in script directory" - exit 1 - fi - - if [ ! -f "$SCRIPT_DIR/dvr-extract.sh" ]; then - error "dvr-extract.sh not found in script directory" - exit 1 - fi -} - -# Fetch shows to process from API -fetch_shows() { - # Log to stderr so it doesn't corrupt the output - log "Fetching shows to process from API..." >&2 - log "API URL: $API_BASE_URL/recording/shows" >&2 - - # Add verbose output for debugging - if [ "${DEBUG:-0}" = "1" ]; then - log "API Key: ${API_KEY:0:10}..." >&2 # Show first 10 chars for debugging - fi - - local response - local http_code - - # Use -w to get HTTP status code - response=$(curl -s -w "\n__HTTP_CODE__:%{http_code}" \ - -H "X-Recording-Api-Key: $API_KEY" \ - "$API_BASE_URL/recording/shows") - - # Extract HTTP code from response - http_code=$(echo "$response" | grep "__HTTP_CODE__:" | cut -d: -f2) - response=$(echo "$response" | grep -v "__HTTP_CODE__:") - - if [ "$http_code" != "200" ]; then - error "API returned HTTP $http_code" - if [ "${DEBUG:-0}" = "1" ]; then - error "Response: $response" - fi - return 1 - fi - - # Check if response is valid JSON - if ! echo "$response" | jq empty 2>/dev/null; then - error "Invalid JSON response from API" - if [ "${DEBUG:-0}" = "1" ]; then - error "Response: $response" - fi - return 1 - fi - - # Check if response has success flag - local success=$(echo "$response" | jq -r '.success // false') - if [ "$success" != "true" ]; then - local error_msg=$(echo "$response" | jq -r '.message // "Unknown error"') - error "API error: $error_msg" - return 1 - fi - - # Return the data array - echo "$response" | jq -r '.data[] | @json' -} - -# Extract recording using dvr-extract.sh -extract_recording() { - local source="$1" - local start="$2" - local end="$3" - local output_file="$4" - - log "Extracting recording for source: $source from $start to $end" >&2 - - # Convert ISO8601 to the format expected by the extraction script - # The script expects: "YYYY-MM-DD HH:MM:SS" in Europe/Berlin timezone - # The input is already in Europe/Berlin timezone (with +02:00 offset) - # We need to preserve the local time, not convert it - local formatted_start=$(echo "$start" | sed 's/T/ /' | sed 's/+.*//') - local formatted_end=$(echo "$end" | sed 's/T/ /' | sed 's/+.*//') - - # Use the extraction script - if [ -f "$SCRIPT_DIR/dvr-extract.sh" ]; then - log "Running: $SCRIPT_DIR/dvr-extract.sh '$source' '$formatted_start' '$formatted_end' '$output_file' '$S3_ALIAS'" >&2 - "$SCRIPT_DIR/dvr-extract.sh" "$source" "$formatted_start" "$formatted_end" "$output_file" "$S3_ALIAS" - else - error "dvr-extract.sh not found in script directory" - return 1 - fi - - return $? -} - -# Convert to HLS using dvr-convert.sh -convert_to_hls() { - local input_file="$1" - local output_dir="$2" - - log "Converting to HLS format..." - - "$SCRIPT_DIR/dvr-convert.sh" "$input_file" "$output_dir" - - return $? -} - -# Upload to S3 -upload_to_s3() { - local local_dir="$1" - local s3_path="$2" - - log "Uploading to S3: $s3_path" >&2 - - # Use mc mirror to upload - mc mirror --overwrite "$local_dir/" "$S3_ALIAS/$S3_BUCKET/$s3_path/" - - if [ $? -eq 0 ]; then - log "Upload successful to $S3_ALIAS/$S3_BUCKET/$s3_path/" >&2 - return 0 - else - error "Failed to upload to S3" - return 1 - fi -} - -# Create recording via API -create_recording() { - local show_id="$1" - local title="$2" - local m3u8_url="$3" - local description="$4" - - log "Creating recording in database..." >&2 - - # Create a temporary file for the JSON payload - local temp_file=$(mktemp) - - # Write JSON payload to temp file - cat < "$temp_file" -{ - "show_id": $show_id, - "title": "$title", - "m3u8_url": "$m3u8_url", - "description": "$description" -} -EOF - - # Make API call using the temp file - local response - response=$(curl -s -X POST \ - -H "X-Recording-Api-Key: $API_KEY" \ - -H "Content-Type: application/json" \ - -d "@$temp_file" \ - "$API_BASE_URL/recording/create") - - # Clean up temp file - rm -f "$temp_file" - - if echo "$response" | jq -e '.success' > /dev/null 2>&1; then - log "Recording created successfully" >&2 - return 0 - else - error "Failed to create recording: $response" - return 1 - fi -} - -# Process a single show -process_show() { - local show_json="$1" - - # Validate input - if [ -z "$show_json" ] || [ "$show_json" = "null" ]; then - error "Invalid show data received" - return 1 - fi - - log "Processing show JSON: $show_json" >&2 - - # Parse show data - local show_id=$(echo "$show_json" | jq -r '.show_id // ""') - local source=$(echo "$show_json" | jq -r '.source // ""') - local show_slug=$(echo "$show_json" | jq -r '.show // ""') - local start=$(echo "$show_json" | jq -r '.start // ""') - local end=$(echo "$show_json" | jq -r '.end // ""') - local title=$(echo "$show_json" | jq -r '.title // ""') - local description=$(echo "$show_json" | jq -r '.description // ""') - - # Validate required fields - if [ -z "$show_id" ] || [ -z "$source" ] || [ -z "$start" ] || [ -z "$end" ]; then - error "Missing required fields in show data" - error "Show data: $show_json" - return 1 - fi - - log "Processing show: $title (ID: $show_id)" - - # Create temporary directory for this show - local work_dir="$TEMP_DIR/$show_slug" - log "Creating work directory: $work_dir" >&2 - mkdir -p "$work_dir" - - # Extract recording - local extracted_file="$work_dir/extracted.mp4" - log "Starting extraction for show $show_id" >&2 - log "Calling: extract_recording '$source' '$start' '$end' '$extracted_file'" >&2 - if ! extract_recording "$source" "$start" "$end" "$extracted_file"; then - error "Failed to extract recording for show $show_id" - rm -rf "$work_dir" - return 1 - fi - - # Check if extracted file exists - if [ ! -f "$extracted_file" ]; then - error "Extracted file does not exist: $extracted_file" - rm -rf "$work_dir" - return 1 - fi - - local extracted_size=$(ls -lh "$extracted_file" 2>/dev/null | awk '{print $5}') - log "Extraction complete. File size: $extracted_size" >&2 - - # Convert to HLS - local hls_dir="$work_dir/hls" - if ! convert_to_hls "$extracted_file" "$hls_dir"; then - error "Failed to convert recording to HLS for show $show_id" - rm -rf "$work_dir" - return 1 - fi - - # Upload to S3 - local s3_path="$S3_BASE_PATH/$EVENT_SLUG/$show_slug" - local m3u8_url - - if ! upload_to_s3 "$hls_dir" "$s3_path"; then - error "Failed to upload recording to S3 for show $show_id" - rm -rf "$work_dir" - return 1 - fi - - # Construct the master playlist URL - m3u8_url="https://s3.eventwolf.de/${S3_BUCKET}/${s3_path}/extracted_master.m3u8" - - # Create recording in database - if create_recording "$show_id" "$title" "$m3u8_url" "$description"; then - log "Successfully processed show: $title" - else - warning "Recording uploaded but failed to update database for show $show_id" - fi - - # Clean up - rm -rf "$work_dir" - - return 0 -} - -# Main execution -main() { - log "DVR Recording Processing Script Starting..." - - # Check requirements - check_requirements - - # Create temp directory - mkdir -p "$TEMP_DIR" - - # Fetch shows to process - set +e # Don't exit on error - shows=$(fetch_shows) - fetch_result=$? - set -e # Re-enable exit on error - - # Check if fetch_shows failed - if [ $fetch_result -ne 0 ]; then - error "Failed to fetch shows from API. Check your API key and URL." - error "API_BASE_URL: $API_BASE_URL" - error "To debug, run: DEBUG=1 $0" - exit 1 - fi - - if [ -z "$shows" ]; then - log "No shows to process" - exit 0 - fi - - # Debug: log the shows we got - log "Found $(echo "$shows" | wc -l) show(s) to process" - - # Process each show - local total=0 - local successful=0 - local failed=0 - - while IFS= read -r show_json; do - # Skip empty lines - if [ -z "$show_json" ] || [ "$show_json" = "null" ]; then - continue - fi - - total=$((total + 1)) - - # Always show what we're processing - log "Processing show $total" >&2 - - # Parse JSON - if ! show_data=$(echo "$show_json" | jq -r '.' 2>/dev/null); then - error "Failed to parse JSON: $show_json" - failed=$((failed + 1)) - continue - fi - - if process_show "$show_data"; then - successful=$((successful + 1)) - else - failed=$((failed + 1)) - fi - - # Add a small delay between processing shows - sleep 2 - done <<< "$shows" - - # Summary - log "Processing complete!" - log "Total: $total | Successful: $successful | Failed: $failed" - - # Clean up temp directory - rm -rf "$TEMP_DIR" - - exit 0 -} - -# Run main function -main "$@" \ No newline at end of file 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 @@