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 -[![Scala Run Sample App](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml/badge.svg)](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml) [![Maven metadata URL](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Frepo1.maven.org%2Fmaven2%2Fcom%2Flistennotes%2Fpodcast-api%2Fmaven-metadata.xml)](https://search.maven.org/artifact/com.listennotes/podcast-api) +[![Scala CI](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml/badge.svg)](https://github.com/ListenNotes/podcast-api-scala/actions/workflows/run-sample-app.yml) [![Maven metadata URL](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Frepo1.maven.org%2Fmaven2%2Fcom%2Flistennotes%2Fpodcast-api%2Fmaven-metadata.xml)](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) - +Powered by Listen Notes ## 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 "$@"