From d44e67186d2162959eb0608452527a3e71d787e4 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 21:16:27 +0100 Subject: [PATCH] feat(playlist): add ProPlaylistGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement ProPlaylistGenerator following ProFileGenerator pattern - Static generate() + generateAndWrite() methods - Support header, presentation, placeholder item types - Build nested protobuf: PlaylistDocument → root → child → items - Helper methods: buildApplicationInfo, newUuid, colorFromArray - Default music key (MUSIC_KEY_C) for presentations - 9 tests, 35 assertions — all pass - Verified with mixed item generation Task 9 of proplaylist-module plan complete --- php/src/ProPlaylistGenerator.php | 181 +++++++++++++++++++++++ php/tests/ProPlaylistGeneratorTest.php | 193 +++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 php/src/ProPlaylistGenerator.php create mode 100644 php/tests/ProPlaylistGeneratorTest.php diff --git a/php/src/ProPlaylistGenerator.php b/php/src/ProPlaylistGenerator.php new file mode 100644 index 0000000..2942385 --- /dev/null +++ b/php/src/ProPlaylistGenerator.php @@ -0,0 +1,181 @@ +setApplicationInfo(self::buildApplicationInfo()); + $document->setType(PlaylistDocumentType::TYPE_PRESENTATION); + + $rootPlaylist = new Playlist(); + $rootPlaylist->setUuid(self::newUuid()); + $rootPlaylist->setName('PLAYLIST'); + $rootPlaylist->setType(Playlist\Type::TYPE_PLAYLIST); + + $playlist = new Playlist(); + $playlist->setUuid(self::newUuid()); + $playlist->setName($name); + $playlist->setType(Playlist\Type::TYPE_PLAYLIST); + + $playlistItems = new PlaylistItems(); + $itemMessages = []; + foreach ($items as $itemData) { + $itemMessages[] = self::buildPlaylistItem($itemData); + } + $playlistItems->setItems($itemMessages); + $playlist->setItems($playlistItems); + + $playlistArray = new PlaylistArray(); + $playlistArray->setPlaylists([$playlist]); + $rootPlaylist->setPlaylists($playlistArray); + + $document->setRootNode($rootPlaylist); + + return new PlaylistArchive($document, $embeddedFiles); + } + + public static function generateAndWrite( + string $filePath, + string $name, + array $items, + array $embeddedFiles = [], + ): PlaylistArchive { + $archive = self::generate($name, $items, $embeddedFiles); + ProPlaylistWriter::write($archive, $filePath); + + return $archive; + } + + private static function buildPlaylistItem(array $data): PlaylistItem + { + $item = new PlaylistItem(); + $item->setUuid(self::newUuid()); + $item->setName((string) ($data['name'] ?? '')); + + $type = (string) ($data['type'] ?? ''); + switch ($type) { + case 'header': + $header = new Header(); + $header->setColor(self::colorFromArray($data['color'] ?? [])); + $item->setHeader($header); + break; + + case 'presentation': + $presentation = new Presentation(); + $presentation->setDocumentPath(self::urlFromString((string) ($data['path'] ?? ''))); + if (isset($data['arrangement_uuid'])) { + $presentation->setArrangement(self::uuidFromString((string) $data['arrangement_uuid'])); + } + if (isset($data['arrangement_name'])) { + $presentation->setArrangementName((string) $data['arrangement_name']); + } + + $musicKey = new MusicKeyScale(); + $musicKey->setMusicKey(MusicKey::MUSIC_KEY_C); + $presentation->setUserMusicKey($musicKey); + + $item->setPresentation($presentation); + break; + + case 'placeholder': + $item->setPlaceholder(new Placeholder()); + break; + + default: + throw new InvalidArgumentException(sprintf('Unsupported playlist item type: %s', $type)); + } + + return $item; + } + + private static function urlFromString(string $path): URL + { + $url = new URL(); + $url->setAbsoluteString($path); + + return $url; + } + + private static function buildApplicationInfo(): ApplicationInfo + { + $version = new Version(); + $version->setBuild('335544354'); + + $applicationInfo = new ApplicationInfo(); + $applicationInfo->setPlatform(Platform::PLATFORM_MACOS); + $applicationInfo->setApplication(Application::APPLICATION_PROPRESENTER); + $applicationInfo->setPlatformVersion($version); + $applicationInfo->setApplicationVersion($version); + + return $applicationInfo; + } + + private static function newUuid(): UUID + { + return self::uuidFromString(self::newUuidString()); + } + + private static function uuidFromString(string $uuid): UUID + { + $message = new UUID(); + $message->setString($uuid); + + return $message; + } + + private static function colorFromArray(array $rgba): Color + { + $color = new Color(); + $color->setRed((float) ($rgba[0] ?? 0.0)); + $color->setGreen((float) ($rgba[1] ?? 0.0)); + $color->setBlue((float) ($rgba[2] ?? 0.0)); + $color->setAlpha((float) ($rgba[3] ?? 1.0)); + + return $color; + } + + private static function newUuidString(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + $hex = bin2hex($bytes); + + return sprintf( + '%s-%s-%s-%s-%s', + substr($hex, 0, 8), + substr($hex, 8, 4), + substr($hex, 12, 4), + substr($hex, 16, 4), + substr($hex, 20, 12), + ); + } +} diff --git a/php/tests/ProPlaylistGeneratorTest.php b/php/tests/ProPlaylistGeneratorTest.php new file mode 100644 index 0000000..ec882d2 --- /dev/null +++ b/php/tests/ProPlaylistGeneratorTest.php @@ -0,0 +1,193 @@ +tmpDir = sys_get_temp_dir() . '/propresenter-playlist-generator-test-' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + if (!is_dir($this->tmpDir)) { + return; + } + + foreach (scandir($this->tmpDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + @unlink($this->tmpDir . '/' . $entry); + } + + @rmdir($this->tmpDir); + } + + #[Test] + public function testGenerateBuildsNestedPlaylistStructure(): void + { + $archive = ProPlaylistGenerator::generate('Sunday Service', []); + + $this->assertInstanceOf(PlaylistArchive::class, $archive); + $this->assertSame('Sunday Service', $archive->getName()); + $this->assertSame(PlaylistDocumentType::TYPE_PRESENTATION, $archive->getType()); + + $root = $archive->getRootNode(); + $this->assertSame('PLAYLIST', $root->getName()); + $this->assertSame(PlaylistType::TYPE_PLAYLIST, $root->getType()); + $this->assertTrue($root->isContainer()); + + $playlist = $archive->getPlaylistNode(); + $this->assertNotNull($playlist); + $this->assertSame('Sunday Service', $playlist->getName()); + $this->assertSame(PlaylistType::TYPE_PLAYLIST, $playlist->getType()); + $this->assertTrue($playlist->isLeaf()); + } + + #[Test] + public function testGenerateBuildsHeaderItem(): void + { + $archive = ProPlaylistGenerator::generate('Service', [ + ['type' => 'header', 'name' => 'Welcome', 'color' => [0.1, 0.2, 0.3, 1.0]], + ]); + + $entry = $archive->getEntries()[0]; + $this->assertSame('header', $entry->getType()); + $this->assertSame('Welcome', $entry->getName()); + $headerColor = $entry->getHeaderColor(); + $this->assertNotNull($headerColor); + $this->assertEqualsWithDelta(0.1, $headerColor[0], 0.00001); + $this->assertEqualsWithDelta(0.2, $headerColor[1], 0.00001); + $this->assertEqualsWithDelta(0.3, $headerColor[2], 0.00001); + $this->assertEqualsWithDelta(1.0, $headerColor[3], 0.00001); + } + + #[Test] + public function testGenerateBuildsPresentationItemWithDefaultMusicKey(): void + { + $archive = ProPlaylistGenerator::generate('Service', [ + ['type' => 'presentation', 'name' => 'Amazing Grace', 'path' => 'file:///songs/amazing-grace.pro'], + ]); + + $entry = $archive->getEntries()[0]; + $this->assertSame('presentation', $entry->getType()); + $this->assertSame('Amazing Grace', $entry->getName()); + $this->assertSame('file:///songs/amazing-grace.pro', $entry->getDocumentPath()); + + $musicKey = $entry->getPlaylistItem()->getPresentation()?->getUserMusicKey(); + $this->assertNotNull($musicKey); + $this->assertSame(MusicKey::MUSIC_KEY_C, $musicKey->getMusicKey()); + } + + #[Test] + public function testGenerateBuildsPresentationItemWithArrangementData(): void + { + $archive = ProPlaylistGenerator::generate('Service', [ + [ + 'type' => 'presentation', + 'name' => 'Song A', + 'path' => 'file:///songs/song-a.pro', + 'arrangement_uuid' => '11111111-2222-3333-4444-555555555555', + 'arrangement_name' => 'normal', + ], + ]); + + $entry = $archive->getEntries()[0]; + $this->assertTrue($entry->hasArrangement()); + $this->assertSame('11111111-2222-3333-4444-555555555555', $entry->getArrangementUuid()); + $this->assertSame('normal', $entry->getArrangementName()); + } + + #[Test] + public function testGenerateBuildsPlaceholderItem(): void + { + $archive = ProPlaylistGenerator::generate('Service', [ + ['type' => 'placeholder', 'name' => 'Slot1'], + ]); + + $entry = $archive->getEntries()[0]; + $this->assertSame('placeholder', $entry->getType()); + $this->assertSame('Slot1', $entry->getName()); + } + + #[Test] + public function testGenerateBuildsMixedItemOrder(): void + { + $archive = ProPlaylistGenerator::generate('Service', [ + ['type' => 'header', 'name' => 'Welcome', 'color' => [0.0, 0.5, 0.8, 1.0]], + ['type' => 'presentation', 'name' => 'Song', 'path' => 'file:///songs/song.pro'], + ['type' => 'placeholder', 'name' => 'Slot1'], + ]); + + $this->assertSame(['header', 'presentation', 'placeholder'], array_map( + static fn ($entry) => $entry->getType(), + $archive->getEntries(), + )); + } + + #[Test] + public function testGenerateKeepsEmbeddedFiles(): void + { + $archive = ProPlaylistGenerator::generate('Service', [], [ + 'song-a.pro' => 'song-bytes', + 'background.jpg' => 'image-bytes', + ]); + + $this->assertSame( + ['song-a.pro' => 'song-bytes', 'background.jpg' => 'image-bytes'], + $archive->getEmbeddedFiles(), + ); + } + + #[Test] + public function testGenerateAndWriteCreatesReadablePlaylistFile(): void + { + $filePath = $this->tmpDir . '/generated.proplaylist'; + + ProPlaylistGenerator::generateAndWrite( + $filePath, + 'Service', + [ + ['type' => 'header', 'name' => 'Welcome', 'color' => [0.1, 0.2, 0.3, 1.0]], + ['type' => 'presentation', 'name' => 'Song', 'path' => 'file:///songs/song.pro'], + ['type' => 'placeholder', 'name' => 'Slot1'], + ], + ['song.pro' => 'dummy-song-bytes'], + ); + + $this->assertFileExists($filePath); + + $archive = ProPlaylistReader::read($filePath); + $this->assertSame('Service', $archive->getName()); + $this->assertSame(3, $archive->getEntryCount()); + $this->assertArrayHasKey('song.pro', $archive->getEmbeddedFiles()); + } + + #[Test] + public function testGenerateThrowsForUnsupportedItemType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported playlist item type: cue'); + + ProPlaylistGenerator::generate('Service', [ + ['type' => 'cue', 'name' => 'Not supported in generator'], + ]); + } +}