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;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Service;
|
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;
|
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||||
|
|
||||||
class PlaylistExportService
|
class PlaylistExportService
|
||||||
|
|
@ -22,18 +24,28 @@ public function generatePlaylist(Service $service): array
|
||||||
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
$skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
|
||||||
$skippedEmpty = 0;
|
$skippedEmpty = 0;
|
||||||
|
|
||||||
$exportService = new ProExportService();
|
$exportService = new ProExportService;
|
||||||
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
$tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
|
||||||
mkdir($tempDir, 0755, true);
|
mkdir($tempDir, 0755, true);
|
||||||
|
|
||||||
$playlistItems = [];
|
$playlistItems = [];
|
||||||
$embeddedFiles = [];
|
$embeddedFiles = [];
|
||||||
|
|
||||||
|
$this->addSlidePresentation(
|
||||||
|
'information',
|
||||||
|
'Informationen',
|
||||||
|
$service,
|
||||||
|
$tempDir,
|
||||||
|
$playlistItems,
|
||||||
|
$embeddedFiles,
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($matchedSongs as $serviceSong) {
|
foreach ($matchedSongs as $serviceSong) {
|
||||||
$song = $serviceSong->song;
|
$song = $serviceSong->song;
|
||||||
|
|
||||||
if (! $song || $song->groups()->count() === 0) {
|
if (! $song || $song->groups()->count() === 0) {
|
||||||
$skippedEmpty++;
|
$skippedEmpty++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +63,24 @@ 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)) {
|
if (empty($playlistItems)) {
|
||||||
$this->deleteDirectory($tempDir);
|
$this->deleteDirectory($tempDir);
|
||||||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||||
|
|
@ -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
|
private function deleteDirectory(string $dir): void
|
||||||
{
|
{
|
||||||
if (! is_dir($dir)) {
|
if (! is_dir($dir)) {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\SongGroup;
|
|
||||||
use App\Models\SongSlide;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -90,6 +88,113 @@ public function test_download_pro_roundtrip_import_export(): void
|
||||||
$exportResponse->assertOk();
|
$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
|
private function assertStringContains(string $needle, ?string $haystack): void
|
||||||
{
|
{
|
||||||
$this->assertNotNull($haystack);
|
$this->assertNotNull($haystack);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue