From 193d37b1ce26bf7f8eb8731a13baf25dcd70a3b7 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 7 Jul 2026 08:57:31 +0000 Subject: [PATCH] =?UTF-8?q?Display=20CMS:=20Upload-Fehler=20abfangen=20und?= =?UTF-8?q?=20kaputte=20Bilder=20im=20Player=20=C3=BCberspringen.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mediathek-Uploads legen keinen Datensatz mehr an, wenn der Datei-Write fehlschlägt (verhindert 404-Einträge auf den Displays). Der Player überspringt Bilder mit Ladefehler nach 2s statt Schwarzbild zu zeigen. Co-authored-by: Cursor --- app/Livewire/Admin/Cms/DisplayMediaPicker.php | 11 +- app/Services/DisplayMediaService.php | 9 +- public/_cabinet/display/index.html | 155 ++++++++++++------ .../admin/cms/display-media-library.blade.php | 13 +- tests/Feature/DisplayMediaTest.php | 57 +++++++ tests/Feature/DisplayVersionApiTest.php | 28 +++- 6 files changed, 211 insertions(+), 62 deletions(-) diff --git a/app/Livewire/Admin/Cms/DisplayMediaPicker.php b/app/Livewire/Admin/Cms/DisplayMediaPicker.php index 27b2b900..8ab72eda 100644 --- a/app/Livewire/Admin/Cms/DisplayMediaPicker.php +++ b/app/Livewire/Admin/Cms/DisplayMediaPicker.php @@ -69,8 +69,15 @@ class DisplayMediaPicker extends Component $service = app(DisplayMediaService::class); $lastMedia = null; - foreach ($this->quickUploads as $file) { - $lastMedia = $service->storeUpload($file); + try { + foreach ($this->quickUploads as $file) { + $lastMedia = $service->storeUpload($file); + } + } catch (\RuntimeException $exception) { + $this->quickUploads = []; + $this->addError('quickUploads', $exception->getMessage()); + + return; } $this->quickUploads = []; diff --git a/app/Services/DisplayMediaService.php b/app/Services/DisplayMediaService.php index b1a7f3c7..6f333c54 100644 --- a/app/Services/DisplayMediaService.php +++ b/app/Services/DisplayMediaService.php @@ -7,12 +7,15 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Process\Process; class DisplayMediaService { /** * Store an uploaded file and create a DisplayMedia record. + * + * @throws RuntimeException When the file cannot be written to storage. */ public function storeUpload(UploadedFile $file, ?string $collection = null): DisplayMedia { @@ -22,7 +25,11 @@ class DisplayMediaService $datePath = now()->format('Y/m'); $relativePath = "display-media/{$datePath}/{$storageName}"; - Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName); + $storedPath = Storage::disk('public')->putFileAs("display-media/{$datePath}", $file, $storageName); + + if ($storedPath === false || ! Storage::disk('public')->exists($relativePath)) { + throw new RuntimeException("Die Datei \"{$filename}\" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen."); + } $type = in_array($extension, ['mp4', 'webm', 'mov']) ? 'video' : 'image'; diff --git a/public/_cabinet/display/index.html b/public/_cabinet/display/index.html index f74e429d..eee68e97 100644 --- a/public/_cabinet/display/index.html +++ b/public/_cabinet/display/index.html @@ -562,6 +562,37 @@ function escapeHtml(value) { return div.innerHTML; } +function detectDisplayPortalBaseUrl() { + const { protocol, hostname, port } = window.location; + const portSuffix = port ? `:${port}` : ''; + + if (hostname === 'cabinet.b2in.eu') { + return 'https://portal.b2in.eu'; + } + + if (hostname.startsWith('cabinet.')) { + return `${protocol}//${hostname.replace(/^cabinet\./, 'portal.')}${portSuffix}`; + } + + return window.location.origin; +} + +const DISPLAY_PORTAL_BASE_URL = detectDisplayPortalBaseUrl(); + +function resolveDisplayUrl(url) { + if (!url) return ''; + if (url.startsWith('http://') || url.startsWith('https://')) return url; + if (url.startsWith('/_cabinet/')) return url; + if (url.startsWith('/') && DISPLAY_PORTAL_BASE_URL !== window.location.origin) { + return `${DISPLAY_PORTAL_BASE_URL}${url}`; + } + if (url.startsWith('/')) return url; + if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`; + if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`; + if (url.startsWith('../')) return url; + return `../${url}`; +} + function normalizeQrUrl(url) { if (!url) return ''; if (url.startsWith('http://') || url.startsWith('https://')) return url; @@ -583,7 +614,9 @@ function qrCodeImageUrl(url, size = 300, margin = 8, extraParams = {}) { } function setQrImage(img, url, options = {}) { - const baseSrc = options.imageUrl || qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {}); + const baseSrc = options.imageUrl + ? resolveDisplayUrl(options.imageUrl) + : qrCodeImageUrl(url, options.size || 300, options.margin || 8, options.params || {}); if (!img || !baseSrc) return false; const maxAttempts = options.maxAttempts || 3; @@ -636,7 +669,7 @@ class DisplayPlayer { this.displayId = this.detectDisplayId(); // API - this.BASE_URL = this.detectBaseUrl(); + this.BASE_URL = DISPLAY_PORTAL_BASE_URL; this.API_CONFIG = this.detectConfigUrl(); this.API_CHECK = this.detectCheckUrl(); this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`; @@ -762,11 +795,7 @@ class DisplayPlayer { } detectBaseUrl() { - const hostname = window.location.hostname; - if (hostname === 'cabinet.b2in.eu') { - return 'https://portal.b2in.eu'; - } - return window.location.origin; + return DISPLAY_PORTAL_BASE_URL; } // ======================================== @@ -779,27 +808,25 @@ class DisplayPlayer { try { if (!this.displayId && !this.previewToken && !this.moduleId) { await this.fetchOverview(); - return; + } else { + await this.fetchConfig(); + + if (this.playlist.length === 0) { + this.showError('Keine Versionen zugewiesen'); + this.scheduleRetry(); + } else { + this.hideLoading(); + this.startPlaylist(); + } } - - await this.fetchConfig(); - - if (this.playlist.length === 0) { - this.showError('Keine Versionen zugewiesen'); - this.scheduleRetry(); - return; - } - - this.hideLoading(); - this.startPlaylist(); } catch (error) { console.error('[Display] Init failed:', error); this.showError('Konfiguration konnte nicht geladen werden'); this.scheduleRetry(); } - this.startPolling(); - this.recordFileVersion(); + this.startConfigPolling(); + this.startFileVersionPolling(); this.scheduleAutoReload(); } @@ -832,7 +859,7 @@ class DisplayPlayer { this.renderOverview(data.displays || []); } - startPolling() { + startConfigPolling() { if (!this.API_CHECK) { return; } @@ -844,7 +871,7 @@ class DisplayPlayer { const data = await response.json(); if (data.updated_at !== this.cachedTimestamp) { - console.log('[Display] Change detected, reloading...'); + console.log('[Display] Playlist geändert – Seite wird neu geladen'); location.reload(); } } catch (error) { @@ -857,17 +884,33 @@ class DisplayPlayer { } } }, this.POLL_INTERVAL); + } + startFileVersionPolling() { + this.recordFileVersion(); setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL); } + playerFileCheckUrl() { + const url = new URL(window.location.href); + url.searchParams.set('_player_filecheck', '1'); + + return url.toString(); + } + + readPlayerFileVersion(response) { + return response.headers.get('ETag') + || response.headers.get('Last-Modified') + || response.headers.get('Content-Length'); + } + async recordFileVersion() { try { - const response = await fetch(window.location.href, { + const response = await fetch(this.playerFileCheckUrl(), { method: 'HEAD', cache: 'no-store', }); - this.fileVersion = response.headers.get('ETag') || response.headers.get('Last-Modified'); + this.fileVersion = this.readPlayerFileVersion(response); } catch (e) { // Ignorieren – wird beim nächsten Check erneut versucht } @@ -875,14 +918,23 @@ class DisplayPlayer { async checkFileVersion() { try { - const response = await fetch(window.location.href, { + const response = await fetch(this.playerFileCheckUrl(), { method: 'HEAD', cache: 'no-store', }); - const version = response.headers.get('ETag') || response.headers.get('Last-Modified'); + const version = this.readPlayerFileVersion(response); - if (this.fileVersion && version && version !== this.fileVersion) { - console.log('[Display] Datei geändert – Seite wird neu geladen'); + if (!version) { + return; + } + + if (this.fileVersion === null) { + this.fileVersion = version; + return; + } + + if (version !== this.fileVersion) { + console.log('[Display] Player-Datei geändert – Seite wird neu geladen'); location.reload(); } } catch (e) { @@ -1238,12 +1290,7 @@ class VideoDisplayRenderer { } resolveAssetUrl(src) { - if (!src) return ''; - if (src.startsWith('http')) return src; - if (src.startsWith('/')) return src; - if (src.startsWith('../')) return src; - // Assets relative to _cabinet - return `../${src}`; + return resolveDisplayUrl(src); } destroy() { @@ -1451,15 +1498,31 @@ class B2inRenderer { // Image const img = document.createElement('img'); img.style.cssText = 'width:100%;height:100%;object-fit:cover;'; + + let handled = false; + const finish = () => { + if (handled || this.destroyed) return; + handled = true; + clearTimeout(this.itemTimer); + this.showItem(index + 1); + }; + + // Broken image (e.g. deleted media, 404): skip after a short grace + // period instead of showing a black frame for the full duration. + // The delay also prevents a rapid loop when every image is broken. + img.onerror = () => { + console.warn(`[B2in] Image error: ${item.media_url}`); + this.hideProgress(); + setTimeout(finish, 2000); + }; + img.src = this.resolveUrl(item.media_url); incoming.appendChild(img); const duration = (item.duration_seconds || this.settings.default_image_duration || 10) * 1000; this.showProgress(duration); - this.itemTimer = setTimeout(() => { - if (!this.destroyed) this.showItem(index + 1); - }, duration); + this.itemTimer = setTimeout(finish, duration); } // Crossfade @@ -1484,13 +1547,7 @@ class B2inRenderer { } resolveUrl(url) { - if (!url) return ''; - if (url.startsWith('http')) return url; - if (url.startsWith('/')) return url; - if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`; - if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`; - if (url.startsWith('../')) return url; - return `../${url}`; + return resolveDisplayUrl(url); } destroy() { @@ -1856,13 +1913,7 @@ class OffersRenderer { } resolveUrl(url) { - if (!url) return ''; - if (url.startsWith('http')) return url; - if (url.startsWith('/')) return url; - if (url.startsWith('../assets/')) return `/_cabinet/assets/${url.slice('../assets/'.length)}`; - if (!url.includes('/') && url.endsWith('.svg')) return `/_cabinet/assets/${url}`; - if (url.startsWith('../')) return url; - return `../${url}`; + return resolveDisplayUrl(url); } escapeHtml(str) { diff --git a/resources/views/livewire/admin/cms/display-media-library.blade.php b/resources/views/livewire/admin/cms/display-media-library.blade.php index d4905839..a0ecdb01 100644 --- a/resources/views/livewire/admin/cms/display-media-library.blade.php +++ b/resources/views/livewire/admin/cms/display-media-library.blade.php @@ -88,9 +88,16 @@ $handleUploads = function () { $service = app(DisplayMediaService::class); $count = 0; - foreach ($this->uploads as $file) { - $service->storeUpload($file); - $count++; + try { + foreach ($this->uploads as $file) { + $service->storeUpload($file); + $count++; + } + } catch (\RuntimeException $exception) { + $this->uploads = []; + $this->addError('uploads', $exception->getMessage()); + + return; } $this->uploads = []; diff --git a/tests/Feature/DisplayMediaTest.php b/tests/Feature/DisplayMediaTest.php index d63de487..0cb85e96 100644 --- a/tests/Feature/DisplayMediaTest.php +++ b/tests/Feature/DisplayMediaTest.php @@ -252,6 +252,63 @@ it('generates a poster thumbnail when a real video is uploaded', function () { @unlink($videoPath); }); +it('throws and creates no record when the file cannot be written to storage', function () { + $disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $disk->shouldReceive('putFileAs')->once()->andReturn(false); + Storage::shouldReceive('disk')->with('public')->andReturn($disk); + + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('broken.jpg'); + + expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class); + expect(DisplayMedia::count())->toBe(0); +}); + +it('throws and creates no record when the stored file does not exist afterwards', function () { + $disk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $disk->shouldReceive('putFileAs')->once()->andReturn('display-media/2026/07/ghost.jpg'); + $disk->shouldReceive('exists')->once()->andReturn(false); + Storage::shouldReceive('disk')->with('public')->andReturn($disk); + + $service = app(DisplayMediaService::class); + $file = UploadedFile::fake()->image('ghost.jpg'); + + expect(fn () => $service->storeUpload($file))->toThrow(RuntimeException::class); + expect(DisplayMedia::count())->toBe(0); +}); + +it('shows a validation error in the media library when the storage write fails', function () { + $this->mock(DisplayMediaService::class) + ->shouldReceive('storeUpload') + ->once() + ->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.')); + + $file = UploadedFile::fake()->image('broken.jpg'); + + Volt::test('admin.cms.display-media-library') + ->set('uploads', [$file]) + ->call('handleUploads') + ->assertHasErrors('uploads'); + + expect(DisplayMedia::count())->toBe(0); +}); + +it('shows a validation error in the media picker when the storage write fails', function () { + $this->mock(DisplayMediaService::class) + ->shouldReceive('storeUpload') + ->once() + ->andThrow(new RuntimeException('Die Datei "broken.jpg" konnte nicht im Storage gespeichert werden. Bitte Schreibrechte und Speicherplatz prüfen.')); + + $file = UploadedFile::fake()->image('broken.jpg'); + + Livewire\Livewire::test(\App\Livewire\Admin\Cms\DisplayMediaPicker::class) + ->set('quickUploads', [$file]) + ->assertHasErrors('quickUploads') + ->assertNotDispatched('display-media-selected'); + + expect(DisplayMedia::count())->toBe(0); +}); + it('does not set a thumbnail when the video cannot be decoded', function () { $service = app(DisplayMediaService::class); $file = UploadedFile::fake()->create('broken.mp4', 500, 'video/mp4'); diff --git a/tests/Feature/DisplayVersionApiTest.php b/tests/Feature/DisplayVersionApiTest.php index 37d20b05..4e0bae5a 100644 --- a/tests/Feature/DisplayVersionApiTest.php +++ b/tests/Feature/DisplayVersionApiTest.php @@ -444,6 +444,15 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('container-type: size;') ->toContain("if (hostname === 'cabinet.b2in.eu') {") ->toContain("return 'https://portal.b2in.eu';") + ->toContain('startConfigPolling') + ->toContain('startFileVersionPolling') + ->toContain('playerFileCheckUrl') + ->toContain('readPlayerFileVersion') + ->toContain('Player-Datei geändert') + ->toContain('Playlist geändert') + ->toContain('DISPLAY_PORTAL_BASE_URL') + ->toContain("url.startsWith('/_cabinet/')") + ->toContain('`${DISPLAY_PORTAL_BASE_URL}${url}`') ->toContain('/api/display/overview') ->toContain('Aktive Live-Displays') ->toContain('renderOverview(data.displays || [])') @@ -481,6 +490,15 @@ test('display player keeps previews in a strict 9 by 16 viewport', function () { ->toContain('background: none'); }); +test('display player skips broken images instead of showing a black frame', function () { + $player = file_get_contents(public_path('_cabinet/display/index.html')); + + expect($player) + ->toContain('img.onerror = () => {') + ->toContain('[B2in] Image error:') + ->toContain('setTimeout(finish, 2000);'); +}); + test('display player stops looping when the playlist has no playable content', function () { $player = file_get_contents(public_path('_cabinet/display/index.html')); @@ -510,10 +528,12 @@ test('preview player is served with a revalidation cache header', function () { $display = Display::factory()->create(['preview_token' => 'fresh-token']); $version = DisplayVersion::factory()->create(); - foreach ([ - '/preview/fresh-token', - "/preview/module/{$version->id}", - ] as $url) { + foreach ( + [ + '/preview/fresh-token', + "/preview/module/{$version->id}", + ] as $url + ) { $response = $this->get($url); $response->assertSuccessful(); expect($response->headers->get('Cache-Control'))->toContain('no-cache');