Files

93 lines
4.8 KiB
Swift

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)) }
}
}
}