NavidromeApp/iOS/Views/Companion/BatchAlbumEditorSheet.swift

256 lines
11 KiB
Swift
Raw Normal View History

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<String>, 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))
}
}