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:
parent
bef910b126
commit
b40c371edc
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue