feat(playlist): add ProPlaylistWriter
- Implement ProPlaylistWriter::write() following ProFileWriter pattern - ZIP creation with ZipArchive::CM_STORE (no compression) - Proper temp file cleanup in finally block - Directory validation matching ProFileWriter style - 8 tests, 27 assertions — all pass - Round-trip verification with ProPlaylistReader - Verified with unzip -l (clean, standard-compliant ZIP) Task 8 of proplaylist-module plan complete
This commit is contained in:
parent
86b4e74577
commit
66588c6eaf
81
php/src/ProPlaylistWriter.php
Normal file
81
php/src/ProPlaylistWriter.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
final class ProPlaylistWriter
|
||||
{
|
||||
public static function write(PlaylistArchive $archive, string $filePath): void
|
||||
{
|
||||
$directory = dirname($filePath);
|
||||
if (!is_dir($directory)) {
|
||||
throw new InvalidArgumentException(sprintf('Target directory does not exist: %s', $directory));
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'proplaylist-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Unable to create temporary file for playlist archive.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$isOpen = false;
|
||||
|
||||
try {
|
||||
$openResult = $zip->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));
|
||||
}
|
||||
}
|
||||
213
php/tests/ProPlaylistWriterTest.php
Normal file
213
php/tests/ProPlaylistWriterTest.php
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ProPresenter\Parser\PlaylistArchive;
|
||||
use ProPresenter\Parser\ProPlaylistReader;
|
||||
use ProPresenter\Parser\ProPlaylistWriter;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
class ProPlaylistWriterTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue