Musicbrainz api add on

The MusicBrainz feature adds:
	•	Magnifying glass in the toolbar — searches by current title + artist
	•	A picker sheet showing up to 10 results with album, year, country,
label, track number
	•	Blue suggestion pills appear below each field that differs from the
MB match
	•	Tap a pill to accept it — auto-checks the field for saving
	•	Green magnifying glass once a match is selected, tap again to change
This commit is contained in:
Dallas Groot 2026-04-11 01:08:24 -07:00
parent d37dc8fb44
commit 15ed38e13b
2 changed files with 308 additions and 50 deletions

View file

@ -1,5 +1,97 @@
import SwiftUI
// MARK: - MusicBrainz Models
struct MBRecording: Identifiable {
let id: String
let title: String
let artist: String
let album: String
let albumArtist: String
let year: String
let trackNumber: String
let discNumber: String
let genre: String
let country: String
let label: String
}
// MARK: - MusicBrainz Service
struct MusicBrainzService {
static let baseURL = "https://musicbrainz.org/ws/2"
static let userAgent = "NavidromePlayer/1.0 (ca.dallasgroot.navidromeplayer.app)"
static func search(title: String, artist: String) async -> [MBRecording] {
let query = "recording:\"\(title)\" AND artist:\"\(artist)\""
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "\(baseURL)/recording?query=\(encoded)&limit=10&fmt=json") else {
return []
}
var req = URLRequest(url: url)
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
req.timeoutInterval = 10
guard let (data, _) = try? await URLSession.shared.data(for: req),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let recordings = json["recordings"] as? [[String: Any]] else {
return []
}
var results: [MBRecording] = []
for rec in recordings {
let id = rec["id"] as? String ?? UUID().uuidString
let recTitle = rec["title"] as? String ?? ""
// Artist credit
let credits = rec["artist-credit"] as? [[String: Any]] ?? []
let artistName = credits.compactMap {
($0["artist"] as? [String: Any])?["name"] as? String
}.joined(separator: ", ")
// First release
let releases = rec["releases"] as? [[String: Any]] ?? []
let release = releases.first
let albumTitle = release?["title"] as? String ?? ""
let country = release?["country"] as? String ?? ""
let releaseDate = release?["date"] as? String ?? ""
let year = String(releaseDate.prefix(4))
// Label
let labelInfo = (release?["label-info"] as? [[String: Any]])?.first
let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? ""
// Track number within release
let media = release?["media"] as? [[String: Any]]
let track = (media?.first?["tracks"] as? [[String: Any]])?.first
let trackPos = (track?["number"] as? String) ?? (track?["position"].map { "\($0)" } ?? "")
let discPos = media?.first?["position"].map { "\($0)" } ?? ""
// Album artist from release artist-credit
let releaseCredits = release?["artist-credit"] as? [[String: Any]] ?? []
let releaseArtist = releaseCredits.compactMap {
($0["artist"] as? [String: Any])?["name"] as? String
}.joined(separator: ", ")
results.append(MBRecording(
id: id, title: recTitle,
artist: artistName,
album: albumTitle,
albumArtist: releaseArtist.isEmpty ? artistName : releaseArtist,
year: year,
trackNumber: trackPos,
discNumber: discPos == "1" ? "" : discPos,
genre: "",
country: country,
label: labelName
))
}
return results
}
}
// MARK: - TrackEditorView
struct TrackEditorView: View {
let song: Song
@Environment(\.dismiss) private var dismiss
@ -24,6 +116,13 @@ struct TrackEditorView: View {
@State private var editTrackNumber = false
@State private var editDiscNumber = false
// MusicBrainz
@State private var isSearchingMB = false
@State private var mbResults: [MBRecording] = []
@State private var selectedMB: MBRecording?
@State private var showMBPicker = false
@State private var mbError: String?
@State private var isSaving = false
@State private var errorMessage: String?
@State private var showSuccess = false
@ -40,11 +139,11 @@ struct TrackEditorView: View {
/// Live preview of where the file will end up after restructure.
private var pathPreview: String {
let aa = editAlbumArtist ? albumArtist : albumArtist
let alb = editAlbum ? album : album
let t = editTitle ? title : title
let tn = editTrackNumber ? (Int(trackNumber) ?? 0) : (song.track ?? 0)
let dn = editDiscNumber ? (Int(discNumber) ?? 0) : (song.discNumber ?? 0)
let aa = albumArtist
let alb = album
let t = title
let tn = Int(trackNumber) ?? (song.track ?? 0)
let dn = Int(discNumber) ?? (song.discNumber ?? 0)
let ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac")
let prefix: String
@ -75,10 +174,6 @@ struct TrackEditorView: View {
_year = State(initialValue: song.year.map { "\($0)" } ?? "")
_trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "")
_discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "")
// Use albumArtist from the Song model (decoded from Subsonic's albumArtist field).
// Falls back to artist only if albumArtist is nil this is the correct behaviour
// for non-compilation tracks where artist == albumArtist.
_albumArtist = State(initialValue: song.albumArtist ?? song.artist ?? "")
}
@ -115,12 +210,42 @@ struct TrackEditorView: View {
Text("File Info")
}
// MusicBrainz suggestion banner
if let mb = selectedMB {
Section {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(.green)
.font(.system(size: 13))
Text("MusicBrainz: \(mb.album)")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
Spacer()
Button("Change") { showMBPicker = true }
.font(.system(size: 12))
.foregroundColor(accentPink)
}
if !mb.label.isEmpty {
Text("\(mb.country) · \(mb.label)")
.font(.system(size: 11))
.foregroundColor(.gray)
}
}
.padding(.vertical, 2)
} header: {
Text("MusicBrainz Match")
} footer: {
Text("Tap ↓ next to any field to apply the suggestion.")
}
}
// Editable fields
Section {
checkField("Title", text: $title, isEnabled: $editTitle)
checkField("Artist", text: $artist, isEnabled: $editArtist)
checkField("Album", text: $album, isEnabled: $editAlbum)
checkField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist)
mbCheckField("Title", text: $title, isEnabled: $editTitle, mbValue: selectedMB?.title)
mbCheckField("Artist", text: $artist, isEnabled: $editArtist, mbValue: selectedMB?.artist)
mbCheckField("Album", text: $album, isEnabled: $editAlbum, mbValue: selectedMB?.album)
mbCheckField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist, mbValue: selectedMB?.albumArtist)
} header: {
Text("Basic Info")
} footer: {
@ -128,15 +253,15 @@ struct TrackEditorView: View {
}
Section {
checkField("Genre", text: $genre, isEnabled: $editGenre)
checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad)
checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad)
checkField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, keyboard: .numberPad)
mbCheckField("Genre", text: $genre, isEnabled: $editGenre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
mbCheckField("Year", text: $year, isEnabled: $editYear, mbValue: selectedMB?.year, keyboard: .numberPad)
mbCheckField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, mbValue: selectedMB?.trackNumber, keyboard: .numberPad)
mbCheckField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, mbValue: selectedMB?.discNumber.isEmpty == false ? selectedMB?.discNumber : nil, keyboard: .numberPad)
} header: {
Text("Details")
}
// Path preview always visible
// Path preview
Section {
VStack(alignment: .leading, spacing: 4) {
Text("New path")
@ -171,20 +296,42 @@ struct TrackEditorView: View {
.foregroundColor(.gray)
}
ToolbarItem(placement: .navigationBarTrailing) {
if settings.isEnabled {
Button(action: save) {
if isSaving {
ProgressView().tint(accentPink)
HStack(spacing: 14) {
// MusicBrainz search button
Button(action: searchMusicBrainz) {
if isSearchingMB {
ProgressView().tint(.white).scaleEffect(0.8)
} else {
Text("Save")
.fontWeight(.semibold)
.foregroundColor(hasSelection ? accentPink : .gray)
Image(systemName: "magnifyingglass")
.foregroundColor(selectedMB != nil ? .green : .white)
}
}
.disabled(isSaving || !hasSelection || song.path == nil)
.disabled(isSearchingMB)
if settings.isEnabled {
Button(action: save) {
if isSaving {
ProgressView().tint(accentPink)
} else {
Text("Save")
.fontWeight(.semibold)
.foregroundColor(hasSelection ? accentPink : .gray)
}
}
.disabled(isSaving || !hasSelection || song.path == nil)
}
}
}
}
.sheet(isPresented: $showMBPicker) {
MusicBrainzPickerSheet(
results: mbResults,
onSelect: { rec in
selectedMB = rec
showMBPicker = false
}
)
}
.overlay {
if showSuccess {
VStack(spacing: 12) {
@ -210,12 +357,29 @@ struct TrackEditorView: View {
}
}
// MARK: - MusicBrainz Search
private func searchMusicBrainz() {
isSearchingMB = true
mbError = nil
Task {
let results = await MusicBrainzService.search(title: title, artist: artist)
await MainActor.run {
isSearchingMB = false
if results.isEmpty {
mbError = "No results found on MusicBrainz"
errorMessage = mbError
} else {
mbResults = results
showMBPicker = true
}
}
}
}
// MARK: - Fetch companion details to populate album artist + disc number
private func fetchCompanionDetails() async {
// The Subsonic Song model now decodes albumArtist directly from the API.
// This fetch is a fallback to get the correct album_artist from the
// Companion's own DB (which may have been manually corrected).
guard let path = song.path,
let baseURL = CompanionSettings.shared.baseURL else { return }
@ -226,7 +390,6 @@ struct TrackEditorView: View {
let response = try? JSONDecoder().decode(CompanionLibraryResponse.self, from: data),
let songs = response.songs else { return }
// Match by relative_path
if let match = songs.first(where: { $0.relative_path == path || path.hasSuffix($0.relative_path) }) {
await MainActor.run {
if albumArtist.isEmpty || albumArtist == (song.artist ?? "") {
@ -261,7 +424,6 @@ struct TrackEditorView: View {
try await api.editMetadata(request)
// Notify the watch so it can update its offline store metadata
WatchConnectivityManager.shared.notifyTagsUpdated(
songId: song.id,
title: request.title,
@ -299,28 +461,123 @@ struct TrackEditorView: View {
.font(.system(size: 14))
}
// MARK: - Checkmark Field
// MARK: - Checkmark Field with optional MusicBrainz suggestion
private func checkField(_ label: String, text: Binding<String>, isEnabled: Binding<Bool>, keyboard: UIKeyboardType = .default) -> some View {
HStack(spacing: 10) {
Button(action: { isEnabled.wrappedValue.toggle() }) {
Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4))
private func mbCheckField(
_ label: String,
text: Binding<String>,
isEnabled: Binding<Bool>,
mbValue: String?,
keyboard: UIKeyboardType = .default
) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Button(action: { isEnabled.wrappedValue.toggle() }) {
Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4))
}
.buttonStyle(.plain)
Text(label)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray)
.frame(width: 85, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
.disabled(!isEnabled.wrappedValue)
.keyboardDoneButton()
}
.buttonStyle(.plain)
.font(.system(size: 13))
Text(label)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray)
.frame(width: 85, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
.disabled(!isEnabled.wrappedValue)
.keyboardDoneButton()
// MusicBrainz suggestion pill only show if different from current value
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
Button(action: {
text.wrappedValue = mb
isEnabled.wrappedValue = true
}) {
HStack(spacing: 4) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 10))
Text(mb)
.font(.system(size: 11))
.lineLimit(1)
}
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.35))
.cornerRadius(8)
}
.buttonStyle(.plain)
.padding(.leading, 30)
}
}
.font(.system(size: 13))
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
}
}
// MARK: - MusicBrainz Picker Sheet
struct MusicBrainzPickerSheet: View {
let results: [MBRecording]
let onSelect: (MBRecording) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(results) { rec in
Button(action: { onSelect(rec) }) {
VStack(alignment: .leading, spacing: 4) {
Text(rec.title)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(.white)
Text(rec.artist)
.font(.system(size: 13))
.foregroundColor(.gray)
HStack(spacing: 8) {
if !rec.album.isEmpty {
Text(rec.album)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.6))
}
if !rec.year.isEmpty {
Text(rec.year)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
}
if !rec.country.isEmpty {
Text(rec.country)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.4))
}
if !rec.trackNumber.isEmpty {
Text("Track \(rec.trackNumber)")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.4))
}
}
if !rec.label.isEmpty {
Text(rec.label)
.font(.system(size: 11))
.foregroundColor(.gray.opacity(0.7))
}
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
.navigationTitle("MusicBrainz Results")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Cancel") { dismiss() }
.foregroundColor(.gray)
}
}
}
}
}

View file

@ -1136,6 +1136,7 @@ struct MyMusicView: View {
let grouped = Album(
id: album.id, name: album.name,
artist: "Various Artists", artistId: nil,
albumArtist: "Various Artists",
coverArt: album.coverArt, songCount: album.songCount,
duration: album.duration, playCount: album.playCount,
created: album.created, starred: album.starred,