Everything works well and good structure.
This commit is contained in:
141
Sources/MusicBrainz/MusicBrainzClient.swift
Normal file
141
Sources/MusicBrainz/MusicBrainzClient.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user