From 4e5728b46dc878b58cdd1743e5a8211b44e6bb94 Mon Sep 17 00:00:00 2001 From: Wenbin Fang Date: Fri, 25 Sep 2026 14:30:08 -0700 Subject: [PATCH] Add deletePlaylist support for PHP SDK 3.1.0 --- .github/workflows/php.yml | 2 +- README.md | 39 +++++++++++++--- listennotes/ApiMethods.php | 6 +++ listennotes/Client.php | 2 +- listennotes/api-contract.json | 21 ++++++++- tests/PodcastApiTest.php | 75 ++++++++++++++++++++++--------- tests/integration/MockApiTest.php | 4 ++ 7 files changed, 119 insertions(+), 30 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 5bbf741..001aa20 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -42,7 +42,7 @@ jobs: test ! -e tests test -f listennotes/api-contract.json composer install --no-dev --no-interaction --no-progress - php -r 'require "vendor/autoload.php"; $client = new ListenNotes\PodcastApi\Client(); if ($client::VERSION !== "3.0.0" || count(get_class_methods(ListenNotes\PodcastApi\ApiMethods::class)) !== 30) { exit(1); }' + php -r 'require "vendor/autoload.php"; $client = new ListenNotes\PodcastApi\Client(); if ($client::VERSION !== "3.1.0" || count(get_class_methods(ListenNotes\PodcastApi\ApiMethods::class)) !== 31 || !method_exists($client, "deletePlaylist")) { exit(1); }' integration: name: Integration (public mock API) runs-on: ubuntu-latest diff --git a/README.md b/README.md index c8a49f2..33d0b4c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ If you have any questions, please contact [hello@listennotes.com](hello@listenno - [`fetchPodcastsByDomain`](#fetchpodcastsbydomain) — `GET /podcasts/domains/{domain_name}` - [`createPlaylist`](#createplaylist) — `POST /playlists` - [`updatePlaylist`](#updateplaylist) — `PUT /playlists/{id}` +- [`deletePlaylist`](#deleteplaylist) — `DELETE /playlists/{id}` - [`addPlaylistItem`](#addplaylistitem) — `POST /playlists/{id}/items` - [`deletePlaylistItem`](#deleteplaylistitem) — `DELETE /playlists/{id}/items/{item_id}` - [`updatePlaylistItemNotes`](#updateplaylistitemnotes) — `PUT /playlists/{id}/items/{item_id}` @@ -138,13 +139,14 @@ And you can see some sample code [here](https://github.com/ListenNotes/podcast-a -## Compatibility in 3.0.0 +## Compatibility in 3.x -All 25 existing methods retain their array arguments and response-body strings. -Use `getStatusCode()` and `getHeaders()` to inspect the latest response. The five -playlist write methods are new. PHP 8.4 is now the minimum supported version. +Existing methods retain their array arguments and response-body strings. +Use `getStatusCode()` and `getHeaders()` to inspect the latest response. Version +3.0.0 added five playlist write methods; 3.1.0 adds `deletePlaylist`. PHP 8.4 +remains the minimum supported version. -The default User-Agent is `podcast-api-php 3.0.0`. Requests have a 30-second total +The default User-Agent is `podcast-api-php 3.1.0`. Requests have a 30-second total timeout and a connection timeout of at most 10 seconds. Pass a positive timeout in seconds as the second constructor argument to change the total timeout. Redirects and automatic request retries are disabled. HTTP 201 and other 2xx @@ -805,6 +807,31 @@ print_r(json_decode($response, true, 512, JSON_THROW_ON_ERROR)); [Full API documentation](https://www.listennotes.com/api/docs/#put-api-v2-playlists-id) +### deletePlaylist + +Delete a playlist. + +`DELETE /playlists/{id}` + +Permanently delete a playlist, including all episode and podcast references saved in this specific playlist and their notes. The actual episodes and podcasts remain in the Listen Notes podcast database. + +**Warning: Deletion cannot be undone. Once deleted, the playlist is gone, regardless of how many episodes or podcasts it contains. You, the developer, are responsible for adding a confirmation step in your app's UI before calling this endpoint to prevent accidental deletion.** + +Only playlists owned by your admin API account can be modified; contributor membership does not grant write access. + +```php +deletePlaylist([ + 'id' => 'm1pe7z60bsw' +]); +print_r(json_decode($response, true, 512, JSON_THROW_ON_ERROR)); +``` + +[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id) + ### addPlaylistItem Add an episode or podcast to a playlist. @@ -905,5 +932,5 @@ README sections are generated by the Listen Notes monorepo's `devtools/api-sdks/sync.py php`. Edit the canonical spec/registry there, not these outputs. The SDK, tests, and packaged distribution work independently. -Packagist derives package versions from Git tags. `Client::VERSION` is 3.0.0; +Packagist derives package versions from Git tags. `Client::VERSION` is 3.1.0; publication/tagging is a separate release step after review and CI. diff --git a/listennotes/ApiMethods.php b/listennotes/ApiMethods.php index ee584bc..230af1d 100644 --- a/listennotes/ApiMethods.php +++ b/listennotes/ApiMethods.php @@ -168,6 +168,12 @@ public function updatePlaylist(array $arrOptions = []): string [], $arrOptions); } + public function deletePlaylist(array $arrOptions = []): string + { + return $this->requestApi('DELETE', '/playlists/{id}', + [], $arrOptions); + } + public function addPlaylistItem(array $arrOptions = []): string { return $this->requestApi('POST', '/playlists/{id}/items', diff --git a/listennotes/Client.php b/listennotes/Client.php index 17adc5c..b0394a0 100644 --- a/listennotes/Client.php +++ b/listennotes/Client.php @@ -6,7 +6,7 @@ final class Client extends Http\Curl { - public const VERSION = '3.0.0'; + public const VERSION = '3.1.0'; use ApiMethods; } diff --git a/listennotes/api-contract.json b/listennotes/api-contract.json index 204d99a..2cf6377 100644 --- a/listennotes/api-contract.json +++ b/listennotes/api-contract.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "version": "3.0.0", + "version": "3.1.0", "operations": [ { "operationId": "search", @@ -815,6 +815,25 @@ "summary": "Update playlist metadata.", "description": "Update any subset of name, description, visibility, and type. Omitted fields remain unchanged; at least one field is required. Switching to private rotates the playlist RSS secret. Type selects the saved default view (episode_list or podcast_list) and the returned listennotes_url; changing it preserves all existing episodes and podcasts.\n\nOnly playlists owned by your admin API account can be modified; contributor membership does not grant write access." }, + { + "operationId": "deletePlaylist", + "func": "deletePlaylist", + "available_from": "3.1.0", + "method": "DELETE", + "path": "/playlists/{id}", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true + } + ], + "example_params": { + "id": "m1pe7z60bsw" + }, + "summary": "Delete a playlist.", + "description": "Permanently delete a playlist, including all episode and podcast references saved in this specific playlist and their notes. The actual episodes and podcasts remain in the Listen Notes podcast database.\n\n**Warning: Deletion cannot be undone. Once deleted, the playlist is gone, regardless of how many episodes or podcasts it contains. You, the developer, are responsible for adding a confirmation step in your app's UI before calling this endpoint to prevent accidental deletion.**\n\nOnly playlists owned by your admin API account can be modified; contributor membership does not grant write access." + }, { "operationId": "addPlaylistItem", "func": "addPlaylistItem", diff --git a/tests/PodcastApiTest.php b/tests/PodcastApiTest.php index 4714ad4..46df582 100644 --- a/tests/PodcastApiTest.php +++ b/tests/PodcastApiTest.php @@ -127,7 +127,7 @@ public function testEveryGeneratedMethod(array $op): void self::assertSame($data['body'], $this->client->getRequestBody()); self::assertSame(200, $this->client->getStatusCode()); self::assertSame('17', $this->client->getHeaders()['x-listenapi-usage']); - self::assertSame('podcast-api-php 3.0.0', $data['headers']['user-agent']); + self::assertSame('podcast-api-php ' . Client::VERSION, $data['headers']['user-agent']); if (in_array($op['method'], ['POST', 'PUT'], true)) { self::assertSame('application/x-www-form-urlencoded', $data['headers']['content-type']); } else { self::assertSame('', $data['body']); } @@ -176,6 +176,12 @@ public function testEncodingEmptyAndOmittedFields(): void public function testMissingIdentifiersFailBeforeConnecting(): void { + foreach ([[], ['id' => null], ['id' => ''], ['id' => false]] as $params) { + try { $this->client->deletePlaylist($params); self::fail('Missing identifier was accepted'); } + catch (Exception\InvalidRequestException $e) { + self::assertSame('Missing required path parameter: id', $e->getMessage()); + } + } foreach ([[], ['id' => null], ['id' => ''], ['id' => 'abc'], ['id' => 'abc', 'item_id' => false]] as $params) { try { $this->client->deletePlaylistItem($params); self::fail('Missing identifier was accepted'); } catch (Exception\InvalidRequestException $e) { self::assertStringContainsString('path parameter', $e->getMessage()); } @@ -183,6 +189,24 @@ public function testMissingIdentifiersFailBeforeConnecting(): void self::assertSame('', file_get_contents(self::$log)); } + public function testDeletePlaylistEncodesIdWithoutQueryOrBody(): void + { + $params = ['id' => 'a/b ?#%+é']; + $before = $params; + $response = $this->client->deletePlaylist($params); + self::assertIsString($response); + $data = json_decode($response, true, 512, JSON_THROW_ON_ERROR); + self::assertSame($before, $params); + self::assertSame('DELETE', $data['method']); + self::assertSame('/api/v2/playlists/a%2Fb%20%3F%23%25%2B%C3%A9', $data['uri']); + self::assertSame([], $data['query']); + self::assertSame('', $data['body']); + self::assertArrayNotHasKey('content-type', $data['headers']); + self::assertSame(200, $this->client->getStatusCode()); + self::assertSame('17', $this->client->getHeaders()['x-listenapi-usage']); + self::assertCount(1, file(self::$log)); + } + public function testRequestStateAndHeadersDoNotLeak(): void { $first = new FixtureClient(self::$origin, 'first-key'); @@ -192,6 +216,7 @@ public function testRequestStateAndHeadersDoNotLeak(): void $first->setRequestHeader('x-test', 'after: value'); foreach ([['createPlaylist', ['name' => 'test'], 'POST'], ['search', ['q' => 'test'], 'GET'], ['deletePlaylistItem', ['id' => 'abc', 'item_id' => 1], 'DELETE'], ['search', ['q' => 'test'], 'GET'], + ['createPlaylist', ['name' => 'test'], 'POST'], ['deletePlaylist', ['id' => 'abc'], 'DELETE'], ['updatePlaylist', ['id' => 'abc', 'description' => ''], 'PUT'], ['search', ['q' => 'test'], 'GET']] as [$method, $params, $http]) { $data = json_decode($first->$method($params), true); self::assertSame($http, $data['method']); @@ -235,19 +260,23 @@ public static function errors(): array #[DataProvider('errors')] public function testErrorsPreserveDetailsAndNeverRetryOrRedirect(int $status, string $class): void { - try { $this->client->post(self::$origin . '/?status=' . $status, ['name' => 'test']); self::fail('Expected API error'); } - catch (Exception\ListenApiException $e) { - self::assertInstanceOf($class, $e); - self::assertSame($status, $e->getStatus()); - self::assertSame($status, $e->getCode()); - self::assertSame('17', $e->getResponseHeaders()['x-listenapi-usage']); - self::assertStringContainsString('HTTP ' . $status, $e->getMessage()); - if ($status >= 400) { - self::assertStringContainsString('Specific API error', $e->getMessage()); - self::assertSame('{"error":"Specific API error"}', $e->getResponseBody()); + foreach ([fn () => $this->client->post(self::$origin . '/?status=' . $status, ['name' => 'test']), + fn () => $this->client->deletePlaylist(['id' => 'abc', 'status' => $status])] as $request) { + file_put_contents(self::$log, ''); + try { $request(); self::fail('Expected API error'); } + catch (Exception\ListenApiException $e) { + self::assertInstanceOf($class, $e); + self::assertSame($status, $e->getStatus()); + self::assertSame($status, $e->getCode()); + self::assertSame('17', $e->getResponseHeaders()['x-listenapi-usage']); + self::assertStringContainsString('HTTP ' . $status, $e->getMessage()); + if ($status >= 400) { + self::assertStringContainsString('Specific API error', $e->getMessage()); + self::assertSame('{"error":"Specific API error"}', $e->getResponseBody()); + } } + self::assertCount(1, file(self::$log)); } - self::assertCount(1, file(self::$log)); } public function testNonJsonErrorsAndExceptionDefaults(): void @@ -264,15 +293,19 @@ public function testNonJsonErrorsAndExceptionDefaults(): void public function testTimeoutsWrapConnectionFailureAndClearResponseState(): void { $client = new FixtureClient(self::$origin, null, 0.05); - $client->search(['q' => 'ok']); - try { $client->get(self::$origin . '/?delay=1'); self::fail('Expected timeout'); } - catch (Exception\APIConnectionException $e) { - self::assertSame(0, $e->getStatus()); - self::assertSame(0, $client->getStatusCode()); - self::assertSame([], $client->getHeaders()); - self::assertSame('', $e->getResponseBody()); - } finally { usleep(180000); } - self::assertCount(2, file(self::$log)); + foreach ([fn () => $client->get(self::$origin . '/?delay=1'), + fn () => $client->deletePlaylist(['id' => 'abc', 'delay' => 1])] as $request) { + file_put_contents(self::$log, ''); + $client->search(['q' => 'ok']); + try { $request(); self::fail('Expected timeout'); } + catch (Exception\APIConnectionException $e) { + self::assertSame(0, $e->getStatus()); + self::assertSame(0, $client->getStatusCode()); + self::assertSame([], $client->getHeaders()); + self::assertSame('', $e->getResponseBody()); + } finally { usleep(180000); } + self::assertCount(2, file(self::$log)); + } } public function testInvalidTimeoutsAndHeaders(): void diff --git a/tests/integration/MockApiTest.php b/tests/integration/MockApiTest.php index 16f2355..8698d68 100644 --- a/tests/integration/MockApiTest.php +++ b/tests/integration/MockApiTest.php @@ -95,6 +95,10 @@ public function testAllMethodsAgainstPublicMock(array $op): void self::assertIsArray($payload['data']); self::assertContains($payload['type'], ['episode', 'podcast']); } elseif ($op['operationId'] === 'deletePlaylistItem') { self::assertTrue($payload['deleted']); self::assertIsInt($payload['id']); + } elseif ($op['operationId'] === 'deletePlaylist') { + self::assertTrue($payload['deleted']); + self::assertSame($op['example_params']['id'], $payload['id']); + self::assertSame('', $this->client->getRequestBody()); } }