Everything works well and good structure.

This commit is contained in:
cdricms
2026-03-21 17:41:59 +01:00
parent ada1c12f57
commit 2d1e8e1044
30 changed files with 1068 additions and 875 deletions

View File

@@ -0,0 +1,51 @@
import Foundation
public enum Gender: String, Sendable {
case male, female, other
case notApplicable = "not applicable"
}
public enum ArtistType: String, Sendable {
case person, group, orchestra, choir, character, other
}
public enum Country: String, Sendable {
case af = "AF" // Afghanistan
case al = "AL" // Albania
case dz = "DZ" // Algeria
case ad = "AD" // Andorra
case ao = "AO" // Angola
case ar = "AR" // Argentina
case am = "AM" // Armenia
case au = "AU" // Australia
case at = "AT" // Austria
case az = "AZ" // Azerbaijan
case be = "BE" // Belgium
case br = "BR" // Brazil
case ca = "CA" // Canada
case cn = "CN" // China
case dk = "DK" // Denmark
case fi = "FI" // Finland
case fr = "FR" // France
case de = "DE" // Germany
case gr = "GR" // Greece
case `in` = "IN" // India
case id = "ID" // Indonesia
case ie = "IE" // Ireland
case it = "IT" // Italy
case jp = "JP" // Japan
case mx = "MX" // Mexico
case nl = "NL" // Netherlands
case nz = "NZ" // New Zealand
case no = "NO" // Norway
case pl = "PL" // Poland
case pt = "PT" // Portugal
case ru = "RU" // Russia
case kr = "KR" // South Korea
case es = "ES" // Spain
case se = "SE" // Sweden
case ch = "CH" // Switzerland
case tr = "TR" // Turkey
case gb = "GB" // United Kingdom
case us = "US" // United States
}

View File

@@ -0,0 +1,8 @@
import Foundation
internal struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}

View File

@@ -0,0 +1,20 @@
import Foundation
public struct Area: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .area
public let id: String
public let type: String?
public let name: String
public let sortName: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, type, name, disambiguation, score, relations
case sortName = "sort-name"
case lifeSpan = "life-span"
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
public struct Artist: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .artist
public let id: String
public let name: String
public let sortName: String?
public let type: String?
public let country: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case name
case sortName = "sort-name"
case type
case country
case disambiguation
case lifeSpan = "life-span"
case relations
case score
}
public var imageURL: URL? {
guard let relations else { return nil }
// Look for 'image' or 'wikimedia commons'
for rel in relations {
if rel.type == "image" || rel.type == "wikimedia commons" {
if let resource = rel.url?.resource {
return URL(string: resource)
}
}
}
return nil
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
public struct Event: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .event
public let id: String
public let name: String
public let time: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, time, disambiguation, score, relations
case lifeSpan = "life-span"
}
}

View File

@@ -0,0 +1,17 @@
import Foundation
public struct Instrument: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .instrument
public let id: String
public let name: String
public let type: String?
public let description: String?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, type, description, disambiguation, score, relations
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
public struct Label: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .label
public let id: String
public let name: String
public let type: String?
public let labelCode: Int?
public let country: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, type, country, disambiguation, score, relations
case labelCode = "label-code"
case lifeSpan = "life-span"
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
public struct Place: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .place
public let id: String
public let name: String
public let type: String?
public let address: String?
public let area: Area?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, type, address, area, disambiguation, score, relations
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
public struct Recording: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .recording
public let id: String
public let title: String
public let length: Int?
public let video: Bool?
public let disambiguation: String?
public let firstReleaseDate: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case title
case length
case video
case disambiguation
case firstReleaseDate = "first-release-date"
case relations
case score
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public struct Release: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .release
public let id: String
public let title: String
public let status: String?
public let date: String?
public let country: String?
public let barcode: String?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case title
case status
case date
case country
case barcode
case disambiguation
case relations
case score
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
public struct ReleaseGroup: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .releaseGroup
public let id: String
public let title: String
public let primaryType: String?
public let artistCredit: [ArtistCredit]?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, title, disambiguation, score, relations
case primaryType = "primary-type"
case artistCredit = "artist-credit"
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
public struct Series: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .series
public let id: String
public let name: String
public let type: String?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, type, disambiguation, score, relations
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
public struct ArtistCredit: Codable, Sendable {
public let name: String
public let artist: Artist?
public let joinphrase: String?
}

View File

