import SwiftUI // MARK: - MusicBrainz Models struct MBRecording: Identifiable { let id: String let title: String let artist: String let album: String let albumArtist: String let year: String let trackNumber: String let discNumber: String let genre: String let country: String let label: String } // MARK: - MusicBrainz Service struct MusicBrainzService { static let baseURL = "https://musicbrainz.org/ws/2" static let userAgent = "NavidromePlayer/1.0 (ca.dallasgroot.navidromeplayer.app)" static func search(title: String, artist: String) async -> [MBRecording] { let query = "recording:\"\(title)\" AND artist:\"\(artist)\"" guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(baseURL)/recording?query=\(encoded)&limit=10&fmt=json") else { return [] } var req = URLRequest(url: url) req.setValue(userAgent, forHTTPHeaderField: "User-Agent") req.timeoutInterval = 10 guard let (data, _) = try? await URLSession.shared.data(for: req), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let recordings = json["recordings"] as? [[String: Any]] else { return [] } var results: [MBRecording] = [] for rec in recordings { let id = rec["id"] as? String ?? UUID().uuidString let recTitle = rec["title"] as? String ?? "" // Artist credit let credits = rec["artist-credit"] as? [[String: Any]] ?? [] let artistName = credits.compactMap { ($0["artist"] as? [String: Any])?["name"] as? String }.joined(separator: ", ") // First release let releases = rec["releases"] as? [[String: Any]] ?? [] let release = releases.first let albumTitle = release?["title"] as? String ?? "" let country = release?["country"] as? String ?? "" let releaseDate = release?["date"] as? String ?? "" let year = String(releaseDate.prefix(4)) // Label let labelInfo = (release?["label-info"] as? [[String: Any]])?.first let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? "" // Track number within release let media = release?["media"] as? [[String: Any]] let track = (media?.first?["tracks"] as? [[String: Any]])?.first let trackPos = (track?["number"] as? String) ?? (track?["position"].map { "\($0)" } ?? "") let discPos = media?.first?["position"].map { "\($0)" } ?? "" // Album artist from release artist-credit let releaseCredits = release?["artist-credit"] as? [[String: Any]] ?? [] let releaseArtist = releaseCredits.compactMap { ($0["artist"] as? [String: Any])?["name"] as? String }.joined(separator: ", ") results.append(MBRecording( id: id, title: recTitle, artist: artistName, album: albumTitle, albumArtist: releaseArtist.isEmpty ? artistName : releaseArtist, year: year, trackNumber: trackPos, discNumber: discPos == "1" ? "" : discPos, genre: "", country: country, label: labelName )) } return results } /// Search MusicBrainz by album/release name — used by batch editor. static func searchAlbum(album: String, artist: String) async -> [MBRecording] { let query = "release:\"\(album)\" AND artist:\"\(artist)\"" guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: "\(baseURL)/release?query=\(encoded)&limit=10&fmt=json") else { return [] } var req = URLRequest(url: url) req.setValue(userAgent, forHTTPHeaderField: "User-Agent") req.timeoutInterval = 10 guard let (data, _) = try? await URLSession.shared.data(for: req), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let releases = json["releases"] as? [[String: Any]] else { return [] } var results: [MBRecording] = [] for rel in releases { let id = rel["id"] as? String ?? UUID().uuidString let title = rel["title"] as? String ?? "" let date = rel["date"] as? String ?? "" let year = String(date.prefix(4)) let country = rel["country"] as? String ?? "" let credits = rel["artist-credit"] as? [[String: Any]] ?? [] let artistName = credits.compactMap { ($0["artist"] as? [String: Any])?["name"] as? String }.joined(separator: ", ") let labelInfo = (rel["label-info"] as? [[String: Any]])?.first let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? "" // Get track count from media let media = rel["media"] as? [[String: Any]] ?? [] let trackCount = media.compactMap { $0["track-count"] as? Int }.reduce(0, +) results.append(MBRecording( id: id, title: title, artist: artistName, album: title, albumArtist: artistName, year: year, trackNumber: trackCount > 0 ? "\(trackCount) tracks" : "", discNumber: media.count > 1 ? "\(media.count) discs" : "", genre: "", country: country, label: labelName )) } return results } } // MARK: - TrackEditorView struct TrackEditorView: View { let song: Song @Environment(\.dismiss) private var dismiss // Field values @State private var title: String @State private var artist: String @State private var album: String @State private var albumArtist: String @State private var genre: String @State private var year: String @State private var trackNumber: String @State private var discNumber: String // Checkmarks — only checked fields get sent to the API @State private var editTitle = false @State private var editArtist = false @State private var editAlbum = false @State private var editAlbumArtist = false @State private var editGenre = false @State private var editYear = false @State private var editTrackNumber = false @State private var editDiscNumber = false // MusicBrainz @State private var isSearchingMB = false @State private var mbResults: [MBRecording] = [] @State private var selectedMB: MBRecording? @State private var showMBPicker = false @State private var mbError: String? // Cover art @State private var pendingCoverImage: UIImage? = nil @State private var removeCoverArt = false @State private var showImagePicker = false @State private var isSaving = false @State private var errorMessage: String? @State private var showSuccess = false @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 hasSelection: Bool { editTitle || editArtist || editAlbum || editAlbumArtist || editGenre || editYear || editTrackNumber || editDiscNumber || pendingCoverImage != nil || removeCoverArt } private var coverArtChanged: Bool { pendingCoverImage != nil || removeCoverArt } /// Live preview of where the file will end up after restructure. private var pathPreview: String { let aa = albumArtist let alb = album let t = title let tn = Int(trackNumber) ?? (song.track ?? 0) let dn = Int(discNumber) ?? (song.discNumber ?? 0) let ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac") let prefix: String if dn > 1 { prefix = String(format: "%02d-%02d", dn, tn) } else if tn > 0 { prefix = String(format: "%02d", tn) } else { prefix = "" } let sanitized = { (s: String) -> String in s.replacingOccurrences(of: "[/\\\\:*?\"<>|]", with: "", options: .regularExpression).trimmingCharacters(in: .whitespaces) } let folder = "\(sanitized(aa.isEmpty ? "Unknown Artist" : aa))/\(sanitized(alb.isEmpty ? "Unknown Album" : alb))" let file = prefix.isEmpty ? "\(sanitized(t)).\(ext)" : "\(prefix) \(sanitized(t)).\(ext)" return "\(folder)/\(file)" } init(song: Song) { self.song = song _title = State(initialValue: song.title) _artist = State(initialValue: song.artist ?? "") _album = State(initialValue: song.album ?? "") _genre = State(initialValue: song.genre ?? "") _year = State(initialValue: song.year.map { "\($0)" } ?? "") _trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "") _discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "") _albumArtist = State(initialValue: song.albumArtist ?? song.artist ?? "") } 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("Configure the Companion API in Settings to edit track metadata.") .font(.system(size: 13)) .foregroundColor(.gray) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 12) } } else { // File info + cover art Section { HStack(alignment: .top, spacing: 12) { // File details VStack(alignment: .leading, spacing: 6) { infoRow("File", song.path ?? song.id) if let suffix = song.suffix { infoRow("Format", suffix.uppercased()) } if let bitRate = song.bitRate { infoRow("Bit Rate", "\(bitRate) kbps") } } Spacer() // Cover art thumbnail — top right coverArtWidget(songId: song.coverArt) } } header: { Text("File Info") } // 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) 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.") } } // Editable fields Section { mbCheckField("Title", text: $title, isEnabled: $editTitle, mbValue: selectedMB?.title) mbCheckField("Artist", text: $artist, isEnabled: $editArtist, mbValue: selectedMB?.artist) mbCheckField("Album", text: $album, isEnabled: $editAlbum, mbValue: selectedMB?.album) mbCheckField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist, mbValue: selectedMB?.albumArtist) } header: { Text("Basic Info") } footer: { Text("Check fields you want to change. Only checked fields are updated.") } Section { mbCheckField("Genre", text: $genre, isEnabled: $editGenre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil) mbCheckField("Year", text: $year, isEnabled: $editYear, mbValue: selectedMB?.year, keyboard: .numberPad) mbCheckField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, mbValue: selectedMB?.trackNumber, keyboard: .numberPad) mbCheckField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, mbValue: selectedMB?.discNumber.isEmpty == false ? selectedMB?.discNumber : nil, keyboard: .numberPad) } header: { Text("Details") } // Path preview Section { VStack(alignment: .leading, spacing: 4) { Text("New path") .font(.system(size: 11)) .foregroundColor(.gray) Text(pathPreview) .font(.system(size: 12, design: .monospaced)) .foregroundColor(.white.opacity(0.7)) .lineLimit(3) } .padding(.vertical, 4) } header: { Text("After Save") } footer: { Text("The file will be moved to this path on the server after tags are saved.") } if let error = errorMessage { Section { Text(error) .font(.system(size: 13)) .foregroundColor(.red) } } } } .navigationTitle("Edit Tags") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } .foregroundColor(.gray) } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 14) { // MusicBrainz search button Button(action: searchMusicBrainz) { if isSearchingMB { ProgressView().tint(.white).scaleEffect(0.8) } else { Image(systemName: "magnifyingglass") .foregroundColor(selectedMB != nil ? .green : .white) } } .disabled(isSearchingMB) if settings.isEnabled { Button(action: save) { if isSaving { ProgressView().tint(accentPink) } else { Text("Save") .fontWeight(.semibold) .foregroundColor(hasSelection ? accentPink : .gray) } } .disabled(isSaving || !hasSelection || song.path == nil) } } } } .sheet(isPresented: $showMBPicker) { MusicBrainzPickerSheet( results: mbResults, onSelect: { rec in selectedMB = rec showMBPicker = false } ) } .sheet(isPresented: $showImagePicker) { ImagePickerView { image in pendingCoverImage = image removeCoverArt = false } } .overlay { if showSuccess { VStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 44)) .foregroundColor(.green) Text("Tags Updated") .font(.system(size: 16, weight: .medium)) .foregroundColor(.white) Text("File moved to new path") .font(.system(size: 13)) .foregroundColor(.gray) } .padding(30) .background(.ultraThinMaterial) .cornerRadius(16) .transition(.scale.combined(with: .opacity)) } } .task { await fetchCompanionDetails() } } } // MARK: - MusicBrainz Search private func searchMusicBrainz() { isSearchingMB = true mbError = nil Task { let results = await MusicBrainzService.search(title: title, artist: artist) await MainActor.run { isSearchingMB = false if results.isEmpty { mbError = "No results found on MusicBrainz" errorMessage = mbError } else { mbResults = results showMBPicker = true } } } } // MARK: - Fetch companion details to populate album artist + disc number private func fetchCompanionDetails() async { guard let path = song.path, let baseURL = CompanionSettings.shared.baseURL else { return } var components = URLComponents(url: baseURL.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false) components?.queryItems = [URLQueryItem(name: "album", value: song.album ?? "")] guard let url = components?.url, let (data, _) = try? await URLSession.shared.data(from: url), let response = try? JSONDecoder().decode(CompanionLibraryResponse.self, from: data), let songs = response.songs else { return } if let match = songs.first(where: { $0.relative_path == path || path.hasSuffix($0.relative_path) }) { await MainActor.run { if albumArtist.isEmpty || albumArtist == (song.artist ?? "") { albumArtist = match.album_artist } if discNumber.isEmpty, let dn = match.disc_number { discNumber = "\(dn)" } } } } // MARK: - Save private func save() { guard let path = song.path else { return } isSaving = true errorMessage = nil Task { do { // Handle cover art first if let image = pendingCoverImage, let companionId = companionSongId() { try await api.uploadCoverArt(songId: companionId, image: image) } else if removeCoverArt, let companionId = companionSongId() { try await api.deleteCoverArt(songId: companionId) } // Only send metadata edit if any tag fields are checked let hasTagChanges = editTitle || editArtist || editAlbum || editAlbumArtist || editGenre || editYear || editTrackNumber || editDiscNumber if hasTagChanges { let request = MetadataEditRequest( relativePath: path, title: editTitle ? (title.isEmpty ? nil : title) : nil, artist: editArtist ? (artist.isEmpty ? nil : artist) : nil, album: editAlbum ? (album.isEmpty ? nil : album) : nil, albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil, genre: editGenre ? (genre.isEmpty ? nil : genre) : nil, year: editYear ? Int(year) : nil, trackNumber: editTrackNumber ? Int(trackNumber) : nil ) try await api.editMetadata(request) WatchConnectivityManager.shared.notifyTagsUpdated( songId: song.id, title: request.title, artist: request.artist, album: request.album, albumArtist: request.albumArtist ) } await MainActor.run { isSaving = false withAnimation { showSuccess = true } DebugLogger.shared.log("Tags updated: \(title) — \(artist)", category: "Companion") } try? await Task.sleep(for: .seconds(1.2)) await MainActor.run { dismiss() } } catch { await MainActor.run { isSaving = false errorMessage = error.localizedDescription } } } } /// Extract the Companion song ID from coverArt field ("companion:{id}") private func companionSongId() -> String? { guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return nil } return String(coverArt.dropFirst("companion:".count)) } // MARK: - Info Row (read-only) private func infoRow(_ label: String, _ value: String) -> some View { HStack { Text(label).foregroundColor(.gray) Spacer() Text(value).foregroundColor(.white.opacity(0.7)).lineLimit(1) } .font(.system(size: 14)) } // MARK: - Cover Art Widget private func coverArtWidget(songId: String?) -> some View { CoverArtEditorWidget( existingCoverArtId: songId, pendingImage: $pendingCoverImage, removeArt: $removeCoverArt, showPicker: $showImagePicker, size: 72 ) } // MARK: - Checkmark Field with optional MusicBrainz suggestion private func mbCheckField( _ label: String, text: Binding, isEnabled: Binding, mbValue: String?, keyboard: UIKeyboardType = .default ) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 10) { Button(action: { isEnabled.wrappedValue.toggle() }) { Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle") .font(.system(size: 20)) .foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4)) } .buttonStyle(.plain) Text(label) .foregroundColor(isEnabled.wrappedValue ? .white : .gray) .frame(width: 85, alignment: .leading) TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) .foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5)) .disabled(!isEnabled.wrappedValue) .keyboardDoneButton() } .font(.system(size: 13)) // MusicBrainz suggestion pill — only show if different from current value if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue { Button(action: { text.wrappedValue = mb isEnabled.wrappedValue = true }) { 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, 30) } } .padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0) } } // MARK: - MusicBrainz Picker Sheet struct MusicBrainzPickerSheet: View { let results: [MBRecording] let onSelect: (MBRecording) -> Void @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List(results) { rec in Button(action: { onSelect(rec) }) { VStack(alignment: .leading, spacing: 4) { Text(rec.title) .font(.system(size: 14, weight: .semibold)) .foregroundColor(.white) Text(rec.artist) .font(.system(size: 13)) .foregroundColor(.gray) HStack(spacing: 8) { if !rec.album.isEmpty { Text(rec.album) .font(.system(size: 12)) .foregroundColor(.white.opacity(0.6)) } if !rec.year.isEmpty { Text(rec.year) .font(.system(size: 12)) .foregroundColor(.white.opacity(0.5)) } if !rec.country.isEmpty { Text(rec.country) .font(.system(size: 11)) .foregroundColor(.white.opacity(0.4)) } if !rec.trackNumber.isEmpty { Text("Track \(rec.trackNumber)") .font(.system(size: 11)) .foregroundColor(.white.opacity(0.4)) } } if !rec.label.isEmpty { Text(rec.label) .font(.system(size: 11)) .foregroundColor(.gray.opacity(0.7)) } } .padding(.vertical, 4) } .buttonStyle(.plain) } .navigationTitle("MusicBrainz Results") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Cancel") { dismiss() } .foregroundColor(.gray) } } } } } // MARK: - Cover Art Widget (shared by TrackEditorView and BatchAlbumEditorSheet) struct CoverArtEditorWidget: View { let existingCoverArtId: String? @Binding var pendingImage: UIImage? @Binding var removeArt: Bool @Binding var showPicker: Bool let size: CGFloat private var isChanged: Bool { pendingImage != nil || removeArt } var body: some View { ZStack(alignment: .bottomTrailing) { Group { if let img = pendingImage { Image(uiImage: img) .resizable().scaledToFill() } else if removeArt { ZStack { Color.gray.opacity(0.2) Image(systemName: "photo.slash") .font(.system(size: size * 0.35)) .foregroundColor(.gray) } } else if let id = existingCoverArtId, id.hasPrefix("companion:"), let url = CompanionSettings.shared.baseURL? .appendingPathComponent("library/cover-art/\(id.dropFirst("companion:".count))") { AsyncImage(url: url) { phase in switch phase { case .success(let img): img.resizable().scaledToFill() default: ZStack { Color.gray.opacity(0.15) Image(systemName: "music.note") .font(.system(size: size * 0.35)) .foregroundColor(.gray) } } } } else { ZStack { Color.gray.opacity(0.15) Image(systemName: "photo.badge.plus") .font(.system(size: size * 0.35)) .foregroundColor(.gray.opacity(0.7)) } } } .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(isChanged ? Color.red.opacity(0.9) : Color.clear, lineWidth: 2) .shadow(color: isChanged ? .red.opacity(0.6) : .clear, radius: 4) ) if pendingImage != nil || (existingCoverArtId != nil && !removeArt) { Menu { Button { showPicker = true } label: { Label("Replace", systemImage: "photo") } Button(role: .destructive) { pendingImage = nil removeArt = true } label: { Label("Remove", systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle.fill") .font(.system(size: 18)) .foregroundColor(.white) .shadow(radius: 2) } .offset(x: 4, y: 4) } } .frame(width: size, height: size) .onTapGesture { if pendingImage == nil && existingCoverArtId == nil { showPicker = true } } } } // MARK: - Image Picker (UIKit wrapper) struct ImagePickerView: UIViewControllerRepresentable { let onPick: (UIImage) -> Void func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) } func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.sourceType = .photoLibrary picker.allowsEditing = true picker.delegate = context.coordinator return picker } func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let onPick: (UIImage) -> Void init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick } func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { let img = (info[.editedImage] ?? info[.originalImage]) as? UIImage picker.dismiss(animated: true) if let img { onPick(img) } } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { picker.dismiss(animated: true) } } }