diff --git a/php/src/ProPlaylistWriter.php b/php/src/ProPlaylistWriter.php new file mode 100644 index 0000000..32c3c75 --- /dev/null +++ b/php/src/ProPlaylistWriter.php @@ -0,0 +1,81 @@ +open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + if ($openResult !== true) { + throw new RuntimeException(sprintf('Failed to create playlist archive: %s', $filePath)); + } + $isOpen = true; + + $protoBytes = $archive->getDocument()->serializeToString(); + self::addStoredEntry($zip, 'data', $protoBytes, $filePath); + + foreach ($archive->getEmbeddedFiles() as $entryName => $contents) { + self::addStoredEntry($zip, $entryName, $contents, $filePath); + } + + if (!$zip->close()) { + throw new RuntimeException(sprintf('Failed to finalize playlist archive: %s', $filePath)); + } + $isOpen = false; + + self::moveTempFileToTarget($tempPath, $filePath); + } finally { + if ($isOpen) { + $zip->close(); + } + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + private static function addStoredEntry(ZipArchive $zip, string $entryName, string $contents, string $filePath): void + { + if (!$zip->addFromString($entryName, $contents)) { + throw new RuntimeException(sprintf('Failed to add ZIP entry %s: %s', $entryName, $filePath)); + } + + if (!$zip->setCompressionName($entryName, ZipArchive::CM_STORE)) { + throw new RuntimeException(sprintf('Failed to set store compression for %s: %s', $entryName, $filePath)); + } + } + + private static function moveTempFileToTarget(string $tempPath, string $filePath): void + { + if (@rename($tempPath, $filePath)) { + return; + } + + if (@copy($tempPath, $filePath) && @unlink($tempPath)) { + return; + } + + throw new RuntimeException(sprintf('Unable to write playlist file: %s', $filePath)); + } +} diff --git a/php/tests/ProPlaylistWriterTest.php b/php/tests/ProPlaylistWriterTest.php new file mode 100644 index 0000000..31817e0 --- /dev/null +++ b/php/tests/ProPlaylistWriterTest.php @@ -0,0 +1,213 @@ +tmpDir = sys_get_temp_dir() . '/propresenter-playlist-writer-test-' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + if (is_dir($this->tmpDir)) { + $this->removeDirectoryRecursively($this->tmpDir); + } + } + + #[Test] + public function writeThrowsWhenTargetDirectoryDoesNotExist(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/missing/out.proplaylist'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Target directory does not exist: %s', dirname($targetPath))); + + ProPlaylistWriter::write($archive, $targetPath); + } + + #[Test] + public function writeCreatesArchiveFile(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/written.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + + $this->assertFileExists($targetPath); + $this->assertGreaterThan(0, filesize($targetPath)); + } + + #[Test] + public function writeAddsDataEntryToZip(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/with-data.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + + $zip = new ZipArchive(); + try { + $openResult = $zip->open($targetPath); + $this->assertTrue($openResult === true, sprintf('Unable to open written playlist zip, code: %s', (string) $openResult)); + $this->assertNotFalse($zip->getFromName('data')); + } finally { + if ($zip->status === ZipArchive::ER_OK) { + $zip->close(); + } + } + } + + #[Test] + public function writeUsesStoreCompressionForAllEntries(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/store-only.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + + $zip = new ZipArchive(); + try { + $openResult = $zip->open($targetPath); + $this->assertTrue($openResult === true, sprintf('Unable to open written playlist zip, code: %s', (string) $openResult)); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $stat = $zip->statIndex($i); + $this->assertIsArray($stat); + $this->assertSame(ZipArchive::CM_STORE, $stat['comp_method']); + } + } finally { + if ($zip->status === ZipArchive::ER_OK) { + $zip->close(); + } + } + } + + #[Test] + public function writeIncludesEmbeddedProFilesAtRootLevel(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/embedded-pro.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + + $zip = new ZipArchive(); + try { + $openResult = $zip->open($targetPath); + $this->assertTrue($openResult === true, sprintf('Unable to open written playlist zip, code: %s', (string) $openResult)); + + foreach (array_keys($archive->getEmbeddedProFiles()) as $proPath) { + $this->assertStringNotContainsString('/', $proPath); + $this->assertNotFalse($zip->locateName($proPath)); + } + } finally { + if ($zip->status === ZipArchive::ER_OK) { + $zip->close(); + } + } + } + + #[Test] + public function writeIncludesEmbeddedMediaFilesAtOriginalPaths(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/embedded-media.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + + $zip = new ZipArchive(); + try { + $openResult = $zip->open($targetPath); + $this->assertTrue($openResult === true, sprintf('Unable to open written playlist zip, code: %s', (string) $openResult)); + + foreach (array_keys($archive->getEmbeddedMediaFiles()) as $mediaPath) { + $this->assertNotFalse($zip->locateName($mediaPath)); + } + } finally { + if ($zip->status === ZipArchive::ER_OK) { + $zip->close(); + } + } + } + + #[Test] + public function writeSupportsRoundTripWithReader(): void + { + $archive = $this->readReferenceArchive(); + $targetPath = $this->tmpDir . '/roundtrip.proplaylist'; + + ProPlaylistWriter::write($archive, $targetPath); + $roundTripArchive = ProPlaylistReader::read($targetPath); + + $this->assertSame($archive->getName(), $roundTripArchive->getName()); + $this->assertSame($archive->getEntryCount(), $roundTripArchive->getEntryCount()); + $this->assertSame( + array_keys($archive->getEmbeddedFiles()), + array_keys($roundTripArchive->getEmbeddedFiles()), + ); + } + + #[Test] + public function writeCleansUpTempFileWhenTargetPathIsDirectory(): void + { + $archive = $this->readReferenceArchive(); + $before = glob(sys_get_temp_dir() . '/proplaylist-*'); + if ($before === false) { + $before = []; + } + + $this->expectException(RuntimeException::class); + try { + ProPlaylistWriter::write($archive, $this->tmpDir); + } finally { + $after = glob(sys_get_temp_dir() . '/proplaylist-*'); + if ($after === false) { + $after = []; + } + + sort($before); + sort($after); + $this->assertSame($before, $after); + } + } + + private function readReferenceArchive(): PlaylistArchive + { + return ProPlaylistReader::read(dirname(__DIR__, 2) . '/ref/TestPlaylist.proplaylist'); + } + + private function removeDirectoryRecursively(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $entryPath = $path . '/' . $entry; + if (is_dir($entryPath)) { + $this->removeDirectoryRecursively($entryPath); + continue; + } + + @unlink($entryPath); + } + + @rmdir($path); + } +}