feat(playlist): add ProPlaylistGenerator
- 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
This commit is contained in:
parent
66588c6eaf
commit
d44e67186d
181
php/src/ProPlaylistGenerator.php
Normal file
181
php/src/ProPlaylistGenerator.php
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Rv\Data\ApplicationInfo;
|
||||
use Rv\Data\ApplicationInfo\Application;
|
||||
use Rv\Data\ApplicationInfo\Platform;
|
||||
use Rv\Data\Color;
|
||||
use Rv\Data\MusicKeyScale;
|
||||
use Rv\Data\MusicKeyScale\MusicKey;
|
||||
use Rv\Data\Playlist;
|
||||
use Rv\Data\Playlist\PlaylistArray;
|
||||
use Rv\Data\Playlist\PlaylistItems;
|
||||
use Rv\Data\PlaylistDocument;
|
||||
use Rv\Data\PlaylistDocument\Type as PlaylistDocumentType;
|
||||
use Rv\Data\PlaylistItem;
|
||||
use Rv\Data\PlaylistItem\Header;
|
||||
use Rv\Data\PlaylistItem\Placeholder;
|
||||
use Rv\Data\PlaylistItem\Presentation;
|
||||
use Rv\Data\URL;
|
||||
use Rv\Data\UUID;
|
||||
use Rv\Data\Version;
|
||||
|
||||
final class ProPlaylistGenerator
|
||||
{
|
||||
public static function generate(
|
||||
string $name,
|
||||
array $items,
|
||||
array $embeddedFiles = [],
|
||||
): PlaylistArchive {
|
||||
$document = new PlaylistDocument();
|
||||
$document->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),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
php/tests/ProPlaylistGeneratorTest.php
Normal file
193
php/tests/ProPlaylistGeneratorTest.php
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser\Tests;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ProPresenter\Parser\PlaylistArchive;
|
||||
use ProPresenter\Parser\ProPlaylistGenerator;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use Rv\Data\MusicKeyScale\MusicKey;
|
||||
use Rv\Data\Playlist\Type as PlaylistType;
|
||||
use Rv\Data\PlaylistDocument\Type as PlaylistDocumentType;
|
||||
|
||||
class ProPlaylistGeneratorTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue