diff --git a/Shared/Models/Models.swift b/Shared/Models/Models.swift index 19f5028..7d03f2a 100644 --- a/Shared/Models/Models.swift +++ b/Shared/Models/Models.swift @@ -114,6 +114,7 @@ struct Album: Codable, Identifiable { let name: String let artist: String? let artistId: String? + let albumArtist: String? let coverArt: String? let songCount: Int? let duration: Int? @@ -129,6 +130,7 @@ struct AlbumWithSongs: Codable, Identifiable { let name: String let artist: String? let artistId: String? + let albumArtist: String? let coverArt: String? let songCount: Int? let duration: Int? @@ -152,6 +154,7 @@ struct Song: Codable, Identifiable { let title: String let album: String? let artist: String? + let albumArtist: String? let track: Int? let year: Int? let genre: String? @@ -351,6 +354,7 @@ struct CompanionSong: Codable, Identifiable { title: title, album: album, artist: artist, + albumArtist: album_artist, track: track_number, year: year, genre: genre.isEmpty ? nil : genre, @@ -390,18 +394,19 @@ struct CompanionAlbum: Codable, Identifiable { func toAlbum() -> Album { Album( - id: id, - name: album, - artist: album_artist, - artistId: nil, - coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" }, - songCount: track_count, - duration: nil, - playCount: nil, - created: nil, - starred: nil, - year: year, - genre: nil + id: id, + name: album, + artist: album_artist, + artistId: nil, + albumArtist: album_artist, + coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" }, + songCount: track_count, + duration: nil, + playCount: nil, + created: nil, + starred: nil, + year: year, + genre: nil ) } } diff --git a/Shared/Storage/LibraryCache.swift b/Shared/Storage/LibraryCache.swift index a805be9..3353966 100644 --- a/Shared/Storage/LibraryCache.swift +++ b/Shared/Storage/LibraryCache.swift @@ -78,6 +78,20 @@ class LibraryCache: ObservableObject { try? encoded.write(to: cacheURL(for: key), options: .atomic) } } + + func remove(key: String) { + try? FileManager.default.removeItem(at: cacheURL(for: key)) + } + + /// Remove all album detail caches (keyed as "album_ALBUMID") so stale + /// song paths from before a file restructure aren't served to the Companion. + func removeAlbumDetails() { + guard let files = try? FileManager.default.contentsOfDirectory( + at: cacheDir, includingPropertiesForKeys: nil) else { return } + for url in files where url.lastPathComponent.hasPrefix("album_") { + try? FileManager.default.removeItem(at: url) + } + } func load(_ type: T.Type, key: String) -> T? { let url = cacheURL(for: key) diff --git a/iOS/Data/SyncEngine.swift b/iOS/Data/SyncEngine.swift index 64c783e..428f3c2 100644 --- a/iOS/Data/SyncEngine.swift +++ b/iOS/Data/SyncEngine.swift @@ -42,6 +42,10 @@ class SyncEngine: ObservableObject { .receive(on: DispatchQueue.main) .debounce(for: .seconds(2), scheduler: DispatchQueue.main) .sink { [weak self] _ in + // Paths may have changed due to file restructuring — invalidate song caches + // so next fetch returns the fresh Navidrome paths instead of stale ones + LibraryCache.shared.remove(key: "all_songs_sorted") + LibraryCache.shared.removeAlbumDetails() self?.lastSyncTimestamp = 0 self?.syncIfNeeded() } diff --git a/iOS/Views/Companion/BatchAlbumEditorSheet.swift b/iOS/Views/Companion/BatchAlbumEditorSheet.swift index 84f2ebe..3bfca33 100644 --- a/iOS/Views/Companion/BatchAlbumEditorSheet.swift +++ b/iOS/Views/Companion/BatchAlbumEditorSheet.swift @@ -26,8 +26,8 @@ struct BatchAlbumEditorSheet: View { init(album: AlbumWithSongs) { self.album = album _albumName = State(initialValue: album.name) - _albumArtist = State(initialValue: album.artist ?? "") - _artist = State(initialValue: album.artist ?? "") + _albumArtist = State(initialValue: album.albumArtist ?? album.artist ?? "") + _artist = State(initialValue: album.albumArtist ?? album.artist ?? "") _genre = State(initialValue: album.genre ?? "") _year = State(initialValue: album.year != nil ? "\(album.year!)" : "") } diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index fbe00f1..c409fe7 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -528,12 +528,15 @@ class CompanionPushClient: ObservableObject { case "batch_metadata_updated": NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data) case "cover_art_updated": - // Clear image cache so updated cover art loads immediately ImageCache.shared.clearAll() NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data) case "artist_photo_updated": ImageCache.shared.clearAll() NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data) + case "conflicts_updated": + // Notify ConflictManager to refresh — use NotificationCenter to avoid + // a cross-module dependency between Foundation and SwiftUI layers. + NotificationCenter.default.post(name: .companionConflictsUpdated, object: nil, userInfo: msg.data) case "profile": if let path = msg.data?["path"] as? String, let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]), @@ -585,11 +588,114 @@ struct PushMessage: Codable { } extension Notification.Name { - static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated") - static let companionTrackUploaded = Notification.Name("companionTrackUploaded") - static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated") + static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated") + static let companionTrackUploaded = Notification.Name("companionTrackUploaded") + static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated") static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated") - static let companionLibraryChanged = Notification.Name("companionLibraryChanged") + static let companionLibraryChanged = Notification.Name("companionLibraryChanged") + static let companionConflictsUpdated = Notification.Name("companionConflictsUpdated") +} + +// MARK: - Conflict Models +// Defined here so CompanionAPIService can reference them without a separate file. + +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { self.value = value } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let v = try? container.decode([String].self) { value = v; return } + if let v = try? container.decode([String: String].self) { value = v; return } + if let v = try? container.decode(String.self) { value = v; return } + if let v = try? container.decode(Int.self) { value = v; return } + value = "" + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case let v as [String]: try container.encode(v) + case let v as [String: String]: try container.encode(v) + case let v as String: try container.encode(v) + case let v as Int: try container.encode(v) + default: try container.encode("") + } + } +} + +struct LibraryConflict: Codable, Identifiable { + let type: String + let severity: String + let title: String + let detail: String + let affected_paths: [String] + let fix_action: String? + let fix_data: [String: AnyCodable]? + + var id: String { "\(type)|\(title)" } +} + +struct ConflictsResponse: Codable { + let total: Int + let errors: Int + let warnings: Int + let issues: [LibraryConflict] +} + +// MARK: - Conflict Manager +// Defined here (in CompanionAPIService.swift) so it compiles before +// DownloadsSettingsView.swift and LibraryConflictsView.swift reference it. + +@MainActor +class ConflictManager: ObservableObject { + static let shared = ConflictManager() + + @Published var conflicts: ConflictsResponse? + @Published var isLoading = false + @Published var lastError: String? + @Published var isFixing: String? = nil + + private let api = CompanionAPIService() + + var badgeCount: Int { conflicts?.errors ?? 0 } + var totalCount: Int { conflicts?.total ?? 0 } + + init() { + NotificationCenter.default.addObserver( + forName: .companionConflictsUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + Task { await self?.refresh() } + } + } + + func refresh() async { + guard CompanionSettings.shared.isEnabled else { return } + isLoading = true + lastError = nil + do { + conflicts = try await api.fetchConflicts() + } catch { + lastError = error.localizedDescription + } + isLoading = false + } + + func fix(_ conflict: LibraryConflict) async { + guard let action = conflict.fix_action else { return } + isFixing = conflict.id + do { + try await api.fixConflict(action: action, fixData: conflict.fix_data) + try? await Task.sleep(for: .seconds(2)) + await refresh() + } catch { + lastError = error.localizedDescription + } + isFixing = nil + } } // MARK: - Library Fetch (Phase 1) @@ -693,6 +799,37 @@ extension CompanionAPIService { return CompanionSettings.shared.baseURL? .appendingPathComponent("library/artist-photo/\(encoded)") } + + /// Fetch all library conflicts and issues. + func fetchConflicts() async throws -> ConflictsResponse { + let base = try baseURL() + let url = base.appendingPathComponent("library/conflicts") + let (data, response) = try await session.data(from: url) + try validateResponse(response) + return try JSONDecoder().decode(ConflictsResponse.self, from: data) + } + + /// Fix a specific conflict by action type. + func fixConflict(action: String, fixData: [String: AnyCodable]?) async throws { + let base = try baseURL() + let url = base.appendingPathComponent("library/fix-conflict") + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Build body — AnyCodable.value can be String, Int, or [String] + var bodyFix: [String: Any] = [:] + if let fd = fixData { + for (k, v) in fd { + bodyFix[k] = v.value + } + } + let body: [String: Any] = ["action": action, "fix_data": bodyFix] + req.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (_, response) = try await session.data(for: req) + try validateResponse(response) + } } // MARK: - Errors diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index 63a2575..913ec6d 100644 --- a/iOS/Views/Companion/MultiAlbumEditorSheet.swift +++ b/iOS/Views/Companion/MultiAlbumEditorSheet.swift @@ -250,8 +250,8 @@ struct MultiAlbumEditorSheet: View { // Pre-fill from first album if let first = loaded.first { if albumName.isEmpty { albumName = first.name } - if albumArtist.isEmpty { albumArtist = first.artist ?? "" } - if artist.isEmpty { artist = first.artist ?? "" } + if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" } + if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } if genre.isEmpty { genre = first.genre ?? "" } if year.isEmpty, let y = first.year { year = "\(y)" } } diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index 6dd994e..ebc6bd0 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -1,5 +1,97 @@ 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 + } +} + +// MARK: - TrackEditorView + struct TrackEditorView: View { let song: Song @Environment(\.dismiss) private var dismiss @@ -24,6 +116,13 @@ struct TrackEditorView: View { @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? + @State private var isSaving = false @State private var errorMessage: String? @State private var showSuccess = false @@ -40,11 +139,11 @@ struct TrackEditorView: View { /// Live preview of where the file will end up after restructure. private var pathPreview: String { - let aa = editAlbumArtist ? albumArtist : albumArtist - let alb = editAlbum ? album : album - let t = editTitle ? title : title - let tn = editTrackNumber ? (Int(trackNumber) ?? 0) : (song.track ?? 0) - let dn = editDiscNumber ? (Int(discNumber) ?? 0) : (song.discNumber ?? 0) + 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 @@ -75,10 +174,7 @@ struct TrackEditorView: View { _year = State(initialValue: song.year.map { "\($0)" } ?? "") _trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "") _discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "") - - // Pre-populate album artist from companion cover art ID if available - // Falls back to artist; will be overwritten by fetchCompanionDetails on appear - _albumArtist = State(initialValue: song.artist ?? "") + _albumArtist = State(initialValue: song.albumArtist ?? song.artist ?? "") } var body: some View { @@ -114,12 +210,42 @@ struct TrackEditorView: View { 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 { - checkField("Title", text: $title, isEnabled: $editTitle) - checkField("Artist", text: $artist, isEnabled: $editArtist) - checkField("Album", text: $album, isEnabled: $editAlbum) - checkField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist) + 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: { @@ -127,15 +253,15 @@ struct TrackEditorView: View { } Section { - checkField("Genre", text: $genre, isEnabled: $editGenre) - checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad) - checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad) - checkField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, keyboard: .numberPad) + 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 — always visible + // Path preview Section { VStack(alignment: .leading, spacing: 4) { Text("New path") @@ -170,20 +296,42 @@ struct TrackEditorView: View { .foregroundColor(.gray) } ToolbarItem(placement: .navigationBarTrailing) { - if settings.isEnabled { - Button(action: save) { - if isSaving { - ProgressView().tint(accentPink) + HStack(spacing: 14) { + // MusicBrainz search button + Button(action: searchMusicBrainz) { + if isSearchingMB { + ProgressView().tint(.white).scaleEffect(0.8) } else { - Text("Save") - .fontWeight(.semibold) - .foregroundColor(hasSelection ? accentPink : .gray) + Image(systemName: "magnifyingglass") + .foregroundColor(selectedMB != nil ? .green : .white) } } - .disabled(isSaving || !hasSelection || song.path == nil) + .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 + } + ) + } .overlay { if showSuccess { VStack(spacing: 12) { @@ -209,21 +357,47 @@ struct TrackEditorView: View { } } + // 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 coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return } - let companionId = String(coverArt.dropFirst("companion:".count)) - guard let baseURL = CompanionSettings.shared.baseURL else { return } - let url = baseURL.appendingPathComponent("library/song/\(companionId)") - guard let (data, _) = try? await URLSession.shared.data(from: url), - let json = try? JSONDecoder().decode(CompanionSong.self, from: data) else { return } - await MainActor.run { - if albumArtist.isEmpty || albumArtist == artist { - albumArtist = json.album_artist - } - if discNumber.isEmpty, let dn = json.disc_number { - discNumber = "\(dn)" + 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)" + } } } } @@ -250,7 +424,6 @@ struct TrackEditorView: View { try await api.editMetadata(request) - // Notify the watch so it can update its offline store metadata WatchConnectivityManager.shared.notifyTagsUpdated( songId: song.id, title: request.title, @@ -288,28 +461,123 @@ struct TrackEditorView: View { .font(.system(size: 14)) } - // MARK: - Checkmark Field + // MARK: - Checkmark Field with optional MusicBrainz suggestion - private func checkField(_ label: String, text: Binding, isEnabled: Binding, keyboard: UIKeyboardType = .default) -> some View { - 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)) + 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() } - .buttonStyle(.plain) + .font(.system(size: 13)) - 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() + // 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) + } } - .font(.system(size: 13)) + .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) + } + } + } + } +} + diff --git a/iOS/Views/Library/AlbumDetailView.swift b/iOS/Views/Library/AlbumDetailView.swift index 334ff3a..9a89292 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -526,20 +526,22 @@ struct AlbumDetailView: View { let stdSongs = songs.map { $0.toSong() } let coverArt = songs.first.map { "companion:\($0.id)" } let totalDuration = stdSongs.compactMap { $0.duration }.reduce(0, +) + let albumArtist = songs.first?.album_artist ?? artist return AlbumWithSongs( - id: albumId, - name: name, - artist: artist, - artistId: nil, - coverArt: coverArt, - songCount: songs.count, - duration: totalDuration > 0 ? totalDuration : nil, - playCount: nil, - created: nil, - starred: nil, - year: songs.first?.year, - genre: songs.first?.genre, - song: stdSongs + id: albumId, + name: name, + artist: artist, + artistId: nil, + albumArtist: albumArtist, + coverArt: coverArt, + songCount: songs.count, + duration: totalDuration > 0 ? totalDuration : nil, + playCount: nil, + created: nil, + starred: nil, + year: songs.first?.year, + genre: songs.first?.genre, + song: stdSongs ) } @@ -622,6 +624,7 @@ struct AlbumGridView: View { result.append(Album( id: album.id, name: album.name, artist: "Various Artists", artistId: nil, + albumArtist: "Various Artists", coverArt: album.coverArt, songCount: album.songCount, duration: album.duration, playCount: album.playCount, created: album.created, starred: album.starred, diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index 4edb177..5c936e9 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -502,9 +502,11 @@ struct SettingsView: View { @EnvironmentObject var serverManager: ServerManager @ObservedObject var quality = StreamingQuality.shared @ObservedObject var debugLogger = DebugLogger.shared - + @State private var showVisualizerSettings = false @State private var cacheSizeText = "..." + @State private var conflictErrorCount = 0 + @State private var conflictTotalCount = 0 private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) @@ -546,6 +548,30 @@ struct SettingsView: View { } } } + + if CompanionSettings.shared.isEnabled { + NavigationLink { + LibraryConflictsView() + } label: { + HStack { + Text("Issues & Conflicts") + Spacer() + if conflictErrorCount > 0 { + Text("\(conflictErrorCount)") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Color(red: 1, green: 0.176, blue: 0.333)) + .clipShape(Capsule()) + } else if conflictTotalCount > 0 { + Text("\(conflictTotalCount)") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + } + } } // WiFi Streaming @@ -712,6 +738,12 @@ struct SettingsView: View { } .onAppear { cacheSizeText = computeCacheSize() + conflictErrorCount = ConflictManager.shared.badgeCount + conflictTotalCount = ConflictManager.shared.totalCount + } + .onReceive(NotificationCenter.default.publisher(for: .companionConflictsUpdated)) { _ in + conflictErrorCount = ConflictManager.shared.badgeCount + conflictTotalCount = ConflictManager.shared.totalCount } } } diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index ae6c97d..fb4e1d4 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -1136,6 +1136,7 @@ struct MyMusicView: View { let grouped = Album( id: album.id, name: album.name, artist: "Various Artists", artistId: nil, + albumArtist: "Various Artists", coverArt: album.coverArt, songCount: album.songCount, duration: album.duration, playCount: album.playCount, created: album.created, starred: album.starred, diff --git a/iOS/Views/Library/RadioView.swift b/iOS/Views/Library/RadioView.swift index a73164c..0ba8e9a 100644 --- a/iOS/Views/Library/RadioView.swift +++ b/iOS/Views/Library/RadioView.swift @@ -325,7 +325,7 @@ struct RadioView: View { let radioSong = Song( id: station.id, parent: nil, isDir: nil, title: station.name, album: "Radio", - artist: station.homePageUrl ?? "Internet Radio", + artist: station.homePageUrl ?? "Internet Radio", albumArtist: nil, track: nil, year: nil, genre: nil, coverArt: nil, size: nil, contentType: nil, suffix: nil, transcodedContentType: nil, transcodedSuffix: nil, diff --git a/iOS/Views/NowPlaying/NowPlayingView.swift b/iOS/Views/NowPlaying/NowPlayingView.swift index 7e6dcec..3a4a1dc 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -1162,6 +1162,7 @@ struct NowPlayingView: View { let updated = Song( id: song.id, parent: song.parent, isDir: song.isDir, title: song.title, album: song.album, artist: song.artist, + albumArtist: song.albumArtist, track: song.track, year: song.year, genre: song.genre, coverArt: song.coverArt, size: song.size, contentType: song.contentType, suffix: song.suffix, @@ -1538,7 +1539,7 @@ struct RadioRecordingsView: View { let song = Song( id: rec.id.uuidString, parent: nil, isDir: nil, title: rec.name, album: "Recording", - artist: stationName, + artist: stationName, albumArtist: nil, track: nil, year: nil, genre: nil, coverArt: nil, size: nil, contentType: nil, suffix: nil, transcodedContentType: nil, transcodedSuffix: nil,