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
47 changes: 44 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,20 @@ and redirects disabled. The SDK does not add retries. `WithHTTPClient` accepts
your own HTTP client and its timeout, redirect, proxy, and transport policies.
`WithBaseURL` supports local test servers and sends your key to that chosen URL.

### Upgrading from 3.0

The `HTTPClient` interface now also requires
`DeletePlaylist(id string, args map[string]string) (*Response, error)`.
Custom implementations and mocks of this interface must add the method.
Clients returned by `NewClient` already implement it.

### Upgrading from 1.x

- Change imports and `go get` commands to
`github.com/ListenNotes/podcast-api-go/v3`, as required by Go module versioning.
- Use Go 1.26+. Existing method names, positional identifiers, string parameter
maps, `Response.Data`, `Response.Stats`, and `ToJSON()` remain available.
- The `HTTPClient` interface adds five playlist methods; custom implementations
- The `HTTPClient` interface adds six playlist write methods; custom implementations
of that interface must add them too.
- Match errors with `errors.Is(err, listennotes.ErrUnauthorized)`, replacing
direct equality checks. Use `errors.As(err, &apiError)` with
Expand Down Expand Up @@ -90,14 +97,14 @@ go test -tags=integration -run '^TestMockIntegration' -count=1 -timeout=3m ./...
```

Integration tests use only the fixed public mock origin, with no API key,
environment-selected destination, proxy, or redirects. They cover all 30 methods.
environment-selected destination, proxy, or redirects. They cover all 31 methods.
The mock is stateless; these tests do not establish production write persistence
or authorization. `go run ./example` makes one explicit read-only mock request.

The monorepo's `sync.py go` generates methods, the contract, test dispatch,
compile-only examples, and the marked README sections. Do not hand-edit generated
outputs. This module builds and tests independently of the monorepo. Publishing
the `v3.0.0` Git tag and enabling website snippets are separate release steps.
the `v3.1.0` Git tag and enabling website snippets are separate release steps.

## Method index

Expand Down Expand Up @@ -130,6 +137,7 @@ the `v3.0.0` Git tag and enabling website snippets are separate release steps.
- [`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 Down Expand Up @@ -963,6 +971,39 @@ func 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.

