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
48 changes: 43 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,11 +25,11 @@ Maven:
<dependency>
<groupId>com.listennotes</groupId>
<artifactId>podcast-api</artifactId>
<version>3.0.0</version>
<version>3.1.0</version>
</dependency>
```

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

Expand Down Expand Up @@ -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")`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -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<String, String> 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.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/listennotes/podcast_api/ApiMethods.java
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,16 @@ public ApiResponse updatePlaylist(Map<String, String> 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<String, String> 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
Expand Down
49 changes: 47 additions & 2 deletions src/test/java/com/listennotes/podcast_api/ClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
class ClientTest {
@TestFactory Stream<DynamicTest> 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<String, String> parameters = new HashMap<>();
Expand Down Expand Up @@ -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<String, String> 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<Integer, Class<? extends ListenApiException>> 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());
Expand Down Expand Up @@ -127,6 +164,14 @@ class ClientTest {
.getMessage().contains(missing));
}
assertThrows(InvalidRequestException.class, () -> client.fetchPodcastById(Map.of("id", " ")));
for (Map<String, String> parameters : List.of(Map.<String, String>of(), Map.of("id", ""), Map.of("id", " "))) {
assertTrue(assertThrows(InvalidRequestException.class, () -> client.deletePlaylist(parameters))
.getMessage().contains("id"));
}
assertThrows(InvalidRequestException.class, () -> client.deletePlaylist(null));
Map<String, String> 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"));
Expand Down Expand Up @@ -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)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 20 additions & 1 deletion src/test/resources/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