import SwiftUI /// Batch-edit metadata for all songs in an album. /// Fixes "Various Artists" albums that split into per-artist albums in Navidrome. 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 @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() private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) init(album: AlbumWithSongs) { self.album = album _albumName = State(initialValue: album.name) _albumArtist = State(initialValue: album.artist ?? "") _artist = State(initialValue: album.artist ?? "") _genre = State(initialValue: album.genre ?? "") _year = State(initialValue: album.year != nil ? "\(album.year!)" : "") } 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 { // Scope Section { HStack { Text("Tracks") .foregroundColor(.gray) Spacer() Text("\(album.song?.count ?? 0) songs") .foregroundColor(.white) } .font(.system(size: 14)) } header: { Text("Batch Edit") } footer: { Text("Changes will be applied to ALL \(album.song?.count ?? 0) tracks in this album on the server. Navidrome will rescan automatically.") } // Fields Section { editField("Album", text: $albumName) editField("Album Artist", text: $albumArtist) editField("Genre", text: $genre) editField("Year", text: $year, keyboard: .numberPad) } header: { Text("Album Tags") } Section { Toggle("Set artist on all tracks", isOn: $applyArtistToAll) .tint(accentPink) if applyArtistToAll { editField("Artist", text: $artist) } } header: { Text("Artist Override") } footer: { if applyArtistToAll { Text("This will set the same artist on every track — useful for fixing compilation albums that split into separate artist albums.") } else { Text("Each track keeps its original artist. Only album-level tags are changed.") } } // Track preview Section { ForEach(album.song ?? [], 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() if song.path != nil { Image(systemName: "checkmark.circle") .font(.system(size: 11)) .foregroundColor(.green.opacity(0.5)) } else { Image(systemName: "xmark.circle") .font(.system(size: 11)) .foregroundColor(.red.opacity(0.5)) } } } } header: { Text("Tracks to Update") } // Progress / Results if isSaving { Section { VStack(spacing: 8) { ProgressView(value: progress) .tint(accentPink) Text("Updating \(Int(progress * Double(album.song?.count ?? 0)))/\(album.song?.count ?? 0)...") .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 { Button("Apply All", action: applyBatch) .fontWeight(.semibold) .foregroundColor(accentPink) .disabled(isSaving || albumName.isEmpty) } } } } } // MARK: - Apply Batch private func applyBatch() { guard let songs = album.song, !songs.isEmpty else { return } // Collect paths — skip songs without file paths let paths = songs.compactMap { $0.path } guard !paths.isEmpty else { resultMessage = "No tracks have file paths" return } isSaving = true progress = 0 failedTracks = [] resultMessage = nil Task { do { // Build a single batch request with ALL tag fields let request = BatchMetadataEditRequest( relativePaths: paths, title: nil, // Keep original titles 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)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")", category: "Companion" ) await MainActor.run { progress = 0.3 } let result = try await api.batchEditMetadata(request) 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)" } } } } catch { await MainActor.run { isSaving = false resultMessage = "Failed: \(error.localizedDescription)" } } } } // MARK: - Helpers private func editField(_ label: String, text: Binding, keyboard: UIKeyboardType = .default) -> some View { HStack { Text(label) .foregroundColor(.gray) .frame(width: 100, alignment: .leading) TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) } .font(.system(size: 14)) } }