```go
package main

import (
"fmt"
listennotes "github.com/ListenNotes/podcast-api-go/v3"
)

func main() {
client := listennotes.NewClient("")
response, err := client.DeletePlaylist("m1pe7z60bsw", nil)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(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.
Expand Down
21 changes: 20 additions & 1 deletion 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
2 changes: 2 additions & 0 deletions api_dispatch_test.go

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

7 changes: 7 additions & 0 deletions api_methods.go

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

2 changes: 1 addition & 1 deletion constants.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package listennotes

// Version is the SDK version sent in the User-Agent header.
const Version = "3.0.0"
const Version = "3.1.0"

// Base urls for access the available api endpoints
const (
Expand Down
94 changes: 76 additions & 18 deletions contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func loadSDKContract(t *testing.T) []sdkOperation {
if err := json.Unmarshal(data, &contract); err != nil {
t.Fatal(err)
}
if contract.Version != Version || len(contract.Operations) != 30 {
if contract.Version != Version || len(contract.Operations) != 31 {
t.Fatalf("unexpected contract version/method count: %s/%d", contract.Version, len(contract.Operations))
}
return contract.Operations
Expand Down Expand Up @@ -160,6 +160,39 @@ func TestNestedPathsAndEmptyValues(t *testing.T) {
}
}

func TestDeletePlaylistEncodesIDWithoutQueryOrBody(t *testing.T) {
for name, args := range map[string]map[string]string{"nil": nil, "empty": {}, "path field": {"id": "must-not-leak"}} {
t.Run(name, func(t *testing.T) {
id := "abc/def?# café%"
before := maps.Clone(args)
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Method != http.MethodDelete || r.URL.EscapedPath() != "/api/v2/playlists/"+url.PathEscape(id) || r.URL.RawQuery != "" {
t.Errorf("incorrect deletion request: %s %s", r.Method, r.URL)
}
body, _ := io.ReadAll(r.Body)
if len(body) != 0 || r.Header.Get("Content-Type") != "" {
t.Error("playlist deletion must not send a request body or content type")
}
w.Header().Set(ResponseHeaderKeyUsage, "12")
json.NewEncoder(w).Encode(map[string]interface{}{"id": id, "deleted": true})
}))
defer server.Close()
response, err := NewClient("fixture-key", WithBaseURL(server.URL+"/api/v2")).DeletePlaylist(id, args)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != 200 || response.Data["id"] != id || response.Data["deleted"] != true || response.Stats.Usage != 12 {
t.Fatalf("lost deletion response details: %+v", response)
}
if requests.Load() != 1 || !maps.Equal(args, before) {
t.Fatal("deletion retried or changed the caller's parameters")
}
})
}
}

func TestWriteQueryAndBodyFieldsAreSeparated(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
Expand All @@ -177,7 +210,7 @@ func TestWriteQueryAndBodyFieldsAreSeparated(t *testing.T) {
}

func TestHTTPFailuresRetainDetailsAndNeverRetry(t *testing.T) {
for _, code := range []int{301, 307, 400, 401, 403, 404, 405, 418, 429, 500, 502, 503} {
for _, code := range []int{301, 302, 307, 308, 400, 401, 403, 404, 405, 418, 429, 500, 502, 503} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -188,23 +221,30 @@ func TestHTTPFailuresRetainDetailsAndNeverRetry(t *testing.T) {
fmt.Fprint(w, "exact server explanation")
}))
defer server.Close()
response, err := NewClient("", WithBaseURL(server.URL)).CreatePlaylist(map[string]string{"name": "fixture"})
var apiError *APIError
if !errors.As(err, &apiError) || apiError.StatusCode != code || apiError.Body != "exact server explanation" || apiError.Headers.Get("X-Fixture") != "retained" {
t.Fatalf("missing HTTP error context: %v", err)
}
if response == nil || response.Stats.Usage != 10 || response.Body != apiError.Body || requests.Load() != 1 {
t.Fatal("lost response or retried write")
}
expected := errMap[code]
if expected == nil {
expected = ErrUnexpectedStatus
if code >= 500 {
expected = ErrInternalServerError
client := NewClient("", WithBaseURL(server.URL))
for index, operation := range []string{"createPlaylist", "deletePlaylist"} {
args := map[string]string{"name": "fixture"}
if operation == "deletePlaylist" {
args = map[string]string{"id": "fixture"}
}
response, err := callSDKMethod(client, operation, args)
var apiError *APIError
if !errors.As(err, &apiError) || apiError.StatusCode != code || apiError.Body != "exact server explanation" || apiError.Headers.Get("X-Fixture") != "retained" {
t.Fatalf("missing HTTP error context: %v", err)
}
if response == nil || response.Stats.Usage != 10 || response.Body != apiError.Body || requests.Load() != int32(index+1) {
t.Fatal("lost response or retried write")
}
expected := errMap[code]
if expected == nil {
expected = ErrUnexpectedStatus
if code >= 500 {
expected = ErrInternalServerError
}
}
if !errors.Is(err, expected) {
t.Fatalf("wrong classification: %v", err)
}
}
if !errors.Is(err, expected) {
t.Fatalf("wrong classification: %v", err)
}
})
}
Expand Down Expand Up @@ -232,9 +272,15 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) {
if _, err := NewClient("fixture-key", WithBaseURL(redirect.URL)).Search(nil); !errors.Is(err, ErrUnexpectedStatus) || leaked.Load() != 0 {
t.Fatalf("redirect followed: %v", err)
}
if _, err := NewClient("fixture-key", WithBaseURL(redirect.URL)).DeletePlaylist("list", nil); !errors.Is(err, ErrUnexpectedStatus) || leaked.Load() != 0 {
t.Fatalf("deletion redirect followed: %v", err)
}
var seen sync.Map
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen.Store(r.URL.Query().Get("q"), r.Header.Get(RequestHeaderKeyAPI))
if r.Method == http.MethodDelete {
seen.Store(r.URL.Path, r.Header.Get(RequestHeaderKeyAPI))
}
fmt.Fprint(w, `{}`)
}))
defer server.Close()
Expand All @@ -250,6 +296,9 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) {
if _, err := client.Search(map[string]string{"q": fmt.Sprint(i % 2)}); err != nil {
t.Error(err)
}
if _, err := client.DeletePlaylist(fmt.Sprint(i%2), nil); err != nil {
t.Error(err)
}
})
}
wg.Wait()
Expand All @@ -259,6 +308,12 @@ func TestDefaultRedirectsAndClientIsolation(t *testing.T) {
if key, _ := seen.Load("1"); key != "second" {
t.Error("second key was overwritten")
}
if key, _ := seen.Load("/playlists/0"); key != "first" {
t.Error("first deletion key was overwritten")
}
if key, _ := seen.Load("/playlists/1"); key != "second" {
t.Error("second deletion key was overwritten")
}
}

func TestTimeoutAndInvalidParameters(t *testing.T) {
Expand All @@ -281,6 +336,9 @@ func TestTimeoutAndInvalidParameters(t *testing.T) {
defer fast.Close()
client = NewClient("", WithBaseURL(fast.URL), WithHTTPClient(nil), nil)
for _, id := range []string{"", ".", ".."} {
if _, err := client.DeletePlaylist(id, nil); err == nil {
t.Errorf("accepted invalid playlist deletion identifier %q", id)
}
if _, err := client.DeletePlaylistItem(id, "23", nil); err == nil {
t.Errorf("accepted invalid playlist identifier %q", id)
}
Expand Down
10 changes: 10 additions & 0 deletions examples_test.go

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

7 changes: 7 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ func TestMockIntegrationAllMethods(t *testing.T) {
if response.Data["deleted"] != true {
t.Error("missing deletion confirmation")
}
case "deletePlaylist":
if response.Data["deleted"] != true || response.Data["id"] != op.ExampleParams["id"] {
t.Error("incorrect playlist deletion confirmation")
}
if guard.last.URL.Path != "/api/v2/playlists/m1pe7z60bsw" || guard.last.URL.RawQuery != "" || guard.body != "" || guard.last.Header.Get("Content-Type") != "" {
t.Errorf("incorrect playlist deletion request: %s", guard.last.URL)
}
}
})
time.Sleep(100 * time.Millisecond)
Expand Down
Loading