diff --git a/Cargo.lock b/Cargo.lock index 477ac0e..77c6116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -594,7 +594,7 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "podcast-api" -version = "3.0.0" +version = "3.1.0" dependencies = [ "reqwest", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a06f504..7803508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "podcast-api" -version = "3.0.0" +version = "3.1.0" authors = ["Listen Notes, Inc. "] edition = "2024" rust-version = "1.88" diff --git a/README.md b/README.md index a39e5e7..b347f2c 100644 --- a/README.md +++ b/README.md @@ -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"] } ``` @@ -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}` @@ -774,6 +775,32 @@ async fn main() -> Result<(), Box> { [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> { + 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. diff --git a/src/api-contract.json b/src/api-contract.json index 6d812f8..6fbfffe 100644 --- a/src/api-contract.json +++ b/src/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": "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", diff --git a/src/api_methods.rs b/src/api_methods.rs index 2081a62..08020f5 100644 --- a/src/api_methods.rs +++ b/src/api_methods.rs @@ -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 { + 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 { diff --git a/tests/client_tests.rs b/tests/client_tests.rs index 905ad6c..c032d35 100644 --- a/tests/client_tests.rs +++ b/tests/client_tests.rs @@ -127,7 +127,7 @@ fn pairs(value: &str) -> BTreeMap { 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() @@ -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(), @@ -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::>(), - ["PUT", "GET", "DELETE", "GET"] + ["PUT", "GET", "DELETE", "DELETE", "GET"] ); assert_eq!( requests .iter() .map(|r| r.headers["x-listenapi-key"].as_str()) .collect::>(), - ["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())); @@ -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(), ) @@ -269,22 +294,27 @@ 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] @@ -292,12 +322,20 @@ 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(¶ms).await, Err(Error::InvalidParameter(_)))); + assert!(matches!( + client.delete_playlist("list", ¶ms).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/", @@ -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(_))); } diff --git a/tests/mock_integration.rs b/tests/mock_integration.rs index 2a3adbb..86de5f7 100644 --- a/tests/mock_integration.rs +++ b/tests/mock_integration.rs @@ -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 { @@ -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; diff --git a/tests/support/methods.rs b/tests/support/methods.rs index 34ee457..f95c8b9 100644 --- a/tests/support/methods.rs +++ b/tests/support/methods.rs @@ -32,6 +32,7 @@ pub async fn call(client: &Client<'_>, operation: &str, params: &Value) -> Resul "getPodcastsByDomainName" => client.fetch_podcasts_by_domain(&scalar(¶ms["domain_name"]), params).await, "createPlaylist" => client.create_playlist(params).await, "updatePlaylist" => client.update_playlist(&scalar(¶ms["id"]), params).await, + "deletePlaylist" => client.delete_playlist(&scalar(¶ms["id"]), params).await, "addPlaylistItem" => client.add_playlist_item(&scalar(¶ms["id"]), params).await, "deletePlaylistItem" => client.delete_playlist_item(&scalar(¶ms["id"]), &scalar(¶ms["item_id"]), params).await, "updatePlaylistItemNotes" => client.update_playlist_item_notes(&scalar(¶ms["id"]), &scalar(¶ms["item_id"]), params).await,