diff --git a/iOS/Views/Companion/BatchUploadView.swift b/iOS/Views/Companion/BatchUploadView.swift index d894657..6458eb3 100644 --- a/iOS/Views/Companion/BatchUploadView.swift +++ b/iOS/Views/Companion/BatchUploadView.swift @@ -12,6 +12,8 @@ struct BatchUploadView: View { @State private var genre = "" @State private var year = "" @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) @@ -135,15 +137,17 @@ struct BatchUploadView: View { .textInputAutocapitalization(.words) TextField("Year", text: $year) .keyboardType(.numberPad) - + Toggle("Keep Offline After Upload", isOn: $keepOffline) .tint(accentPink) + Toggle("Preserve Composer Tags", isOn: $preserveComposer) + .tint(accentPink) + Toggle("Preserve Lyrics Tags", isOn: $preserveLyrics) + .tint(accentPink) } header: { Text("Batch Tags") } footer: { - Text(keepOffline - ? "Uploaded files will be kept in local storage for offline playback." - : "Uploaded files will be deleted locally. Re-download from server for offline use.") + Text("Composer and Lyrics tags are stripped by default — enable only if your files have data you want to keep.") } } @@ -204,13 +208,15 @@ struct BatchUploadView: View { } else { Button(action: { let meta = UploadMetadata( - title: "", // Per-file from filename + title: "", artist: artist, album: album, albumArtist: albumArtist.isEmpty ? artist : albumArtist, genre: genre, year: year, - trackNumber: "" // Auto-assigned per file + trackNumber: "", + preserveComposer: preserveComposer, + preserveLyrics: preserveLyrics ) manager.startBatchUpload(metadata: meta, keepOffline: keepOffline) }) { diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index ed97b69..c0dd16b 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -156,6 +156,8 @@ struct UploadMetadata { var genre: String var year: String var trackNumber: String + var preserveComposer: Bool = false + var preserveLyrics: Bool = false } // MARK: - Companion API Service @@ -401,6 +403,8 @@ actor CompanionAPIService { if !metadata.genre.isEmpty { field("genre", metadata.genre) } if !metadata.year.isEmpty { field("year", metadata.year) } 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 filename = fileURL.lastPathComponent diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index 913ec6d..f2c320b 100644 --- a/iOS/Views/Companion/MultiAlbumEditorSheet.swift +++ b/iOS/Views/Companion/MultiAlbumEditorSheet.swift @@ -1,38 +1,60 @@ import SwiftUI -/// Batch-edit tags across one or more albums. -/// Fetches full album data for each ID, then applies changes to all tracks. +/// Batch-edit tags across one or more albums selected from the library. +/// Mirrors BatchAlbumEditorSheet but handles multiple albums. struct MultiAlbumEditorSheet: View { let albumIds: [String] var onDismiss: (() -> Void)? = nil - + @Environment(\.dismiss) private var dismiss @EnvironmentObject var serverManager: ServerManager - + @State private var albums: [AlbumWithSongs] = [] @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 artist: String = "" - @State private var genre: String = "" - @State private var year: String = "" - @State private var applyArtistToAll = false - + @State private var artist: String = "" + @State private var genre: String = "" + @State private var year: String = "" + @State private var applyArtistToAll = false + + // Song removal + @State private var excludedSongIds: Set = [] + + // 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 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) - - private var allSongs: [Song] { - albums.flatMap { $0.song ?? [] } + + private var allSongs: [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 { NavigationStack { Group { @@ -48,38 +70,58 @@ struct MultiAlbumEditorSheet: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - onDismiss?() - dismiss() - } - .foregroundColor(.gray) + Button("Cancel") { onDismiss?(); dismiss() } + .foregroundColor(.gray) } ToolbarItem(placement: .navigationBarTrailing) { if settings.isEnabled && !isLoadingAlbums { - Button("Apply", action: applyBatch) - .fontWeight(.semibold) - .foregroundColor(accentPink) - .disabled(isSaving || albumName.isEmpty) + HStack(spacing: 14) { + Button(action: searchMusicBrainz) { + if isSearchingMB { + 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() } } } - - // MARK: - Loading - + + // MARK: - Loading / No API views + private var loadingView: some View { VStack(spacing: 16) { - ProgressView() - .tint(accentPink) + ProgressView().tint(accentPink) Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...") .font(.system(size: 14)) .foregroundColor(.gray) } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + private var noApiView: some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") @@ -94,122 +136,171 @@ struct MultiAlbumEditorSheet: View { } .padding(40) } - + // MARK: - Editor Form - + private var editorForm: some View { List { - // Scope summary + // Cover art + scope header Section { - HStack { - Text("Albums") - .foregroundColor(.gray) + HStack(alignment: .top, spacing: 14) { + CoverArtEditorWidget( + 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() - Text("\(albums.count)") - .foregroundColor(.white) } - HStack { - Text("Total Tracks") - .foregroundColor(.gray) - Spacer() - Text("\(allSongs.count)") - .foregroundColor(.white) - } - - // Show album names - ForEach(albums) { album in - HStack(spacing: 8) { - AsyncCoverArt(coverArtId: album.coverArt, size: 40) - .frame(width: 32, height: 32) - .cornerRadius(3) - VStack(alignment: .leading, spacing: 1) { - Text(album.name) + .padding(.vertical, 4) + } header: { Text("Selected Albums") } footer: { + Text("Changes apply to selected tracks across all albums. 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) - .lineLimit(1) - Text("\(album.artist ?? "Unknown") · \(album.song?.count ?? 0) tracks") + 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.") } - } header: { - Text("Selected Albums") - } footer: { - Text("Changes apply to ALL tracks across all selected albums.") } - - // Tag fields + + // Album tag 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") - } - + 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") } + // Artist override Section { Toggle("Set same artist on all tracks", isOn: $applyArtistToAll) .tint(accentPink) - if applyArtistToAll { - editField("Artist", text: $artist) + mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist) } - } header: { - Text("Artist Override") - } footer: { + } header: { Text("Artist Override") } footer: { 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 { Text("Each track keeps its current artist.") } } - - // Track preview + + // Track list with swipe-to-remove Section { ForEach(allSongs, id: \.id) { song in HStack(spacing: 8) { Text("\(song.track ?? 0)") .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.gray) + .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .gray) .frame(width: 20) VStack(alignment: .leading, spacing: 1) { Text(song.title) .font(.system(size: 13)) - .foregroundColor(.white) + .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .white) .lineLimit(1) Text(applyArtistToAll ? artist : (song.artist ?? "")) .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) } 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") .font(.system(size: 10)) .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: { - 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 { Section { VStack(spacing: 8) { - ProgressView(value: progress) - .tint(accentPink) - Text("Updating \(Int(progress * Double(allSongs.count)))/\(allSongs.count)...") + 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(alignment: .leading, spacing: 4) { @@ -226,13 +317,12 @@ struct MultiAlbumEditorSheet: View { } } } - + // MARK: - Load Albums - + private func loadAlbums() async { isLoadingAlbums = true var loaded: [AlbumWithSongs] = [] - for id in albumIds { do { 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") } } - await MainActor.run { albums = loaded isLoadingAlbums = false - - // Pre-fill from first album 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 artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } - if genre.isEmpty { genre = first.genre ?? "" } + if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } + if genre.isEmpty { genre = first.genre ?? "" } if year.isEmpty, let y = first.year { year = "\(y)" } } } } - - // MARK: - Apply Batch - - private func applyBatch() { - let songs = allSongs - guard !songs.isEmpty else { return } - - let paths = songs.compactMap { $0.path } - guard !paths.isEmpty else { - resultMessage = "No tracks have file paths" - return + + // MARK: - MusicBrainz + + 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 { - 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, " + - "album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")", - category: "Companion" - ) - - await MainActor.run { progress = 0.3 } - - let result = try await api.batchEditMetadata(request) + // Cover art — use first included song's companion ID + if let image = pendingCoverImage, let cid = coverArtCompanionId { + try await api.uploadCoverArt(songId: cid, image: image) + } else if removeCoverArt, let cid = coverArtCompanionId { + try await api.deleteCoverArt(songId: cid) + } - // Notify the watch for every song across all albums - for album in albums { - for song in album.song ?? [] { + await MainActor.run { progress = 0.2 } + + 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( songId: song.id, title: nil, @@ -308,20 +414,24 @@ struct MultiAlbumEditorSheet: View { 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)" } + 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 { @@ -332,19 +442,45 @@ struct MultiAlbumEditorSheet: View { } } } - - // 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) - .keyboardDoneButton() + + // MARK: - Field with optional MB suggestion pill + + private func mbEditField( + _ label: String, + text: Binding, + 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) + } } - .font(.system(size: 14)) + .padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0) } }