431 lines
19 KiB
Swift
431 lines
19 KiB
Swift
import SwiftUI
|
|
|
|
/// Batch-edit metadata for all songs in an album.
|
|
struct BatchAlbumEditorSheet: View {
|
|
let album: AlbumWithSongs
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var albumName: String
|
|
@State private var albumArtist: String
|
|
@State private var artist: String
|
|
@State private var genre: String
|
|
@State private var year: String
|
|
@State private var applyArtistToAll = false
|
|
|
|
// Song removal — start with all songs included
|
|
@State private var includedSongs: [Song]
|
|
|
|
// MusicBrainz
|
|
@State private var isSearchingMB = false
|
|
@State private var mbResults: [MBRecording] = []
|
|
@State private var selectedMB: MBRecording?
|
|
@State private var showMBPicker = false
|
|
|
|
// Cover art
|
|
@State private var pendingCoverImage: UIImage? = nil
|
|
@State private var removeCoverArt = false
|
|
@State private var showImagePicker = false
|
|
|
|
@State private var isSaving = false
|
|
@State private var progress: Double = 0
|
|
@State private var resultMessage: String?
|
|
@State private var failedTracks: [String] = []
|
|
|
|
@ObservedObject private var settings = CompanionSettings.shared
|
|
|
|
private let api = CompanionAPIService.shared
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
|
|
|
// The cover art ID to use — first included song with a companion coverArt
|
|
private var coverArtSongId: String? {
|
|
includedSongs.first(where: {
|
|
$0.coverArt?.hasPrefix("companion:") == true
|
|
})?.coverArt
|
|
}
|
|
|
|
private var coverArtCompanionId: String? {
|
|
guard let id = coverArtSongId, id.hasPrefix("companion:") else { return nil }
|
|
return String(id.dropFirst("companion:".count))
|
|
}
|
|
|
|
init(album: AlbumWithSongs) {
|
|
self.album = album
|
|
_albumName = State(initialValue: album.name)
|
|
_albumArtist = State(initialValue: album.albumArtist ?? album.artist ?? "")
|
|
_artist = State(initialValue: album.albumArtist ?? album.artist ?? "")
|
|
_genre = State(initialValue: album.genre ?? "")
|
|
_year = State(initialValue: album.year != nil ? "\(album.year!)" : "")
|
|
_includedSongs = State(initialValue: album.song ?? [])
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if !settings.isEnabled {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.yellow)
|
|
Text("Companion API Required")
|
|
.font(.system(size: 15, weight: .medium))
|
|
Text("Enable the Companion API in Settings to batch-edit tags.")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
} else {
|
|
// Cover art + scope header
|
|
Section {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
CoverArtEditorWidget(
|
|
existingCoverArtId: coverArtSongId,
|
|
pendingImage: $pendingCoverImage,
|
|
removeArt: $removeCoverArt,
|
|
showPicker: $showImagePicker,
|
|
size: 72
|
|
)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(albumName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.lineLimit(2)
|
|
Text("\(includedSongs.count) of \(album.song?.count ?? 0) tracks selected")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
if pendingCoverImage != nil {
|
|
Text("Cover art will be replaced")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.red.opacity(0.8))
|
|
} else if removeCoverArt {
|
|
Text("Cover art will be removed")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.red.opacity(0.8))
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 4)
|
|
} header: { Text("Batch Edit") } footer: {
|
|
Text("Changes apply to selected tracks only. Navidrome rescans automatically.")
|
|
}
|
|
|
|
// 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.")
|
|
}
|
|
}
|
|
|
|
// Album tag fields
|
|
Section {
|
|
mbEditField("Album", text: $albumName, mbValue: selectedMB?.album)
|
|
mbEditField("Album Artist", text: $albumArtist, mbValue: selectedMB?.albumArtist)
|
|
mbEditField("Genre", text: $genre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
|
|
mbEditField("Year", text: $year, mbValue: selectedMB?.year, keyboard: .numberPad)
|
|
} header: { Text("Album Tags") }
|
|
|
|
Section {
|
|
Toggle("Set artist on all tracks", isOn: $applyArtistToAll)
|
|
.tint(accentPink)
|
|
if applyArtistToAll {
|
|
mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist)
|
|
}
|
|
} header: { Text("Artist Override") } footer: {
|
|
if applyArtistToAll {
|
|
Text("Sets the same artist on every selected track.")
|
|
} else {
|
|
Text("Each track keeps its original artist.")
|
|
}
|
|
}
|
|
|
|
// Track list — swipe to remove
|
|
Section {
|
|
ForEach(includedSongs, id: \.id) { song in
|
|
HStack(spacing: 8) {
|
|
Text("\(song.track ?? 0)")
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundColor(.gray)
|
|
.frame(width: 24)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(song.title)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
Text(applyArtistToAll ? artist : (song.artist ?? ""))
|
|
.font(.system(size: 11))
|
|
.foregroundColor(applyArtistToAll ? accentPink : .gray)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Image(systemName: song.path != nil ? "checkmark.circle" : "xmark.circle")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(song.path != nil ? .green.opacity(0.5) : .red.opacity(0.5))
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(role: .destructive) {
|
|
withAnimation {
|
|
includedSongs.removeAll { $0.id == song.id }
|
|
}
|
|
} label: {
|
|
Label("Remove", systemImage: "minus.circle")
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Tracks to Update (\(includedSongs.count))")
|
|
Spacer()
|
|
if includedSongs.count < (album.song?.count ?? 0) {
|
|
Button("Reset") {
|
|
withAnimation { includedSongs = album.song ?? [] }
|
|
}
|
|
.font(.system(size: 12))
|
|
.foregroundColor(accentPink)
|
|
}
|
|
}
|
|
} footer: {
|
|
Text("Swipe left on a track to remove it from this batch edit.")
|
|
}
|
|
|
|
// Progress / Results
|
|
if isSaving {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
ProgressView(value: progress).tint(accentPink)
|
|
Text("Updating \(Int(progress * Double(includedSongs.count)))/\(includedSongs.count)...")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
|
|
if let msg = resultMessage {
|
|
Section {
|
|
VStack(spacing: 4) {
|
|
Text(msg)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(failedTracks.isEmpty ? .green : .yellow)
|
|
ForEach(failedTracks, id: \.self) { t in
|
|
Text("✗ \(t)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Album Tags")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") { dismiss() }.foregroundColor(.gray)
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if settings.isEnabled {
|
|
HStack(spacing: 14) {
|
|
// MusicBrainz search
|
|
Button(action: searchMusicBrainz) {
|
|
if isSearchingMB {
|
|
ProgressView().tint(.white).scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(selectedMB != nil ? .green : .white)
|
|
}
|
|
}
|
|
.disabled(isSearchingMB)
|
|
|
|
Button("Apply All", action: applyBatch)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(accentPink)
|
|
.disabled(isSaving || (albumName.isEmpty && pendingCoverImage == nil && !removeCoverArt))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showMBPicker) {
|
|
MusicBrainzPickerSheet(results: mbResults) { rec in
|
|
selectedMB = rec
|
|
showMBPicker = false
|
|
}
|
|
}
|
|
.sheet(isPresented: $showImagePicker) {
|
|
ImagePickerView { image in
|
|
pendingCoverImage = image
|
|
removeCoverArt = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MusicBrainz (album release search)
|
|
|
|
private func searchMusicBrainz() {
|
|
isSearchingMB = true
|
|
Task {
|
|
let results = await MusicBrainzService.searchAlbum(
|
|
album: albumName, artist: albumArtist
|
|
)
|
|
await MainActor.run {
|
|
isSearchingMB = false
|
|
if !results.isEmpty {
|
|
mbResults = results
|
|
showMBPicker = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Apply Batch
|
|
|
|
private func applyBatch() {
|
|
guard !includedSongs.isEmpty || pendingCoverImage != nil || removeCoverArt else { return }
|
|
|
|
isSaving = true
|
|
progress = 0
|
|
failedTracks = []
|
|
resultMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
// 1. Cover art
|
|
if let image = pendingCoverImage {
|
|
if let cid = coverArtCompanionId {
|
|
try await api.uploadCoverArt(songId: cid, image: image)
|
|
} else if let path = includedSongs.first(where: { $0.path != nil })?.path {
|
|
// Fallback: no companion ID — use relative path endpoint
|
|
try await api.uploadCoverArtByPath(relativePath: path, image: image)
|
|
}
|
|
} else if removeCoverArt, let cid = coverArtCompanionId {
|
|
try await api.deleteCoverArt(songId: cid)
|
|
}
|
|
|
|
await MainActor.run { progress = 0.2 }
|
|
|
|
// 2. Tag edit
|
|
let paths = includedSongs.compactMap { $0.path }
|
|
if !paths.isEmpty {
|
|
let request = BatchMetadataEditRequest(
|
|
relativePaths: paths,
|
|
title: nil,
|
|
artist: applyArtistToAll ? artist : nil,
|
|
album: albumName.isEmpty ? nil : albumName,
|
|
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
|
|
genre: genre.isEmpty ? nil : genre,
|
|
year: Int(year)
|
|
)
|
|
|
|
DebugLogger.shared.log(
|
|
"Batch edit: \(paths.count) tracks, album='\(albumName)', albumArtist='\(albumArtist)'",
|
|
category: "Companion"
|
|
)
|
|
|
|
await MainActor.run { progress = 0.4 }
|
|
|
|
let result = try await api.batchEditMetadata(request)
|
|
|
|
// Notify watch
|
|
for song in includedSongs {
|
|
WatchConnectivityManager.shared.notifyTagsUpdated(
|
|
songId: song.id,
|
|
title: nil,
|
|
artist: applyArtistToAll ? (artist.isEmpty ? nil : artist) : nil,
|
|
album: albumName.isEmpty ? nil : albumName,
|
|
albumArtist: albumArtist.isEmpty ? nil : albumArtist
|
|
)
|
|
}
|
|
|
|
await MainActor.run {
|
|
progress = 1.0
|
|
isSaving = false
|
|
let succeeded = result.succeeded?.count ?? 0
|
|
let failed = result.failed ?? []
|
|
if failed.isEmpty {
|
|
resultMessage = "✓ All \(succeeded) tracks updated"
|
|
} else {
|
|
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
|
|
failedTracks = failed.map { "✗ \($0.path): \($0.error)" }
|
|
}
|
|
}
|
|
} else {
|
|
await MainActor.run {
|
|
progress = 1.0
|
|
isSaving = false
|
|
resultMessage = "✓ Cover art updated"
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isSaving = false
|
|
resultMessage = "Failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Field with optional MB suggestion pill
|
|
|
|
private func mbEditField(
|
|
_ label: String,
|
|
text: Binding<String>,
|
|
mbValue: String?,
|
|
keyboard: UIKeyboardType = .default
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(label)
|
|
.foregroundColor(.gray)
|
|
.frame(width: 100, alignment: .leading)
|
|
TextField(label, text: text)
|
|
.keyboardType(keyboard)
|
|
.multilineTextAlignment(.trailing)
|
|
.keyboardDoneButton()
|
|
}
|
|
.font(.system(size: 14))
|
|
|
|
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
|
|
Button {
|
|
text.wrappedValue = mb
|
|
} label: {
|
|
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, 4)
|
|
}
|
|
}
|
|
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
|
|
}
|
|
}
|