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**