From aa115e0279d25676a2731caf8d105071ba3ba915 Mon Sep 17 00:00:00 2001 From: Wenbin Fang Date: Fri, 25 Sep 2026 15:16:22 -0700 Subject: [PATCH] Add DeletePlaylist support for .NET SDK 3.1.0 --- README.md | 35 ++++++- scripts/verify-package.sh | 15 +++ src/PodcastAPI.Tests/ClientTests.cs | 100 +++++++++++++++---- src/PodcastAPI.Tests/MethodDispatch.cs | 1 + src/PodcastAPI.Tests/MockIntegrationTests.cs | 7 ++ src/PodcastAPI.Tests/api-contract.json | 21 +++- src/PodcastAPI/ApiMethods.cs | 6 ++ src/PodcastAPI/PodcastAPI.csproj | 4 +- src/SampleApp/GeneratedExamples.cs | 11 ++ 9 files changed, 176 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index fcbcda9..ffcdaf2 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ See the runnable [sample application](src/SampleApp/Sample.cs). ### Migrating from 1.x - Requires .NET 8+. The 25 existing public method names and parameter-dictionary - calling convention remain. Five playlist write methods are added. + calling convention remain. Six playlist write methods are available in 3.1.0, + including permanent playlist deletion. - RestSharp has been removed. The public `client.restClient` and `response.response` fields are removed; use constructor transport options and `response.StatusCode` / `response.Headers` instead. `ApiResponse`'s constructor @@ -91,7 +92,7 @@ See the runnable [sample application](src/SampleApp/Sample.cs). no longer loses its path identifiers. Dispose the SDK client when finished. - Every non-2xx status now throws, including 403 and redirects. SDK exceptions share `ListenApiException` as their base. Redirects are not followed by the - default transport, and the user agent is `podcast-api-dotnet 3.0.0`. + default transport, and the user agent is `podcast-api-dotnet `. ## Method index @@ -124,6 +125,7 @@ See the runnable [sample application](src/SampleApp/Sample.cs). - [`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}` @@ -838,6 +840,33 @@ Console.WriteLine(response.ToJSON()); [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. + +```csharp +using System; +using System.Collections.Generic; + +using var client = new PodcastAPI.Client(Environment.GetEnvironmentVariable("LISTEN_API_KEY")); +var parameters = new Dictionary +{ + ["id"] = "m1pe7z60bsw", +}; +var response = await client.DeletePlaylist(parameters); +Console.WriteLine(response.ToJSON()); +``` + +[Full API documentation](https://www.listennotes.com/api/docs/#delete-api-v2-playlists-id) + ### AddPlaylistItem Add an episode or podcast to a playlist. @@ -944,7 +973,7 @@ LISTEN_API_MOCK_INTEGRATION=1 dotnet test src/PodcastAPI.Tests --filter 'TestCat ``` It calls only `https://listen-api-test.listennotes.com/api/v2` with no API key, -including all five playlist writes. It never reads credentials from the environment +including all six playlist writes. It never reads credentials from the environment or permits a destination override. The mock does not prove persistence or production permissions. CI runs these checks separately from offline tests. diff --git a/scripts/verify-package.sh b/scripts/verify-package.sh index 079b9a4..b88e8da 100755 --- a/scripts/verify-package.sh +++ b/scripts/verify-package.sh @@ -53,12 +53,27 @@ var response = await client.UpdatePlaylistItemNotes(new Dictionary()!.id != 23) throw new Exception("Packaged response did not deserialize"); +var deleted = await client.DeletePlaylist(new Dictionary { ["id"] = "playlist/encoded" }); +if (deleted.StatusCode != HttpStatusCode.OK || !(bool)deleted.ToJSON()!.deleted || + (string)deleted.ToJSON()!.id != "playlist/encoded") + throw new Exception("Packaged deletion response did not deserialize"); Console.WriteLine("Packaged SDK and all README examples verified without API requests."); sealed class OfflineHandler : HttpMessageHandler { protected override async Task SendAsync(HttpRequestMessage request, CancellationToken token) { + if (request.Method == HttpMethod.Delete) + { + if (request.RequestUri!.AbsolutePath != "/api/v2/playlists/playlist%2Fencoded" || + request.RequestUri.Query != "" || request.Content is not null || + request.Headers.Contains("X-ListenAPI-Key")) + throw new Exception("Packaged deletion request contract mismatch"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"playlist/encoded\",\"deleted\":true}"), + }; + } if (request.Method != HttpMethod.Put || request.RequestUri!.AbsolutePath != "/api/v2/playlists/playlist%2Fencoded/items/23" || request.Headers.Contains("X-ListenAPI-Key") || diff --git a/src/PodcastAPI.Tests/ClientTests.cs b/src/PodcastAPI.Tests/ClientTests.cs index 9d70bcc..270b2fc 100644 --- a/src/PodcastAPI.Tests/ClientTests.cs +++ b/src/PodcastAPI.Tests/ClientTests.cs @@ -19,9 +19,11 @@ public void CiUsesTheExpectedRuntime() if (expected is not null) Assert.AreEqual(expected, Environment.Version.Major.ToString()); } - internal static readonly JsonElement[] Operations = JsonDocument.Parse( + private static readonly JsonElement Contract = JsonDocument.Parse( File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "api-contract.json"))) - .RootElement.GetProperty("operations").EnumerateArray().Select(op => op.Clone()).ToArray(); + .RootElement; + internal static readonly JsonElement[] Operations = Contract.GetProperty("operations") + .EnumerateArray().Select(op => op.Clone()).ToArray(); public static IEnumerable OperationCases => Operations.Select(op => new object[] { op.GetProperty("operationId").GetString()! }); @@ -76,7 +78,7 @@ public async Task EveryGeneratedMethodMatchesContract(string operation) CollectionAssert.AreEquivalent(query.ToArray(), Decode(request.RequestUri.Query).ToArray()); CollectionAssert.AreEquivalent(body.ToArray(), Decode(request.Content is null ? null : await request.Content.ReadAsStringAsync(token)).ToArray()); Assert.AreEqual("fixture-key", request.Headers.GetValues("X-ListenAPI-Key").Single()); - Assert.AreEqual("podcast-api-dotnet 3.0.0", request.Headers.UserAgent.ToString()); + Assert.AreEqual("podcast-api-dotnet " + Contract.GetProperty("version").GetString(), request.Headers.UserAgent.ToString()); Assert.AreEqual("application/json", request.Headers.Accept.Single().MediaType); if (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put) Assert.AreEqual("application/x-www-form-urlencoded", request.Content!.Headers.ContentType!.MediaType); @@ -92,6 +94,33 @@ public async Task EveryGeneratedMethodMatchesContract(string operation) Assert.AreEqual(1, handler.Calls); } + [TestMethod] + public async Task DeletePlaylistEncodesItsIdentifierWithoutQueryOrBody() + { + const string id = "a/b ?#%é"; + var parameters = new Dictionary { ["id"] = id }; + using var handler = new Handler((request, _) => + { + Assert.AreEqual(HttpMethod.Delete, request.Method); + Assert.AreEqual("/api/v2/playlists/a%2Fb%20%3F%23%25%C3%A9", request.RequestUri!.AbsolutePath); + Assert.AreEqual("", request.RequestUri.Query); + Assert.IsNull(request.Content); + var response = Response(200, "{\"id\":\"a/b ?#%é\",\"deleted\":true}"); + response.Headers.Add("X-ListenAPI-Usage", "12"); + return Task.FromResult(response); + }); + using var http = new HttpClient(handler); + using var client = new Client(httpClient: http); + var response = await client.DeletePlaylist(parameters); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual(id, (string)response.ToJSON()!.id); + Assert.AreEqual(true, (bool)response.ToJSON()!.deleted); + Assert.AreEqual(12, response.GetUsage()); + Assert.AreEqual(id, parameters["id"]); + Assert.AreEqual(1, parameters.Count); + Assert.AreEqual(1, handler.Calls); + } + [TestMethod] public async Task NestedIdentifiersQueryAndEmptyBodyValuesAreEncoded() { @@ -152,17 +181,31 @@ public async Task RepeatedWritesAreNotRetriedAndOmittedNotesStayOmitted() [DataRow(500, typeof(ListenApiException))] [DataRow(503, typeof(ListenApiException))] [DataRow(302, typeof(ListenApiException))] + [DataRow(307, typeof(ListenApiException))] + [DataRow(308, typeof(ListenApiException))] [DataRow(422, typeof(ListenApiException))] public async Task HttpErrorsRetainResponseWithoutRetries(int status, Type type) { - using var handler = new Handler((_, _) => Task.FromResult(Response(status, "{\"error\":\"episode not found\"}"))); + using var handler = new Handler((_, _) => + { + var response = Response(status, "{\"error\":\"Exact reason\"}"); + response.Headers.Add("X-ListenAPI-Usage", "123"); + return Task.FromResult(response); + }); using var http = new HttpClient(handler); using var client = new Client(httpClient: http); - var error = await Assert.ThrowsAsync(() => client.CreatePlaylist(new Dictionary { ["name"] = "test" })); - Assert.AreEqual(type, error.GetType()); - Assert.AreEqual(status, (int)error.Response!.StatusCode); - Assert.AreEqual("episode not found", (string)error.Response.ToJSON()!.error); - Assert.AreEqual(1, handler.Calls); + foreach (var operation in new[] { "createPlaylist", "deletePlaylist" }) + { + var parameters = operation == "createPlaylist" + ? new Dictionary { ["name"] = "test" } + : new Dictionary { ["id"] = "playlist" }; + var error = await Assert.ThrowsAsync(() => MethodDispatch.Call(operation, client, parameters)); + Assert.AreEqual(type, error.GetType()); + Assert.AreEqual(status, (int)error.Response!.StatusCode); + Assert.AreEqual("Exact reason", (string)error.Response.ToJSON()!.error); + Assert.AreEqual(123, error.Response.GetUsage()); + } + Assert.AreEqual(2, handler.Calls); } [TestMethod] @@ -227,7 +270,12 @@ public async Task PathValidationHappensBeforeSending() using var http = new HttpClient(handler); using var client = new Client(httpClient: http); foreach (var value in new[] { "", " ", ".", ".." }) + { await Assert.ThrowsExactlyAsync(() => client.FetchPlaylistById(new Dictionary { ["id"] = value })); + await Assert.ThrowsExactlyAsync(() => client.DeletePlaylist(new Dictionary { ["id"] = value })); + } + await Assert.ThrowsExactlyAsync(() => client.DeletePlaylist()); + await Assert.ThrowsExactlyAsync(() => client.DeletePlaylist(new Dictionary())); await Assert.ThrowsExactlyAsync(() => client.DeletePlaylistItem(new Dictionary { ["id"] = "playlist" })); Assert.AreEqual(0, handler.Calls); Assert.ThrowsExactly(() => new Client(timeout: TimeSpan.Zero)); @@ -236,29 +284,40 @@ public async Task PathValidationHappensBeforeSending() } [TestMethod] - public async Task CancellationTimeoutAndConnectionFailureAreDistinct() + [DataRow("getLanguages")] + [DataRow("deletePlaylist")] + public async Task CancellationTimeoutAndConnectionFailureAreDistinct(string operation) { + var parameters = operation == "deletePlaylist" + ? new Dictionary { ["id"] = "playlist" } : new Dictionary(); using var handler = new Handler(async (_, token) => { await Task.Delay(System.Threading.Timeout.Infinite, token); return Response(); }); using var http = new HttpClient(handler); using var client = new Client(httpClient: http, timeout: TimeSpan.FromMilliseconds(50)); - var timeout = await Assert.ThrowsExactlyAsync(() => client.FetchPodcastLanguages()); + var timeout = await Assert.ThrowsExactlyAsync(() => MethodDispatch.Call(operation, client, parameters)); Assert.IsNull(timeout.Response); + Assert.AreEqual(1, handler.Calls); using var cancel = new CancellationTokenSource(); cancel.Cancel(); - await Assert.ThrowsAsync(() => client.FetchPodcastLanguages(cancellationToken: cancel.Token)); + await Assert.ThrowsAsync(() => MethodDispatch.Call(operation, client, parameters, cancel.Token)); + Assert.AreEqual(1, handler.Calls); using var inFlight = new CancellationTokenSource(TimeSpan.FromMilliseconds(25)); using var slower = new Client(httpClient: http, timeout: TimeSpan.FromSeconds(10)); - await Assert.ThrowsAsync(() => slower.FetchPodcastLanguages(cancellationToken: inFlight.Token)); + await Assert.ThrowsAsync(() => MethodDispatch.Call(operation, slower, parameters, inFlight.Token)); + Assert.AreEqual(2, handler.Calls); using var failed = new Handler((_, _) => throw new HttpRequestException("must not leak a secret request URI")); using var failedHttp = new HttpClient(failed); using var disconnected = new Client(httpClient: failedHttp); - var error = await Assert.ThrowsExactlyAsync(() => disconnected.FetchPodcastLanguages()); + var error = await Assert.ThrowsExactlyAsync(() => MethodDispatch.Call(operation, disconnected, parameters)); Assert.IsFalse(error.ToString().Contains("secret request URI", StringComparison.Ordinal)); Assert.AreEqual(1, failed.Calls); } [TestMethod] - public async Task DefaultTransportDoesNotFollowRedirects() + [DataRow("getLanguages", 302)] + [DataRow("deletePlaylist", 302)] + [DataRow("deletePlaylist", 307)] + [DataRow("deletePlaylist", 308)] + public async Task DefaultTransportDoesNotFollowRedirects(string operation, int status) { using var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); @@ -269,13 +328,18 @@ public async Task DefaultTransportDoesNotFollowRedirects() using var connection = await listener.AcceptTcpClientAsync(deadline.Token); await using var stream = connection.GetStream(); using var reader = new StreamReader(stream, leaveOpen: true); + var expectedRequest = operation == "deletePlaylist" + ? "DELETE /api/v2/playlists/playlist HTTP/1.1" : "GET /api/v2/languages HTTP/1.1"; + Assert.AreEqual(expectedRequest, await reader.ReadLineAsync(deadline.Token)); while (!string.IsNullOrEmpty(await reader.ReadLineAsync(deadline.Token))) { } - var response = Encoding.ASCII.GetBytes($"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{port}/redirected\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"); + var response = Encoding.ASCII.GetBytes($"HTTP/1.1 {status} Redirect\r\nLocation: http://127.0.0.1:{port}/redirected\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}"); await stream.WriteAsync(response, deadline.Token); }, deadline.Token); using var client = new Client("fixture-key", baseUrl: new Uri($"http://127.0.0.1:{port}/api/v2")); - var error = await Assert.ThrowsExactlyAsync(() => client.FetchPodcastLanguages(cancellationToken: deadline.Token)); - Assert.AreEqual(302, (int)error.Response!.StatusCode); + var parameters = operation == "deletePlaylist" + ? new Dictionary { ["id"] = "playlist" } : new Dictionary(); + var error = await Assert.ThrowsExactlyAsync(() => MethodDispatch.Call(operation, client, parameters, deadline.Token)); + Assert.AreEqual(status, (int)error.Response!.StatusCode); await server; Assert.IsFalse(listener.Pending()); } diff --git a/src/PodcastAPI.Tests/MethodDispatch.cs b/src/PodcastAPI.Tests/MethodDispatch.cs index b184fa5..7ae1505 100644 --- a/src/PodcastAPI.Tests/MethodDispatch.cs +++ b/src/PodcastAPI.Tests/MethodDispatch.cs @@ -33,6 +33,7 @@ public static Task Call(string operation, Client client, "getPodcastsByDomainName" => client.FetchPodcastsByDomain(parameters, token), "createPlaylist" => client.CreatePlaylist(parameters, token), "updatePlaylist" => client.UpdatePlaylist(parameters, token), + "deletePlaylist" => client.DeletePlaylist(parameters, token), "addPlaylistItem" => client.AddPlaylistItem(parameters, token), "deletePlaylistItem" => client.DeletePlaylistItem(parameters, token), "updatePlaylistItemNotes" => client.UpdatePlaylistItemNotes(parameters, token), diff --git a/src/PodcastAPI.Tests/MockIntegrationTests.cs b/src/PodcastAPI.Tests/MockIntegrationTests.cs index cad8e75..42ed443 100644 --- a/src/PodcastAPI.Tests/MockIntegrationTests.cs +++ b/src/PodcastAPI.Tests/MockIntegrationTests.cs @@ -17,6 +17,7 @@ public async Task AllMethodsReachOnlyThePublicMockWithoutCredentials() // No environment API key or base URL; redirects are disabled by Client. using var client = new Client(); Assert.AreEqual("https://listen-api-test.listennotes.com/api/v2/", client.BaseUrl.AbsoluteUri); + Assert.AreEqual(31, ClientTests.Operations.Length); foreach (var op in ClientTests.Operations) { var operation = op.GetProperty("operationId").GetString()!; @@ -26,6 +27,12 @@ public async Task AllMethodsReachOnlyThePublicMockWithoutCredentials() Assert.IsNotNull(json, operation); Assert.IsTrue(json.Count > 0, operation); if (operation == "deletePlaylistItem") Assert.AreEqual(true, (bool?)json["deleted"], operation); + if (operation == "deletePlaylist") + { + Assert.AreEqual(System.Net.HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual(true, (bool?)json["deleted"]); + Assert.AreEqual(ClientTests.Examples(op)["id"], (string?)json["id"]); + } if (operation is "createPlaylist" or "updatePlaylist" or "addPlaylistItem" or "updatePlaylistItemNotes") Assert.IsNotNull(json["id"], operation); } diff --git a/src/PodcastAPI.Tests/api-contract.json b/src/PodcastAPI.Tests/api-contract.json index 7074409..011d14d 100644 --- a/src/PodcastAPI.Tests/api-contract.json +++ b/src/PodcastAPI.Tests/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/src/PodcastAPI/ApiMethods.cs b/src/PodcastAPI/ApiMethods.cs index bd57acf..0aadf29 100644 --- a/src/PodcastAPI/ApiMethods.cs +++ b/src/PodcastAPI/ApiMethods.cs @@ -165,6 +165,12 @@ public Task UpdatePlaylist(IDictionary? parameters Request("/playlists/{id}", HttpMethod.Put, new string[] { "id" }, new string[] { }, parameters, cancellationToken); + // Delete a playlist. (DELETE /playlists/{id}) + public Task DeletePlaylist(IDictionary? parameters = null, + CancellationToken cancellationToken = default) => + Request("/playlists/{id}", HttpMethod.Delete, + new string[] { "id" }, new string[] { }, parameters, cancellationToken); + // Add an episode or podcast to a playlist. (POST /playlists/{id}/items) public Task AddPlaylistItem(IDictionary? parameters = null, CancellationToken cancellationToken = default) => diff --git a/src/PodcastAPI/PodcastAPI.csproj b/src/PodcastAPI/PodcastAPI.csproj index 4e46c24..897a4fd 100644 --- a/src/PodcastAPI/PodcastAPI.csproj +++ b/src/PodcastAPI/PodcastAPI.csproj @@ -1,7 +1,7 @@ net8.0 - 3.0.0 + 3.1.0 PodcastAPI The official .NET library for the Listen Notes Podcast API. Listen Notes, Inc. @@ -15,7 +15,7 @@ LICENSE Listen Notes, Inc. README.md - Playlist writes, generated API methods, HttpClient transport, cancellation, and .NET 8+ support. See README for migration details. + Add DeletePlaylist for permanent playlist deletion, with cancellation support. Deletion cannot be undone; add a confirmation step in your application's UI before calling it. See README for usage. diff --git a/src/SampleApp/GeneratedExamples.cs b/src/SampleApp/GeneratedExamples.cs index aa93b50..b6cd3ae 100644 --- a/src/SampleApp/GeneratedExamples.cs +++ b/src/SampleApp/GeneratedExamples.cs @@ -342,6 +342,17 @@ public static async Task UpdatePlaylist() Console.WriteLine(response.ToJSON()); } + public static async Task DeletePlaylist() + { + using var client = new PodcastAPI.Client(Environment.GetEnvironmentVariable("LISTEN_API_KEY")); + var parameters = new Dictionary + { + ["id"] = "m1pe7z60bsw", + }; + var response = await client.DeletePlaylist(parameters); + Console.WriteLine(response.ToJSON()); + } + public static async Task AddPlaylistItem() { using var client = new PodcastAPI.Client(Environment.GetEnvironmentVariable("LISTEN_API_KEY"));