Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 33 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
<?php
require 'vendor/autoload.php';

$client = new ListenNotes\PodcastApi\Client(getenv('LISTEN_API_KEY') ?: null);
$response = $client->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.
Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions listennotes/ApiMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion listennotes/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

final class Client extends Http\Curl
{
public const VERSION = '3.0.0';
public const VERSION = '3.1.0';

use ApiMethods;
}
21 changes: 20 additions & 1 deletion listennotes/api-contract.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"version": "3.0.0",
"version": "3.1.0",
"operations": [
{
"operationId": "search",
Expand Down Expand Up @@ -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",
Expand Down
75 changes: 54 additions & 21 deletions tests/PodcastApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']); }
Expand Down Expand Up @@ -176,13 +176,37 @@ 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()); }
}
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');
Expand All @@ -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']);
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/integration/MockApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

Expand Down
Loading