Add Tonuino Manager website and Sparkle updates
This commit is contained in:
commit
1cac427ff2
44 files changed
+5861
No files matched your search
@@ -0,0 +1,92 @@
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user