@@ -0,0 +1,21 @@
import Foundation
public struct CoverArtImage: Codable, Sendable {
public let image: String
public let thumbnails: [String: String]
public let types: [String]
public let front: Bool
public let back: Bool
public let edit: Int
public let comment: String
public let id: String
}
public struct CoverArtResponse: Codable, Sendable {
public let images: [CoverArtImage]
public let release: String
public var frontImage: CoverArtImage? {
images.first { $0.front }
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
public struct LifeSpan: Codable, Sendable {
public let begin: String?
public let end: String?
public let ended: Bool?
}

View File

@@ -0,0 +1,14 @@
import Foundation
public struct Relation: Codable, Sendable {
public let type: String
public let direction: String
public let url: URLResource?
public let artist: Artist?
public let release: Release?
// Add other entities as needed
enum CodingKeys: String, CodingKey {
case type, direction, url, artist, release
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
public struct URLResource: Codable, Sendable {
public let id: String
public let resource: String
}

View File

@@ -0,0 +1,9 @@
import Foundation
public struct URLReference: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .url
public let id: String
public let resource: String
public let score: Int?
}

View File

@@ -0,0 +1,17 @@
import Foundation
public struct Work: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .work
public let id: String
public let title: String
public let type: String?
public let language: String?
public let disambiguation: String?
public let relations: [Relation]?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, title, type, language, disambiguation, score, relations
}
}

View File

@@ -1,863 +0,0 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public enum MusicBrainzError: Error {
case badURL
case badServerResponse(Int)
case decodingError(Error)
case coordinator(Error)
}
// MARK: - Generic Support
internal struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
public enum MusicBrainzEntity: String, Sendable {
case area
case artist
case event
case instrument
case label
case place
case recording
case release
case releaseGroup = "release-group"
case series
case work
case url
public var responseKey: String {
switch self {
case .series: return "series"
default: return "\(self.rawValue)s"
}
}
}
public struct MusicBrainzEntityType<T: MusicBrainzSearchable>: Sendable {
public let entity: MusicBrainzEntity
public static var area: MusicBrainzEntityType<Area> { .init(entity: .area) }
public static var artist: MusicBrainzEntityType<Artist> { .init(entity: .artist) }
public static var event: MusicBrainzEntityType<Event> { .init(entity: .event) }
public static var instrument: MusicBrainzEntityType<Instrument> { .init(entity: .instrument) }
public static var label: MusicBrainzEntityType<Label> { .init(entity: .label) }
public static var place: MusicBrainzEntityType<Place> { .init(entity: .place) }
public static var recording: MusicBrainzEntityType<Recording> { .init(entity: .recording) }
public static var release: MusicBrainzEntityType<Release> { .init(entity: .release) }
public static var releaseGroup: MusicBrainzEntityType<ReleaseGroup> {
.init(entity: .releaseGroup)
}
public static var series: MusicBrainzEntityType<Series> { .init(entity: .series) }
public static var work: MusicBrainzEntityType<Work> { .init(entity: .work) }
public static var url: MusicBrainzEntityType<URLReference> { .init(entity: .url) }
}
public protocol MusicBrainzSearchable: Codable, Sendable {
static var entityType: MusicBrainzEntity { get }
}
public struct SearchResponse<T: MusicBrainzSearchable>: Codable, Sendable {
public let count: Int
public let offset: Int
public let entities: [T]
private enum CodingKeys: String, CodingKey {
case count, offset
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
count = try container.decode(Int.self, forKey: .count)
offset = try container.decode(Int.self, forKey: .offset)
let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
guard let key = DynamicCodingKey(stringValue: T.entityType.responseKey) else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Invalid response key for entity type"
)
)
}
entities = try dynamicContainer.decode([T].self, forKey: key)
}
}
// MARK: - Structured Queries
public enum Gender: String, Sendable {
case male, female, other
case notApplicable = "not applicable"
}
public enum ArtistType: String, Sendable {
case person, group, orchestra, choir, character, other
}
public enum Country: String, Sendable {
case af = "AF" // Afghanistan
case al = "AL" // Albania
case dz = "DZ" // Algeria
case ad = "AD" // Andorra
case ao = "AO" // Angola
case ar = "AR" // Argentina
case am = "AM" // Armenia
case au = "AU" // Australia
case at = "AT" // Austria
case az = "AZ" // Azerbaijan
case be = "BE" // Belgium
case br = "BR" // Brazil
case ca = "CA" // Canada
case cn = "CN" // China
case dk = "DK" // Denmark
case fi = "FI" // Finland
case fr = "FR" // France
case de = "DE" // Germany
case gr = "GR" // Greece
case `in` = "IN" // India
case id = "ID" // Indonesia
case ie = "IE" // Ireland
case it = "IT" // Italy
case jp = "JP" // Japan
case mx = "MX" // Mexico
case nl = "NL" // Netherlands
case nz = "NZ" // New Zealand
case no = "NO" // Norway
case pl = "PL" // Poland
case pt = "PT" // Portugal
case ru = "RU" // Russia
case kr = "KR" // South Korea
case es = "ES" // Spain
case se = "SE" // Sweden
case ch = "CH" // Switzerland
case tr = "TR" // Turkey
case gb = "GB" // United Kingdom
case us = "US" // United States
// ... add more as needed or provide a way to use others
}
public struct MusicBrainzSearchAction<T: MusicBrainzSearchable>: Sendable {
public let entity: MusicBrainzEntity
public let query: String
fileprivate static func buildQuery(_ fields: [String: Any?], raw: String?) -> String {
var parts: [String] = []
for (key, value) in fields {
if let value {
let stringValue: String
if let val = value as? String {
stringValue = val
} else if let val = value as? CustomStringConvertible {
stringValue = val.description
} else if let val = value as? (any RawRepresentable),
let rawVal = val.rawValue as? String
{
stringValue = rawVal
} else {
continue
}
if !stringValue.isEmpty {
let escaped = stringValue.replacingOccurrences(of: "\"", with: "\\\"")
parts.append("\(key):\"\(escaped)\"")
}
}
}
if let raw, !raw.isEmpty {
parts.append(raw)
}
return parts.joined(separator: " AND ")
}
}
extension MusicBrainzSearchAction where T == Area {
public static func area(
name: String? = nil,
type: String? = nil,
iso: String? = nil,
aid: String? = nil,
alias: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"area": name, "type": type, "iso": iso, "aid": aid,
"alias": alias, "begin": begin, "comment": comment, "end": end,
"ended": ended?.description, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .area, query: q)
}
}
extension MusicBrainzSearchAction where T == Artist {
public static func artist(
name: String? = nil,
gender: Gender? = nil,
country: Country? = nil,
type: ArtistType? = nil,
alias: String? = nil,
area: String? = nil,
arid: String? = nil,
begin: String? = nil,
beginarea: String? = nil,
comment: String? = nil,
end: String? = nil,
endarea: String? = nil,
ended: Bool? = nil,
ipi: String? = nil,
isni: String? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"artist": name, "gender": gender?.rawValue, "country": country?.rawValue,
"type": type?.rawValue,
"alias": alias, "area": area, "arid": arid, "begin": begin, "beginarea": beginarea,
"comment": comment, "end": end, "endarea": endarea, "ended": ended?.description,
"ipi": ipi, "isni": isni, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .artist, query: q)
}
}
extension MusicBrainzSearchAction where T == Event {
public static func event(
name: String? = nil,
type: String? = nil,
artist: String? = nil,
place: String? = nil,
aid: String? = nil,
area: String? = nil,
arid: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
eid: String? = nil,
pid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"event": name, "type": type, "artist": artist, "place": place,
"aid": aid, "area": area, "arid": arid, "begin": begin,
"comment": comment, "end": end, "ended": ended?.description,
"eid": eid, "pid": pid, "tag": tag,
], raw: raw)
return .init(entity: .event, query: q)
}
}
extension MusicBrainzSearchAction where T == Instrument {
public static func instrument(
name: String? = nil,
type: String? = nil,
description: String? = nil,
alias: String? = nil,
comment: String? = nil,
iid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"instrument": name, "type": type, "description": description,
"alias": alias, "comment": comment, "iid": iid, "tag": tag,
], raw: raw)
return .init(entity: .instrument, query: q)
}
}
extension MusicBrainzSearchAction where T == Label {
public static func label(
name: String? = nil,
type: String? = nil,
code: String? = nil,
country: Country? = nil,
alias: String? = nil,
area: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
ipi: String? = nil,
isni: String? = nil,
laid: String? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"label": name, "type": type, "code": code, "country": country?.rawValue,
"alias": alias, "area": area, "begin": begin, "comment": comment,
"end": end, "ended": ended?.description, "ipi": ipi, "isni": isni,
"laid": laid, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .label, query: q)
}
}
extension MusicBrainzSearchAction where T == Place {
public static func place(
name: String? = nil,
type: String? = nil,
address: String? = nil,
area: String? = nil,
alias: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
lat: String? = nil,
long: String? = nil,
pid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"place": name, "type": type, "address": address, "area": area,
"alias": alias, "begin": begin, "comment": comment, "end": end,
"ended": ended?.description, "lat": lat, "long": long, "pid": pid,
], raw: raw)
return .init(entity: .place, query: q)
}
}
extension MusicBrainzSearchAction where T == Recording {
public static func recording(
title: String? = nil,
artist: String? = nil,
release: String? = nil,
isrc: String? = nil,
arid: String? = nil,
comment: String? = nil,
country: Country? = nil,
date: String? = nil,
dur: String? = nil,
firstreleasedate: String? = nil,
format: String? = nil,
number: String? = nil,
position: String? = nil,
primarytype: String? = nil,
reid: String? = nil,
rgid: String? = nil,
rid: String? = nil,
secondarytype: String? = nil,
status: String? = nil,
tag: String? = nil,
tid: String? = nil,
tnum: String? = nil,
video: Bool? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"recording": title, "artist": artist, "release": release, "isrc": isrc,
"arid": arid, "comment": comment, "country": country?.rawValue, "date": date,
"dur": dur, "firstreleasedate": firstreleasedate, "format": format,
"number": number, "position": position, "primarytype": primarytype,
"reid": reid, "rgid": rgid, "rid": rid, "secondarytype": secondarytype,
"status": status, "tag": tag, "tid": tid, "tnum": tnum, "video": video?.description,
], raw: raw)
return .init(entity: .recording, query: q)
}
}
extension MusicBrainzSearchAction where T == Release {
public static func release(
title: String? = nil,
artist: String? = nil,
label: String? = nil,
barcode: String? = nil,
status: String? = nil,
arid: String? = nil,
asin: String? = nil,
catno: String? = nil,
comment: String? = nil,
country: Country? = nil,
date: String? = nil,
discids: String? = nil,
format: String? = nil,
laid: String? = nil,
mediums: String? = nil,
primarytype: String? = nil,
puid: String? = nil,
reid: String? = nil,
rgid: String? = nil,
script: String? = nil,
secondarytype: String? = nil,
tag: String? = nil,
tracks: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"release": title, "artist": artist, "label": label, "barcode": barcode,
"status": status,
"arid": arid, "asin": asin, "catno": catno, "comment": comment,
"country": country?.rawValue,
"date": date, "discids": discids, "format": format, "laid": laid,
"mediums": mediums,
"primarytype": primarytype, "puid": puid, "reid": reid, "rgid": rgid,
"script": script,
"secondarytype": secondarytype, "tag": tag, "tracks": tracks,
], raw: raw)
return .init(entity: .release, query: q)
}
}
extension MusicBrainzSearchAction where T == ReleaseGroup {
public static func releaseGroup(
title: String? = nil,
artist: String? = nil,
type: String? = nil,
arid: String? = nil,
comment: String? = nil,
primarytype: String? = nil,
rgid: String? = nil,
releases: String? = nil,
secondarytype: String? = nil,
status: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"releasegroup": title, "artist": artist, "type": type, "arid": arid,
"comment": comment, "primarytype": primarytype, "rgid": rgid,
"releases": releases, "secondarytype": secondarytype, "status": status, "tag": tag,
], raw: raw)
return .init(entity: .releaseGroup, query: q)
}
}
extension MusicBrainzSearchAction where T == Series {
public static func series(
name: String? = nil,
type: String? = nil,
alias: String? = nil,
comment: String? = nil,
sid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"series": name, "type": type, "alias": alias, "comment": comment,
"sid": sid, "tag": tag,
], raw: raw)
return .init(entity: .series, query: q)
}
}
extension MusicBrainzSearchAction where T == Work {
public static func work(
title: String? = nil,
artist: String? = nil,
type: String? = nil,
iswc: String? = nil,
alias: String? = nil,
arid: String? = nil,
comment: String? = nil,
lang: String? = nil,
tag: String? = nil,
wid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"work": title, "artist": artist, "type": type, "iswc": iswc,
"alias": alias, "arid": arid, "comment": comment, "lang": lang,
"tag": tag, "wid": wid,
], raw: raw)
return .init(entity: .work, query: q)
}
}
extension MusicBrainzSearchAction where T == URLReference {
public static func url(
resource: String? = nil,
targettype: String? = nil,
targetid: String? = nil,
uid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"url": resource, "targettype": targettype, "targetid": targetid, "uid": uid,
], raw: raw)
return .init(entity: .url, query: q)
}
}
// MARK: - Entity Models
public struct LifeSpan: Codable, Sendable {
public let begin: String?
public let end: String?
public let ended: Bool?
}
public struct Area: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .area
public let id: String
public let type: String?
public let name: String
public let sortName: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, type, name, disambiguation, score
case sortName = "sort-name"
case lifeSpan = "life-span"
}
}
public struct Artist: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .artist
public let id: String
public let name: String
public let sortName: String?
public let type: String?
public let country: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case name
case sortName = "sort-name"
case type
case country
case disambiguation
case lifeSpan = "life-span"
case score
}
}
public struct Release: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .release
public let id: String
public let title: String
public let status: String?
public let date: String?
public let country: String?
public let barcode: String?
public let disambiguation: String?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case title
case status
case date
case country
case barcode
case disambiguation
case score
}
}
public struct Recording: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .recording
public let id: String
public let title: String
public let length: Int?
public let video: Bool?
public let disambiguation: String?
public let firstReleaseDate: String?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id
case title
case length
case video
case disambiguation
case firstReleaseDate = "first-release-date"
case score
}
}
public struct Event: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .event
public let id: String
public let name: String
public let time: String?
public let disambiguation: String?
public let lifeSpan: LifeSpan?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, time, disambiguation, score
case lifeSpan = "life-span"
}
}
public struct Instrument: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .instrument
public let id: String
public let name: String
public let type: String?
public let description: String?
public let disambiguation: String?
public let score: Int?
}
public struct Label: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .label
public let id: String
public let name: String
public let type: String?
public let labelCode: Int?
public let country: String?
public let disambiguation: String?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, name, type, country, disambiguation, score
case labelCode = "label-code"
}
}
public struct Place: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .place
public let id: String
public let name: String
public let type: String?
public let address: String?
public let area: Area?
public let disambiguation: String?
public let score: Int?
}
public struct ArtistCredit: Codable, Sendable {
public let name: String
public let artist: Artist?
public let joinphrase: String?
}
public struct ReleaseGroup: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .releaseGroup
public let id: String
public let title: String
public let primaryType: String?
public let artistCredit: [ArtistCredit]?
public let disambiguation: String?
public let score: Int?
enum CodingKeys: String, CodingKey {
case id, title, disambiguation, score
case primaryType = "primary-type"
case artistCredit = "artist-credit"
}
}
public struct Series: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .series
public let id: String
public let name: String
public let type: String?
public let disambiguation: String?
public let score: Int?
}
public struct Work: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .work
public let id: String
public let title: String
public let type: String?
public let language: String?
public let disambiguation: String?
public let score: Int?
}
public struct URLReference: MusicBrainzSearchable {
public static let entityType: MusicBrainzEntity = .url
public let id: String
public let resource: String
public let score: Int?
}
// MARK: - Client
public struct MusicBrainzClient: Sendable {
public let clientAgent: ClientAgent
internal static let baseURL: URL = URL(string: "https://musicbrainz.org/ws/2")!
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(_ endpoint: String, queryItems: [URLQueryItem] = [])
async throws(MusicBrainzError) -> Data
{
let cleanEndpoint = endpoint.hasPrefix("/") ? String(endpoint.dropFirst()) : endpoint
let url = MusicBrainzClient.baseURL.appending(path: cleanEndpoint)
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
throw .badURL
}
var allQueryItems = [URLQueryItem(name: "fmt", value: "json")]
allQueryItems.append(contentsOf: queryItems)
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<T>(_ type: MusicBrainzEntityType<T>, id: String)
async throws(MusicBrainzError) -> T
{
let data = try await fetchRaw("/\(type.entity.rawValue)/\(id)")
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw .decodingError(error)
}
}
public func search<T>(
_ action: MusicBrainzSearchAction<T>,
limit: Int = 25,
offset: Int = 0
) async throws(MusicBrainzError) -> SearchResponse<T> {
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<T>.self, from: data)
} catch {
throw .decodingError(error)
}
}
@available(
*, deprecated, message: "Use search(_ action: MusicBrainzSearchAction<T>, ...) instead"
)
public func search<T>(
_ type: MusicBrainzEntityType<T>,
query: String,
limit: Int = 25,
offset: Int = 0
) async throws(MusicBrainzError) -> SearchResponse<T> {
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<T>.self, from: data)
} catch {
throw .decodingError(error)
}
}
}
// MARK: - Coordinator
public actor MusicBrainzCoordinator {
private var nextAvailableTime: ContinuousClock.Instant = .now
private let clock = ContinuousClock()
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func perform(_ request: URLRequest) async throws(MusicBrainzError) -> (Data, URLResponse)
{
let now = clock.now
let executionTime = max(now, nextAvailableTime)
nextAvailableTime = executionTime + .seconds(1)
let delay = now.duration(to: executionTime)
if delay > .zero {
do {
try await Task.sleep(for: delay)
} catch {
throw .coordinator(error)
}
}
do {
return try await session.data(for: request)
} catch {
throw .coordinator(error)
}
}
}

View File

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

View File

@@ -0,0 +1,37 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
public actor MusicBrainzCoordinator {
private var nextAvailableTime: ContinuousClock.Instant = .now
private let clock = ContinuousClock()
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func perform(_ request: URLRequest) async throws(MusicBrainzError) -> (Data, URLResponse)
{
let now = clock.now
let executionTime = max(now, nextAvailableTime)
nextAvailableTime = executionTime + .seconds(1)
let delay = now.duration(to: executionTime)
if delay > .zero {
do {
try await Task.sleep(for: delay)
} catch {
throw .coordinator(error)
}
}
do {
return try await session.data(for: request)
} catch {
throw .coordinator(error)
}
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
public enum MusicBrainzEntity: String, Sendable {
case area
case artist
case event
case instrument
case label
case place
case recording
case release
case releaseGroup = "release-group"
case series
case work
case url
public var responseKey: String {
switch self {
case .series: return "series"
default: return "\(self.rawValue)s"
}
}
}

View File

@@ -0,0 +1,20 @@
import Foundation
public struct MusicBrainzEntityType<T: MusicBrainzSearchable>: Sendable {
public let entity: MusicBrainzEntity
public static var area: MusicBrainzEntityType<Area> { .init(entity: .area) }
public static var artist: MusicBrainzEntityType<Artist> { .init(entity: .artist) }
public static var event: MusicBrainzEntityType<Event> { .init(entity: .event) }
public static var instrument: MusicBrainzEntityType<Instrument> { .init(entity: .instrument) }
public static var label: MusicBrainzEntityType<Label> { .init(entity: .label) }
public static var place: MusicBrainzEntityType<Place> { .init(entity: .place) }
public static var recording: MusicBrainzEntityType<Recording> { .init(entity: .recording) }
public static var release: MusicBrainzEntityType<Release> { .init(entity: .release) }
public static var releaseGroup: MusicBrainzEntityType<ReleaseGroup> {
.init(entity: .releaseGroup)
}
public static var series: MusicBrainzEntityType<Series> { .init(entity: .series) }
public static var work: MusicBrainzEntityType<Work> { .init(entity: .work) }
public static var url: MusicBrainzEntityType<URLReference> { .init(entity: .url) }
}

View File

@@ -0,0 +1,8 @@
import Foundation
public enum MusicBrainzError: Error {
case badURL
case badServerResponse(Int)
case decodingError(Error)
case coordinator(Error)
}

View File

@@ -0,0 +1,5 @@
import Foundation
public protocol MusicBrainzSearchable: Codable, Sendable {
static var entityType: MusicBrainzEntity { get }
}

View File

@@ -0,0 +1,365 @@
import Foundation
public struct MusicBrainzSearchAction<T: MusicBrainzSearchable>: Sendable {
public let entity: MusicBrainzEntity
public let query: String
fileprivate static func buildQuery(_ fields: [String: Any?], raw: String?) -> String {
var parts: [String] = []
for (key, value) in fields {
if let value {
let stringValue: String
if let val = value as? String {
stringValue = val
} else if let val = value as? CustomStringConvertible {
stringValue = val.description
} else if let val = value as? (any RawRepresentable),
let rawVal = val.rawValue as? String
{
stringValue = rawVal
} else {
continue
}
if !stringValue.isEmpty {
let escaped = stringValue.replacingOccurrences(of: "\"", with: "\\\"")
parts.append("\(key):\"\(escaped)\"")
}
}
}
if let raw, !raw.isEmpty {
parts.append(raw)
}
return parts.joined(separator: " AND ")
}
}
extension MusicBrainzSearchAction where T == Area {
public static func area(
name: String? = nil,
type: String? = nil,
iso: String? = nil,
aid: String? = nil,
alias: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"area": name, "type": type, "iso": iso, "aid": aid,
"alias": alias, "begin": begin, "comment": comment, "end": end,
"ended": ended?.description, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .area, query: q)
}
}
extension MusicBrainzSearchAction where T == Artist {
public static func artist(
name: String? = nil,
gender: Gender? = nil,
country: Country? = nil,
type: ArtistType? = nil,
alias: String? = nil,
area: String? = nil,
arid: String? = nil,
begin: String? = nil,
beginarea: String? = nil,
comment: String? = nil,
end: String? = nil,
endarea: String? = nil,
ended: Bool? = nil,
ipi: String? = nil,
isni: String? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"artist": name, "gender": gender?.rawValue, "country": country?.rawValue,
"type": type?.rawValue,
"alias": alias, "area": area, "arid": arid, "begin": begin, "beginarea": beginarea,
"comment": comment, "end": end, "endarea": endarea, "ended": ended?.description,
"ipi": ipi, "isni": isni, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .artist, query: q)
}
}
extension MusicBrainzSearchAction where T == Event {
public static func event(
name: String? = nil,
type: String? = nil,
artist: String? = nil,
place: String? = nil,
aid: String? = nil,
area: String? = nil,
arid: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
eid: String? = nil,
pid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"event": name, "type": type, "artist": artist, "place": place,
"aid": aid, "area": area, "arid": arid, "begin": begin,
"comment": comment, "end": end, "ended": ended?.description,
"eid": eid, "pid": pid, "tag": tag,
], raw: raw)
return .init(entity: .event, query: q)
}
}
extension MusicBrainzSearchAction where T == Instrument {
public static func instrument(
name: String? = nil,
type: String? = nil,
description: String? = nil,
alias: String? = nil,
comment: String? = nil,
iid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"instrument": name, "type": type, "description": description,
"alias": alias, "comment": comment, "iid": iid, "tag": tag,
], raw: raw)
return .init(entity: .instrument, query: q)
}
}
extension MusicBrainzSearchAction where T == Label {
public static func label(
name: String? = nil,
type: String? = nil,
code: String? = nil,
country: Country? = nil,
alias: String? = nil,
area: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
ipi: String? = nil,
isni: String? = nil,
laid: String? = nil,
sortname: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"label": name, "type": type, "code": code, "country": country?.rawValue,
"alias": alias, "area": area, "begin": begin, "comment": comment,
"end": end, "ended": ended?.description, "ipi": ipi, "isni": isni,
"laid": laid, "sortname": sortname, "tag": tag,
], raw: raw)
return .init(entity: .label, query: q)
}
}
extension MusicBrainzSearchAction where T == Place {
public static func place(
name: String? = nil,
type: String? = nil,
address: String? = nil,
area: String? = nil,
alias: String? = nil,
begin: String? = nil,
comment: String? = nil,
end: String? = nil,
ended: Bool? = nil,
lat: String? = nil,
long: String? = nil,
pid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"place": name, "type": type, "address": address, "area": area,
"alias": alias, "begin": begin, "comment": comment, "end": end,
"ended": ended?.description, "lat": lat, "long": long, "pid": pid,
], raw: raw)
return .init(entity: .place, query: q)
}
}
extension MusicBrainzSearchAction where T == Recording {
public static func recording(
title: String? = nil,
artist: String? = nil,
release: String? = nil,
isrc: String? = nil,
arid: String? = nil,
comment: String? = nil,
country: Country? = nil,
date: String? = nil,
dur: String? = nil,
firstreleasedate: String? = nil,
format: String? = nil,
number: String? = nil,
position: String? = nil,
primarytype: String? = nil,
reid: String? = nil,
rgid: String? = nil,
rid: String? = nil,
secondarytype: String? = nil,
status: String? = nil,
tag: String? = nil,
tid: String? = nil,
tnum: String? = nil,
video: Bool? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"recording": title, "artist": artist, "release": release, "isrc": isrc,
"arid": arid, "comment": comment, "country": country?.rawValue, "date": date,
"dur": dur, "firstreleasedate": firstreleasedate, "format": format,
"number": number, "position": position, "primarytype": primarytype,
"reid": reid, "rgid": rgid, "rid": rid, "secondarytype": secondarytype,
"status": status, "tag": tag, "tid": tid, "tnum": tnum, "video": video?.description,
], raw: raw)
return .init(entity: .recording, query: q)
}
}
extension MusicBrainzSearchAction where T == Release {
public static func release(
title: String? = nil,
artist: String? = nil,
label: String? = nil,
barcode: String? = nil,
status: String? = nil,
arid: String? = nil,
asin: String? = nil,
catno: String? = nil,
comment: String? = nil,
country: Country? = nil,
date: String? = nil,
discids: String? = nil,
format: String? = nil,
laid: String? = nil,
mediums: String? = nil,
primarytype: String? = nil,
puid: String? = nil,
reid: String? = nil,
rgid: String? = nil,
script: String? = nil,
secondarytype: String? = nil,
tag: String? = nil,
tracks: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"release": title, "artist": artist, "label": label, "barcode": barcode,
"status": status,
"arid": arid, "asin": asin, "catno": catno, "comment": comment,
"country": country?.rawValue,
"date": date, "discids": discids, "format": format, "laid": laid,
"mediums": mediums,
"primarytype": primarytype, "puid": puid, "reid": reid, "rgid": rgid,
"script": script,
"secondarytype": secondarytype, "tag": tag, "tracks": tracks,
], raw: raw)
return .init(entity: .release, query: q)
}
}
extension MusicBrainzSearchAction where T == ReleaseGroup {
public static func releaseGroup(
title: String? = nil,
artist: String? = nil,
type: String? = nil,
arid: String? = nil,
comment: String? = nil,
primarytype: String? = nil,
rgid: String? = nil,
releases: String? = nil,
secondarytype: String? = nil,
status: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"releasegroup": title, "artist": artist, "type": type, "arid": arid,
"comment": comment, "primarytype": primarytype, "rgid": rgid,
"releases": releases, "secondarytype": secondarytype, "status": status, "tag": tag,
], raw: raw)
return .init(entity: .releaseGroup, query: q)
}
}
extension MusicBrainzSearchAction where T == Series {
public static func series(
name: String? = nil,
type: String? = nil,
alias: String? = nil,
comment: String? = nil,
sid: String? = nil,
tag: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"series": name, "type": type, "alias": alias, "comment": comment,
"sid": sid, "tag": tag,
], raw: raw)
return .init(entity: .series, query: q)
}
}
extension MusicBrainzSearchAction where T == Work {
public static func work(
title: String? = nil,
artist: String? = nil,
type: String? = nil,
iswc: String? = nil,
alias: String? = nil,
arid: String? = nil,
comment: String? = nil,
lang: String? = nil,
tag: String? = nil,
wid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"work": title, "artist": artist, "type": type, "iswc": iswc,
"alias": alias, "arid": arid, "comment": comment, "lang": lang,
"tag": tag, "wid": wid,
], raw: raw)
return .init(entity: .work, query: q)
}
}
extension MusicBrainzSearchAction where T == URLReference {
public static func url(
resource: String? = nil,
targettype: String? = nil,
targetid: String? = nil,
uid: String? = nil,
raw: String? = nil
) -> Self {
let q = buildQuery(
[
"url": resource, "targettype": targettype, "targetid": targetid, "uid": uid,
], raw: raw)
return .init(entity: .url, query: q)
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
public struct SearchResponse<T: MusicBrainzSearchable>: Codable, Sendable {
public let count: Int
public let offset: Int
public let entities: [T]
private enum CodingKeys: String, CodingKey {
case count, offset
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
count = try container.decode(Int.self, forKey: .count)
offset = try container.decode(Int.self, forKey: .offset)
let dynamicContainer = try decoder.container(keyedBy: DynamicCodingKey.self)
guard let key = DynamicCodingKey(stringValue: T.entityType.responseKey) else {
throw DecodingError.dataCorrupted(
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Invalid response key for entity type"
)
)
}
entities = try dynamicContainer.decode([T].self, forKey: key)
}
}

View File

@@ -7,22 +7,37 @@ let client = MusicBrainzClient(
do {
print("--- Artist Search: 'Michael Jackson' ---")
let artistSearch = try await client.search(
.artist(name: "Michael Jackson", gender: .male, country: .us, type: .person), limit: 5)
print("Total results: \(artistSearch.count)")
for artist in artistSearch.entities {
print("- \(artist.name) (\(artist.id))")
if let disambiguation = artist.disambiguation {
print(" (\(disambiguation))")
.artist(name: "Michael Jackson", gender: .male, country: .us, type: .person), limit: 1)
if let artist = artistSearch.entities.first {
print("- Found Artist: \(artist.name) (\(artist.id))")
// Fetch artist with URL relationships to find an image
print(" Fetching artist details (with url-rels)...")
let detailedArtist = try await client.fetch(.artist, id: artist.id, includes: ["url-rels"])
if let imageURL = detailedArtist.imageURL {
print(" Artist Image URL: \(imageURL)")
} else {
print(" No image found for artist.")
}
}
print("\n--- Release Search: 'Thriller' ---")
let releaseSearch = try await client.search(.release(title: "Thriller"), limit: 5)
print("Total results: \(releaseSearch.count)")
for release in releaseSearch.entities {
print("- \(release.title) (\(release.id))")
if let date = release.date {
print(" Date: \(date)")
let releaseSearch = try await client.search(.release(title: "Thriller"), limit: 1)
if let release = releaseSearch.entities.first {
print("- Found Release: \(release.title) (\(release.id))")
// Fetch cover art from Cover Art Archive
print(" Fetching cover art...")
do {
let coverArt = try await client.fetchCoverArt(releaseId: release.id)
if let front = coverArt.frontImage {
print(" Front Cover Image: \(front.image)")
print(" Thumbnails: \(front.thumbnails)")
}
} catch {
print(" Could not fetch cover art: \(error)")
}
}