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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "podcast-api"
version = "3.0.0"
version = "3.1.0"
authors = ["Listen Notes, Inc. <hello@listennotes.com>"]
edition = "2024"
rust-version = "1.88"
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ and publishing; no separate package manager is needed.

```toml
[dependencies]
podcast-api = "3.0.0"
podcast-api = "3.1.0"
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
Expand Down Expand Up @@ -130,6 +130,7 @@ crates.io and GitHub release creation are separate review steps.
- [`fetch_podcasts_by_domain`](#fetch_podcasts_by_domain) — `GET /podcasts/domains/{domain_name}`
- [`create_playlist`](#create_playlist) — `POST /playlists`
- [`update_playlist`](#update_playlist) — `PUT /playlists/{id}`
- [`delete_playlist`](#delete_playlist) — `DELETE /playlists/{id}`
- [`add_playlist_item`](#add_playlist_item) — `POST /playlists/{id}/items`
- [`delete_playlist_item`](#delete_playlist_item) — `DELETE /playlists/{id}/items/{item_id}`
- [`update_playlist_item_notes`](#update_playlist_item_notes) — `PUT /playlists/{id}/items/{item_id}`
Expand Down Expand Up @@ -774,6 +775,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

[Full API documentation](https://www.listennotes.com/api/docs/#put-api-v2-playlists-id)

### delete_playlist

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.

```rust,no_run
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = podcast_api::Client::new(None);
let response = client.delete_playlist("m1pe7z60bsw", &json!({})).await?;
println!("{}", response.json().await?);
Ok(())
}
```

[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id)

### add_playlist_item

Add an episode or podcast to a playlist.
Expand Down
21 changes: 20 additions & 1 deletion src/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": "delete_playlist",
"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": "add_playlist_item",
Expand Down
7 changes: 7 additions & 0 deletions src/api_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,13 @@ impl Client<'_> {
&[("id", id)], &[], parameters).await
}

/// Delete a playlist.
/// See [full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id).
pub async fn delete_playlist(&self, id: &str, parameters: &Value) -> Result<Response> {
self.request_api(Method::DELETE, "/playlists/{id}",
&[("id", id)], &[], parameters).await
}

/// Add an episode or podcast to a playlist.
/// See [full API documentation](https://www.listennotes.com/api/docs/#post-api-v2-playlists-id-items).
pub async fn add_playlist_item(&self, id: &str, parameters: &Value) -> Result<Response> {
Expand Down
78 changes: 59 additions & 19 deletions tests/client_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ fn pairs(value: &str) -> BTreeMap<String, String> {
async fn every_generated_method_obeys_the_contract() {
let contract = support::contract();
let operations = contract["operations"].as_array().unwrap();
assert_eq!(operations.len(), 30);
assert_eq!(operations.len(), 31);
let fixture = Fixture::new(
operations
.iter()
Expand Down Expand Up @@ -220,9 +220,32 @@ async fn encodes_nested_paths_and_preserves_empty_and_scalar_values() {
assert!(!pairs(query).contains_key("skip"));
}

#[tokio::test]
async fn delete_playlist_encodes_id_without_query_or_body() {
let id = "a/b?#%é";
let payload = json!({"id": id, "deleted": true});
let fixture = Fixture::new(vec![(200, "X-ListenAPI-Usage: 12\r\n".into(), payload.to_string())]).await;
let response = fixture
.client(None)
.delete_playlist(id, &json!({"id": "must-not-leak", "skip": null}))
.await
.unwrap();
assert_eq!(response.response.status().as_u16(), 200);
assert_eq!(response.response.headers()["x-listenapi-usage"], "12");
assert_eq!(response.request.method(), reqwest::Method::DELETE);
assert_eq!(response.request.url().query(), None);
assert!(response.request.body().is_none());
assert_eq!(response.json().await.unwrap(), payload);
let requests = fixture.finish().await;
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].target, "/api/v2/playlists/a%2Fb%3F%23%25%C3%A9");
assert!(requests[0].body.is_empty());
assert!(!requests[0].headers.contains_key("content-type"));
}

#[tokio::test]
async fn client_keys_user_agents_and_request_methods_are_isolated() {
let fixture = Fixture::new(vec![reply(200), reply(200), reply(200), reply(200)]).await;
let fixture = Fixture::new(vec![reply(200), reply(200), reply(200), reply(200), reply(200)]).await;
let first = fixture.client(Some("first"));
let second = Client::new_custom(
Client::http_client_builder().no_proxy().build().unwrap(),
Expand All @@ -234,18 +257,19 @@ async fn client_keys_user_agents_and_request_methods_are_isolated() {
first.update_playlist("list", &json!({"description":""})).await.unwrap();
second.fetch_my_playlists(&json!({})).await.unwrap();
first.delete_playlist_item("list", "7", &json!({})).await.unwrap();
first.delete_playlist("list", &json!({})).await.unwrap();
first.search(&json!({"q":"hello"})).await.unwrap();
let requests = fixture.finish().await;
assert_eq!(
requests.iter().map(|r| r.method.as_str()).collect::<Vec<_>>(),
["PUT", "GET", "DELETE", "GET"]
["PUT", "GET", "DELETE", "DELETE", "GET"]
);
assert_eq!(
requests
.iter()
.map(|r| r.headers["x-listenapi-key"].as_str())
.collect::<Vec<_>>(),
["first", "second", "first", "first"]
["first", "second", "first", "first", "first"]
);
assert_eq!(requests[1].headers["user-agent"], "custom-agent");
assert!(requests[1..].iter().all(|r| r.body.is_empty()));
Expand All @@ -257,9 +281,10 @@ async fn errors_preserve_response_details_and_are_not_retried_or_redirected() {
let fixture = Fixture::new(
statuses
.iter()
.flat_map(|status| [*status; 2])
.map(|status| {
(
*status,
status,
"Location: http://127.0.0.1:1/never\r\nX-ListenAPI-Usage: 13\r\n".into(),
"{\"error\":\"precise reason\"}".into(),
)
Expand All @@ -269,35 +294,48 @@ async fn errors_preserve_response_details_and_are_not_retried_or_redirected() {
.await;
let client = fixture.client(None);
for status in statuses {
let error = client.create_playlist(&json!({"name":"test"})).await.unwrap_err();
let context = error.api_error().unwrap();
assert_eq!(context.status.as_u16(), status);
assert_eq!(context.headers["x-listenapi-usage"], "13");
assert!(context.body.contains("precise reason"));
assert!(error.to_string().contains("precise reason"));
match status {
400 => assert!(matches!(error, Error::InvalidRequestError(_))),
401 => assert!(matches!(error, Error::AuthenticationError(_))),
403 => assert!(matches!(error, Error::PermissionDeniedError(_))),
404 => assert!(matches!(error, Error::NotFoundError(_))),
429 => assert!(matches!(error, Error::RateLimitError(_))),
_ => assert!(matches!(error, Error::ListenApiError(_))),
for method in ["create_playlist", "delete_playlist"] {
let error = match method {
"create_playlist" => client.create_playlist(&json!({"name":"test"})).await.unwrap_err(),
_ => client.delete_playlist("list", &json!({})).await.unwrap_err(),
};
let context = error.api_error().unwrap();
assert_eq!(context.status.as_u16(), status);
assert_eq!(context.headers["x-listenapi-usage"], "13");
assert!(context.body.contains("precise reason"));
assert!(error.to_string().contains("precise reason"));
match status {
400 => assert!(matches!(error, Error::InvalidRequestError(_))),
401 => assert!(matches!(error, Error::AuthenticationError(_))),
403 => assert!(matches!(error, Error::PermissionDeniedError(_))),
404 => assert!(matches!(error, Error::NotFoundError(_))),
429 => assert!(matches!(error, Error::RateLimitError(_))),
_ => assert!(matches!(error, Error::ListenApiError(_))),
}
}
}
assert_eq!(fixture.finish().await.len(), statuses.len());
assert_eq!(fixture.finish().await.len(), statuses.len() * 2);
}

#[tokio::test]
async fn local_validation_never_sends_invalid_requests() {
let client = Client::new(None).with_base_url("http://127.0.0.1:1/api/v2").unwrap();
for params in [Value::Null, json!([]), json!("not an object")] {
assert!(matches!(client.search(&params).await, Err(Error::InvalidParameter(_))));
assert!(matches!(
client.delete_playlist("list", &params).await,
Err(Error::InvalidParameter(_))
));
}
for id in ["", ".", ".."] {
assert!(matches!(
client.fetch_podcast_by_id(id, &json!({})).await,
Err(Error::InvalidParameter(_))
));
assert!(matches!(
client.delete_playlist(id, &json!({})).await,
Err(Error::InvalidParameter(_))
));
}
for base in [
"ftp://host/",
Expand Down Expand Up @@ -358,4 +396,6 @@ async fn custom_timeouts_remain_effective() {
.unwrap();
let error = client.search(&json!({})).await.unwrap_err();
assert!(matches!(error, Error::ApiConnectionError(_)));
let error = client.delete_playlist("list", &json!({})).await.unwrap_err();
assert!(matches!(error, Error::ApiConnectionError(_)));
}
9 changes: 9 additions & 0 deletions tests/mock_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ async fn all_methods_against_the_public_mock() {
);
assert!(!response.request.headers().contains_key("x-listenapi-key"));
assert_eq!(response.request.method().as_str(), op["method"]);
if name == "deletePlaylist" {
assert_eq!(response.request.url().path(), "/api/v2/playlists/m1pe7z60bsw");
assert_eq!(response.request.url().query(), None);
assert!(response.request.body().is_none());
}
let status = if matches!(name, "createPlaylist" | "addPlaylistItem") {
201
} else {
Expand Down Expand Up @@ -68,6 +73,10 @@ async fn all_methods_against_the_public_mock() {
assert_eq!(body["deleted"], true);
assert!(body["id"].is_u64());
}
"deletePlaylist" => {
assert_eq!(body["deleted"], true);
assert_eq!(body["id"], op["example_params"]["id"]);
}
_ => {}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
Expand Down
1 change: 1 addition & 0 deletions tests/support/methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub async fn call(client: &Client<'_>, operation: &str, params: &Value) -> Resul
"getPodcastsByDomainName" => client.fetch_podcasts_by_domain(&scalar(&params["domain_name"]), params).await,
"createPlaylist" => client.create_playlist(params).await,
"updatePlaylist" => client.update_playlist(&scalar(&params["id"]), params).await,
"deletePlaylist" => client.delete_playlist(&scalar(&params["id"]), params).await,
"addPlaylistItem" => client.add_playlist_item(&scalar(&params["id"]), params).await,
"deletePlaylistItem" => client.delete_playlist_item(&scalar(&params["id"]), &scalar(&params["item_id"]), params).await,
"updatePlaylistItemNotes" => client.update_playlist_item_notes(&scalar(&params["id"]), &scalar(&params["item_id"]), params).await,
Expand Down
Loading