142 lines
4.0 KiB
Swift
142 lines
4.0 KiB
Swift
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public struct MusicBrainzClient: Sendable {
|
|
public let clientAgent: ClientAgent
|
|
internal static let baseURL: URL = URL(string: "https://musicbrainz.org/ws/2")!
|
|
internal static let caaBaseURL: URL = URL(string: "https://coverartarchive.org")!
|
|
|
|
public init(clientAgent: ClientAgent) {
|
|
self.clientAgent = clientAgent
|
|
}
|
|
|
|
private let coordinator: MusicBrainzCoordinator = .init()
|
|
|
|
public struct ClientAgent: Sendable, CustomStringConvertible {
|
|
public let appName: String
|
|
public let version: String
|
|
public let email: String
|
|
|
|
public init(appName: String, version: String, email: String) {
|
|
self.appName = appName
|
|
self.version = version
|
|
self.email = email
|
|
}
|
|
|
|
public var description: String {
|
|
"\(appName)/\(version) (\(email))"
|
|
}
|
|
}
|
|
|
|
internal func fetchRaw(
|
|
baseURL: URL = MusicBrainzClient.baseURL,
|
|
_ endpoint: String,
|
|
queryItems: [URLQueryItem] = []
|
|
) async throws(MusicBrainzError) -> Data {
|
|
let cleanEndpoint = endpoint.hasPrefix("/") ? String(endpoint.dropFirst()) : endpoint
|
|
let url = baseURL.appending(path: cleanEndpoint)
|
|
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
throw .badURL
|
|
}
|
|
|
|
var allQueryItems = queryItems
|
|
if baseURL == MusicBrainzClient.baseURL {
|
|
if !allQueryItems.contains(where: { $0.name == "fmt" }) {
|
|
allQueryItems.append(URLQueryItem(name: "fmt", value: "json"))
|
|
}
|
|
}
|
|
components.queryItems = allQueryItems
|
|
|
|
guard let finalURL = components.url else {
|
|
throw .badURL
|
|
}
|
|
|
|
var request = URLRequest(url: finalURL)
|
|
request.setValue(clientAgent.description, forHTTPHeaderField: "User-Agent")
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
|
|
let (data, response) = try await coordinator.perform(request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw .badServerResponse(0)
|
|
}
|
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
throw .badServerResponse(httpResponse.statusCode)
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
public func fetch<T>(_ type: MusicBrainzEntityType<T>, id: String, includes: [String] = [])
|
|
async throws(MusicBrainzError) -> T
|
|
{
|
|
var queryItems: [URLQueryItem] = []
|
|
if !includes.isEmpty {
|
|
queryItems.append(URLQueryItem(name: "inc", value: includes.joined(separator: "+")))
|
|
}
|
|
|
|
let data = try await fetchRaw("/\(type.entity.rawValue)/\(id)", queryItems: queryItems)
|
|
do {
|
|
return try JSONDecoder().decode(T.self, from: data)
|
|
} catch {
|
|
throw .decodingError(error)
|
|
}
|
|
}
|
|
|
|
public func search<T>(
|
|
_ action: MusicBrainzSearchAction<T>,
|
|
limit: Int = 25,
|
|
offset: Int = 0
|
|
) async throws(MusicBrainzError) -> SearchResponse<T> {
|
|
let queryItems = [
|
|
URLQueryItem(name: "query", value: action.query),
|
|
URLQueryItem(name: "limit", value: "\(limit)"),
|
|
URLQueryItem(name: "offset", value: "\(offset)"),
|
|
]
|
|
let data = try await fetchRaw("/\(action.entity.rawValue)", queryItems: queryItems)
|
|
do {
|
|
return try JSONDecoder().decode(SearchResponse<T>.self, from: data)
|
|
} catch {
|
|
throw .decodingError(error)
|
|
}
|
|
}
|
|
|
|
public func fetchCoverArt(releaseId: String) async throws(MusicBrainzError) -> CoverArtResponse
|
|
{
|
|
let data = try await fetchRaw(
|
|
baseURL: MusicBrainzClient.caaBaseURL, "/release/\(releaseId)")
|
|
do {
|
|
return try JSONDecoder().decode(CoverArtResponse.self, from: data)
|
|
} catch {
|
|
throw .decodingError(error)
|
|
}
|
|
}
|
|
|
|
@available(
|
|
*, deprecated, message: "Use search(_ action: MusicBrainzSearchAction<T>, ...) instead"
|
|
)
|
|
public func search<T>(
|
|
_ type: MusicBrainzEntityType<T>,
|
|
query: String,
|
|
limit: Int = 25,
|
|
offset: Int = 0
|
|
) async throws(MusicBrainzError) -> SearchResponse<T> {
|
|
let queryItems = [
|
|
URLQueryItem(name: "query", value: query),
|
|
URLQueryItem(name: "limit", value: "\(limit)"),
|
|
URLQueryItem(name: "offset", value: "\(offset)"),
|
|
]
|
|
let data = try await fetchRaw("/\(type.entity.rawValue)", queryItems: queryItems)
|
|
do {
|
|
return try JSONDecoder().decode(SearchResponse<T>.self, from: data)
|
|
} catch {
|
|
throw .decodingError(error)
|
|
}
|
|
}
|
|
}
|