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 -[![Kotlin Run Sample App](https://github.com/ListenNotes/podcast-api-kotlin/actions/workflows/run-sample-app.yml/badge.svg)](https://github.com/ListenNotes/podcast-api-kotlin/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) +[![Kotlin CI](https://github.com/ListenNotes/podcast-api-kotlin/actions/workflows/run-sample-app.yml/badge.svg)](https://github.com/ListenNotes/podcast-api-kotlin/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 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) - +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 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 "$@"