diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 8baf0af..3f6caa2 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -7,6 +7,7 @@ use App\Models\Slide; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Illuminate\Support\Carbon; use Inertia\Inertia; use Inertia\Response; @@ -221,10 +222,25 @@ public function reopen(Service $service): RedirectResponse ->with('success', 'Service wurde wieder geoeffnet.'); } - public function download(Service $service): JsonResponse + public function download(Service $service): JsonResponse|BinaryFileResponse { - return response()->json([ - 'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.', - ]); + if (! $service->finalized_at) { + 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); + } } } diff --git a/app/Services/PlaylistExportService.php b/app/Services/PlaylistExportService.php new file mode 100644 index 0000000..a502211 --- /dev/null +++ b/app/Services/PlaylistExportService.php @@ -0,0 +1,91 @@ +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); + } +} diff --git a/resources/js/Pages/Services/Index.vue b/resources/js/Pages/Services/Index.vue index 57949c4..a87aa80 100644 --- a/resources/js/Pages/Services/Index.vue +++ b/resources/js/Pages/Services/Index.vue @@ -132,12 +132,29 @@ async function downloadService(serviceId) { try { const response = await fetch(route('services.download', serviceId), { headers: { - 'Accept': 'application/json', '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 { showToast('Fehler beim Herunterladen.', 'warning') } diff --git a/tests/Feature/FinalizationTest.php b/tests/Feature/FinalizationTest.php index fb394a2..7dfa792 100644 --- a/tests/Feature/FinalizationTest.php +++ b/tests/Feature/FinalizationTest.php @@ -186,15 +186,24 @@ 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()]); $response = $this->actingAs($this->user) ->getJson(route('services.download', $service)); - $response->assertOk() + $response->assertUnprocessable() ->assertJson([ - 'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.', + 'message' => 'Keine Songs mit Inhalt zum Exportieren gefunden.', ]); });