256 lines
11 KiB
Swift
256 lines
11 KiB
Swift
|
|
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))
|
||
|
|
}
|
||
|
|
}
|