feat(services): implement .proplaylist export for finalized services

- Add PlaylistExportService that generates .proplaylist with embedded .pro files
- Update ServiceController::download() with real playlist export (replaces placeholder)
- Return 403 for non-finalized services, 422 when no exportable songs found
- Update frontend downloadService() to handle binary file blob response
- Replace obsolete placeholder test with proper 403 and 422 behavior tests
This commit is contained in:
Thorsten Bus 2026-03-02 12:27:55 +01:00
parent ca7160068e
commit 747d2c3c07
4 changed files with 143 additions and 10 deletions

View file

@ -7,6 +7,7 @@
use App\Models\Slide; use App\Models\Slide;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@ -221,10 +222,25 @@ public function reopen(Service $service): RedirectResponse
->with('success', 'Service wurde wieder geoeffnet.'); ->with('success', 'Service wurde wieder geoeffnet.');
} }
public function download(Service $service): JsonResponse public function download(Service $service): JsonResponse|BinaryFileResponse
{ {
return response()->json([ if (! $service->finalized_at) {
'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.', abort(403, 'Nur abgeschlossene Services können heruntergeladen werden.');
]); }
try {
$playlistService = new \App\Services\PlaylistExportService();
$result = $playlistService->generatePlaylist($service);
$response = response()->download($result['path'], $result['filename']);
if ($result['skipped'] > 0) {
$response->headers->set('X-Skipped-Songs', (string) $result['skipped']);
}
return $response->deleteFileAfterSend(false);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
} }
} }

View file

@ -0,0 +1,91 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceSong;
use ProPresenter\Parser\ProPlaylistGenerator;
class PlaylistExportService
{
/** @return array{path: string, filename: string, skipped: int} */
public function generatePlaylist(Service $service): array
{
$service->loadMissing('serviceSongs.song.groups.slides');
$matchedSongs = $service->serviceSongs()
->whereNotNull('song_id')
->orderBy('order')
->with('song.groups.slides')
->get();
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0;
$exportService = new ProExportService();
$tempDir = sys_get_temp_dir() . '/playlist-export-' . uniqid();
mkdir($tempDir, 0755, true);
$playlistItems = [];
$embeddedFiles = [];
foreach ($matchedSongs as $serviceSong) {
$song = $serviceSong->song;
if (! $song || $song->groups()->count() === 0) {
$skippedEmpty++;
continue;
}
$proPath = $exportService->generateProFile($song);
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title) . '.pro';
$destPath = $tempDir . '/' . $proFilename;
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = $destPath;
$playlistItems[] = [
'type' => 'presentation',
'name' => $song->title,
'path' => $proFilename,
];
}
if (empty($playlistItems)) {
$this->deleteDirectory($tempDir);
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
}
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
$playlistName = $service->title . ' - ' . $dateFormatted;
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title) . '_' . $dateFormatted . '.proplaylist';
$outputPath = $tempDir . '/' . $outputFilename;
ProPlaylistGenerator::generateAndWrite($outputPath, $playlistName, $playlistItems, $embeddedFiles);
return [
'path' => $outputPath,
'filename' => $outputFilename,
'skipped' => $skippedUnmatched + $skippedEmpty,
'temp_dir' => $tempDir,
];
}
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -132,12 +132,29 @@ async function downloadService(serviceId) {
try { try {
const response = await fetch(route('services.download', serviceId), { const response = await fetch(route('services.download', serviceId), {
headers: { headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
}, },
}) })
const data = await response.json()
showToast(data.message, 'warning') if (!response.ok) {
const data = await response.json().catch(() => ({}))
showToast(data.message || 'Fehler beim Herunterladen.', 'warning')
return
}
const disposition = response.headers.get('content-disposition')
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
const filename = filenameMatch?.[1] || 'playlist.proplaylist'
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
showToast('Playlist wurde heruntergeladen.', 'success')
} catch { } catch {
showToast('Fehler beim Herunterladen.', 'warning') showToast('Fehler beim Herunterladen.', 'warning')
} }

View file

@ -186,15 +186,24 @@
expect($service->fresh()->finalized_at)->toBeNull(); expect($service->fresh()->finalized_at)->toBeNull();
}); });
test('download gibt placeholder nachricht zurueck', function () { test('download nicht-finalisierter service gibt 403 zurueck', function () {
$service = Service::factory()->create(['finalized_at' => null]);
$response = $this->actingAs($this->user)
->getJson(route('services.download', $service));
$response->assertForbidden();
});
test('download finalisierter service ohne songs gibt 422 zurueck', function () {
$service = Service::factory()->create(['finalized_at' => now()]); $service = Service::factory()->create(['finalized_at' => now()]);
$response = $this->actingAs($this->user) $response = $this->actingAs($this->user)
->getJson(route('services.download', $service)); ->getJson(route('services.download', $service));
$response->assertOk() $response->assertUnprocessable()
->assertJson([ ->assertJson([
'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.', 'message' => 'Keine Songs mit Inhalt zum Exportieren gefunden.',
]); ]);
}); });