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:
parent
ca7160068e
commit
747d2c3c07
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
app/Services/PlaylistExportService.php
Normal file
91
app/Services/PlaylistExportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue