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(_ type: MusicBrainzEntityType, 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( _ action: MusicBrainzSearchAction, limit: Int = 25, offset: Int = 0 ) async throws(MusicBrainzError) -> SearchResponse { 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.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, ...) instead" ) public func search( _ type: MusicBrainzEntityType, query: String, limit: Int = 25, offset: Int = 0 ) async throws(MusicBrainzError) -> SearchResponse { 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.self, from: data) } catch { throw .decodingError(error) } } }