I think we're done

This commit is contained in:
cdricms
2026-03-21 18:25:21 +01:00
parent 2d1e8e1044
commit ba9e2db1b9
26 changed files with 281 additions and 63 deletions

View File

@@ -4,3 +4,38 @@ Documentation and reference for the MusicBrainz API implementation.
## Search API Reference ## Search API Reference
- [MusicBrainz Search API Documentation](https://musicbrainz.org/doc/MusicBrainz_API/Search) - [MusicBrainz Search API Documentation](https://musicbrainz.org/doc/MusicBrainz_API/Search)
## SwiftUI and SwiftData Readiness
The library is designed to be cross-platform and modern-Swift ready.
### SwiftUI
All models conform to `Identifiable`, `Hashable`, and `Equatable`. This makes them perfect for:
- Efficient list rendering using `ForEach`.
- State management with `@State` or `@Observable`.
- Value-based equality checks.
### SwiftData Bridging
Since this library is cross-platform, it does not import `SwiftData` directly. To persist MusicBrainz entities in SwiftData, you should create separate `@Model` classes and bridge them.
**Example:**
```swift
import SwiftData
import MusicBrainz
@Model
class PersistentArtist {
@Attribute(.unique) var id: String
var name: String
var country: String?
init(from artist: MusicBrainz.Artist) {
self.id = artist.id
self.name = artist.name
self.country = artist.country
}
}
```
Bridging DTOs to local storage models ensures that your persistence layer remains decoupled from the network layer, which is a best practice.

View File

@@ -1,15 +1,15 @@
import Foundation import Foundation
public enum Gender: String, Sendable { public enum Gender: String, Sendable, Hashable, Equatable {
case male, female, other case male, female, other
case notApplicable = "not applicable" case notApplicable = "not applicable"
} }
public enum ArtistType: String, Sendable { public enum ArtistType: String, Sendable, Hashable, Equatable {
case person, group, orchestra, choir, character, other case person, group, orchestra, choir, character, other
} }
public enum Country: String, Sendable { public enum Country: String, Sendable, Hashable, Equatable {
case af = "AF" // Afghanistan case af = "AF" // Afghanistan
case al = "AL" // Albania case al = "AL" // Albania
case dz = "DZ" // Algeria case dz = "DZ" // Algeria
@@ -49,3 +49,31 @@ public enum Country: String, Sendable {
case gb = "GB" // United Kingdom case gb = "GB" // United Kingdom
case us = "US" // United States case us = "US" // United States
} }
public enum ReleaseFormat: String, Codable, Sendable, Hashable, Equatable {
case cd = "CD"
case cdR = "CD-R"
case enhancedCD = "Enhanced CD"
case vinyl = "Vinyl"
case vinyl12 = "12\" Vinyl"
case vinyl10 = "10\" Vinyl"
case vinyl7 = "7\" Vinyl"
case cassette = "Cassette"
case digitalMedia = "Digital Media"
case dvd = "DVD"
case dvdVideo = "DVD-Video"
case dvdAudio = "DVD-Audio"
case bluRay = "Blu-ray"
case miniDisc = "MiniDisc"
case sacd = "SACD"
case ld = "LD"
case vhs = "VHS"
case other = "Other"
// Fallback decoding for unknown formats
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = ReleaseFormat(rawValue: rawValue) ?? .other
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct Artist: MusicBrainzSearchable { public struct Artist: MusicBrainzSearchable, Identifiable, Hashable, Equatable {
public static let entityType: MusicBrainzEntity = .artist public static let entityType: MusicBrainzEntity = .artist
public let id: String public let id: String
@@ -11,9 +11,11 @@ public struct Artist: MusicBrainzSearchable {
public let disambiguation: String? public let disambiguation: String?
public let lifeSpan: LifeSpan? public let lifeSpan: LifeSpan?
public let relations: [Relation]? public let relations: [Relation]?
public let releases: [Release]?
public let releaseGroups: [ReleaseGroup]?
public let score: Int? public let score: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey, Hashable, Equatable {
case id case id
case name case name
case sortName = "sort-name" case sortName = "sort-name"
@@ -22,6 +24,8 @@ public struct Artist: MusicBrainzSearchable {
case disambiguation case disambiguation
case lifeSpan = "life-span" case lifeSpan = "life-span"
case relations case relations
case releases
case releaseGroups = "release-groups"
case score case score
} }
@@ -31,7 +35,15 @@ public struct Artist: MusicBrainzSearchable {
for rel in relations { for rel in relations {
if rel.type == "image" || rel.type == "wikimedia commons" { if rel.type == "image" || rel.type == "wikimedia commons" {
if let resource = rel.url?.resource { if let resource = rel.url?.resource {
return URL(string: resource) var urlString = resource
// Handle Wikimedia Commons page URLs by converting to Special:FilePath
if urlString.contains("commons.wikimedia.org/wiki/File:") {
let filename = urlString.components(separatedBy: "/wiki/File:").last ?? ""
if !filename.isEmpty {
urlString = "https://commons.wikimedia.org/wiki/Special:FilePath/\(filename)"
}
}
return URL(string: urlString)
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct Recording: MusicBrainzSearchable { public struct Recording: MusicBrainzSearchable, Identifiable, Hashable, Equatable {
public static let entityType: MusicBrainzEntity = .recording public static let entityType: MusicBrainzEntity = .recording
public let id: String public let id: String
@@ -12,7 +12,7 @@ public struct Recording: MusicBrainzSearchable {
public let relations: [Relation]? public let relations: [Relation]?
public let score: Int? public let score: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey, Hashable, Equatable {
case id case id
case title case title
case length case length
@@ -22,4 +22,16 @@ public struct Recording: MusicBrainzSearchable {
case relations case relations
case score case score
} }
public var youtubeURL: URL? {
guard let relations else { return nil }
for rel in relations {
if let resource = rel.url?.resource {
if rel.type == "youtube" || resource.contains("youtube.com") || resource.contains("youtu.be") {
return URL(string: resource)
}
}
}
return nil
}
} }

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct Release: MusicBrainzSearchable { public struct Release: MusicBrainzSearchable, Identifiable, Hashable, Equatable {
public static let entityType: MusicBrainzEntity = .release public static let entityType: MusicBrainzEntity = .release
public let id: String public let id: String
@@ -11,9 +11,10 @@ public struct Release: MusicBrainzSearchable {
public let barcode: String? public let barcode: String?
public let disambiguation: String? public let disambiguation: String?
public let relations: [Relation]? public let relations: [Relation]?
public let media: [Media]?
public let score: Int? public let score: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey, Hashable, Equatable {
case id case id
case title case title
case status case status
@@ -22,6 +23,7 @@ public struct Release: MusicBrainzSearchable {
case barcode case barcode
case disambiguation case disambiguation
case relations case relations
case media
case score case score
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct ArtistCredit: Codable, Sendable { public struct ArtistCredit: Codable, Sendable, Hashable, Equatable {
public let name: String public let name: String
public let artist: Artist? public let artist: Artist?
public let joinphrase: String? public let joinphrase: String?

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct CoverArtImage: Codable, Sendable { public struct CoverArtImage: Codable, Sendable, Identifiable, Hashable, Equatable {
public let image: String public let image: String
public let thumbnails: [String: String] public let thumbnails: [String: String]
public let types: [String] public let types: [String]
@@ -11,7 +11,7 @@ public struct CoverArtImage: Codable, Sendable {
public let id: String public let id: String
} }
public struct CoverArtResponse: Codable, Sendable { public struct CoverArtResponse: Codable, Sendable, Hashable, Equatable {
public let images: [CoverArtImage] public let images: [CoverArtImage]
public let release: String public let release: String

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct LifeSpan: Codable, Sendable { public struct LifeSpan: Codable, Sendable, Hashable, Equatable {
public let begin: String? public let begin: String?
public let end: String? public let end: String?
public let ended: Bool? public let ended: Bool?

View File

@@ -0,0 +1,22 @@
import Foundation
public struct Track: Codable, Sendable, Identifiable, Hashable, Equatable {
public let id: String
public let number: String
public let position: Int
public let title: String
public let length: Int?
public let recording: Recording?
}
public struct Media: Codable, Sendable, Hashable, Equatable {
public let position: Int
public let format: ReleaseFormat?
public let trackCount: Int
public let tracks: [Track]?
enum CodingKeys: String, CodingKey {
case position, format, tracks
case trackCount = "track-count"
}
}

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct Relation: Codable, Sendable { public struct Relation: Codable, Sendable, Hashable, Equatable {
public let type: String public let type: String
public let direction: String public let direction: String
public let url: URLResource? public let url: URLResource?

View File

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

View File

@@ -0,0 +1,20 @@
import Foundation
public struct WikimediaResponse: Codable, Sendable {
public let query: WikimediaQuery
}
public struct WikimediaQuery: Codable, Sendable {
public let pages: [String: WikimediaPage]
}
public struct WikimediaPage: Codable, Sendable {
public let pageid: Int?
public let title: String
public let imageinfo: [WikimediaImageInfo]?
}
public struct WikimediaImageInfo: Codable, Sendable {
public let url: String
public let descriptionurl: String
}

View File

@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct URLReference: MusicBrainzSearchable { public struct URLReference: MusicBrainzSearchable, Identifiable, Hashable, Equatable {
public static let entityType: MusicBrainzEntity = .url public static let entityType: MusicBrainzEntity = .url
public let id: String public let id: String

View File

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

View File

@@ -106,10 +106,8 @@ public struct MusicBrainzClient: Sendable {
} }
} }
public func fetchCoverArt(releaseId: String) async throws(MusicBrainzError) -> CoverArtResponse public func fetchCoverArt(releaseId: String) async throws(MusicBrainzError) -> CoverArtResponse {
{ let data = try await fetchRaw(baseURL: MusicBrainzClient.caaBaseURL, "/release/\(releaseId)")
let data = try await fetchRaw(
baseURL: MusicBrainzClient.caaBaseURL, "/release/\(releaseId)")
do { do {
return try JSONDecoder().decode(CoverArtResponse.self, from: data) return try JSONDecoder().decode(CoverArtResponse.self, from: data)
} catch { } catch {
@@ -117,6 +115,47 @@ public struct MusicBrainzClient: Sendable {
} }
} }
public func resolveWikimediaImageURL(_ url: URL) async throws(MusicBrainzError) -> URL {
let urlString = url.absoluteString
guard urlString.contains("commons.wikimedia.org/wiki/File:") || urlString.contains("Special:FilePath") else {
return url
}
let filename: String
if urlString.contains("/wiki/File:") {
filename = urlString.components(separatedBy: "/wiki/File:").last ?? ""
} else if urlString.contains("Special:FilePath/") {
filename = urlString.components(separatedBy: "Special:FilePath/").last ?? ""
} else {
return url
}
guard !filename.isEmpty else { return url }
let wikimediaBaseURL = URL(string: "https://commons.wikimedia.org/w/api.php")!
let queryItems = [
URLQueryItem(name: "action", value: "query"),
URLQueryItem(name: "titles", value: "File:\(filename)"),
URLQueryItem(name: "prop", value: "imageinfo"),
URLQueryItem(name: "iiprop", value: "url"),
URLQueryItem(name: "format", value: "json"),
]
let data = try await fetchRaw(baseURL: wikimediaBaseURL, "", queryItems: queryItems)
do {
let response = try JSONDecoder().decode(WikimediaResponse.self, from: data)
if let page = response.query.pages.values.first,
let directURLString = page.imageinfo?.first?.url,
let directURL = URL(string: directURLString)
{
return directURL
}
return url
} catch {
throw .decodingError(error)
}
}
@available( @available(
*, deprecated, message: "Use search(_ action: MusicBrainzSearchAction<T>, ...) instead" *, deprecated, message: "Use search(_ action: MusicBrainzSearchAction<T>, ...) instead"
) )

View File

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

View File

@@ -1,11 +1,11 @@
import Foundation import Foundation
public struct SearchResponse<T: MusicBrainzSearchable>: Codable, Sendable { public struct SearchResponse<T: MusicBrainzSearchable>: Codable, Sendable, Hashable, Equatable {
public let count: Int public let count: Int
public let offset: Int public let offset: Int
public let entities: [T] public let entities: [T]
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey, Hashable, Equatable {
case count, offset case count, offset
} }

View File

@@ -12,32 +12,42 @@ do {
if let artist = artistSearch.entities.first { if let artist = artistSearch.entities.first {
print("- Found Artist: \(artist.name) (\(artist.id))") print("- Found Artist: \(artist.name) (\(artist.id))")
// Fetch artist with URL relationships to find an image // Fetch artist with URL relationships and releases
print(" Fetching artist details (with url-rels)...") print(" Fetching artist details (with url-rels + releases)...")
let detailedArtist = try await client.fetch(.artist, id: artist.id, includes: ["url-rels"]) let detailedArtist = try await client.fetch(
.artist, id: artist.id, includes: ["url-rels", "releases"])
if let imageURL = detailedArtist.imageURL { if let imageURL = detailedArtist.imageURL {
print(" Artist Image URL: \(imageURL)") print(" Artist Image Shortcut: \(imageURL)")
} else { }
print(" No image found for artist.")
if let releases = detailedArtist.releases {
print(" Releases count: \(releases.count)")
for release in releases.prefix(5) {
print(" - \(release.title) (\(release.id))")
} }
} }
print("\n--- Release Search: 'Thriller' ---") // Fetch tracks for the first release
let releaseSearch = try await client.search(.release(title: "Thriller"), limit: 1) if let release = detailedArtist.releases?.first {
print("\n--- Fetching tracks for release: '\(release.title)' ---")
if let release = releaseSearch.entities.first { let detailedRelease = try await client.fetch(
print("- Found Release: \(release.title) (\(release.id))") .release, id: release.id, includes: ["recordings"])
if let media = detailedRelease.media {
// Fetch cover art from Cover Art Archive for medium in media {
print(" Fetching cover art...") print(
do { " Medium \(medium.position) (\(medium.format?.rawValue ?? "Unknown format")):"
let coverArt = try await client.fetchCoverArt(releaseId: release.id) )
if let front = coverArt.frontImage { if let tracks = medium.tracks {
print(" Front Cover Image: \(front.image)") for track in tracks {
print(" Thumbnails: \(front.thumbnails)") print(" \(track.number). \(track.title)")
if let youtubeURL = track.recording?.youtubeURL {
print(" YouTube: \(youtubeURL)")
}
}
}
}
} }
} catch {
print(" Could not fetch cover art: \(error)")
} }
} }

View File

@@ -157,5 +157,43 @@ import Testing
""".data(using: .utf8)! """.data(using: .utf8)!
let artist = try JSONDecoder().decode(Artist.self, from: json) let artist = try JSONDecoder().decode(Artist.self, from: json)
#expect(artist.imageURL?.absoluteString == "https://commons.wikimedia.org/wiki/File:Michael_Jackson_1984.jpg") #expect(artist.imageURL?.absoluteString == "https://commons.wikimedia.org/wiki/Special:FilePath/Michael_Jackson_1984.jpg")
}
@Test func testRecordingYouTubeURL() throws {
let json = """
{
"id": "abc",
"title": "Billie Jean",
"relations": [
{
"type": "video",
"direction": "forward",
"url": {
"id": "456",
"resource": "https://www.youtube.com/watch?v=Zi_XLOBDo_Y"
}
}
]
}
""".data(using: .utf8)!
let recording = try JSONDecoder().decode(Recording.self, from: json)
#expect(recording.youtubeURL?.absoluteString == "https://www.youtube.com/watch?v=Zi_XLOBDo_Y")
}
@Test func testReleaseFormatDecoding() throws {
let json = """
[
"CD",
"Digital Media",
"Unknown Format XY"
]
""".data(using: .utf8)!
let formats = try JSONDecoder().decode([ReleaseFormat].self, from: json)
#expect(formats.count == 3)
#expect(formats[0] == .cd)
#expect(formats[1] == .digitalMedia)
#expect(formats[2] == .other)
} }