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
34 changes: 3 additions & 31 deletions .github/workflows/run-sample-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -21,43 +16,20 @@ jobs:
runs-on: ubuntu-24.04
steps:
- uses: actions/[email protected]
- uses: actions/[email protected]
with:
repository: ListenNotes/podcast-api-java
ref: b697b4026f0123820459427b3579b737b592d303
path: .ci-java-sdk
- uses: actions/[email protected]
with:
distribution: temurin
java-version: ${{ matrix.java }}
- uses: gradle/actions/[email protected]
- 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/[email protected]
- uses: actions/[email protected]
with:
repository: ListenNotes/podcast-api-java
ref: b697b4026f0123820459427b3579b737b592d303
path: .ci-java-sdk
- uses: actions/[email protected]
with:
distribution: temurin
java-version: '25'
- uses: gradle/actions/[email protected]
- 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/[email protected]
- uses: actions/[email protected]
with:
distribution: temurin
java-version: '17'
- uses: gradle/actions/[email protected]
with:
cache-disabled: true
- run: ./gradlew --no-daemon check
- run: ./gradlew --no-daemon integrationTest
70 changes: 53 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 [[email protected]]([email protected]?subject=Questions+about+the+Scala+SDK+of+Listen+API)
If you have any questions, please contact [[email protected]](mailto:[email protected]?subject=Questions+about+the+Scala+SDK+of+Listen+API)

<a href="https://www.listennotes.com/api/"><img src="https://raw.githubusercontent.com/ListenNotes/ListenApiDemo/master/web/src/powered_by_listennotes.png" width="300" />
<a href="https://www.listennotes.com/api/"><img src="https://raw.githubusercontent.com/ListenNotes/ListenApiDemo/master/web/src/powered_by_listennotes.png" width="300" alt="Powered by Listen Notes" /></a>


## Method index
Expand Down Expand Up @@ -46,6 +46,7 @@ If you have any questions, please contact [[email protected]](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}`
Expand All @@ -54,9 +55,8 @@ If you have any questions, please contact [[email protected]](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...
Expand All @@ -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
Expand All @@ -77,7 +77,7 @@ Add this dependency to your project's POM:
<dependency>
<groupId>com.listennotes</groupId>
<artifactId>podcast-api</artifactId>
<version>3.0.0</version>
<version>3.1.0</version>
</dependency>
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions app/src/test/java/podcast/api/testing/Support.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Request> 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 -> {
Expand All @@ -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);
}
Expand Down
21 changes: 20 additions & 1 deletion app/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
83 changes: 63 additions & 20 deletions app/src/test/scala/podcast/api/scala/demo/ExamplesTest.scala
Original file line number Diff line number Diff line change
@@ -1,33 +1,71 @@
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
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 = {
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Loading