diff --git a/php/src/ProPlaylistReader.php b/php/src/ProPlaylistReader.php new file mode 100644 index 0000000..6c77e77 --- /dev/null +++ b/php/src/ProPlaylistReader.php @@ -0,0 +1,85 @@ +open($tempPath) !== true) { + throw new RuntimeException(sprintf('Failed to open playlist archive: %s', $filePath)); + } + $isOpen = true; + + $dataBytes = $zip->getFromName('data'); + if ($dataBytes === false) { + throw new RuntimeException(sprintf('Missing data entry in playlist archive: %s', $filePath)); + } + + $document = new PlaylistDocument(); + $document->mergeFromString($dataBytes); + + $embeddedFiles = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false || $name === 'data') { + continue; + } + + $contents = $zip->getFromIndex($i); + if ($contents === false) { + throw new RuntimeException(sprintf('Unable to read ZIP entry %s: %s', $name, $filePath)); + } + + $embeddedFiles[$name] = $contents; + } + + return new PlaylistArchive($document, $embeddedFiles); + } finally { + if ($isOpen) { + $zip->close(); + } + @unlink($tempPath); + } + } +} diff --git a/php/tests/ProPlaylistReaderTest.php b/php/tests/ProPlaylistReaderTest.php new file mode 100644 index 0000000..88b0ac1 --- /dev/null +++ b/php/tests/ProPlaylistReaderTest.php @@ -0,0 +1,192 @@ +repoRoot() . '/ref/does-not-exist.proplaylist'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Playlist file not found: %s', $filePath)); + ProPlaylistReader::read($filePath); + } + + #[Test] + public function readThrowsOnEmptyFile(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'proplaylist-empty-'); + if ($filePath === false) { + self::fail('Unable to create temporary test file.'); + } + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Playlist file is empty: %s', $filePath)); + ProPlaylistReader::read($filePath); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function readThrowsOnInvalidZipFormat(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'proplaylist-invalid-'); + if ($filePath === false) { + self::fail('Unable to create temporary test file.'); + } + + try { + file_put_contents($filePath, 'not-a-zip-archive'); + + $this->expectException(RuntimeException::class); + ProPlaylistReader::read($filePath); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function readReturnsPlaylistArchiveForTestPlaylist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/TestPlaylist.proplaylist'); + + $this->assertInstanceOf(PlaylistArchive::class, $archive); + $this->assertSame('TestPlaylist', $archive->getName()); + $this->assertGreaterThan(0, $archive->getEntryCount()); + } + + #[Test] + public function readExtractsEmbeddedFilesFromTestPlaylist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/TestPlaylist.proplaylist'); + $embeddedFiles = $archive->getEmbeddedFiles(); + + $this->assertNotEmpty($embeddedFiles); + $this->assertArrayNotHasKey('data', $embeddedFiles); + $this->assertGreaterThanOrEqual(2, count($archive->getEmbeddedProFiles())); + $this->assertGreaterThanOrEqual(1, count($archive->getEmbeddedMediaFiles())); + } + + #[Test] + public function readParsesEmbeddedSongsLazilyFromTestPlaylist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/TestPlaylist.proplaylist'); + $embeddedProFiles = $archive->getEmbeddedProFiles(); + + $this->assertNotEmpty($embeddedProFiles); + $firstProFilename = array_key_first($embeddedProFiles); + $this->assertNotNull($firstProFilename); + + $song = $archive->getEmbeddedSong((string) $firstProFilename); + $this->assertNotNull($song); + $this->assertNotSame('', $song->getName()); + } + + #[Test] + public function readHandlesGottesdienstPlaylist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/ExamplePlaylists/Gottesdienst.proplaylist'); + + $this->assertNotSame('', $archive->getName()); + $this->assertGreaterThan(0, $archive->getEntryCount()); + $this->assertNotEmpty($archive->getEmbeddedFiles()); + } + + #[Test] + public function readHandlesGottesdienst2Playlist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/ExamplePlaylists/Gottesdienst 2.proplaylist'); + + $this->assertNotSame('', $archive->getName()); + $this->assertGreaterThan(0, $archive->getEntryCount()); + $this->assertNotEmpty($archive->getEmbeddedFiles()); + } + + #[Test] + public function readHandlesGottesdienst3Playlist(): void + { + $archive = ProPlaylistReader::read($this->repoRoot() . '/ref/ExamplePlaylists/Gottesdienst 3.proplaylist'); + + $this->assertNotSame('', $archive->getName()); + $this->assertGreaterThan(0, $archive->getEntryCount()); + $this->assertNotEmpty($archive->getEmbeddedFiles()); + } + + #[Test] + public function readCleansUpTempFileWhenZipOpenFails(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'proplaylist-badzip-'); + if ($filePath === false) { + self::fail('Unable to create temporary test file.'); + } + + $before = glob(sys_get_temp_dir() . '/proplaylist-*'); + if ($before === false) { + $before = []; + } + + try { + file_put_contents($filePath, str_repeat('x', 128)); + + try { + ProPlaylistReader::read($filePath); + self::fail('Expected RuntimeException was not thrown.'); + } catch (RuntimeException) { + } + + $after = glob(sys_get_temp_dir() . '/proplaylist-*'); + if ($after === false) { + $after = []; + } + + sort($before); + sort($after); + $this->assertSame($before, $after); + } finally { + @unlink($filePath); + } + } + + #[Test] + public function readThrowsWhenDataEntryIsMissing(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'proplaylist-nodata-'); + if ($filePath === false) { + self::fail('Unable to create temporary test file.'); + } + + $zip = new \ZipArchive(); + + try { + $openResult = $zip->open($filePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + $this->assertTrue($openResult === true, sprintf('Unable to open zip for test, code: %s', (string) $openResult)); + + $zip->addFromString('song1.pro', 'dummy-song'); + $zip->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Missing data entry in playlist archive: %s', $filePath)); + ProPlaylistReader::read($filePath); + } finally { + @unlink($filePath); + } + } +}