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",