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 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 {
|
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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue