From b3181aecf5836e3cec024b381565bb33dfdb328c Mon Sep 17 00:00:00 2001 From: Wenbin Fang Date: Fri, 25 Sep 2026 15:30:26 -0700 Subject: [PATCH] Add deletePlaylist support for Java SDK 3.1.0 --- README.md | 48 ++++++++++++++++-- gradle.properties | 2 +- .../listennotes/podcast_api/ApiMethods.java | 10 ++++ .../listennotes/podcast_api/ClientTest.java | 49 ++++++++++++++++++- .../podcast_api/MockIntegrationTest.java | 5 ++ src/test/resources/api-contract.json | 21 +++++++- 6 files changed, 126 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 04acf85..c4b73c8 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ Questions? Contact [hello@listennotes.com](mailto:hello@listennotes.com). ## Installation -Version 3.0.0 requires **Java 17 or later**. It uses the JDK HTTP client and +Version 3 requires **Java 17 or later**. It uses the JDK HTTP client and `org.json` for JSON responses. The Gradle wrapper is included for development. Gradle, with Maven Central enabled: ```groovy -implementation 'com.listennotes:podcast-api:3.0.0' +implementation 'com.listennotes:podcast-api:3.1.0' ``` Maven: @@ -25,11 +25,11 @@ Maven: com.listennotes podcast-api - 3.0.0 + 3.1.0 ``` -These coordinates become installable when 3.0.0 is published to Maven Central. +These coordinates become installable when 3.1.0 is published to Maven Central. ## Usage @@ -61,6 +61,13 @@ so `Map.of("id", playlistId, "item_id", itemId, "notes", "")` clears notes. `justListen()`, `fetchPodcastLanguages()`, and `fetchPodcastRegions()` also retain their existing no-argument overloads. +Since 3.1.0, `deletePlaylist(Map.of("id", playlistId))` permanently deletes a +playlist and all references and notes saved in it. This cannot be undone; +add a confirmation step in your application's UI before calling it. The actual +episodes and podcasts remain in the Listen Notes podcast database. Only playlists +owned by your admin API account can be modified. Deleting an already deleted +playlist returns 404 (`NotFoundException`). + Clients retain their own credentials and settings and can make concurrent requests. The default request timeout is 30 seconds and connection timeout is 5 seconds. Use `setResponseTimeoutMs(10000)` or `setUserAgent("my-app/1.0")` @@ -91,7 +98,7 @@ restore the thread's interrupt flag. - Upgrade the runtime from Java 8 to Java 17 or newer. - All 25 existing method names, constructors, map arguments, and JSON response - helpers remain available. Five playlist write methods are added below. + helpers remain available. Six playlist write methods are added below. - Path values are now encoded and caller maps are no longer modified. - All non-2xx responses throw, including 403 and redirects; inspect `exception.getResponse()` for server error details instead of matching messages. @@ -171,6 +178,7 @@ separate from creating the draft. Do not store credentials or signing keys in Gi - [`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}` @@ -966,6 +974,36 @@ public class Example { [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. + +```java +import java.util.HashMap; +import java.util.Map; +import com.listennotes.podcast_api.Client; +import com.listennotes.podcast_api.exception.ListenApiException; + +public class Example { + public static void main(String[] args) throws ListenApiException { + Client client = new Client(System.getenv("LISTEN_API_KEY")); + Map parameters = new HashMap<>(); + parameters.put("id", "m1pe7z60bsw"); + System.out.println(client.deletePlaylist(parameters).toJSON().toString(2)); + } +} +``` + +[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id) + ### addPlaylistItem Add an episode or podcast to a playlist. diff --git a/gradle.properties b/gradle.properties index eec92fd..af6da5f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP_ID=com.listennotes -VERSION_NAME=3.0.0 +VERSION_NAME=3.1.0 POM_URL=https://github.com/ListenNotes/podcast-api-java POM_SCM_URL=https://github.com/ListenNotes/podcast-api-java diff --git a/src/main/java/com/listennotes/podcast_api/ApiMethods.java b/src/main/java/com/listennotes/podcast_api/ApiMethods.java index 634beb5..1ebd3cc 100644 --- a/src/main/java/com/listennotes/podcast_api/ApiMethods.java +++ b/src/main/java/com/listennotes/podcast_api/ApiMethods.java @@ -294,6 +294,16 @@ public ApiResponse updatePlaylist(Map parameters) throws ListenA new String[] {"id"}, new String[] {}, parameters); } + /** Delete a playlist. + * @param parameters path, query, and form values + * @return API response, including response headers + * @throws ListenApiException on invalid input, connection failure, or a non-2xx response + */ + public ApiResponse deletePlaylist(Map parameters) throws ListenApiException { + return requestApi("DELETE", "/playlists/{id}", + new String[] {"id"}, new String[] {}, parameters); + } + /** Add an episode or podcast to a playlist. * @param parameters path, query, and form values * @return API response, including response headers diff --git a/src/test/java/com/listennotes/podcast_api/ClientTest.java b/src/test/java/com/listennotes/podcast_api/ClientTest.java index 47caa9e..fe45441 100644 --- a/src/test/java/com/listennotes/podcast_api/ClientTest.java +++ b/src/test/java/com/listennotes/podcast_api/ClientTest.java @@ -22,7 +22,7 @@ class ClientTest { @TestFactory Stream everyGeneratedMethodMatchesContract() throws Exception { var operations = TestSupport.operations(); - assertEquals(30, operations.size()); + assertEquals(31, operations.size()); return operations.stream().map(operation -> DynamicTest.dynamicTest(operation.getString("func"), () -> { try (var server = new TestSupport()) { Map parameters = new HashMap<>(); @@ -83,6 +83,43 @@ class ClientTest { } } + @Test void deletePlaylistEncodesIdAndPreservesResponsesWithoutRetries() throws Exception { + try (var server = new TestSupport()) { + Client client = new Client("test-key", server.baseUrl()); + Map parameters = Map.of("id", "list/+ ?#é"); + String body = new JSONObject().put("id", parameters.get("id")).put("deleted", true).toString(); + server.handler = request -> new TestSupport.Reply(200, body, Map.of("X-ListenAPI-Usage", "12")); + ApiResponse response = client.deletePlaylist(parameters); + assertEquals(200, response.getStatusCode()); + assertEquals(body, response.toString()); + assertEquals(parameters.get("id"), response.toJSON().getString("id")); + assertTrue(response.toJSON().getBoolean("deleted")); + assertEquals(12, response.getUsage()); + var request = server.take(); + assertEquals("DELETE", request.method()); + assertEquals("/api/v2/playlists/list%2F%2B%20%3F%23%C3%A9", request.uri().getRawPath()); + assertNull(request.uri().getRawQuery()); + assertEquals("", request.body()); + assertNull(request.headers().get("Content-Type")); + assertEquals(List.of("test-key"), request.headers().get("X-ListenAPI-Key")); + assertEquals(Map.of("id", "list/+ ?#é"), parameters); + + Map> errors = Map.of( + 401, AuthenticationException.class, 403, PermissionDeniedException.class, + 404, NotFoundException.class, 429, RateLimitException.class, 500, ListenApiException.class); + for (var entry : errors.entrySet()) { + server.handler = ignored -> new TestSupport.Reply(entry.getKey(), "{\"error\":\"Cannot delete playlist\"}", + Map.of("X-ListenAPI-Usage", "13")); + ListenApiException error = assertThrows(entry.getValue(), () -> client.deletePlaylist(parameters)); + assertEquals(entry.getKey(), error.getStatusCode()); + assertEquals("Cannot delete playlist", error.getResponse().toJSON().getString("error")); + assertEquals(13, error.getResponse().getUsage()); + assertEquals(1, server.requests.size()); + server.take(); + } + } + } + @Test void instanceSettingsAndConcurrentCallsAreIndependent() throws Exception { try (var server = new TestSupport()) { Client first = new Client("first-key", server.baseUrl()); @@ -127,6 +164,14 @@ class ClientTest { .getMessage().contains(missing)); } assertThrows(InvalidRequestException.class, () -> client.fetchPodcastById(Map.of("id", " "))); + for (Map parameters : List.of(Map.of(), Map.of("id", ""), Map.of("id", " "))) { + assertTrue(assertThrows(InvalidRequestException.class, () -> client.deletePlaylist(parameters)) + .getMessage().contains("id")); + } + assertThrows(InvalidRequestException.class, () -> client.deletePlaylist(null)); + Map nullId = new HashMap<>(); + nullId.put("id", null); + assertThrows(InvalidRequestException.class, () -> client.deletePlaylist(nullId)); assertThrows(IllegalArgumentException.class, () -> client.setResponseTimeoutMs(0)); assertThrows(IllegalArgumentException.class, () -> client.setResponseTimeoutMs(null)); assertThrows(IllegalArgumentException.class, () -> client.setUserAgent("bad\r\nvalue")); @@ -244,7 +289,7 @@ void legacyConnectionAndResponseConstructorsRemainUsable() throws Exception { Files.writeString(file, matcher.group(1).replace("public class Example", "public class " + name)); arguments.add(file.toString()); } - assertEquals(31, count); + assertEquals(32, count); assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, arguments.toArray(String[]::new))); } } diff --git a/src/test/java/com/listennotes/podcast_api/MockIntegrationTest.java b/src/test/java/com/listennotes/podcast_api/MockIntegrationTest.java index 23ab27a..4973dab 100644 --- a/src/test/java/com/listennotes/podcast_api/MockIntegrationTest.java +++ b/src/test/java/com/listennotes/podcast_api/MockIntegrationTest.java @@ -15,6 +15,11 @@ class MockIntegrationTest { ApiResponse response = TestSupport.call(client, operation, TestSupport.examples(operation)); assertTrue(List.of(200, 201).contains(response.getStatusCode())); assertFalse(response.toJSON().isEmpty()); + if (operation.getString("operationId").equals("deletePlaylist")) { + assertEquals(200, response.getStatusCode()); + assertTrue(response.toJSON().getBoolean("deleted")); + assertEquals(operation.getJSONObject("example_params").getString("id"), response.toJSON().getString("id")); + } })); } @Test void unicodeQuery() throws Exception { diff --git a/src/test/resources/api-contract.json b/src/test/resources/api-contract.json index 204d99a..2cf6377 100644 --- a/src/test/resources/api-contract.json +++ b/src/test/resources/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",