diff --git a/.github/workflows/run-sample-app.yml b/.github/workflows/run-sample-app.yml
index 009a78d..24af3a0 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 4cef6bd..132bafb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Podcast API Scala Library
-[](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml) [](https://search.maven.org/artifact/com.listennotes/podcast-api)
+[](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml) [](https://central.sonatype.com/artifact/com.listennotes/podcast-api)
The Podcast API Scala library provides convenient access to the [Listen Notes Podcast API](https://www.listennotes.com/api/) from
applications written in the Scala language.
@@ -10,9 +10,9 @@ Simple and no-nonsense podcast search, directory, and insights API. Search the m
This repo is actually a demo app using [the podcast-api Java library](https://github.com/ListenNotes/podcast-api-java).
You can find example Scala 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+Scala+SDK+of+Listen+API)
+If you have any questions, please contact [hello@listennotes.com](mailto:hello@listennotes.com?subject=Questions+about+the+Scala+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 Scala examples use Java SDK 3.1.0, available from
+Maven Central. This repository does not publish a separate Scala 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
```
@@ -125,6 +125,13 @@ object App {
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(java.util.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. 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
@@ -161,29 +168,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 scala`. Do not hand-edit generated outputs.
@@ -897,6 +906,33 @@ object 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.
+
+```scala
+import com.listennotes.podcast_api.Client
+
+object Example {
+ def main(args: Array[String]): Unit = {
+ val client = new Client(scala.util.Properties.envOrElse("LISTEN_API_KEY", ""))
+ val parameters = new java.util.HashMap[String, String]()
+ 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 64d7a65..67fb6a5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -35,7 +35,7 @@ tasks.register('compileReadmeSources') {
delete(output)
output.mkdirs()
def snippets = (rootProject.file('README.md').text =~ /(?ms)^```scala\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}.scala").text = "package readme.example${index}\n\n" + match[1]
}
diff --git a/app/src/main/scala/podcast/api/scala/demo/GeneratedExamples.scala b/app/src/main/scala/podcast/api/scala/demo/GeneratedExamples.scala
index e1119f3..b7c153e 100644
--- a/app/src/main/scala/podcast/api/scala/demo/GeneratedExamples.scala
+++ b/app/src/main/scala/podcast/api/scala/demo/GeneratedExamples.scala
@@ -210,6 +210,12 @@ object GeneratedExamples {
client.updatePlaylist(parameters)
}
+ def deletePlaylist(client: Client): ApiResponse = {
+ val parameters = new java.util.HashMap[String, String]()
+ parameters.put("id", "m1pe7z60bsw")
+ client.deletePlaylist(parameters)
+ }
+
def addPlaylistItem(client: Client): ApiResponse = {
val parameters = new java.util.HashMap[String, String]()
parameters.put("id", "m1pe7z60bsw")
@@ -262,6 +268,7 @@ object GeneratedExamples {
case "getPodcastsByDomainName" => client.fetchPodcastsByDomain(parameters)
case "createPlaylist" => client.createPlaylist(parameters)
case "updatePlaylist" => client.updatePlaylist(parameters)
+ case "deletePlaylist" => client.deletePlaylist(parameters)
case "addPlaylistItem" => client.addPlaylistItem(parameters)
case "deletePlaylistItem" => client.deletePlaylistItem(parameters)
case "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/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/app/src/test/scala/podcast/api/scala/demo/ExamplesTest.scala b/app/src/test/scala/podcast/api/scala/demo/ExamplesTest.scala
index 2037d24..f4fe8b7 100644
--- a/app/src/test/scala/podcast/api/scala/demo/ExamplesTest.scala
+++ b/app/src/test/scala/podcast/api/scala/demo/ExamplesTest.scala
@@ -1,7 +1,7 @@
package podcast.api.scala.demo
import com.listennotes.podcast_api.Client
-import com.listennotes.podcast_api.exception.PermissionDeniedException
+import com.listennotes.podcast_api.exception.{InvalidRequestException, NotFoundException, PermissionDeniedException}
import org.junit.jupiter.api._
import org.junit.jupiter.api.Assertions._
import podcast.api.testing.Support
@@ -9,25 +9,63 @@ import scala.jdk.CollectionConverters._
class ExamplesTest {
@TestFactory
- def allMethods(): java.util.List[DynamicTest] = Support.operations().asScala.map { op =>
- DynamicTest.dynamicTest(op.getString("func"), () => {
- val server = new Support()
- try {
- val client = new Client("scala-test", server.baseUrl())
- val parameters = Support.parameters(op)
- val before = new java.util.HashMap[String, String](parameters)
- val response = GeneratedExamples.call(client, op.getString("operationId"), parameters)
- assertTrue(response.toJSON().getBoolean("ok"))
- assertEquals(200, response.getStatusCode())
- assertEquals(12, response.getUsage().intValue())
- assertEquals(300, response.getFreeQuota().intValue())
- val request = server.take()
- assertEquals("scala-test", request.key())
- Support.verify(op, parameters, request)
- assertEquals(before, parameters)
- } finally server.close()
- })
- }.asJava
+ def allMethods(): java.util.List[DynamicTest] = {
+ val operations = Support.operations().asScala
+ assertEquals(31, operations.size)
+ operations.map { op =>
+ DynamicTest.dynamicTest(op.getString("func"), () => {
+ val server = new Support()
+ try {
+ val client = new Client("scala-test", server.baseUrl())
+ val parameters = Support.parameters(op)
+ val before = new java.util.HashMap[String, String](parameters)
+ val response = GeneratedExamples.call(client, op.getString("operationId"), parameters)
+ assertTrue(response.toJSON().getBoolean("ok"))
+ assertEquals(200, response.getStatusCode())
+ assertEquals(12, response.getUsage().intValue())
+ assertEquals(300, response.getFreeQuota().intValue())
+ val request = server.take()
+ assertEquals("scala-test", request.key())
+ Support.verify(op, parameters, request)
+ assertEquals(before, parameters)
+ } finally server.close()
+ })
+ }.asJava
+ }
+
+ @Test
+ def deletePlaylistResponseEncodingAndErrors(): Unit = {
+ val server = new Support()
+ try {
+ val client = new Client("scala-test", server.baseUrl())
+ val parameters = Map("id" -> "list/+ ?#é").asJava
+ server.responseBody = """{"id":"list/+ ?#é","deleted":true}"""
+ val response = GeneratedExamples.call(client, "deletePlaylist", parameters)
+ assertEquals(200, response.getStatusCode())
+ assertTrue(response.toJSON().getBoolean("deleted"))
+ assertEquals(parameters.get("id"), response.toJSON().getString("id"))
+ assertEquals(12, response.getUsage().intValue())
+ val 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())
+ assertEquals("scala-test", request.key())
+ assertEquals(Map("id" -> "list/+ ?#é").asJava, parameters)
+ for (invalid <- Seq(Map.empty[String, String], Map("id" -> ""), Map("id" -> " "))) {
+ assertThrows(classOf[InvalidRequestException], () => GeneratedExamples.call(client, "deletePlaylist", invalid.asJava))
+ }
+ server.status = 404
+ server.responseBody = """{"error":"Playlist not found"}"""
+ val error = assertThrows(classOf[NotFoundException], () => GeneratedExamples.deletePlaylist(client))
+ assertEquals(404, error.getStatusCode())
+ assertEquals("Playlist not found", error.getResponse().toJSON().getString("error"))
+ assertEquals(12, error.getResponse().getUsage().intValue())
+ val exampleRequest = server.take()
+ assertEquals("DELETE", exampleRequest.method())
+ assertEquals("/api/v2/playlists/m1pe7z60bsw", exampleRequest.uri().getRawPath())
+ } finally server.close()
+ }
@Test
def nestedPathsEmptyFieldsAndClientIsolation(): Unit = {
@@ -64,6 +102,11 @@ class MockIntegrationTest {
val response = GeneratedExamples.call(new Client(), op.getString("operationId"), Support.parameters(op))
assertTrue(Set(200, 201).contains(response.getStatusCode()))
assertFalse(response.toJSON().isEmpty())
+ if (op.getString("operationId") == "deletePlaylist") {
+ assertEquals(200, response.getStatusCode())
+ assertTrue(response.toJSON().getBoolean("deleted"))
+ assertEquals(Support.parameters(op).get("id"), response.toJSON().getString("id"))
+ }
})
}.asJava
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 "$@"