diff --git a/README.md b/README.md index d0c55be..4228780 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ And you can see some sample code [here](https://github.com/ListenNotes/podcast-a - [`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}` @@ -720,6 +721,29 @@ print(response.json()) [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. + +```python +import os +from listennotes import podcast_api + +client = podcast_api.Client(api_key=os.environ.get("LISTEN_API_KEY")) +response = client.delete_playlist(**{'id': 'm1pe7z60bsw'}) +print(response.json()) +``` + +[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/listennotes/_api_methods.py b/listennotes/_api_methods.py index b3f3e1b..a328ba5 100644 --- a/listennotes/_api_methods.py +++ b/listennotes/_api_methods.py @@ -250,6 +250,14 @@ def update_playlist(self, **kwargs): kwargs, ) + def delete_playlist(self, **kwargs): + return self._request_api( + "DELETE", + "/playlists/{id}", + (), + kwargs, + ) + def add_playlist_item(self, **kwargs): return self._request_api( "POST", diff --git a/listennotes/api-contract.json b/listennotes/api-contract.json index 6d812f8..6fbfffe 100644 --- a/listennotes/api-contract.json +++ b/listennotes/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/listennotes/version.py b/listennotes/version.py index ea9d694..273ffca 100644 --- a/listennotes/version.py +++ b/listennotes/version.py @@ -1 +1 @@ -VERSION = "3.0.0" +VERSION = "3.1.0" diff --git a/tests/test_client.py b/tests/test_client.py index fa61b4a..c0c78de 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ import requests from listennotes import podcast_api +from listennotes.version import VERSION CONTRACT = json.loads( files("listennotes").joinpath("api-contract.json").read_text() @@ -90,11 +91,28 @@ def test_query_encoding_and_forward_compatible_fields(transport): assert request.url.startswith(podcast_api.api_base_test) -def test_delete_podcast_reason_is_query_not_body(transport): - podcast_api.Client().delete_podcast(id="abc", reason="a & b") +@pytest.mark.parametrize( + "method,params,path", + [ + ( + "delete_podcast", + {"id": "abc", "reason": "a & b"}, + "/podcasts/abc?reason=a+%26+b", + ), + ( + "delete_playlist", + {"id": "a/b ?#%"}, + "/playlists/a%2Fb%20%3F%23%25", + ), + ], +) +def test_delete_encodes_path_and_query_without_body( + method, params, path, transport +): + getattr(podcast_api.Client(), method)(**params) request = transport.calls[0][0] assert request.method == "DELETE" - assert request.url.endswith("/podcasts/abc?reason=a+%26+b") + assert request.url == podcast_api.api_base_test + path assert request.body is None @@ -157,13 +175,25 @@ def test_metadata_clearing_and_type(transport): } -@pytest.mark.parametrize("field", ["id", "item_id"]) +@pytest.mark.parametrize( + "method,params,field", + [ + ("delete_playlist", {}, "id"), + ("delete_playlist_item", {"id": "playlist", "item_id": 23}, "id"), + ( + "delete_playlist_item", + {"id": "playlist", "item_id": 23}, + "item_id", + ), + ], +) @pytest.mark.parametrize("value", [None, ""]) -def test_missing_path_parameter_fails_before_http(field, value, transport): - params = {"id": "playlist", "item_id": 23} - params[field] = value +def test_missing_path_parameter_fails_before_http( + method, params, field, value, transport +): + params = {**params, field: value} with pytest.raises(ValueError, match=f"Missing path parameter: {field}"): - podcast_api.Client().delete_playlist_item(**params) + getattr(podcast_api.Client(), method)(**params) assert not transport.calls @@ -182,7 +212,7 @@ def test_clients_keep_independent_keys_sessions_and_configuration(transport): assert transport.calls[0][0].headers["User-Agent"] == "first-agent" assert ( transport.calls[1][0].headers["User-Agent"] - == "podcast-api-python 3.0.0" + == f"podcast-api-python {VERSION}" ) assert first.http_client.session is not second.http_client.session assert ( diff --git a/tests/test_http_utils.py b/tests/test_http_utils.py index 00bc99d..df1bf92 100644 --- a/tests/test_http_utils.py +++ b/tests/test_http_utils.py @@ -173,6 +173,7 @@ def test_exception_without_message_is_printable(): ("fetch_my_playlists", {}, 4), ("create_playlist", {"name": "test"}, 1), ("update_playlist", {"id": "playlist", "description": ""}, 1), + ("delete_playlist", {"id": "playlist"}, 1), ("delete_playlist_item", {"id": "playlist", "item_id": 23}, 1), ], ) diff --git a/tests/test_integration.py b/tests/test_integration.py index 81e97a6..67c5575 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -173,6 +173,14 @@ def test_delete_playlist_item(client): assert response.request.body is None +def test_delete_playlist(client): + response = client.delete_playlist(id=PLAYLIST_ID) + payload = response_json(response, "DELETE", f"/playlists/{PLAYLIST_ID}") + assert payload == {"id": PLAYLIST_ID, "deleted": True} + assert urlparse(response.request.url).query == "" + assert response.request.body is None + + def test_missing_route(client): with pytest.raises(NotFoundError) as exc: client.http_client.get(MOCK_BASE + "/sdk-integration-missing-route")