batch upload quick fix
Companion (main.py): • NAVIDROME_TAGS whitelist — the single source of truth for what tags survive • enforce_tag_whitelist() — whitelist enforcer, replaces blacklist approach • All 5 write points updated: apply_tags, apply_tags_dict, upload-track, upload-tracks, restructure_all • preserve_composer and preserve_lyrics flags on both upload endpoints (default False) • /library/clean-tags now uses whitelist enforcer iOS: • UploadMetadata — preserveComposer and preserveLyrics fields • buildMultipartBody — sends both flags as form fields • BatchUploadView — two toggles, both off by default, wired end-to-end • MultiAlbumEditorSheet — full rewrite matching BatchAlbumEditorSheet: MusicBrainz search, swipe to exclude/include tracks, Reset button, cover art widget with red glow
This commit is contained in:
parent
92a5a54b8d
commit
7a9c837650
3 changed files with 313 additions and 167 deletions
|
|
@ -12,6 +12,8 @@ struct BatchUploadView: View {
|
||||||
@State private var genre = ""
|
@State private var genre = ""
|
||||||
@State private var year = ""
|
@State private var year = ""
|
||||||
@State private var keepOffline = false
|
@State private var keepOffline = false
|
||||||
|
@State private var preserveComposer = false
|
||||||
|
@State private var preserveLyrics = false
|
||||||
|
|
||||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||||
|
|
||||||
|
|
@ -135,15 +137,17 @@ struct BatchUploadView: View {
|
||||||
.textInputAutocapitalization(.words)
|
.textInputAutocapitalization(.words)
|
||||||
TextField("Year", text: $year)
|
TextField("Year", text: $year)
|
||||||
.keyboardType(.numberPad)
|
.keyboardType(.numberPad)
|
||||||
|
|
||||||
Toggle("Keep Offline After Upload", isOn: $keepOffline)
|
Toggle("Keep Offline After Upload", isOn: $keepOffline)
|
||||||
.tint(accentPink)
|
.tint(accentPink)
|
||||||
|
Toggle("Preserve Composer Tags", isOn: $preserveComposer)
|
||||||
|
.tint(accentPink)
|
||||||
|
Toggle("Preserve Lyrics Tags", isOn: $preserveLyrics)
|
||||||
|
.tint(accentPink)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Batch Tags")
|
Text("Batch Tags")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(keepOffline
|
Text("Composer and Lyrics tags are stripped by default — enable only if your files have data you want to keep.")
|
||||||
? "Uploaded files will be kept in local storage for offline playback."
|
|
||||||
: "Uploaded files will be deleted locally. Re-download from server for offline use.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,13 +208,15 @@ struct BatchUploadView: View {
|
||||||
} else {
|
} else {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
let meta = UploadMetadata(
|
let meta = UploadMetadata(
|
||||||
title: "", // Per-file from filename
|
title: "",
|
||||||
artist: artist,
|
artist: artist,
|
||||||
album: album,
|
album: album,
|
||||||
albumArtist: albumArtist.isEmpty ? artist : albumArtist,
|
albumArtist: albumArtist.isEmpty ? artist : albumArtist,
|
||||||
genre: genre,
|
genre: genre,
|
||||||
year: year,
|
year: year,
|
||||||
trackNumber: "" // Auto-assigned per file
|
trackNumber: "",
|
||||||
|
preserveComposer: preserveComposer,
|
||||||
|
preserveLyrics: preserveLyrics
|
||||||
)
|
)
|
||||||
manager.startBatchUpload(metadata: meta, keepOffline: keepOffline)
|
manager.startBatchUpload(metadata: meta, keepOffline: keepOffline)
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,8 @@ struct UploadMetadata {
|
||||||
var genre: String
|
var genre: String
|
||||||
var year: String
|
var year: String
|
||||||
var trackNumber: String
|
var trackNumber: String
|
||||||
|
var preserveComposer: Bool = false
|
||||||
|
var preserveLyrics: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Companion API Service
|
// MARK: - Companion API Service
|
||||||
|
|
@ -401,6 +403,8 @@ actor CompanionAPIService {
|
||||||
if !metadata.genre.isEmpty { field("genre", metadata.genre) }
|
if !metadata.genre.isEmpty { field("genre", metadata.genre) }
|
||||||
if !metadata.year.isEmpty { field("year", metadata.year) }
|
if !metadata.year.isEmpty { field("year", metadata.year) }
|
||||||
if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) }
|
if !metadata.trackNumber.isEmpty { field("track_number", metadata.trackNumber) }
|
||||||
|
field("preserve_composer", metadata.preserveComposer ? "true" : "false")
|
||||||
|
field("preserve_lyrics", metadata.preserveLyrics ? "true" : "false")
|
||||||
|
|
||||||
let fileData = try Data(contentsOf: fileURL)
|
let fileData = try Data(contentsOf: fileURL)
|
||||||
let filename = fileURL.lastPathComponent
|
let filename = fileURL.lastPathComponent
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,60 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Batch-edit tags across one or more albums.
|
/// Batch-edit tags across one or more albums selected from the library.
|
||||||
/// Fetches full album data for each ID, then applies changes to all tracks.
|
/// Mirrors BatchAlbumEditorSheet but handles multiple albums.
|
||||||
struct MultiAlbumEditorSheet: View {
|
struct MultiAlbumEditorSheet: View {
|
||||||
let albumIds: [String]
|
let albumIds: [String]
|
||||||
var onDismiss: (() -> Void)? = nil
|
var onDismiss: (() -> Void)? = nil
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@EnvironmentObject var serverManager: ServerManager
|
@EnvironmentObject var serverManager: ServerManager
|
||||||
|
|
||||||
@State private var albums: [AlbumWithSongs] = []
|
@State private var albums: [AlbumWithSongs] = []
|
||||||
@State private var isLoadingAlbums = true
|
@State private var isLoadingAlbums = true
|
||||||
|
|
||||||
@State private var albumName: String = ""
|
// Editable fields
|
||||||
|
@State private var albumName: String = ""
|
||||||
@State private var albumArtist: String = ""
|
@State private var albumArtist: String = ""
|
||||||
@State private var artist: String = ""
|
@State private var artist: String = ""
|
||||||
@State private var genre: String = ""
|
@State private var genre: String = ""
|
||||||
@State private var year: String = ""
|
@State private var year: String = ""
|
||||||
@State private var applyArtistToAll = false
|
@State private var applyArtistToAll = false
|
||||||
|
|
||||||
|
// Song removal
|
||||||
|
@State private var excludedSongIds: Set<String> = []
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// Save state
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var progress: Double = 0
|
@State private var progress: Double = 0
|
||||||
@State private var resultMessage: String?
|
@State private var resultMessage: String?
|
||||||
@State private var failedTracks: [String] = []
|
@State private var failedTracks: [String] = []
|
||||||
|
|
||||||
@ObservedObject private var settings = CompanionSettings.shared
|
@ObservedObject private var settings = CompanionSettings.shared
|
||||||
|
|
||||||
private let api = CompanionAPIService()
|
private let api = CompanionAPIService()
|
||||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||||
|
|
||||||
private var allSongs: [Song] {
|
private var allSongs: [Song] { albums.flatMap { $0.song ?? [] } }
|
||||||
albums.flatMap { $0.song ?? [] }
|
private var includedSongs: [Song] { allSongs.filter { !excludedSongIds.contains($0.id) } }
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
|
|
@ -48,38 +70,58 @@ struct MultiAlbumEditorSheet: View {
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button("Cancel") {
|
Button("Cancel") { onDismiss?(); dismiss() }
|
||||||
onDismiss?()
|
.foregroundColor(.gray)
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if settings.isEnabled && !isLoadingAlbums {
|
if settings.isEnabled && !isLoadingAlbums {
|
||||||
Button("Apply", action: applyBatch)
|
HStack(spacing: 14) {
|
||||||
.fontWeight(.semibold)
|
Button(action: searchMusicBrainz) {
|
||||||
.foregroundColor(accentPink)
|
if isSearchingMB {
|
||||||
.disabled(isSaving || albumName.isEmpty)
|
ProgressView().tint(.white).scaleEffect(0.8)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.foregroundColor(selectedMB != nil ? .green : .white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSearchingMB)
|
||||||
|
|
||||||
|
Button("Apply", 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
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await loadAlbums() }
|
.task { await loadAlbums() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Loading
|
// MARK: - Loading / No API views
|
||||||
|
|
||||||
private var loadingView: some View {
|
private var loadingView: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ProgressView()
|
ProgressView().tint(accentPink)
|
||||||
.tint(accentPink)
|
|
||||||
Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...")
|
Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var noApiView: some View {
|
private var noApiView: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
|
@ -94,122 +136,171 @@ struct MultiAlbumEditorSheet: View {
|
||||||
}
|
}
|
||||||
.padding(40)
|
.padding(40)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Editor Form
|
// MARK: - Editor Form
|
||||||
|
|
||||||
private var editorForm: some View {
|
private var editorForm: some View {
|
||||||
List {
|
List {
|
||||||
// Scope summary
|
// Cover art + scope header
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack(alignment: .top, spacing: 14) {
|
||||||
Text("Albums")
|
CoverArtEditorWidget(
|
||||||
.foregroundColor(.gray)
|
existingCoverArtId: coverArtSongId,
|
||||||
|
pendingImage: $pendingCoverImage,
|
||||||
|
removeArt: $removeCoverArt,
|
||||||
|
showPicker: $showImagePicker,
|
||||||
|
size: 72
|
||||||
|
)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(albumIds.count == 1 ? albumName : "\(albumIds.count) Albums")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text("\(includedSongs.count) of \(allSongs.count) 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()
|
Spacer()
|
||||||
Text("\(albums.count)")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
}
|
||||||
HStack {
|
.padding(.vertical, 4)
|
||||||
Text("Total Tracks")
|
} header: { Text("Selected Albums") } footer: {
|
||||||
.foregroundColor(.gray)
|
Text("Changes apply to selected tracks across all albums. Navidrome rescans automatically.")
|
||||||
Spacer()
|
}
|
||||||
Text("\(allSongs.count)")
|
|
||||||
.foregroundColor(.white)
|
// MusicBrainz suggestion banner
|
||||||
}
|
if let mb = selectedMB {
|
||||||
|
Section {
|
||||||
// Show album names
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
ForEach(albums) { album in
|
HStack {
|
||||||
HStack(spacing: 8) {
|
Image(systemName: "checkmark.seal.fill")
|
||||||
AsyncCoverArt(coverArtId: album.coverArt, size: 40)
|
.foregroundColor(.green)
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.cornerRadius(3)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(album.name)
|
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
|
Text("MusicBrainz: \(mb.album)")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.lineLimit(1)
|
Spacer()
|
||||||
Text("\(album.artist ?? "Unknown") · \(album.song?.count ?? 0) tracks")
|
Button("Change") { showMBPicker = true }
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(accentPink)
|
||||||
|
}
|
||||||
|
if !mb.label.isEmpty {
|
||||||
|
Text("\(mb.country) · \(mb.label)")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
} header: { Text("MusicBrainz Match") } footer: {
|
||||||
|
Text("Tap ↓ next to any field to apply the suggestion.")
|
||||||
}
|
}
|
||||||
} header: {
|
|
||||||
Text("Selected Albums")
|
|
||||||
} footer: {
|
|
||||||
Text("Changes apply to ALL tracks across all selected albums.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag fields
|
// Album tag fields
|
||||||
Section {
|
Section {
|
||||||
editField("Album", text: $albumName)
|
mbEditField("Album", text: $albumName, mbValue: selectedMB?.album)
|
||||||
editField("Album Artist", text: $albumArtist)
|
mbEditField("Album Artist", text: $albumArtist, mbValue: selectedMB?.albumArtist)
|
||||||
editField("Genre", text: $genre)
|
mbEditField("Genre", text: $genre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
|
||||||
editField("Year", text: $year, keyboard: .numberPad)
|
mbEditField("Year", text: $year, mbValue: selectedMB?.year, keyboard: .numberPad)
|
||||||
} header: {
|
} header: { Text("Album Tags") }
|
||||||
Text("Album Tags")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Artist override
|
// Artist override
|
||||||
Section {
|
Section {
|
||||||
Toggle("Set same artist on all tracks", isOn: $applyArtistToAll)
|
Toggle("Set same artist on all tracks", isOn: $applyArtistToAll)
|
||||||
.tint(accentPink)
|
.tint(accentPink)
|
||||||
|
|
||||||
if applyArtistToAll {
|
if applyArtistToAll {
|
||||||
editField("Artist", text: $artist)
|
mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: { Text("Artist Override") } footer: {
|
||||||
Text("Artist Override")
|
|
||||||
} footer: {
|
|
||||||
if applyArtistToAll {
|
if applyArtistToAll {
|
||||||
Text("Every track gets the same artist — useful for fixing compilations that split into per-artist albums.")
|
Text("Every selected track gets the same artist.")
|
||||||
} else {
|
} else {
|
||||||
Text("Each track keeps its current artist.")
|
Text("Each track keeps its current artist.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track preview
|
// Track list with swipe-to-remove
|
||||||
Section {
|
Section {
|
||||||
ForEach(allSongs, id: \.id) { song in
|
ForEach(allSongs, id: \.id) { song in
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
Text("\(song.track ?? 0)")
|
Text("\(song.track ?? 0)")
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.system(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .gray)
|
||||||
.frame(width: 20)
|
.frame(width: 20)
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(song.title)
|
Text(song.title)
|
||||||
.font(.system(size: 13))
|
.font(.system(size: 13))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(applyArtistToAll ? artist : (song.artist ?? ""))
|
Text(applyArtistToAll ? artist : (song.artist ?? ""))
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(applyArtistToAll ? accentPink : .gray)
|
.foregroundColor(applyArtistToAll ? accentPink.opacity(excludedSongIds.contains(song.id) ? 0.3 : 1) : .gray.opacity(excludedSongIds.contains(song.id) ? 0.4 : 1))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if song.path != nil {
|
if excludedSongIds.contains(song.id) {
|
||||||
|
Text("Excluded")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundColor(.gray.opacity(0.5))
|
||||||
|
} else if song.path != nil {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundColor(.green.opacity(0.4))
|
.foregroundColor(.green.opacity(0.4))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
if excludedSongIds.contains(song.id) {
|
||||||
|
Button {
|
||||||
|
withAnimation { excludedSongIds.remove(song.id) }
|
||||||
|
} label: {
|
||||||
|
Label("Include", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
.tint(accentPink)
|
||||||
|
} else {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
withAnimation { excludedSongIds.insert(song.id) }
|
||||||
|
} label: {
|
||||||
|
Label("Exclude", systemImage: "minus.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("\(allSongs.count) Tracks")
|
HStack {
|
||||||
|
Text("Tracks (\(includedSongs.count) included)")
|
||||||
|
Spacer()
|
||||||
|
if !excludedSongIds.isEmpty {
|
||||||
|
Button("Reset") {
|
||||||
|
withAnimation { excludedSongIds.removeAll() }
|
||||||
|
}
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(accentPink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Swipe left to exclude a track from this edit.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress
|
// Progress / Results
|
||||||
if isSaving {
|
if isSaving {
|
||||||
Section {
|
Section {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 8) {
|
||||||
ProgressView(value: progress)
|
ProgressView(value: progress).tint(accentPink)
|
||||||
.tint(accentPink)
|
Text("Updating \(Int(progress * Double(includedSongs.count)))/\(includedSongs.count)...")
|
||||||
Text("Updating \(Int(progress * Double(allSongs.count)))/\(allSongs.count)...")
|
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let msg = resultMessage {
|
if let msg = resultMessage {
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -226,13 +317,12 @@ struct MultiAlbumEditorSheet: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Load Albums
|
// MARK: - Load Albums
|
||||||
|
|
||||||
private func loadAlbums() async {
|
private func loadAlbums() async {
|
||||||
isLoadingAlbums = true
|
isLoadingAlbums = true
|
||||||
var loaded: [AlbumWithSongs] = []
|
var loaded: [AlbumWithSongs] = []
|
||||||
|
|
||||||
for id in albumIds {
|
for id in albumIds {
|
||||||
do {
|
do {
|
||||||
if let album = try await serverManager.client.getAlbum(id: id) {
|
if let album = try await serverManager.client.getAlbum(id: id) {
|
||||||
|
|
@ -242,64 +332,80 @@ struct MultiAlbumEditorSheet: View {
|
||||||
DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion")
|
DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
albums = loaded
|
albums = loaded
|
||||||
isLoadingAlbums = false
|
isLoadingAlbums = false
|
||||||
|
|
||||||
// Pre-fill from first album
|
|
||||||
if let first = loaded.first {
|
if let first = loaded.first {
|
||||||
if albumName.isEmpty { albumName = first.name }
|
if albumName.isEmpty { albumName = first.name }
|
||||||
if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" }
|
if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" }
|
||||||
if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" }
|
if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" }
|
||||||
if genre.isEmpty { genre = first.genre ?? "" }
|
if genre.isEmpty { genre = first.genre ?? "" }
|
||||||
if year.isEmpty, let y = first.year { year = "\(y)" }
|
if year.isEmpty, let y = first.year { year = "\(y)" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Apply Batch
|
// MARK: - MusicBrainz
|
||||||
|
|
||||||
private func applyBatch() {
|
private func searchMusicBrainz() {
|
||||||
let songs = allSongs
|
isSearchingMB = true
|
||||||
guard !songs.isEmpty else { return }
|
Task {
|
||||||
|
let results = await MusicBrainzService.searchAlbum(
|
||||||
let paths = songs.compactMap { $0.path }
|
album: albumName, artist: albumArtist
|
||||||
guard !paths.isEmpty else {
|
)
|
||||||
resultMessage = "No tracks have file paths"
|
await MainActor.run {
|
||||||
return
|
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
|
isSaving = true
|
||||||
progress = 0
|
progress = 0
|
||||||
failedTracks = []
|
failedTracks = []
|
||||||
resultMessage = nil
|
resultMessage = nil
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let request = BatchMetadataEditRequest(
|
// Cover art — use first included song's companion ID
|
||||||
relativePaths: paths,
|
if let image = pendingCoverImage, let cid = coverArtCompanionId {
|
||||||
title: nil,
|
try await api.uploadCoverArt(songId: cid, image: image)
|
||||||
artist: applyArtistToAll ? artist : nil,
|
} else if removeCoverArt, let cid = coverArtCompanionId {
|
||||||
album: albumName.isEmpty ? nil : albumName,
|
try await api.deleteCoverArt(songId: cid)
|
||||||
albumArtist: albumArtist.isEmpty ? nil : albumArtist,
|
}
|
||||||
genre: genre.isEmpty ? nil : genre,
|
|
||||||
year: Int(year)
|
|
||||||
)
|
|
||||||
|
|
||||||
DebugLogger.shared.log(
|
|
||||||
"Multi-album edit: \(paths.count) tracks across \(albums.count) albums, " +
|
|
||||||
"album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")",
|
|
||||||
category: "Companion"
|
|
||||||
)
|
|
||||||
|
|
||||||
await MainActor.run { progress = 0.3 }
|
|
||||||
|
|
||||||
let result = try await api.batchEditMetadata(request)
|
|
||||||
|
|
||||||
// Notify the watch for every song across all albums
|
await MainActor.run { progress = 0.2 }
|
||||||
for album in albums {
|
|
||||||
for song in album.song ?? [] {
|
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(
|
||||||
|
"Multi-album edit: \(paths.count) tracks across \(albums.count) albums",
|
||||||
|
category: "Companion"
|
||||||
|
)
|
||||||
|
|
||||||
|
await MainActor.run { progress = 0.4 }
|
||||||
|
|
||||||
|
let result = try await api.batchEditMetadata(request)
|
||||||
|
|
||||||
|
for song in includedSongs {
|
||||||
WatchConnectivityManager.shared.notifyTagsUpdated(
|
WatchConnectivityManager.shared.notifyTagsUpdated(
|
||||||
songId: song.id,
|
songId: song.id,
|
||||||
title: nil,
|
title: nil,
|
||||||
|
|
@ -308,20 +414,24 @@ struct MultiAlbumEditorSheet: View {
|
||||||
albumArtist: albumArtist.isEmpty ? nil : albumArtist
|
albumArtist: albumArtist.isEmpty ? nil : albumArtist
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
progress = 1.0
|
progress = 1.0
|
||||||
isSaving = false
|
isSaving = false
|
||||||
|
let succeeded = result.succeeded?.count ?? 0
|
||||||
let succeeded = result.succeeded?.count ?? 0
|
let failed = result.failed ?? []
|
||||||
let failed = result.failed ?? []
|
if failed.isEmpty {
|
||||||
|
resultMessage = "✓ All \(succeeded) tracks updated"
|
||||||
if failed.isEmpty {
|
} else {
|
||||||
resultMessage = "✓ All \(succeeded) tracks updated"
|
resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed"
|
||||||
} else {
|
failedTracks = failed.map { "✗ \($0.path): \($0.error)" }
|
||||||
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 {
|
} catch {
|
||||||
|
|
@ -332,19 +442,45 @@ struct MultiAlbumEditorSheet: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Field with optional MB suggestion pill
|
||||||
|
|
||||||
private func editField(_ label: String, text: Binding<String>, keyboard: UIKeyboardType = .default) -> some View {
|
private func mbEditField(
|
||||||
HStack {
|
_ label: String,
|
||||||
Text(label)
|
text: Binding<String>,
|
||||||
.foregroundColor(.gray)
|
mbValue: String?,
|
||||||
.frame(width: 100, alignment: .leading)
|
keyboard: UIKeyboardType = .default
|
||||||
TextField(label, text: text)
|
) -> some View {
|
||||||
.keyboardType(keyboard)
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
.multilineTextAlignment(.trailing)
|
HStack {
|
||||||
.keyboardDoneButton()
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 14))
|
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue