Display CMS: Upload-Fehler abfangen und kaputte Bilder im Player überspringen.
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 <cursoragent@cursor.com>
This commit is contained in:
parent
2a34adc515
commit
193d37b1ce
6 changed files with 211 additions and 62 deletions
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue