feat(export): embed slide blocks in playlist and add roundtrip test

Add information, moderation, and sermon slide presentations as .pro
files in the generated .proplaylist bundle. Each block queries slides
by type/service, converts stored images, and generates a ProPresenter
presentation via ProFileGenerator.

Add test_download_pro_roundtrip_preserves_content that imports a .pro
file, exports it, re-reads with the parser, and asserts song name,
groups, slides, translations, arrangements, and CCLI metadata survive
the round-trip.
This commit is contained in:
Thorsten Bus 2026-03-02 23:02:30 +01:00
parent bef910b126
commit b40c371edc
2 changed files with 240 additions and 11 deletions

View file

@ -3,7 +3,9 @@
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Slide;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistGenerator;
class PlaylistExportService
@ -22,24 +24,34 @@ public function generatePlaylist(Service $service): array
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
$skippedEmpty = 0;
$exportService = new ProExportService();
$tempDir = sys_get_temp_dir() . '/playlist-export-' . uniqid();
$exportService = new ProExportService;
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
mkdir($tempDir, 0755, true);
$playlistItems = [];
$embeddedFiles = [];
$this->addSlidePresentation(
'information',
'Informationen',
$service,
$tempDir,
$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;
$proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
$destPath = $tempDir.'/'.$proFilename;
rename($proPath, $destPath);
$embeddedFiles[$proFilename] = $destPath;
@ -51,15 +63,33 @@ public function generatePlaylist(Service $service): array
];
}
$this->addSlidePresentation(
'moderation',
'Moderation',
$service,
$tempDir,
$playlistItems,
$embeddedFiles,
);
$this->addSlidePresentation(
'sermon',
'Predigt',
$service,
$tempDir,
$playlistItems,
$embeddedFiles,
);
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;
$playlistName = $service->title.' - '.$dateFormatted;
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist';
$outputPath = $tempDir.'/'.$outputFilename;
ProPlaylistGenerator::generateAndWrite($outputPath, $playlistName, $playlistItems, $embeddedFiles);
@ -71,6 +101,100 @@ public function generatePlaylist(Service $service): array
];
}
private function addSlidePresentation(
string $type,
string $label,
Service $service,
string $tempDir,
array &$playlistItems,
array &$embeddedFiles,
): void {
$query = Slide::where('type', $type)
->whereNull('deleted_at');
if ($type === 'information') {
$query->where(function ($q) use ($service) {
$q->whereNull('service_id')
->orWhere('service_id', $service->id);
});
if ($service->date) {
$query->where(function ($q) use ($service) {
$q->whereNull('expire_date')
->orWhereDate('expire_date', '>=', $service->date);
})->whereDate('uploaded_at', '<=', $service->date);
}
} else {
$query->where('service_id', $service->id);
}
$slides = $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get();
if ($slides->isEmpty()) {
return;
}
$slideDataList = [];
$imageFiles = [];
foreach ($slides as $index => $slide) {
$storedPath = Storage::disk('public')->path('slides/'.$slide->stored_filename);
if (! file_exists($storedPath)) {
continue;
}
$imageFilename = $type.'_'.($index + 1).'_'.$slide->stored_filename;
$destPath = $tempDir.'/'.$imageFilename;
copy($storedPath, $destPath);
$imageFiles[$imageFilename] = $destPath;
$slideDataList[] = [
'text' => '',
'media' => $imageFilename,
'format' => 'JPG',
'label' => $slide->original_filename,
];
}
if (empty($slideDataList)) {
return;
}
$groups = [
[
'name' => $label,
'color' => [0.5, 0.5, 0.5, 1.0],
'slides' => $slideDataList,
],
];
$arrangements = [
[
'name' => 'Standard',
'groupNames' => [$label],
],
];
$proFilename = $label.'.pro';
$proPath = $tempDir.'/'.$proFilename;
ProFileGenerator::generateAndWrite($proPath, $label, $groups, $arrangements);
foreach ($imageFiles as $filename => $path) {
$embeddedFiles[$filename] = $path;
}
$embeddedFiles[$proFilename] = $proPath;
$playlistItems[] = [
'type' => 'presentation',
'name' => $label,
'path' => $proFilename,
];
}
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
@ -83,7 +207,7 @@ private function deleteDirectory(string $dir): void
continue;
}
$path = $dir . '/' . $item;
$path = $dir.'/'.$item;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);

View file

@ -3,8 +3,6 @@
namespace Tests\Feature;
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongSlide;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -90,6 +88,113 @@ public function test_download_pro_roundtrip_import_export(): void
$exportResponse->assertOk();
}
public function test_download_pro_roundtrip_preserves_content(): void
{
$user = User::factory()->create();
// 1. Import the reference .pro file
$sourcePath = base_path('../propresenter-work/ref/Test.pro');
$file = new \Illuminate\Http\UploadedFile($sourcePath, 'Test.pro', 'application/octet-stream', null, true);
$importResponse = $this->actingAs($user)->postJson(route('api.songs.import-pro'), ['file' => $file]);
$importResponse->assertOk();
$songId = $importResponse->json('songs.0.id');
$originalSong = Song::with(['groups.slides', 'arrangements.arrangementGroups.group'])->find($songId);
$this->assertNotNull($originalSong);
// Snapshot original data
$originalGroups = $originalSong->groups->sortBy('order')->values();
$originalArrangements = $originalSong->arrangements;
// 2. Export as .pro
$exportResponse = $this->actingAs($user)->get("/api/songs/{$songId}/download-pro");
$exportResponse->assertOk();
// Save exported content to temp file — BinaryFileResponse delivers a real file
$tempPath = sys_get_temp_dir().'/roundtrip-test-'.uniqid().'.pro';
/** @var \Symfony\Component\HttpFoundation\BinaryFileResponse $baseResponse */
$baseResponse = $exportResponse->baseResponse;
copy($baseResponse->getFile()->getPathname(), $tempPath);
// 3. Re-import the exported file as a new song (different ccli to avoid upsert)
// Use the ProPresenter parser directly to read and verify
$reImported = \ProPresenter\Parser\ProFileReader::read($tempPath);
@unlink($tempPath);
// 4. Assert song name
$this->assertSame($originalSong->title, $reImported->getName());
// 5. Assert groups match (same names, same order)
$reImportedGroups = $reImported->getGroups();
$this->assertCount($originalGroups->count(), $reImportedGroups, 'Group count mismatch');
foreach ($originalGroups as $index => $originalGroup) {
$reImportedGroup = $reImportedGroups[$index];
$this->assertSame($originalGroup->name, $reImportedGroup->getName(), "Group name mismatch at index {$index}");
// Assert slides within group
$originalSlides = $originalGroup->slides->sortBy('order')->values();
$reImportedSlides = $reImported->getSlidesForGroup($reImportedGroup);
$this->assertCount($originalSlides->count(), $reImportedSlides, "Slide count mismatch for group '{$originalGroup->name}'");
foreach ($originalSlides as $slideIndex => $originalSlide) {
$reImportedSlide = $reImportedSlides[$slideIndex];
$this->assertSame(
$originalSlide->text_content,
$reImportedSlide->getPlainText(),
"Slide text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
);
// Assert translation if present
if ($originalSlide->text_content_translated) {
$this->assertTrue($reImportedSlide->hasTranslation(), "Expected translation for group '{$originalGroup->name}' slide {$slideIndex}");
$this->assertSame(
$originalSlide->text_content_translated,
$reImportedSlide->getTranslation()?->getPlainText(),
"Translation text mismatch for group '{$originalGroup->name}' slide {$slideIndex}"
);
}
}
}
// 6. Assert arrangements match (same names, same group order)
$reImportedArrangements = $reImported->getArrangements();
$this->assertCount($originalArrangements->count(), $reImportedArrangements, 'Arrangement count mismatch');
foreach ($originalArrangements as $index => $originalArrangement) {
$reImportedArrangement = $reImported->getArrangementByName($originalArrangement->name);
$this->assertNotNull($reImportedArrangement, "Arrangement '{$originalArrangement->name}' not found in re-import");
$originalGroupNames = $originalArrangement->arrangementGroups
->sortBy('order')
->map(fn ($ag) => $ag->group?->name)
->filter()
->values()
->toArray();
$reImportedGroupNames = array_map(
fn ($group) => $group->getName(),
$reImported->getGroupsForArrangement($reImportedArrangement)
);
$this->assertSame(
$originalGroupNames,
$reImportedGroupNames,
"Arrangement '{$originalArrangement->name}' group order mismatch"
);
}
// 7. Assert CCLI metadata
if ($originalSong->ccli_id) {
$this->assertSame((int) $originalSong->ccli_id, $reImported->getCcliSongNumber());
}
if ($originalSong->author) {
$this->assertSame($originalSong->author, $reImported->getCcliAuthor());
}
}
private function assertStringContains(string $needle, ?string $haystack): void
{
$this->assertNotNull($haystack);