Files
swift-musicbrainz/Sources/MusicBrainz/MusicBrainzClient.swift
2026-03-21 18:25:21 +01:00

181 lines
5.4 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)
}
}
public func resolveWikimediaImageURL(_ url: URL) async throws(MusicBrainzError) -> URL {
let urlString = url.absoluteString
guard urlString.contains("commons.wikimedia.org/wiki/File:") || urlString.contains("Special:FilePath") else {
return url
}
let filename: String
if urlString.contains("/wiki/File:") {
filename = urlString.components(separatedBy: "/wiki/File:").last ?? ""
} else if urlString.contains("Special:FilePath/") {
filename = urlString.components(separatedBy: "Special:FilePath/").last ?? ""
} else {
return url
}
guard !filename.isEmpty else { return url }
let wikimediaBaseURL = URL(string: "https://commons.wikimedia.org/w/api.php")!
let queryItems = [
URLQueryItem(name: "action", value: "query"),
URLQueryItem(name: "titles", value: "File:\(filename)"),
URLQueryItem(name: "prop", value: "imageinfo"),
URLQueryItem(name: "iiprop", value: "url"),
URLQueryItem(name: "format", value: "json"),
]
let data = try await fetchRaw(baseURL: wikimediaBaseURL, "", queryItems: queryItems)
do {
let response = try JSONDecoder().decode(WikimediaResponse.self, from: data)
if let page = response.query.pages.values.first,
let directURLString = page.imageinfo?.first?.url,
let directURL = URL(string: directURLString)
{
return directURL
}
return url
} 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)
}
}
}