From 2c1b8e33708ca145c7cd2f3ffa7c1ccf582263f5 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 1 Mar 2026 20:50:14 +0100 Subject: [PATCH] feat(playlist): add proto field 5, Zip64Fixer, and format spec - Add arrangement_name field 5 to PlaylistItem.Presentation proto - Regenerate PHP proto classes with new field - Implement Zip64Fixer utility to patch ProPresenter's broken ZIP headers - Add comprehensive test suite for Zip64Fixer (7 tests, 37 assertions) - Create pp_playlist_spec.md documenting .proplaylist file format Wave 1 of proplaylist-module plan complete (Tasks 1-3) --- php/generated/GPBMetadata/Playlist.php | 2 +- .../Rv/Data/PlaylistItem/Presentation.php | 27 + php/proto/playlist.proto | 1 + php/src/Zip64Fixer.php | 137 +++++ php/tests/Zip64FixerTest.php | 198 ++++++++ spec/pp_playlist_spec.md | 470 ++++++++++++++++++ 6 files changed, 834 insertions(+), 1 deletion(-) create mode 100644 php/src/Zip64Fixer.php create mode 100644 php/tests/Zip64FixerTest.php create mode 100644 spec/pp_playlist_spec.md diff --git a/php/generated/GPBMetadata/Playlist.php b/php/generated/GPBMetadata/Playlist.php index a0dcbe2..7466ddb 100644 --- a/php/generated/GPBMetadata/Playlist.php +++ b/php/generated/GPBMetadata/Playlist.php @@ -24,7 +24,7 @@ class Playlist \GPBMetadata\Url::initOnce(); \GPBMetadata\Uuid::initOnce(); $pool->internalAddGeneratedFile( - "\x0A\x92\x12\x0A\x0Eplaylist.proto\x12\x07rv.data\x1A\x0Bcolor.proto\x1A\x09cue.proto\x1A\x0ChotKey.proto\x1A\x13musicKeyScale.proto\x1A\x14planningCenter.proto\x1A\x09url.proto\x1A\x0Auuid.proto\"\x91\x0A\x0A\x08Playlist\x12\x1B\x0A\x04uuid\x18\x01 \x01(\x0B2\x0D.rv.data.UUID\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\$\x0A\x04type\x18\x03 \x01(\x0E2\x16.rv.data.Playlist.Type\x12\x10\x0A\x08expanded\x18\x04 \x01(\x08\x12*\x0A\x13targeted_layer_uuid\x18\x05 \x01(\x0B2\x0D.rv.data.UUID\x12*\x0A\x14smart_directory_path\x18\x06 \x01(\x0B2\x0C.rv.data.URL\x12 \x0A\x07hot_key\x18\x07 \x01(\x0B2\x0F.rv.data.HotKey\x12\x1A\x0A\x04cues\x18\x08 \x03(\x0B2\x0C.rv.data.Cue\x12#\x0A\x08children\x18\x09 \x03(\x0B2\x11.rv.data.Playlist\x12\x18\x0A\x10timecode_enabled\x18\x0A \x01(\x08\x12,\x0A\x06timing\x18\x0B \x01(\x0E2\x1C.rv.data.Playlist.TimingType\x123\x0A\x0Cstartup_info\x18\x10 \x01(\x0B2\x1D.rv.data.Playlist.StartupInfo\x124\x0A\x09playlists\x18\x0C \x01(\x0B2\x1F.rv.data.Playlist.PlaylistArrayH\x00\x120\x0A\x05items\x18\x0D \x01(\x0B2\x1F.rv.data.Playlist.PlaylistItemsH\x00\x12<\x0A\x0Fsmart_directory\x18\x0E \x01(\x0B2!.rv.data.Playlist.FolderDirectoryH\x01\x12/\x0A\x08pco_plan\x18\x0F \x01(\x0B2\x1B.rv.data.PlanningCenterPlanH\x01\x1A5\x0A\x0DPlaylistArray\x12\$\x0A\x09playlists\x18\x01 \x03(\x0B2\x11.rv.data.Playlist\x1A5\x0A\x0DPlaylistItems\x12\$\x0A\x05items\x18\x01 \x03(\x0B2\x15.rv.data.PlaylistItem\x1A\xD5\x01\x0A\x0FFolderDirectory\x12%\x0A\x0Fsmart_directory\x18\x01 \x01(\x0B2\x0C.rv.data.URL\x12I\x0A\x0Fimport_behavior\x18\x02 \x01(\x0E20.rv.data.Playlist.FolderDirectory.ImportBehavior\"P\x0A\x0EImportBehavior\x12\x1E\x0A\x1AIMPORT_BEHAVIOR_BACKGROUND\x10\x00\x12\x1E\x0A\x1AIMPORT_BEHAVIOR_FOREGROUND\x10\x01\x1AO\x0A\x03Tag\x12\x1D\x0A\x05color\x18\x01 \x01(\x0B2\x0E.rv.data.Color\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\x1B\x0A\x04uuid\x18\x03 \x01(\x0B2\x0D.rv.data.UUID\x1A)\x0A\x0BStartupInfo\x12\x1A\x0A\x12trigger_on_startup\x18\x01 \x01(\x08\"Z\x0A\x04Type\x12\x10\x0A\x0CTYPE_UNKNOWN\x10\x00\x12\x11\x0A\x0DTYPE_PLAYLIST\x10\x01\x12\x0E\x0A\x0ATYPE_GROUP\x10\x02\x12\x0E\x0A\x0ATYPE_SMART\x10\x03\x12\x0D\x0A\x09TYPE_ROOT\x10\x04\"Y\x0A\x0ATimingType\x12\x14\x0A\x10TIMING_TYPE_NONE\x10\x00\x12\x18\x0A\x14TIMING_TYPE_TIMECODE\x10\x01\x12\x1B\x0A\x17TIMING_TYPE_TIME_OF_DAY\x10\x02B\x0E\x0A\x0CChildrenTypeB\x0A\x0A\x08LinkData\"\xBC\x06\x0A\x0CPlaylistItem\x12\x1B\x0A\x04uuid\x18\x01 \x01(\x0B2\x0D.rv.data.UUID\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\x1B\x0A\x04tags\x18\x07 \x03(\x0B2\x0D.rv.data.UUID\x12\x11\x0A\x09is_hidden\x18\x09 \x01(\x08\x12.\x0A\x06header\x18\x03 \x01(\x0B2\x1C.rv.data.PlaylistItem.HeaderH\x00\x12:\x0A\x0Cpresentation\x18\x04 \x01(\x0B2\".rv.data.PlaylistItem.PresentationH\x00\x12\x1B\x0A\x03cue\x18\x05 \x01(\x0B2\x0C.rv.data.CueH\x00\x12?\x0A\x0Fplanning_center\x18\x06 \x01(\x0B2\$.rv.data.PlaylistItem.PlanningCenterH\x00\x128\x0A\x0Bplaceholder\x18\x08 \x01(\x0B2!.rv.data.PlaylistItem.PlaceholderH\x00\x1AI\x0A\x06Header\x12\x1D\x0A\x05color\x18\x01 \x01(\x0B2\x0E.rv.data.Color\x12 \x0A\x07actions\x18\x02 \x03(\x0B2\x0F.rv.data.Action\x1A\xC8\x01\x0A\x0CPresentation\x12#\x0A\x0Ddocument_path\x18\x01 \x01(\x0B2\x0C.rv.data.URL\x12\"\x0A\x0Barrangement\x18\x02 \x01(\x0B2\x0D.rv.data.UUID\x12?\x0A\x13content_destination\x18\x03 \x01(\x0E2\".rv.data.Action.ContentDestination\x12.\x0A\x0Euser_music_key\x18\x04 \x01(\x0B2\x16.rv.data.MusicKeyScale\x1Ap\x0A\x0EPlanningCenter\x122\x0A\x04item\x18\x01 \x01(\x0B2\$.rv.data.PlanningCenterPlan.PlanItem\x12*\x0A\x0Blinked_data\x18\x02 \x01(\x0B2\x15.rv.data.PlaylistItem\x1A9\x0A\x0BPlaceholder\x12*\x0A\x0Blinked_data\x18\x01 \x01(\x0B2\x15.rv.data.PlaylistItemB\x0A\x0A\x08ItemTypeB4\xF8\x01\x01\xAA\x02\$Pro.SerializationInterop.RVProtoData\xBA\x02\x07RVData_b\x06proto3" + "\x0A\xAC\x12\x0A\x0Eplaylist.proto\x12\x07rv.data\x1A\x0Bcolor.proto\x1A\x09cue.proto\x1A\x0ChotKey.proto\x1A\x13musicKeyScale.proto\x1A\x14planningCenter.proto\x1A\x09url.proto\x1A\x0Auuid.proto\"\x91\x0A\x0A\x08Playlist\x12\x1B\x0A\x04uuid\x18\x01 \x01(\x0B2\x0D.rv.data.UUID\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\$\x0A\x04type\x18\x03 \x01(\x0E2\x16.rv.data.Playlist.Type\x12\x10\x0A\x08expanded\x18\x04 \x01(\x08\x12*\x0A\x13targeted_layer_uuid\x18\x05 \x01(\x0B2\x0D.rv.data.UUID\x12*\x0A\x14smart_directory_path\x18\x06 \x01(\x0B2\x0C.rv.data.URL\x12 \x0A\x07hot_key\x18\x07 \x01(\x0B2\x0F.rv.data.HotKey\x12\x1A\x0A\x04cues\x18\x08 \x03(\x0B2\x0C.rv.data.Cue\x12#\x0A\x08children\x18\x09 \x03(\x0B2\x11.rv.data.Playlist\x12\x18\x0A\x10timecode_enabled\x18\x0A \x01(\x08\x12,\x0A\x06timing\x18\x0B \x01(\x0E2\x1C.rv.data.Playlist.TimingType\x123\x0A\x0Cstartup_info\x18\x10 \x01(\x0B2\x1D.rv.data.Playlist.StartupInfo\x124\x0A\x09playlists\x18\x0C \x01(\x0B2\x1F.rv.data.Playlist.PlaylistArrayH\x00\x120\x0A\x05items\x18\x0D \x01(\x0B2\x1F.rv.data.Playlist.PlaylistItemsH\x00\x12<\x0A\x0Fsmart_directory\x18\x0E \x01(\x0B2!.rv.data.Playlist.FolderDirectoryH\x01\x12/\x0A\x08pco_plan\x18\x0F \x01(\x0B2\x1B.rv.data.PlanningCenterPlanH\x01\x1A5\x0A\x0DPlaylistArray\x12\$\x0A\x09playlists\x18\x01 \x03(\x0B2\x11.rv.data.Playlist\x1A5\x0A\x0DPlaylistItems\x12\$\x0A\x05items\x18\x01 \x03(\x0B2\x15.rv.data.PlaylistItem\x1A\xD5\x01\x0A\x0FFolderDirectory\x12%\x0A\x0Fsmart_directory\x18\x01 \x01(\x0B2\x0C.rv.data.URL\x12I\x0A\x0Fimport_behavior\x18\x02 \x01(\x0E20.rv.data.Playlist.FolderDirectory.ImportBehavior\"P\x0A\x0EImportBehavior\x12\x1E\x0A\x1AIMPORT_BEHAVIOR_BACKGROUND\x10\x00\x12\x1E\x0A\x1AIMPORT_BEHAVIOR_FOREGROUND\x10\x01\x1AO\x0A\x03Tag\x12\x1D\x0A\x05color\x18\x01 \x01(\x0B2\x0E.rv.data.Color\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\x1B\x0A\x04uuid\x18\x03 \x01(\x0B2\x0D.rv.data.UUID\x1A)\x0A\x0BStartupInfo\x12\x1A\x0A\x12trigger_on_startup\x18\x01 \x01(\x08\"Z\x0A\x04Type\x12\x10\x0A\x0CTYPE_UNKNOWN\x10\x00\x12\x11\x0A\x0DTYPE_PLAYLIST\x10\x01\x12\x0E\x0A\x0ATYPE_GROUP\x10\x02\x12\x0E\x0A\x0ATYPE_SMART\x10\x03\x12\x0D\x0A\x09TYPE_ROOT\x10\x04\"Y\x0A\x0ATimingType\x12\x14\x0A\x10TIMING_TYPE_NONE\x10\x00\x12\x18\x0A\x14TIMING_TYPE_TIMECODE\x10\x01\x12\x1B\x0A\x17TIMING_TYPE_TIME_OF_DAY\x10\x02B\x0E\x0A\x0CChildrenTypeB\x0A\x0A\x08LinkData\"\xD6\x06\x0A\x0CPlaylistItem\x12\x1B\x0A\x04uuid\x18\x01 \x01(\x0B2\x0D.rv.data.UUID\x12\x0C\x0A\x04name\x18\x02 \x01(\x09\x12\x1B\x0A\x04tags\x18\x07 \x03(\x0B2\x0D.rv.data.UUID\x12\x11\x0A\x09is_hidden\x18\x09 \x01(\x08\x12.\x0A\x06header\x18\x03 \x01(\x0B2\x1C.rv.data.PlaylistItem.HeaderH\x00\x12:\x0A\x0Cpresentation\x18\x04 \x01(\x0B2\".rv.data.PlaylistItem.PresentationH\x00\x12\x1B\x0A\x03cue\x18\x05 \x01(\x0B2\x0C.rv.data.CueH\x00\x12?\x0A\x0Fplanning_center\x18\x06 \x01(\x0B2\$.rv.data.PlaylistItem.PlanningCenterH\x00\x128\x0A\x0Bplaceholder\x18\x08 \x01(\x0B2!.rv.data.PlaylistItem.PlaceholderH\x00\x1AI\x0A\x06Header\x12\x1D\x0A\x05color\x18\x01 \x01(\x0B2\x0E.rv.data.Color\x12 \x0A\x07actions\x18\x02 \x03(\x0B2\x0F.rv.data.Action\x1A\xE2\x01\x0A\x0CPresentation\x12#\x0A\x0Ddocument_path\x18\x01 \x01(\x0B2\x0C.rv.data.URL\x12\"\x0A\x0Barrangement\x18\x02 \x01(\x0B2\x0D.rv.data.UUID\x12?\x0A\x13content_destination\x18\x03 \x01(\x0E2\".rv.data.Action.ContentDestination\x12.\x0A\x0Euser_music_key\x18\x04 \x01(\x0B2\x16.rv.data.MusicKeyScale\x12\x18\x0A\x10arrangement_name\x18\x05 \x01(\x09\x1Ap\x0A\x0EPlanningCenter\x122\x0A\x04item\x18\x01 \x01(\x0B2\$.rv.data.PlanningCenterPlan.PlanItem\x12*\x0A\x0Blinked_data\x18\x02 \x01(\x0B2\x15.rv.data.PlaylistItem\x1A9\x0A\x0BPlaceholder\x12*\x0A\x0Blinked_data\x18\x01 \x01(\x0B2\x15.rv.data.PlaylistItemB\x0A\x0A\x08ItemTypeB4\xF8\x01\x01\xAA\x02\$Pro.SerializationInterop.RVProtoData\xBA\x02\x07RVData_b\x06proto3" , true); static::$is_initialized = true; diff --git a/php/generated/Rv/Data/PlaylistItem/Presentation.php b/php/generated/Rv/Data/PlaylistItem/Presentation.php index 7f702e9..e6de129 100644 --- a/php/generated/Rv/Data/PlaylistItem/Presentation.php +++ b/php/generated/Rv/Data/PlaylistItem/Presentation.php @@ -30,6 +30,10 @@ class Presentation extends \Google\Protobuf\Internal\Message * Generated from protobuf field .rv.data.MusicKeyScale user_music_key = 4; */ protected $user_music_key = null; + /** + * Generated from protobuf field string arrangement_name = 5; + */ + protected $arrangement_name = ''; /** * Constructor. @@ -41,6 +45,7 @@ class Presentation extends \Google\Protobuf\Internal\Message * @type \Rv\Data\UUID $arrangement * @type int $content_destination * @type \Rv\Data\MusicKeyScale $user_music_key + * @type string $arrangement_name * } */ public function __construct($data = NULL) { @@ -166,5 +171,27 @@ class Presentation extends \Google\Protobuf\Internal\Message return $this; } + /** + * Generated from protobuf field string arrangement_name = 5; + * @return string + */ + public function getArrangementName() + { + return $this->arrangement_name; + } + + /** + * Generated from protobuf field string arrangement_name = 5; + * @param string $var + * @return $this + */ + public function setArrangementName($var) + { + GPBUtil::checkString($var, True); + $this->arrangement_name = $var; + + return $this; + } + } diff --git a/php/proto/playlist.proto b/php/proto/playlist.proto index 5e9c301..e18d956 100755 --- a/php/proto/playlist.proto +++ b/php/proto/playlist.proto @@ -91,6 +91,7 @@ message PlaylistItem { .rv.data.UUID arrangement = 2; .rv.data.Action.ContentDestination content_destination = 3; .rv.data.MusicKeyScale user_music_key = 4; + string arrangement_name = 5; } message PlanningCenter { diff --git a/php/src/Zip64Fixer.php b/php/src/Zip64Fixer.php new file mode 100644 index 0000000..cedf4c4 --- /dev/null +++ b/php/src/Zip64Fixer.php @@ -0,0 +1,137 @@ + $length) { + throw new RuntimeException('EOCD record is truncated.'); + } + + $locatorPosition = self::findLastSignatureBefore($zipData, self::ZIP64_LOCATOR_SIGNATURE, $eocdPosition); + if ($locatorPosition < 0) { + $cdOffset = self::readUInt32LE($zipData, $eocdPosition + 16); + if ($cdOffset === 0xFFFFFFFF) { + throw new RuntimeException('ZIP64 EOCD locator not found.'); + } + + return $zipData; + } + + if ($locatorPosition + 20 > $length) { + throw new RuntimeException('ZIP64 EOCD locator is truncated.'); + } + + $zip64EocdPosition = self::readUInt64LE($zipData, $locatorPosition + 8); + if ($zip64EocdPosition < 0 || $zip64EocdPosition + 56 > $length) { + throw new RuntimeException('ZIP64 EOCD position is out of bounds.'); + } + + if (substr($zipData, $zip64EocdPosition, 4) !== self::ZIP64_EOCD_SIGNATURE) { + throw new RuntimeException('ZIP64 EOCD signature not found at locator position.'); + } + + $zip64CdOffset = self::readUInt64LE($zipData, $zip64EocdPosition + 48); + $correctCdSize = $zip64EocdPosition - $zip64CdOffset; + if ($correctCdSize < 0) { + throw new RuntimeException('Computed central directory size is invalid.'); + } + + $zipData = substr_replace($zipData, self::writeUInt64LE($correctCdSize), $zip64EocdPosition + 40, 8); + + $regularCdSize = $correctCdSize > 0xFFFFFFFF ? 0xFFFFFFFF : (int) $correctCdSize; + return substr_replace($zipData, pack('V', $regularCdSize), $eocdPosition + 12, 4); + } + + private static function findLastSignature(string $data, string $signature): int + { + $position = strrpos($data, $signature); + if ($position === false) { + return -1; + } + + return $position; + } + + private static function findLastSignatureBefore(string $data, string $signature, int $before): int + { + if ($before <= 0) { + return -1; + } + + $slice = substr($data, 0, $before); + $position = strrpos($slice, $signature); + if ($position === false) { + return -1; + } + + return $position; + } + + private static function readUInt32LE(string $data, int $offset): int + { + $chunk = substr($data, $offset, 4); + if (strlen($chunk) !== 4) { + throw new RuntimeException('Unable to read 32-bit little-endian integer.'); + } + + $value = unpack('V', $chunk); + if ($value === false) { + throw new RuntimeException('Unable to unpack 32-bit little-endian integer.'); + } + + return (int) $value[1]; + } + + private static function readUInt64LE(string $data, int $offset): int + { + $chunk = substr($data, $offset, 8); + if (strlen($chunk) !== 8) { + throw new RuntimeException('Unable to read 64-bit little-endian integer.'); + } + + $parts = unpack('Vlow/Vhigh', $chunk); + if ($parts === false) { + throw new RuntimeException('Unable to unpack 64-bit little-endian integer.'); + } + + return (int) ($parts['high'] * 4294967296 + $parts['low']); + } + + private static function writeUInt64LE(int $value): string + { + if ($value < 0) { + throw new RuntimeException('Unable to encode negative 64-bit value.'); + } + + $low = $value & 0xFFFFFFFF; + $high = intdiv($value, 4294967296); + + return pack('V2', $low, $high); + } +} diff --git a/php/tests/Zip64FixerTest.php b/php/tests/Zip64FixerTest.php new file mode 100644 index 0000000..f633140 --- /dev/null +++ b/php/tests/Zip64FixerTest.php @@ -0,0 +1,198 @@ +readFixture('ref/TestPlaylist.proplaylist'); + $fixed = Zip64Fixer::fix($data); + + $zip = $this->openZipFromBytes($fixed); + $this->assertGreaterThan(0, $zip->numFiles); + $zip->close(); + } + + #[Test] + public function fixReturnsValidZipForLargePlaylist(): void + { + $data = $this->readFixture('ref/ExamplePlaylists/Gottesdienst.proplaylist'); + $fixed = Zip64Fixer::fix($data); + + $zip = $this->openZipFromBytes($fixed); + $this->assertGreaterThan(0, $zip->numFiles); + $zip->close(); + } + + #[Test] + public function fixReturnsValidZipForAllRequiredGottesdienstFixtures(): void + { + $files = [ + 'ref/ExamplePlaylists/Gottesdienst.proplaylist', + 'ref/ExamplePlaylists/Gottesdienst 2.proplaylist', + 'ref/ExamplePlaylists/Gottesdienst 3.proplaylist', + ]; + + foreach ($files as $fixture) { + $fixed = Zip64Fixer::fix($this->readFixture($fixture)); + $zip = $this->openZipFromBytes($fixed); + $this->assertGreaterThan(0, $zip->numFiles, $fixture); + $zip->close(); + } + } + + #[Test] + public function fixThrowsOnNonZipData(): void + { + $this->expectException(RuntimeException::class); + Zip64Fixer::fix(random_bytes(256)); + } + + #[Test] + public function fixThrowsOnTooSmallData(): void + { + $this->expectException(RuntimeException::class); + Zip64Fixer::fix(str_repeat('x', 10)); + } + + #[Test] + public function fixPreservesAllEntries(): void + { + $raw = $this->readFixture('ref/TestPlaylist.proplaylist'); + $fixed = Zip64Fixer::fix($raw); + + $expectedEntries = $this->listEntriesWithUnzip($fixed); + $actualEntries = $this->listEntriesWithZipArchive($fixed); + + $expectedEntries = array_map([$this, 'canonicalizeEntryName'], $expectedEntries); + $actualEntries = array_map([$this, 'canonicalizeEntryName'], $actualEntries); + + sort($expectedEntries, SORT_STRING); + sort($actualEntries, SORT_STRING); + + $this->assertSame($expectedEntries, $actualEntries); + } + + #[Test] + public function fixIdempotent(): void + { + $raw = $this->readFixture('ref/TestPlaylist.proplaylist'); + $once = Zip64Fixer::fix($raw); + $twice = Zip64Fixer::fix($once); + + $this->assertSame($once, $twice); + } + + private function readFixture(string $relativePath): string + { + $path = dirname(__DIR__, 2) . '/' . $relativePath; + $data = file_get_contents($path); + $this->assertNotFalse($data, sprintf('Failed to read fixture: %s', $relativePath)); + + return $data; + } + + private function listEntriesWithUnzip(string $zipData): array + { + $path = $this->writeTempZip($zipData); + + try { + $output = shell_exec(sprintf('unzip -l %s 2>&1', escapeshellarg($path))); + $this->assertNotNull($output, 'Failed to execute unzip'); + + $entries = []; + foreach (explode("\n", $output) as $line) { + $trimmed = trim($line); + if ($trimmed === '') { + continue; + } + + $parts = preg_split('/\s+/', $trimmed, 4); + if ($parts === false || count($parts) !== 4) { + continue; + } + + if (!ctype_digit($parts[0])) { + continue; + } + + if (!preg_match('/^\d{2}-\d{2}-\d{4}$/', $parts[1])) { + continue; + } + + if (!preg_match('/^\d{2}:\d{2}$/', $parts[2])) { + continue; + } + + $entries[] = $parts[3]; + } + + return array_values($entries); + } finally { + @unlink($path); + } + } + + private function listEntriesWithZipArchive(string $zipData): array + { + $zip = $this->openZipFromBytes($zipData); + $entries = []; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name !== false) { + $entries[] = $name; + } + } + + $zip->close(); + + return $entries; + } + + private function openZipFromBytes(string $zipData): ZipArchive + { + $path = $this->writeTempZip($zipData); + $zip = new ZipArchive(); + $status = $zip->open($path); + @unlink($path); + + $this->assertTrue($status === true, sprintf('ZipArchive open failed with status: %s', (string) $status)); + + return $zip; + } + + private function writeTempZip(string $zipData): string + { + $path = tempnam(sys_get_temp_dir(), 'zip64-fixer-'); + $this->assertNotFalse($path); + + $result = file_put_contents($path, $zipData); + $this->assertNotFalse($result); + + return $path; + } + + private function canonicalizeEntryName(string $entry): string + { + $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $entry); + if ($ascii === false) { + $ascii = preg_replace('/[^\x20-\x7E]/', '', $entry); + if ($ascii === null) { + return $entry; + } + } + + return str_replace('?', '', $ascii); + } +} diff --git a/spec/pp_playlist_spec.md b/spec/pp_playlist_spec.md new file mode 100644 index 0000000..5ce9b4e --- /dev/null +++ b/spec/pp_playlist_spec.md @@ -0,0 +1,470 @@ +# ProPresenter 7 `.proplaylist` File Format Specification + +**Version:** 1.0 +**Target Audience:** AI agents, automated parsers, developers +**Proto Source:** greyshirtguy/ProPresenter7-Proto v7.16.2 (MIT License) + +--- + +## 1. Overview + +### File Format +- **Extension:** `.proplaylist` +- **Container Format:** ZIP64 archive (PKZIP 4.5+) +- **Compression:** Store only (no deflate compression) +- **Binary Format:** Protocol Buffers (Google protobuf v3) +- **Top-level Message:** `rv.data.Playlist` (defined in `playlist.proto`) +- **Proto Definitions:** greyshirtguy/ProPresenter7-Proto v7.16.2 (MIT) + +### Container Structure +- **Archive Type:** ZIP64 with store compression (compression method 0) +- **ZIP64 EOCD Quirk:** 98-byte discrepancy between ZIP64 EOCD locator offset and actual EOCD position +- **Entry Layout:** + - `data` file at root (protobuf binary) + - `.pro` song files at root (filename only, no directory structure) + - Media files at original absolute paths (minus leading `/`) + +### Known Limitations +- **Binary Fidelity:** Round-trip decode→encode fails on all reference files. Proto definitions are incomplete; unknown fields are lost during serialization. +- **Workaround:** Preserve original binary data if exact binary reproduction is required. + +### File Validity +- **Empty files (0 bytes):** Invalid. Throw exception. +- **Playlists without items:** Valid. Empty playlists are allowed. +- **Deduplication:** Same `.pro` file stored once; media files deduplicated by path. + +--- + +## 2. Playlist Structure + +### Hierarchy Diagram + +``` +PlaylistDocument (ZIP64 archive) +├── data (protobuf binary) +│ └── Playlist (rv.data.Playlist) ← Root container named "PLAYLIST" +│ ├── name (string, field 2) = "PLAYLIST" +│ ├── uuid (rv.data.UUID, field 1) +│ ├── type (rv.data.Playlist.Type, field 3) = TYPE_PLAYLIST (1) +│ └── playlists (rv.data.Playlist.PlaylistArray, field 12) +│ └── playlists[] (rv.data.Playlist) ← Actual named playlist +│ ├── name (string, field 2) ← User-defined name +│ ├── uuid (rv.data.UUID, field 1) +│ ├── type (rv.data.Playlist.Type, field 3) = TYPE_PLAYLIST (1) +│ └── items (rv.data.Playlist.PlaylistItems, field 13) +│ └── items[] (rv.data.PlaylistItem) +│ ├── uuid (rv.data.UUID, field 1) +│ ├── name (string, field 2) +│ └── ItemType (oneof) +│ ├── header (field 3) ← Section divider +│ ├── presentation (field 4) ← Song reference +│ ├── cue (field 5) ← Inline cue +│ ├── planning_center (field 6) ← PCO integration +│ └── placeholder (field 8) ← Empty slot +├── *.pro files (song files, deduplicated) +└── media files (images/videos at original absolute paths) +``` + +### Navigation Paths + +**To access playlist items:** +``` +PlaylistDocument (ZIP) + → data (protobuf) + → Playlist (root "PLAYLIST") + → playlists.playlists[0] (actual playlist) + → items.items[] + → ItemType (oneof) +``` + +**To access presentation references:** +``` +PlaylistItem + → presentation + → document_path (URL) + → arrangement (UUID) + → arrangement_name (string) + → user_music_key (MusicKeyScale) +``` + +**To access header dividers:** +``` +PlaylistItem + → header + → color (Color) + → actions[] (Action) +``` + +--- + +## 3. Fields Reference + +### Playlist (rv.data.Playlist) + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `uuid` | `rv.data.UUID` | 1 | Unique identifier for the playlist | +| `name` | `string` | 2 | Playlist name (root is always "PLAYLIST") | +| `type` | `rv.data.Playlist.Type` | 3 | Playlist type (always TYPE_PLAYLIST = 1) | +| `expanded` | `bool` | 4 | UI expansion state | +| `targeted_layer_uuid` | `rv.data.UUID` | 5 | Target layer UUID | +| `smart_directory_path` | `rv.data.URL` | 6 | Smart playlist directory path | +| `hot_key` | `rv.data.HotKey` | 7 | Keyboard shortcut | +| `cues[]` | `rv.data.Cue` | 8 | Array of cues (not used in observed files) | +| `children[]` | `rv.data.Playlist` | 9 | Array of child playlists (deprecated) | +| `timecode_enabled` | `bool` | 10 | Timecode synchronization enabled | +| `timing` | `rv.data.Playlist.TimingType` | 11 | Timing type (NONE, TIMECODE, TIME_OF_DAY) | +| `playlists` | `rv.data.Playlist.PlaylistArray` | 12 | Child playlists (oneof ChildrenType) | +| `items` | `rv.data.Playlist.PlaylistItems` | 13 | Playlist items (oneof ChildrenType) | +| `smart_directory` | `rv.data.Playlist.FolderDirectory` | 14 | Smart folder config (oneof LinkData) | +| `pco_plan` | `rv.data.PlanningCenterPlan` | 15 | Planning Center plan (oneof LinkData) | +| `startup_info` | `rv.data.Playlist.StartupInfo` | 16 | Startup trigger configuration | + +### Playlist.PlaylistArray + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `playlists[]` | `rv.data.Playlist` | 1 | Array of child playlists | + +### Playlist.PlaylistItems + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `items[]` | `rv.data.PlaylistItem` | 1 | Array of playlist items | + +### PlaylistItem (rv.data.PlaylistItem) + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `uuid` | `rv.data.UUID` | 1 | Unique identifier for the item | +| `name` | `string` | 2 | Item display name | +| `tags[]` | `rv.data.UUID` | 7 | Array of tag UUIDs | +| `is_hidden` | `bool` | 9 | Whether item is hidden in UI | +| `header` | `rv.data.PlaylistItem.Header` | 3 | Section divider (oneof ItemType) | +| `presentation` | `rv.data.PlaylistItem.Presentation` | 4 | Song reference (oneof ItemType) | +| `cue` | `rv.data.Cue` | 5 | Inline cue (oneof ItemType) | +| `planning_center` | `rv.data.PlaylistItem.PlanningCenter` | 6 | PCO integration (oneof ItemType) | +| `placeholder` | `rv.data.PlaylistItem.Placeholder` | 8 | Empty slot (oneof ItemType) | + +### PlaylistItem.Header + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `color` | `rv.data.Color` | 1 | RGBA color (float values 0.0-1.0) | +| `actions[]` | `rv.data.Action` | 2 | Array of actions (rarely used) | + +### PlaylistItem.Presentation + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `document_path` | `rv.data.URL` | 1 | Path to .pro file (URL format) | +| `arrangement` | `rv.data.UUID` | 2 | Arrangement UUID | +| `content_destination` | `rv.data.Action.ContentDestination` | 3 | Content destination layer | +| `user_music_key` | `rv.data.MusicKeyScale` | 4 | User-selected music key | +| `arrangement_name` | `string` | 5 | Arrangement name (UNDOCUMENTED) | + +### PlaylistItem.PlanningCenter + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `item` | `rv.data.PlanningCenterPlan.PlanItem` | 1 | PCO plan item reference | +| `linked_data` | `rv.data.PlaylistItem` | 2 | Linked playlist item | + +### PlaylistItem.Placeholder + +| Field Path | Protobuf Type | Field Number | Description | +|------------|---------------|--------------|-------------| +| `linked_data` | `rv.data.PlaylistItem` | 1 | Linked playlist item | + +--- + +## 4. ZIP64 Container Format + +### Archive Structure +- **Format:** ZIP64 (PKZIP 4.5+) +- **Compression:** Store only (compression method 0, no deflate) +- **Entries:** + 1. `data` file at root (protobuf binary) + 2. `.pro` song files at root (filename only) + 3. Media files at original absolute paths (minus leading `/`) + +### ZIP64 EOCD Quirk +- **Issue:** 98-byte discrepancy between ZIP64 EOCD locator offset and actual EOCD position +- **Observed Pattern:** ZIP64 EOCD locator points to offset that is 98 bytes before actual EOCD record +- **Workaround:** Search backward from end of file for ZIP64 EOCD signature (`0x06064b50`) + +### Entry Layout Example +``` +data ← Protobuf binary +Test.pro ← Song file (filename only) +Oceans.pro ← Song file (filename only) +Users/me/Pictures/slide.jpg ← Media file (absolute path minus leading /) +Users/me/Videos/intro.mp4 ← Media file (absolute path minus leading /) +``` + +### Deduplication Rules +- **Song Files:** Same `.pro` file stored once (by filename) +- **Media Files:** Deduplicated by absolute path +- **Example:** If 3 playlist items reference `Oceans.pro`, only 1 copy is stored in ZIP + +--- + +## 5. Playlist Items + +### Definition +Playlist items represent individual entries in a playlist. Each item has a type (header, presentation, cue, planning_center, placeholder) defined by the `ItemType` oneof field. + +### Item Types + +#### Header (Field 3) +- **Purpose:** Section divider with color +- **Usage:** Visual separator in playlist UI +- **Fields:** `color` (RGBA), `actions[]` (rarely used) +- **Example:** "Worship Set", "Announcements", "Offering" + +#### Presentation (Field 4) +- **Purpose:** Reference to a `.pro` song file +- **Usage:** Most common item type +- **Fields:** + - `document_path` (URL) — Path to `.pro` file + - `arrangement` (UUID) — Arrangement UUID + - `arrangement_name` (string) — Arrangement name (e.g., "normal", "bene", "test2") + - `user_music_key` (MusicKeyScale) — User-selected music key + - `content_destination` (ContentDestination) — Target layer +- **Example:** Reference to "Oceans.pro" with arrangement "normal" + +#### Cue (Field 5) +- **Purpose:** Inline cue (not observed in reference files) +- **Usage:** Embedded cue without external `.pro` file +- **Fields:** Full `rv.data.Cue` message + +#### PlanningCenter (Field 6) +- **Purpose:** Planning Center Online integration +- **Usage:** Link to PCO plan item +- **Fields:** `item` (PlanItem), `linked_data` (PlaylistItem) +- **Note:** Not in scope for this specification + +#### Placeholder (Field 8) +- **Purpose:** Empty slot in playlist +- **Usage:** Reserve space for future item +- **Fields:** `linked_data` (PlaylistItem) + +### Access Pattern +```php +foreach ($playlist->getItems() as $item) { + $uuid = $item->getUuid(); + $name = $item->getName(); + + if ($item->hasPresentation()) { + $presentation = $item->getPresentation(); + $path = $presentation->getDocumentPath()->getAbsoluteString(); + $arrangementName = $presentation->getArrangementName(); + $arrangementUuid = $presentation->getArrangement()->getString(); + } elseif ($item->hasHeader()) { + $header = $item->getHeader(); + $color = $header->getColor(); + } elseif ($item->hasPlaceholder()) { + // Empty slot + } +} +``` + +--- + +## 6. URL Format + +### URL Structure +ProPresenter uses `rv.data.URL` messages with root type and relative path components. + +### Root Types +- **ROOT_USER_HOME (2):** User home directory (`~/`) +- **ROOT_SHOW (10):** ProPresenter library directory + +### Path Construction +- **Format:** `root_type` + `relative_path` +- **Example (ROOT_USER_HOME):** + - Root: `ROOT_USER_HOME (2)` + - Relative: `Music/ProPresenter/Oceans.pro` + - Absolute: `file:///Users/username/Music/ProPresenter/Oceans.pro` +- **Example (ROOT_SHOW):** + - Root: `ROOT_SHOW (10)` + - Relative: `Oceans.pro` + - Absolute: `file:///Users/username/Library/Application Support/RenewedVision/ProPresenter/Oceans.pro` + +### Media File Paths +- **Storage:** Original absolute path minus leading `/` +- **Example:** + - Original: `file:///Users/me/Pictures/slide.jpg` + - ZIP entry: `Users/me/Pictures/slide.jpg` + +--- + +## 7. Protobuf Structure + +### Root Container +- **Message:** `rv.data.Playlist` +- **Name:** Always "PLAYLIST" +- **Type:** Always `TYPE_PLAYLIST (1)` +- **Children:** `playlists` field (PlaylistArray) + +### Actual Playlist +- **Location:** `playlists.playlists[0]` +- **Name:** User-defined (e.g., "Gottesdienst", "Sunday Service") +- **Type:** Always `TYPE_PLAYLIST (1)` +- **Children:** `items` field (PlaylistItems) + +### Nested Structure +``` +Playlist (root "PLAYLIST") + → playlists (PlaylistArray, field 12) + → playlists[] (Playlist) + → items (PlaylistItems, field 13) + → items[] (PlaylistItem) +``` + +### Example (TestPlaylist.proplaylist) +``` +Playlist { + name: "PLAYLIST" + type: TYPE_PLAYLIST (1) + playlists: { + playlists: [ + { + name: "TestPlaylist" + type: TYPE_PLAYLIST (1) + items: { + items: [ + { name: "Worship", header: { color: {...} } }, + { name: "Oceans", presentation: { document_path: {...}, arrangement_name: "normal" } }, + { name: "Amazing Grace", presentation: { document_path: {...}, arrangement_name: "bene" } }, + ] + } + } + ] + } +} +``` + +--- + +## 8. Known Constants + +### Application Info +- **Platform:** macOS 14.8.3 +- **Application:** ProPresenter v20 +- **Observed in:** All reference files + +### Playlist Type +- **Root Playlist:** Always `TYPE_PLAYLIST (1)` +- **Child Playlists:** Always `TYPE_PLAYLIST (1)` +- **Other Types:** `TYPE_GROUP (2)`, `TYPE_SMART (3)`, `TYPE_ROOT (4)` not observed in reference files + +### Root Name +- **Value:** Always "PLAYLIST" +- **Purpose:** Container for actual named playlists + +### Arrangement Name (Field 5) +- **Status:** UNDOCUMENTED in community proto +- **Observed Values:** "normal", "bene", "test2", "Gottesdienst", etc. +- **Purpose:** Human-readable arrangement name (complements arrangement UUID) +- **Frequency:** Present in every `PlaylistItem.Presentation` in reference files + +--- + +## 9. Edge Cases + +### Empty Playlists +- **Items:** 0 items +- **Validity:** Valid +- **Behavior:** `items.items[]` is empty array + +### Playlists Without Presentations +- **Items:** Only headers and placeholders +- **Validity:** Valid +- **Example:** Template playlists with section dividers + +### Missing Arrangement Name +- **Field:** `arrangement_name` (field 5) +- **Behavior:** Empty string or not set +- **Validity:** Valid (fallback to arrangement UUID) + +### Duplicate Song References +- **Scenario:** Same `.pro` file referenced multiple times +- **ZIP Storage:** Single copy of `.pro` file +- **Playlist Items:** Multiple `PlaylistItem.Presentation` entries with same `document_path` + +### Media Files +- **Storage:** Original absolute paths (minus leading `/`) +- **Deduplication:** By absolute path +- **Example:** `Users/me/Pictures/slide.jpg` stored once even if referenced in multiple songs + +--- + +## 10. Reverse-Engineering Evidence + +### Reference Files +- **TestPlaylist.proplaylist:** 4 ZIP entries, 3 items (1 header, 2 presentations) +- **Gottesdienst.proplaylist:** 14MB, 25+ items, multiple media files +- **Gottesdienst 2.proplaylist:** 10MB, similar structure +- **Gottesdienst 3.proplaylist:** 16MB, largest reference file + +### Key Discoveries +1. **ZIP64 EOCD Quirk:** 98-byte offset discrepancy in all files +2. **Store Compression:** No deflate compression (method 0) +3. **Arrangement Name:** Field 5 on `PlaylistItem.Presentation` is undocumented but present in all files +4. **Root Container:** Always named "PLAYLIST" with `TYPE_PLAYLIST (1)` +5. **Deduplication:** Same `.pro` file stored once, media files deduplicated by path + +### Observed Patterns +- **Color Values:** RGBA floats (e.g., `[0.95, 0.27, 0.27, 1.0]` for red) +- **UUID Format:** Standard UUID strings (e.g., `A1B2C3D4-E5F6-G7H8-I9J0-K1L2M3N4O5P6`) +- **Arrangement Names:** User-defined strings (e.g., "normal", "bene", "test2", "Gottesdienst") +- **Media Paths:** Absolute file URLs (e.g., `file:///Users/me/Pictures/slide.jpg`) + +--- + +## Appendix: Proto Field Numbers Quick Reference + +| Message | Field | Number | +|---------|-------|--------| +| Playlist | uuid | 1 | +| Playlist | name | 2 | +| Playlist | type | 3 | +| Playlist | expanded | 4 | +| Playlist | targeted_layer_uuid | 5 | +| Playlist | smart_directory_path | 6 | +| Playlist | hot_key | 7 | +| Playlist | cues | 8 | +| Playlist | children | 9 | +| Playlist | timecode_enabled | 10 | +| Playlist | timing | 11 | +| Playlist | playlists | 12 | +| Playlist | items | 13 | +| Playlist | smart_directory | 14 | +| Playlist | pco_plan | 15 | +| Playlist | startup_info | 16 | +| PlaylistArray | playlists | 1 | +| PlaylistItems | items | 1 | +| PlaylistItem | uuid | 1 | +| PlaylistItem | name | 2 | +| PlaylistItem | header | 3 | +| PlaylistItem | presentation | 4 | +| PlaylistItem | cue | 5 | +| PlaylistItem | planning_center | 6 | +| PlaylistItem | tags | 7 | +| PlaylistItem | placeholder | 8 | +| PlaylistItem | is_hidden | 9 | +| PlaylistItem.Header | color | 1 | +| PlaylistItem.Header | actions | 2 | +| PlaylistItem.Presentation | document_path | 1 | +| PlaylistItem.Presentation | arrangement | 2 | +| PlaylistItem.Presentation | content_destination | 3 | +| PlaylistItem.Presentation | user_music_key | 4 | +| PlaylistItem.Presentation | arrangement_name | 5 | +| PlaylistItem.PlanningCenter | item | 1 | +| PlaylistItem.PlanningCenter | linked_data | 2 | +| PlaylistItem.Placeholder | linked_data | 1 | + +--- + +**End of Specification**