Everything works well and good structure.
This commit is contained in:
51
Sources/MusicBrainz/Enums.swift
Normal file
51
Sources/MusicBrainz/Enums.swift
Normal 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
|
||||||
|
}
|
||||||
8
Sources/MusicBrainz/Internal/DynamicCodingKey.swift
Normal file
8
Sources/MusicBrainz/Internal/DynamicCodingKey.swift
Normal 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 }
|
||||||
|
}
|
||||||
20
Sources/MusicBrainz/Models/Area.swift
Normal file
20
Sources/MusicBrainz/Models/Area.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Sources/MusicBrainz/Models/Artist.swift
Normal file
40
Sources/MusicBrainz/Models/Artist.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/MusicBrainz/Models/Event.swift
Normal file
18
Sources/MusicBrainz/Models/Event.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Sources/MusicBrainz/Models/Instrument.swift
Normal file
17
Sources/MusicBrainz/Models/Instrument.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Sources/MusicBrainz/Models/Label.swift
Normal file
21
Sources/MusicBrainz/Models/Label.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/MusicBrainz/Models/Place.swift
Normal file
18
Sources/MusicBrainz/Models/Place.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Sources/MusicBrainz/Models/Recording.swift
Normal file
25
Sources/MusicBrainz/Models/Recording.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Sources/MusicBrainz/Models/Release.swift
Normal file
27
Sources/MusicBrainz/Models/Release.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Sources/MusicBrainz/Models/ReleaseGroup.swift
Normal file
19
Sources/MusicBrainz/Models/ReleaseGroup.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
Sources/MusicBrainz/Models/Series.swift
Normal file
16
Sources/MusicBrainz/Models/Series.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Sources/MusicBrainz/Models/Shared/ArtistCredit.swift
Normal file
7
Sources/MusicBrainz/Models/Shared/ArtistCredit.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ArtistCredit: Codable, Sendable {
|
||||||
|
public let name: String
|
||||||
|
public let artist: Artist?
|
||||||
|
public let joinphrase: String?
|
||||||
|
}
|
||||||
21
Sources/MusicBrainz/Models/Shared/CoverArt.swift
Normal file
21
Sources/MusicBrainz/Models/Shared/CoverArt.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Sources/MusicBrainz/Models/Shared/LifeSpan.swift
Normal file
7
Sources/MusicBrainz/Models/Shared/LifeSpan.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct LifeSpan: Codable, Sendable {
|
||||||
|
public let begin: String?
|
||||||
|
public let end: String?
|
||||||
|
public let ended: Bool?
|
||||||
|
}
|
||||||
14
Sources/MusicBrainz/Models/Shared/Relation.swift
Normal file
14
Sources/MusicBrainz/Models/Shared/Relation.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Sources/MusicBrainz/Models/Shared/URLResource.swift
Normal file
6
Sources/MusicBrainz/Models/Shared/URLResource.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct URLResource: Codable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let resource: String
|
||||||
|
}
|
||||||
9
Sources/MusicBrainz/Models/URLReference.swift
Normal file
9
Sources/MusicBrainz/Models/URLReference.swift
Normal 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?
|
||||||
|
}
|
||||||
17
Sources/MusicBrainz/Models/Work.swift
Normal file
17
Sources/MusicBrainz/Models/Work.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
141
Sources/MusicBrainz/MusicBrainzClient.swift
Normal file
141
Sources/MusicBrainz/MusicBrainzClient.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Sources/MusicBrainz/MusicBrainzCoordinator.swift
Normal file
37
Sources/MusicBrainz/MusicBrainzCoordinator.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Sources/MusicBrainz/MusicBrainzEntity.swift
Normal file
23
Sources/MusicBrainz/MusicBrainzEntity.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Sources/MusicBrainz/MusicBrainzEntityType.swift
Normal file
20
Sources/MusicBrainz/MusicBrainzEntityType.swift
Normal 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) }
|
||||||
|
}
|
||||||
8
Sources/MusicBrainz/MusicBrainzError.swift
Normal file
8
Sources/MusicBrainz/MusicBrainzError.swift
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum MusicBrainzError: Error {
|
||||||
|
case badURL
|
||||||
|
case badServerResponse(Int)
|
||||||
|
case decodingError(Error)
|
||||||
|
case coordinator(Error)
|
||||||
|
}
|
||||||
5
Sources/MusicBrainz/MusicBrainzSearchable.swift
Normal file
5
Sources/MusicBrainz/MusicBrainzSearchable.swift
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol MusicBrainzSearchable: Codable, Sendable {
|
||||||
|
static var entityType: MusicBrainzEntity { get }
|
||||||
|
}
|
||||||
365
Sources/MusicBrainz/SearchAction.swift
Normal file
365
Sources/MusicBrainz/SearchAction.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/MusicBrainz/SearchResponse.swift
Normal file
28
Sources/MusicBrainz/SearchResponse.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,22 +7,37 @@ let client = MusicBrainzClient(
|
|||||||
do {
|
do {
|
||||||
print("--- Artist Search: 'Michael Jackson' ---")
|
print("--- Artist Search: 'Michael Jackson' ---")
|
||||||
let artistSearch = try await client.search(
|
let artistSearch = try await client.search(
|
||||||
.artist(name: "Michael Jackson", gender: .male, country: .us, type: .person), limit: 5)
|
.artist(name: "Michael Jackson", gender: .male, country: .us, type: .person), limit: 1)
|
||||||
print("Total results: \(artistSearch.count)")
|
|
||||||
for artist in artistSearch.entities {
|
if let artist = artistSearch.entities.first {
|
||||||
print("- \(artist.name) (\(artist.id))")
|
print("- Found Artist: \(artist.name) (\(artist.id))")
|
||||||
if let disambiguation = artist.disambiguation {
|
|
||||||
print(" (\(disambiguation))")
|
// 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' ---")
|
print("\n--- Release Search: 'Thriller' ---")
|
||||||
let releaseSearch = try await client.search(.release(title: "Thriller"), limit: 5)
|
let releaseSearch = try await client.search(.release(title: "Thriller"), limit: 1)
|
||||||
print("Total results: \(releaseSearch.count)")
|
|
||||||
for release in releaseSearch.entities {
|
if let release = releaseSearch.entities.first {
|
||||||
print("- \(release.title) (\(release.id))")
|
print("- Found Release: \(release.title) (\(release.id))")
|
||||||
if let date = release.date {
|
|
||||||
print(" Date: \(date)")
|
// 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,3 +106,56 @@ import Testing
|
|||||||
#expect(response.entities.count == 1)
|
#expect(response.entities.count == 1)
|
||||||
#expect(response.entities[0].resource == "http://www.madonna.com/")
|
#expect(response.entities[0].resource == "http://www.madonna.com/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func testCoverArtDecoding() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"image": "http://coverartarchive.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc/3125412512.jpg",
|
||||||
|
"thumbnails": {
|
||||||
|
"small": "http://coverartarchive.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc/3125412512-250.jpg",
|
||||||
|
"large": "http://coverartarchive.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc/3125412512-500.jpg"
|
||||||
|
},
|
||||||
|
"types": ["Front"],
|
||||||
|
"front": true,
|
||||||
|
"back": false,
|
||||||
|
"edit": 12345,
|
||||||
|
"comment": "",
|
||||||
|
"id": "3125412512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"release": "https://musicbrainz.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc"
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let response = try JSONDecoder().decode(CoverArtResponse.self, from: json)
|
||||||
|
|
||||||
|
#expect(response.images.count == 1)
|
||||||
|
#expect(response.images[0].front == true)
|
||||||
|
#expect(response.frontImage?.image == "http://coverartarchive.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc/3125412512.jpg")
|
||||||
|
#expect(response.images[0].thumbnails["small"] == "http://coverartarchive.org/release/a2d12ee8-9aeb-4d91-bfab-5c21f7a577fc/3125412512-250.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func testArtistImageExtraction() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"id": "f27ec8db-af05-4f36-916e-3d57f91ecf5e",
|
||||||
|
"name": "Michael Jackson",
|
||||||
|
"sort-name": "Jackson, Michael",
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"direction": "forward",
|
||||||
|
"url": {
|
||||||
|
"id": "123",
|
||||||
|
"resource": "https://commons.wikimedia.org/wiki/File:Michael_Jackson_1984.jpg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let artist = try JSONDecoder().decode(Artist.self, from: json)
|
||||||
|
#expect(artist.imageURL?.absoluteString == "https://commons.wikimedia.org/wiki/File:Michael_Jackson_1984.jpg")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user