93 lines
4.8 KiB
Swift
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)) }
|
|
}
|
|
}
|
|
}
|