import Foundation struct MusicBrainzRelease: Decodable, Identifiable, Hashable { let id: String let title: String let date: String? let artistCredit: [ArtistCredit]? let media: [Medium]? let genres: [Genre]? var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" } var year: String { String((date ?? "").prefix(4)) } var genre: String { genres?.map(\.name).joined(separator: ", ") ?? "" } var tracks: [MusicBrainzRecording] { media?.flatMap { medium in (medium.tracks ?? []).map { $0.withDisc(medium.position.map(String.init) ?? medium.number ?? "1") } } ?? [] } enum CodingKeys: String, CodingKey { case id, title, date, media, genres; case artistCredit = "artist-credit" } } struct MusicBrainzRecording: Decodable, Identifiable, Hashable { let recording: RecordingID? let title: String let number: String? let position: Int? let length: Int? let artistCredit: [ArtistCredit]? var id: String { recording?.id ?? "\(discNumber)-\(displayNumber)-\(title)" } private(set) var discNumber = "1" enum CodingKeys: String, CodingKey { case recording, title, number, position, length; case artistCredit = "artist-credit" } init(from decoder: Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) recording = try values.decodeIfPresent(RecordingID.self, forKey: .recording) title = try values.decode(String.self, forKey: .title) number = try values.decodeIfPresent(String.self, forKey: .number) position = try values.decodeIfPresent(Int.self, forKey: .position) length = try values.decodeIfPresent(Int.self, forKey: .length) artistCredit = try values.decodeIfPresent([ArtistCredit].self, forKey: .artistCredit) } var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" } var displayNumber: String { number ?? position.map(String.init) ?? "—" } func withDisc(_ value: String) -> MusicBrainzRecording { var copy = self; copy.discNumber = value; return copy } } struct RecordingID: Decodable, Hashable { let id: String? } struct ArtistCredit: Decodable, Hashable { let name: String? let artist: CreditArtist? let joinphrase: String? } struct CreditArtist: Decodable, Hashable { let name: String? } struct Medium: Decodable, Hashable { let position: Int?; let number: String?; let tracks: [MusicBrainzRecording]? } struct Genre: Decodable, Hashable { let name: String } enum MusicBrainzService { private static let base = URL(string: "https://musicbrainz.org/ws/2")! private static let session: URLSession = { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 25 configuration.httpAdditionalHeaders = ["User-Agent": "TonuinoManager/1.0 (macOS music library manager)"] return URLSession(configuration: configuration) }() static func searchReleases(artist: String, album: String) async throws -> [MusicBrainzRelease] { await RateGate.shared.wait() var components = URLComponents(url: base.appendingPathComponent("release"), resolvingAgainstBaseURL: false)! let query = [album.isEmpty ? nil : "release:\"\(escape(album))\"", artist.isEmpty ? nil : "artist:\"\(escape(artist))\""].compactMap { $0 }.joined(separator: " AND ") components.queryItems = [URLQueryItem(name: "query", value: query.isEmpty ? "*" : query), URLQueryItem(name: "limit", value: "15"), URLQueryItem(name: "fmt", value: "json")] let (data, _) = try await session.data(from: components.url!) return try JSONDecoder().decode(ReleaseSearch.self, from: data).releases } static func loadRelease(id: String) async throws -> MusicBrainzRelease { await RateGate.shared.wait() var components = URLComponents(url: base.appendingPathComponent("release/\(id)"), resolvingAgainstBaseURL: false)! components.queryItems = [URLQueryItem(name: "inc", value: "recordings+artist-credits+genres"), URLQueryItem(name: "fmt", value: "json")] let (data, _) = try await session.data(from: components.url!) return try JSONDecoder().decode(MusicBrainzRelease.self, from: data) } private static func escape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") } private struct ReleaseSearch: Decodable { let releases: [MusicBrainzRelease] } private actor RateGate { static let shared = RateGate() private var lastRequest = Date.distantPast func wait() async { let delay = max(0, 1.1 - Date().timeIntervalSince(lastRequest)) lastRequest = Date().addingTimeInterval(delay) if delay > 0 { try? await Task.sleep(for: .seconds(delay)) } } } }