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 PodcastAPI.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "PodcastAPI"
s.version = "3.0.0"
s.version = "3.1.0"
s.summary = "The Official Swift Library for the Listen Notes Podcast API."
s.homepage = "https://www.listennotes.com/api/"
s.license = { :type => "MIT", :file => "LICENSE" }
Expand Down
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ If you have any questions, please contact [[email protected]](hello@listenno

## Installation

PodcastAPI 3.0.0 requires Swift 6.0+, iOS 16+ or macOS 13+. Swift Package Manager
PodcastAPI 3.1.0 requires Swift 6.0+, iOS 16+ or macOS 13+. Swift Package Manager
also supports Linux. Both distribution methods expose the same `PodcastAPI` module.
There are no external runtime package dependencies. The bundled SwiftyJSON source
is based on upstream 5.0.2 with a Linux number-comparison compatibility fix and
Expand All @@ -26,12 +26,12 @@ Find releases and compatibility information on

In Xcode, choose **File > Add Package Dependency**, enter
`https://github.com/ListenNotes/podcast-api-swift.git`, and select **Up to Next Major
Version** starting at **3.0.0**. Add the **PodcastAPI** library product to your app target.
Version** starting at **3.1.0**. Add the **PodcastAPI** library product to your app target.

For a `Package.swift` manifest, add this to the package's `dependencies`:

```swift
.package(url: "https://github.com/ListenNotes/podcast-api-swift.git", from: "3.0.0")
.package(url: "https://github.com/ListenNotes/podcast-api-swift.git", from: "3.1.0")
```

Add `.product(name: "PodcastAPI", package: "podcast-api-swift")` to your target's
Expand All @@ -40,7 +40,7 @@ Add `.product(name: "PodcastAPI", package: "podcast-api-swift")` to your target'
### CocoaPods

```ruby
pod 'PodcastAPI', '~> 3.0'
pod 'PodcastAPI', '~> 3.1'
```

## Usage
Expand Down Expand Up @@ -163,6 +163,7 @@ The SDK builds and tests independently of that repository.
- [`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 @@ -861,6 +862,34 @@ struct Example {

[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.

```swift
import Foundation
import PodcastAPI

@main
struct Example {
static func main() async throws {
let client = Client(apiKey: ProcessInfo.processInfo.environment["LISTEN_API_KEY", default: ""])
let response = try await client.deletePlaylist(parameters: ["id": "m1pe7z60bsw"])
print(response.toJson()?.description ?? "")
}
}
```

[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
4 changes: 2 additions & 2 deletions Sources/ExampleIOSApp/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- PodcastAPI (3.0.0)
- PodcastAPI (3.1.0)

DEPENDENCIES:
- PodcastAPI (from `../..`)
Expand All @@ -9,7 +9,7 @@ EXTERNAL SOURCES:
:path: "../.."

SPEC CHECKSUMS:
PodcastAPI: 59ee436372898e7428234a3364d8ed7120df585d
PodcastAPI: 7623bb0ec28b47456b410a248793db35cb46bb1f

PODFILE CHECKSUM: a0f3ac29f2e50f06e13ba6997a45f525dbae37c7

Expand Down
14 changes: 14 additions & 0 deletions Sources/PodcastAPI/ApiMethods.swift
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,20 @@ extension Client {
pathNames: ["id"], queryNames: [], parameters: parameters, completion: completion)
}

/// Delete a playlist. (DELETE /playlists/{id})
public func deletePlaylist(parameters: [String: String] = [:]) async throws -> ApiResponse {
try await request(path: "/playlists/{id}", method: "DELETE",
pathNames: ["id"], queryNames: [], parameters: parameters)
}

/// Delete a playlist. (DELETE /playlists/{id})
@discardableResult
public func deletePlaylist(parameters: [String: String] = [:],
completion: @escaping @Sendable (ApiResponse) -> Void) -> URLSessionDataTask? {
request(path: "/playlists/{id}", method: "DELETE",
pathNames: ["id"], queryNames: [], parameters: parameters, completion: completion)
}

/// Add an episode or podcast to a playlist. (POST /playlists/{id}/items)
public func addPlaylistItem(parameters: [String: String] = [:]) async throws -> ApiResponse {
try await request(path: "/playlists/{id}/items", method: "POST",
Expand Down
2 changes: 1 addition & 1 deletion Sources/PodcastAPI/PodcastAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let DEFAULT_USER_AGENT = "podcast-api-swift \(Client.version)"
/// A reusable Listen API client. An empty API key selects the public mock service.
/// Configuration setters are synchronized; each request uses a configuration snapshot.
public final class Client: @unchecked Sendable {
public static let version = "3.0.0"
public static let version = "3.1.0"
private let apiKey: String
private let baseURL: URL
private let session: URLSession
Expand Down
4 changes: 4 additions & 0 deletions Tests/PodcastAPITests/MethodDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ func callMethod(_ operation: String, client: Client, parameters: [String: String
return try await client.createPlaylist(parameters: parameters)
case "updatePlaylist":
return try await client.updatePlaylist(parameters: parameters)
case "deletePlaylist":
return try await client.deletePlaylist(parameters: parameters)
case "addPlaylistItem":
return try await client.addPlaylistItem(parameters: parameters)
case "deletePlaylistItem":
Expand Down Expand Up @@ -129,6 +131,8 @@ func callMethod(_ operation: String, client: Client, parameters: [String: String
return client.createPlaylist(parameters: parameters, completion: completion)
case "updatePlaylist":
return client.updatePlaylist(parameters: parameters, completion: completion)
case "deletePlaylist":
return client.deletePlaylist(parameters: parameters, completion: completion)
case "addPlaylistItem":
return client.addPlaylistItem(parameters: parameters, completion: completion)
case "deletePlaylistItem":
Expand Down
10 changes: 10 additions & 0 deletions Tests/PodcastAPITests/MockIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ final class MockIntegrationTests: XCTestCase, @unchecked Sendable {
let response = try await callMethod(op.id, client: client, parameters: op.example)
XCTAssertTrue((200..<300).contains(response.statusCode ?? 0), op.id)
XCTAssertNotNil(response.toJson(), op.id)
if op.id == "deletePlaylist" {
XCTAssertEqual(response.statusCode, 200)
XCTAssertEqual(response.toJson()?["id"].string, op.example["id"])
XCTAssertEqual(response.toJson()?["deleted"].bool, true)
XCTAssertEqual(response.request?.httpMethod, "DELETE")
XCTAssertEqual(response.request?.url?.path, "/api/v2/playlists/m1pe7z60bsw")
XCTAssertNil(response.request?.url?.query)
XCTAssertNil(response.request?.httpBody)
XCTAssertNil(response.request?.value(forHTTPHeaderField: "Content-Type"))
}
}
}

Expand Down
120 changes: 91 additions & 29 deletions Tests/PodcastAPITests/PodcastAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {

func testEveryAsyncMethodMatchesContract() async throws {
let operations = try contract()
XCTAssertEqual(operations.count, 30)
XCTAssertEqual(operations.count, 31)
for op in operations {
let fixture = Fixture { request in
XCTAssertEqual(request.httpMethod, op.method, op.id)
Expand Down Expand Up @@ -157,6 +157,35 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {
}
}

func testDeletePlaylistEncodesIDWithoutQueryOrBody() async throws {
let requests = expectation(description: "one request per deletion call")
requests.expectedFulfillmentCount = 2
requests.assertForOverFulfill = true
let fixture = Fixture { request in
requests.fulfill()
let components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
XCTAssertEqual(request.httpMethod, "DELETE")
XCTAssertEqual(components.percentEncodedPath, "/api/v2/playlists/a%2Fb%3F%23%25%C3%A9")
XCTAssertNil(components.query)
XCTAssertNil(requestBody(request))
XCTAssertNil(request.value(forHTTPHeaderField: "Content-Type"))
return .response(200, ["X-ListenAPI-Usage": "12"], Data(#"{"id":"a/b?#%é","deleted":true}"#.utf8))
}
let parameters = ["id": "a/b?#%é"]
let asyncResponse = try await fixture.client.deletePlaylist(parameters: parameters)
let callbackResponse: ApiResponse = await withCheckedContinuation { continuation in
fixture.client.deletePlaylist(parameters: parameters) { continuation.resume(returning: $0) }
}
for response in [asyncResponse, callbackResponse] {
XCTAssertNil(response.error)
XCTAssertEqual(response.statusCode, 200)
XCTAssertEqual(response.getUsage(), 12)
XCTAssertEqual(response.toJson()?["id"].string, parameters["id"])
XCTAssertEqual(response.toJson()?["deleted"].bool, true)
}
await fulfillment(of: [requests], timeout: 3)
}

func testNestedIdentifiersAndEmptyFieldsAreEncodedOnce() async throws {
let fixture = Fixture { request in
let components = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
Expand Down Expand Up @@ -195,7 +224,7 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {
XCTAssertEqual(a.value(forHTTPHeaderField: "X-ListenAPI-Key"), "first")
XCTAssertEqual(b.value(forHTTPHeaderField: "X-ListenAPI-Key"), "second")
XCTAssertEqual(a.value(forHTTPHeaderField: "User-Agent"), "custom")
XCTAssertEqual(b.value(forHTTPHeaderField: "User-Agent"), "podcast-api-swift 3.0.0")
XCTAssertEqual(b.value(forHTTPHeaderField: "User-Agent"), "podcast-api-swift \(Client.version)")
XCTAssertEqual(a.timeoutInterval, 7)
XCTAssertEqual(b.timeoutInterval, 30)
let mock = Client(apiKey: " \n")
Expand All @@ -214,6 +243,18 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {

func testInvalidIdentifiersFailBeforeNetwork() async throws {
let fixture = Fixture { _ in XCTFail("Must not send an invalid request"); return .failure(URLError(.badURL)) }
for values in [[:], ["id": ""], ["id": "."], ["id": ".."]] {
do {
_ = try await fixture.client.deletePlaylist(parameters: values)
XCTFail("Expected invalid playlist identifier")
} catch { XCTAssertEqual(error as? PodcastApiError, .invalidRequestError) }
let response: ApiResponse = await withCheckedContinuation { continuation in
let task = fixture.client.deletePlaylist(parameters: values) { continuation.resume(returning: $0) }
XCTAssertNil(task)
}
XCTAssertEqual(response.error, .invalidRequestError)
XCTAssertNil(response.request)
}
for values in [[:], ["id": ""], ["id": "."], ["id": ".."], ["id": "ok"]] {
do {
_ = try await fixture.client.deletePlaylistItem(parameters: values)
Expand All @@ -228,21 +269,38 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {
}

func testHTTPFailuresPreserveStatusHeadersAndBody() async throws {
let statuses: [(Int, PodcastApiError)] = [(302, .unexpectedResponseError), (400, .invalidRequestError),
let statuses: [(Int, PodcastApiError)] = [(302, .unexpectedResponseError), (307, .unexpectedResponseError),
(308, .unexpectedResponseError), (400, .invalidRequestError),
(401, .authenticationError), (403, .permissionDeniedError), (404, .notFoundError),
(422, .invalidRequestError), (429, .tooManyRequestsError), (500, .serverError), (503, .serverError)]
for (status, kind) in statuses {
let fixture = Fixture { _ in .response(status, ["X-ListenAPI-Usage": "123"], Data("{\"error\":\"Exact reason\"}".utf8)) }
do {
_ = try await fixture.client.fetchMyPlaylists()
XCTFail("Expected HTTP error")
} catch let error as ApiRequestError {
XCTAssertEqual(error.response.error, kind)
XCTAssertEqual(error.response.statusCode, status)
XCTAssertEqual(error.response.getUsage(), 123)
XCTAssertEqual(error.localizedDescription, "Exact reason")
XCTAssertNotNil(error.response.data)
let requests = expectation(description: "failed requests are not retried")
requests.expectedFulfillmentCount = 3
requests.assertForOverFulfill = true
let fixture = Fixture { _ in
requests.fulfill()
return .response(status, ["X-ListenAPI-Usage": "123"], Data("{\"error\":\"Exact reason\"}".utf8))
}
for operation in ["getPlaylists", "deletePlaylist"] {
do {
_ = try await callMethod(operation, client: fixture.client, parameters: ["id": "list"])
XCTFail("Expected HTTP error")
} catch let error as ApiRequestError {
XCTAssertEqual(error.response.error, kind)
XCTAssertEqual(error.response.statusCode, status)
XCTAssertEqual(error.response.getUsage(), 123)
XCTAssertEqual(error.localizedDescription, "Exact reason")
XCTAssertNotNil(error.response.data)
}
}
let response: ApiResponse = await withCheckedContinuation { continuation in
fixture.client.deletePlaylist(parameters: ["id": "list"]) { continuation.resume(returning: $0) }
}
XCTAssertEqual(response.error, kind)
XCTAssertEqual(response.statusCode, status)
XCTAssertEqual(response.getUsage(), 123)
XCTAssertEqual(response.toJson()?["error"].string, "Exact reason")
await fulfillment(of: [requests], timeout: 3)
}
}

Expand Down Expand Up @@ -286,26 +344,30 @@ final class PodcastAPITests: XCTestCase, @unchecked Sendable {
}

func testAsyncCancellation() async throws {
let started = expectation(description: "started")
let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation }
let task = Task { try await fixture.client.search() }
await fulfillment(of: [started], timeout: 3)
task.cancel()
do { _ = try await task.value; XCTFail("Expected cancellation") }
catch { XCTAssertTrue(error is CancellationError) }
for operation in ["search", "deletePlaylist"] {
let started = expectation(description: "started")
let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation }
let task = Task { try await callMethod(operation, client: fixture.client, parameters: ["id": "list"]) }
await fulfillment(of: [started], timeout: 3)
task.cancel()
do { _ = try await task.value; XCTFail("Expected cancellation") }
catch { XCTAssertTrue(error is CancellationError) }
}
}

func testCallbackCancellation() async throws {
let started = expectation(description: "started")
let completed = expectation(description: "cancelled")
let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation }
let task = fixture.client.search { response in
XCTAssertEqual((response.httpError as? URLError)?.code, .cancelled)
completed.fulfill()
for operation in ["search", "deletePlaylist"] {
let started = expectation(description: "started")
let completed = expectation(description: "cancelled")
let fixture = Fixture { _ in started.fulfill(); return .waitForCancellation }
let task = callMethod(operation, client: fixture.client, parameters: ["id": "list"]) { response in
XCTAssertEqual((response.httpError as? URLError)?.code, .cancelled)
completed.fulfill()
}
await fulfillment(of: [started], timeout: 3)
task?.cancel()
await fulfillment(of: [completed], timeout: 3)
}
await fulfillment(of: [started], timeout: 3)
task?.cancel()
await fulfillment(of: [completed], timeout: 3)
}

func testSynchronousCallbackFinishesBeforeReturn() {
Expand Down
21 changes: 20 additions & 1 deletion Tests/PodcastAPITests/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
Loading