diff --git a/.github/workflows/run-sample-app.yml b/.github/workflows/run-sample-app.yml
index 9fa26e1..d611e38 100644
--- a/.github/workflows/run-sample-app.yml
+++ b/.github/workflows/run-sample-app.yml
@@ -5,11 +5,6 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
- inputs:
- maven:
- description: Also verify the published 3.0.0 artifact from Maven Central
- type: boolean
- default: false
permissions:
contents: read
jobs:
@@ -21,43 +16,20 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6.1.0
- - uses: actions/checkout@v6.1.0
- with:
- repository: ListenNotes/podcast-api-java
- ref: b697b4026f0123820459427b3579b737b592d303
- path: .ci-java-sdk
- uses: actions/setup-java@v6.0.0
with:
distribution: temurin
java-version: ${{ matrix.java }}
- uses: gradle/actions/setup-gradle@v6.2.0
- - run: ./gradlew --no-daemon --include-build .ci-java-sdk check
- - run: PODCAST_API_JAVA_SOURCE=.ci-java-sdk bash scripts/verify-package.sh
+ - run: ./gradlew --no-daemon check installDist
+ - run: bash scripts/verify-package.sh
public-mock:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6.1.0
- - uses: actions/checkout@v6.1.0
- with:
- repository: ListenNotes/podcast-api-java
- ref: b697b4026f0123820459427b3579b737b592d303
- path: .ci-java-sdk
- uses: actions/setup-java@v6.0.0
with:
distribution: temurin
java-version: '25'
- uses: gradle/actions/setup-gradle@v6.2.0
- - run: ./gradlew --no-daemon --include-build .ci-java-sdk integrationTest
- maven:
- if: ${{ github.event_name == 'workflow_dispatch' && inputs.maven }}
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v6.1.0
- - uses: actions/setup-java@v6.0.0
- with:
- distribution: temurin
- java-version: '17'
- - uses: gradle/actions/setup-gradle@v6.2.0
- with:
- cache-disabled: true
- - run: ./gradlew --no-daemon check
+ - run: ./gradlew --no-daemon integrationTest
diff --git a/README.md b/README.md
index 48d11b7..5467416 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Podcast API Kotlin Library
-[](https://github.com/ListenNotes/podcast-api-kotlin/actions/workflows/run-sample-app.yml) [](https://search.maven.org/artifact/com.listennotes/podcast-api)
+[](https://github.com/ListenNotes/podcast-api-kotlin/actions/workflows/run-sample-app.yml) [](https://central.sonatype.com/artifact/com.listennotes/podcast-api)
The Podcast API Kotlin library provides convenient access to the [Listen Notes Podcast API](https://www.listennotes.com/api/) from
applications written in the Kotlin language.
@@ -10,9 +10,9 @@ Simple and no-nonsense podcast search & directory API. Search the meta data of a
This repo is actually a demo app using [the podcast-api Java library](https://github.com/ListenNotes/podcast-api-java).
You can find example Kotlin code in the README.md of this repo.
-If you have any questions, please contact [hello@listennotes.com](hello@listennotes.com?subject=Questions+about+the+Kotlin+SDK+of+Listen+API)
+If you have any questions, please contact [hello@listennotes.com](mailto:hello@listennotes.com?subject=Questions+about+the+Kotlin+SDK+of+Listen+API)
-
+
## Method index
@@ -46,6 +46,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}`
@@ -54,9 +55,8 @@ If you have any questions, please contact [hello@listennotes.com](hello@listenno
## Installation
-Requires Java 17+. The prepared examples target Java SDK 3.0.0. While that version
-is propagating on Maven Central, use the explicit source-build workflow below.
-These repositories do not publish separate Kotlin or Scala Maven packages.
+Requires Java 17+. These Kotlin examples use Java SDK 3.1.0, available from
+Maven Central. This repository does not publish a separate Kotlin Maven package.
You can install this library for JVM-based languages, including Java, Kotlin, Clojure, Scala, Groovy...
@@ -66,7 +66,7 @@ You can install this library for JVM-based languages, including Java, Kotlin, Cl
Add this dependency to your project's build file:
```groovy
-implementation "com.listennotes:podcast-api:3.0.0"
+implementation "com.listennotes:podcast-api:3.1.0"
```
### Maven users
@@ -77,7 +77,7 @@ Add this dependency to your project's POM:
com.listennotes
podcast-api
- 3.0.0
+ 3.1.0
```
@@ -120,6 +120,13 @@ fun main() {
If `apiKey` is null or "", then we'll connect to a [mock server](https://help.listennotes.com/en/articles/5224500-how-to-test-the-podcast-api-without-an-api-key) that returns fake data for testing purposes.
+Since 3.1.0, `client.deletePlaylist(mapOf("id" to 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. A missing or already-deleted
+playlist returns 404 (`NotFoundException`).
+
You can quickly run sample code using gradle:
```shell
# Use api mock server for test data
@@ -155,29 +162,31 @@ And you can see some sample code [here](https://github.com/ListenNotes/podcast-a
Use the checked-in Gradle wrapper. Default tests run on a loopback HTTP server;
README examples compile without running requests. Integration tests separately
-call all 30 methods on the stateless public mock and never load an API key.
+call all 31 methods on the stateless public mock and never load an API key.
```sh
-./gradlew check
+./gradlew check installDist
./gradlew integrationTest
bash scripts/verify-package.sh
```
-Until Maven Central serves 3.0.0, check out the reviewed Java SDK release source
-and use Gradle's explicit composite build (no Maven-local installation needed):
+CI tests Java 17 and 25 against the published Maven Central package. The standalone
+check copies this repository's package files outside Git and the monorepo, then
+builds and tests them with the same Maven dependency.
+
+To develop against Java SDK source, explicitly opt into Gradle's composite build:
```sh
git clone https://github.com/ListenNotes/podcast-api-java.git ../podcast-api-java
-git -C ../podcast-api-java checkout b697b4026f0123820459427b3579b737b592d303
+git -C ../podcast-api-java checkout v3.1.0
./gradlew --include-build ../podcast-api-java check
./gradlew --include-build ../podcast-api-java integrationTest
PODCAST_API_JAVA_SOURCE=../podcast-api-java bash scripts/verify-package.sh
```
-CI uses that pinned source while publication is pending. Its manual `maven` option
-also verifies a fresh Maven Central consumer. Remove the source override from
-routine CI only after the artifact is publicly resolvable. Java SDK 3 adds playlist
-writes and requires Java 17; map arguments and response/quota helpers are unchanged.
+The source override is optional; ordinary builds resolve Maven Central directly.
+Java SDK 3 adds playlist writes and requires Java 17; map arguments and
+response/quota helpers are unchanged.
The monorepo generates the marked README sections, `GeneratedExamples`, and the
contract snapshot with `sync.py kotlin`. Do not hand-edit generated outputs.
@@ -837,6 +846,31 @@ fun main() {
[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.
+
+```kotlin
+import com.listennotes.podcast_api.Client
+
+fun main() {
+ val client = Client(System.getenv("LISTEN_API_KEY") ?: "")
+ val parameters = HashMap()
+ parameters.put("id", "m1pe7z60bsw")
+ 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/app/build.gradle b/app/build.gradle
index 7ead08e..60a7d35 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -33,7 +33,7 @@ tasks.register('compileReadmeSources') {
delete(output)
output.mkdirs()
def snippets = (rootProject.file('README.md').text =~ /(?ms)^```kotlin\n(.*?)^```/)
- if (snippets.size() != 31) throw new GradleException('Expected usage plus 30 generated README examples')
+ if (snippets.size() != 32) throw new GradleException('Expected usage plus 31 generated README examples')
snippets.eachWithIndex { match, index ->
new File(output, "Example${index}.kt").text = "package readme.example${index}\n\n" + match[1]
}
diff --git a/app/src/main/kotlin/podcast/api/kotlin/demo/GeneratedExamples.kt b/app/src/main/kotlin/podcast/api/kotlin/demo/GeneratedExamples.kt
index 1ecaa89..78a93cc 100644
--- a/app/src/main/kotlin/podcast/api/kotlin/demo/GeneratedExamples.kt
+++ b/app/src/main/kotlin/podcast/api/kotlin/demo/GeneratedExamples.kt
@@ -210,6 +210,12 @@ object GeneratedExamples {
return client.updatePlaylist(parameters)
}
+ fun deletePlaylist(client: Client): ApiResponse {
+ val parameters = HashMap()
+ parameters.put("id", "m1pe7z60bsw")
+ return client.deletePlaylist(parameters)
+ }
+
fun addPlaylistItem(client: Client): ApiResponse {
val parameters = HashMap()
parameters.put("id", "m1pe7z60bsw")
@@ -262,6 +268,7 @@ object GeneratedExamples {
"getPodcastsByDomainName" -> client.fetchPodcastsByDomain(parameters)
"createPlaylist" -> client.createPlaylist(parameters)
"updatePlaylist" -> client.updatePlaylist(parameters)
+ "deletePlaylist" -> client.deletePlaylist(parameters)
"addPlaylistItem" -> client.addPlaylistItem(parameters)
"deletePlaylistItem" -> client.deletePlaylistItem(parameters)
"updatePlaylistItemNotes" -> client.updatePlaylistItemNotes(parameters)
diff --git a/app/src/test/java/podcast/api/testing/Support.java b/app/src/test/java/podcast/api/testing/Support.java
index 96767d4..c099775 100644
--- a/app/src/test/java/podcast/api/testing/Support.java
+++ b/app/src/test/java/podcast/api/testing/Support.java
@@ -15,7 +15,8 @@ public final class Support implements AutoCloseable {
public record Request(String method, URI uri, String key, String body) { }
private final HttpServer server;
private final BlockingQueue requests = new LinkedBlockingQueue<>();
- public int status = 200;
+ public volatile int status = 200;
+ public volatile String responseBody = "{\"ok\":true}";
public Support() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> {
@@ -25,7 +26,7 @@ public Support() throws Exception {
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)));
exchange.getResponseHeaders().set("X-ListenAPI-Usage", "12");
exchange.getResponseHeaders().set("X-ListenAPI-FreeQuota", "300");
- byte[] body = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8);
+ byte[] body = responseBody.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
exchange.getResponseBody().write(body);
}
diff --git a/app/src/test/kotlin/podcast/api/kotlin/demo/ExamplesTest.kt b/app/src/test/kotlin/podcast/api/kotlin/demo/ExamplesTest.kt
index 2b6d34c..bd4a884 100644
--- a/app/src/test/kotlin/podcast/api/kotlin/demo/ExamplesTest.kt
+++ b/app/src/test/kotlin/podcast/api/kotlin/demo/ExamplesTest.kt
@@ -1,14 +1,19 @@
package podcast.api.kotlin.demo
import com.listennotes.podcast_api.Client
+import com.listennotes.podcast_api.exception.InvalidRequestException
+import com.listennotes.podcast_api.exception.NotFoundException
import com.listennotes.podcast_api.exception.PermissionDeniedException
-import org.junit.jupiter.api.*
+import org.junit.jupiter.api.DynamicTest
+import org.junit.jupiter.api.Tag
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.TestFactory
import org.junit.jupiter.api.Assertions.*
import podcast.api.testing.Support
class ExamplesTest {
@TestFactory
- fun allMethods() = Support.operations().map { op ->
+ fun allMethods() = Support.operations().also { assertEquals(31, it.size) }.map { op ->
DynamicTest.dynamicTest(op.getString("func")) {
Support().use { server ->
val client = Client("kotlin-test", server.baseUrl())
@@ -27,6 +32,41 @@ class ExamplesTest {
}
}
+ @Test
+ fun deletePlaylistResponseEncodingAndErrors() {
+ Support().use { server ->
+ val client = Client("kotlin-test", server.baseUrl())
+ val parameters = mapOf("id" to "list/+ ?#é")
+ server.responseBody = """{"id":"list/+ ?#é","deleted":true}"""
+ val response = GeneratedExamples.call(client, "deletePlaylist", parameters)
+ assertEquals(200, response.getStatusCode())
+ assertTrue(response.toJSON().getBoolean("deleted"))
+ assertEquals(parameters["id"], response.toJSON().getString("id"))
+ assertEquals(12, response.getUsage())
+ val request = server.take()
+ assertEquals("DELETE", request.method())
+ assertEquals("/api/v2/playlists/list%2F%2B%20%3F%23%C3%A9", request.uri().rawPath)
+ assertNull(request.uri().rawQuery)
+ assertEquals("", request.body())
+ assertEquals("kotlin-test", request.key())
+ assertEquals(mapOf("id" to "list/+ ?#é"), parameters)
+ for (invalid in listOf(emptyMap(), mapOf("id" to ""), mapOf("id" to " "))) {
+ assertThrows(InvalidRequestException::class.java) {
+ GeneratedExamples.call(client, "deletePlaylist", invalid)
+ }
+ }
+ server.status = 404
+ server.responseBody = """{"error":"Playlist not found"}"""
+ val error = assertThrows(NotFoundException::class.java) { GeneratedExamples.deletePlaylist(client) }
+ assertEquals(404, error.getStatusCode())
+ assertEquals("Playlist not found", error.getResponse().toJSON().getString("error"))
+ assertEquals(12, error.getResponse().getUsage())
+ val exampleRequest = server.take()
+ assertEquals("DELETE", exampleRequest.method())
+ assertEquals("/api/v2/playlists/m1pe7z60bsw", exampleRequest.uri().rawPath)
+ }
+ }
+
@Test
fun nestedPathsEmptyFieldsAndClientIsolation() {
Support().use { first -> Support().use { second ->
@@ -61,6 +101,11 @@ class MockIntegrationTest {
val response = GeneratedExamples.call(Client(), op.getString("operationId"), Support.parameters(op))
assertTrue(response.getStatusCode() in listOf(200, 201))
assertFalse(response.toJSON().isEmpty)
+ if (op.getString("operationId") == "deletePlaylist") {
+ assertEquals(200, response.getStatusCode())
+ assertTrue(response.toJSON().getBoolean("deleted"))
+ assertEquals(Support.parameters(op)["id"], response.toJSON().getString("id"))
+ }
}
}
diff --git a/app/src/test/resources/api-contract.json b/app/src/test/resources/api-contract.json
index 204d99a..2cf6377 100644
--- a/app/src/test/resources/api-contract.json
+++ b/app/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",
diff --git a/gradle.properties b/gradle.properties
index f393947..a4049b3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,4 @@
GROUP_ID=com.listennotes
POM_ARTIFACT_ID=podcast-api
-VERSION_NAME=3.0.0
+VERSION_NAME=3.1.0
org.gradle.warning.mode=all
diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh
index bdbb395..6bf3d0b 100644
--- a/scripts/verify-package.sh
+++ b/scripts/verify-package.sh
@@ -7,6 +7,6 @@ standalone=$(mktemp -d)
trap 'rm -rf "$standalone"' EXIT
(cd "$sdk_root" && tar -cf - settings.gradle gradle.properties gradlew gradlew.bat gradle app/build.gradle app/gradle.lockfile app/src README.md LICENSE) | (cd "$standalone" && tar -xf -)
cd "$standalone"
-args=()
-if [[ -n "$java_source" ]]; then args+=(--include-build "$java_source"); fi
-./gradlew --no-daemon "${args[@]}" check installDist
+set -- check installDist
+if [[ -n "$java_source" ]]; then set -- --include-build "$java_source" "$@"; fi
+./gradlew --no-daemon "$@"