diff --git a/Shared/Models/Models.swift b/Shared/Models/Models.swift index 7d03f2a..19f5028 100644 --- a/Shared/Models/Models.swift +++ b/Shared/Models/Models.swift @@ -114,7 +114,6 @@ 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? @@ -130,7 +129,6 @@ 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? @@ -154,7 +152,6 @@ 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? @@ -354,7 +351,6 @@ struct CompanionSong: Codable, Identifiable { title: title, album: album, artist: artist, - albumArtist: album_artist, track: track_number, year: year, genre: genre.isEmpty ? nil : genre, @@ -394,19 +390,18 @@ struct CompanionAlbum: Codable, Identifiable { func toAlbum() -> Album { Album( - 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 + 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 ) } } diff --git a/Shared/Storage/LibraryCache.swift b/Shared/Storage/LibraryCache.swift index 3353966..a805be9 100644 --- a/Shared/Storage/LibraryCache.swift +++ b/Shared/Storage/LibraryCache.swift @@ -78,20 +78,6 @@ 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 428f3c2..64c783e 100644 --- a/iOS/Data/SyncEngine.swift +++ b/iOS/Data/SyncEngine.swift @@ -42,10 +42,6 @@ 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 3bfca33..84f2ebe 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.albumArtist ?? album.artist ?? "") - _artist = State(initialValue: album.albumArtist ?? album.artist ?? "") + _albumArtist = State(initialValue: album.artist ?? "") + _artist = State(initialValue: 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 c409fe7..fbe00f1 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -528,15 +528,12 @@ 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 ?? [:]), @@ -588,114 +585,11 @@ 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 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 - } + static let companionLibraryChanged = Notification.Name("companionLibraryChanged") } // MARK: - Library Fetch (Phase 1) @@ -799,37 +693,6 @@ 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/ConflictModels.swift b/iOS/Views/Companion/ConflictModels.swift deleted file mode 100644 index d13e792..0000000 --- a/iOS/Views/Companion/ConflictModels.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import SwiftUI - -// MARK: - AnyCodable -// Wraps heterogeneous values in the fix_data dictionary (String, Int, or [String]). -// Must be defined before CompanionAPIService extension references it. - -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("") - } - } -} - -// MARK: - Conflict Models - -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)" } -} - -// SwiftUI display helpers — in an extension so the base struct has no SwiftUI dependency -extension LibraryConflict { - var severityColor: Color { - severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow - } - - var severityIcon: String { - severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" - } - - var typeIcon: String { - switch type { - case "duplicate_album": return "rectangle.on.rectangle.fill" - case "missing_files": return "doc.badge.questionmark" - case "picard_legacy_tags": return "tag.slash.fill" - case "orphaned_tracks": return "link.badge.plus" - case "duplicate_track": return "music.note.list" - case "stale_companion_paths": return "externaldrive.badge.xmark" - default: return "exclamationmark.circle" - } - } -} - -struct ConflictsResponse: Codable { - let total: Int - let errors: Int - let warnings: Int - let issues: [LibraryConflict] -} - -// MARK: - Conflict Manager - -@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 } - - 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 - } -} diff --git a/iOS/Views/Companion/LibraryConflictsView.swift b/iOS/Views/Companion/LibraryConflictsView.swift deleted file mode 100644 index aa84fd1..0000000 --- a/iOS/Views/Companion/LibraryConflictsView.swift +++ /dev/null @@ -1,219 +0,0 @@ -import SwiftUI - -// MARK: - LibraryConflict Display Helpers (SwiftUI) - -extension LibraryConflict { - var severityColor: Color { - severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow - } - var severityIcon: String { - severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" - } - var typeIcon: String { - switch type { - case "duplicate_album": return "rectangle.on.rectangle.fill" - case "missing_files": return "doc.badge.questionmark" - case "picard_legacy_tags": return "tag.slash.fill" - case "orphaned_tracks": return "link.badge.plus" - case "duplicate_track": return "music.note.list" - case "stale_companion_paths": return "externaldrive.badge.xmark" - default: return "exclamationmark.circle" - } - } -} - -// MARK: - Issues & Conflicts View - -struct LibraryConflictsView: View { - @StateObject private var manager = ConflictManager.shared - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - List { - if manager.isLoading { - Section { - HStack { - ProgressView().tint(accentPink) - Text("Scanning library for issues…") - .font(.system(size: 14)) - .foregroundColor(.gray) - .padding(.leading, 8) - } - .padding(.vertical, 4) - } - } else if let error = manager.lastError { - Section { - Text(error) - .font(.system(size: 13)) - .foregroundColor(.red) - } - } else if let conflicts = manager.conflicts { - if conflicts.total == 0 { - Section { - HStack(spacing: 12) { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 28)) - .foregroundColor(.green) - VStack(alignment: .leading, spacing: 2) { - Text("No issues found") - .font(.system(size: 15, weight: .medium)) - Text("Your library looks clean.") - .font(.system(size: 13)) - .foregroundColor(.gray) - } - } - .padding(.vertical, 6) - } - } else { - Section { - HStack(spacing: 16) { - summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink) - summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow) - Spacer() - } - .padding(.vertical, 4) - } header: { - Text("Summary") - } - - let sorted = conflicts.issues.sorted { - ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) - } - - ForEach(sorted) { conflict in - conflictRow(conflict) - } - } - } else { - Section { - Text("Tap Scan to check your library.") - .font(.system(size: 14)) - .foregroundColor(.gray) - } - } - } - .navigationTitle("Issues & Conflicts") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - Task { await manager.refresh() } - }) { - if manager.isLoading { - ProgressView().tint(accentPink).scaleEffect(0.8) - } else { - Image(systemName: "arrow.clockwise") - .foregroundColor(accentPink) - } - } - .disabled(manager.isLoading) - } - } - .task { - if manager.conflicts == nil { - await manager.refresh() - } - } - } - - // MARK: - Conflict Row - - @ViewBuilder - private func conflictRow(_ conflict: LibraryConflict) -> some View { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: conflict.typeIcon) - .font(.system(size: 16)) - .foregroundColor(conflict.severityColor) - Text(conflict.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - Spacer() - Image(systemName: conflict.severityIcon) - .font(.system(size: 12)) - .foregroundColor(conflict.severityColor) - } - - Text(conflict.detail) - .font(.system(size: 12)) - .foregroundColor(.gray) - .fixedSize(horizontal: false, vertical: true) - - if !conflict.affected_paths.isEmpty { - VStack(alignment: .leading, spacing: 2) { - ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in - Text(path) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.45)) - .lineLimit(1) - } - if conflict.affected_paths.count > 3 { - Text("+ \(conflict.affected_paths.count - 3) more") - .font(.system(size: 10)) - .foregroundColor(.gray.opacity(0.6)) - } - } - .padding(6) - .background(Color.white.opacity(0.05)) - .cornerRadius(6) - } - - if let action = conflict.fix_action { - HStack { - Spacer() - Button(action: { - Task { await manager.fix(conflict) } - }) { - if manager.isFixing == conflict.id { - HStack(spacing: 6) { - ProgressView().tint(.white).scaleEffect(0.75) - Text("Fixing…").font(.system(size: 13)) - } - .padding(.horizontal, 16) - .padding(.vertical, 7) - .background(Color.gray.opacity(0.3)) - .cornerRadius(8) - } else { - Text(fixButtonLabel(action)) - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 16) - .padding(.vertical, 7) - .background(conflict.severityColor.opacity(0.25)) - .foregroundColor(conflict.severityColor) - .cornerRadius(8) - } - } - .buttonStyle(.plain) - .disabled(manager.isFixing != nil) - } - } - } - .padding(.vertical, 4) - } - } - - // MARK: - Helpers - - private func summaryBadge(count: Int, label: String, color: Color) -> some View { - VStack(spacing: 2) { - Text("\(count)") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(count > 0 ? color : .gray) - Text(label) - .font(.system(size: 11)) - .foregroundColor(.gray) - } - } - - private func fixButtonLabel(_ action: String) -> String { - switch action { - case "fix_duplicate_album": return "Merge Duplicate" - case "fix_missing_files": return "Remove from DB" - case "fix_picard_tags": return "Fix Tags" - case "fix_stale_paths": return "Rescan Library" - case "fix_orphaned_tracks": return "Trigger Rescan" - default: return "Fix" - } - } -} diff --git a/iOS/Views/Companion/MultiAlbumEditorSheet.swift b/iOS/Views/Companion/MultiAlbumEditorSheet.swift index 913ec6d..63a2575 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.albumArtist ?? first.artist ?? "" } - if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } + if albumArtist.isEmpty { albumArtist = first.artist ?? "" } + if artist.isEmpty { artist = 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 ebc6bd0..6dd994e 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -1,97 +1,5 @@ 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 @@ -116,13 +24,6 @@ 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 @@ -139,11 +40,11 @@ struct TrackEditorView: View { /// 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 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 ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac") let prefix: String @@ -174,7 +75,10 @@ struct TrackEditorView: View { _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 ?? "") + + // 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 ?? "") } var body: some View { @@ -210,42 +114,12 @@ 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 { - 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) + 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) } header: { Text("Basic Info") } footer: { @@ -253,15 +127,15 @@ struct TrackEditorView: View { } 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) + 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) } header: { Text("Details") } - // Path preview + // Path preview — always visible Section { VStack(alignment: .leading, spacing: 4) { Text("New path") @@ -296,42 +170,20 @@ struct TrackEditorView: View { .foregroundColor(.gray) } ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 14) { - // MusicBrainz search button - Button(action: searchMusicBrainz) { - if isSearchingMB { - ProgressView().tint(.white).scaleEffect(0.8) + if settings.isEnabled { + Button(action: save) { + if isSaving { + ProgressView().tint(accentPink) } else { - Image(systemName: "magnifyingglass") - .foregroundColor(selectedMB != nil ? .green : .white) + Text("Save") + .fontWeight(.semibold) + .foregroundColor(hasSelection ? accentPink : .gray) } } - .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) - } + .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) { @@ -357,47 +209,21 @@ 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 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)" - } + 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)" } } } @@ -424,6 +250,7 @@ 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, @@ -461,123 +288,28 @@ struct TrackEditorView: View { .font(.system(size: 14)) } - // MARK: - Checkmark Field with optional MusicBrainz suggestion + // MARK: - Checkmark Field - 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() + 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)) } - .font(.system(size: 13)) + .buttonStyle(.plain) - // 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) - } + 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() } - .padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0) + .font(.system(size: 13)) } } - -// 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 9a89292..334ff3a 100644 --- a/iOS/Views/Library/AlbumDetailView.swift +++ b/iOS/Views/Library/AlbumDetailView.swift @@ -526,22 +526,20 @@ 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, - 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 + 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 ) } @@ -624,7 +622,6 @@ 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 3234a9f..2db5f93 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -502,11 +502,9 @@ 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) @@ -548,30 +546,6 @@ 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 @@ -738,12 +712,6 @@ 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 } } } @@ -998,202 +966,3 @@ struct ManageServersView: View { } } } - -// ============================================================================= -// MARK: - Library Conflicts UI -// Appended here so no new file is needed — avoids Xcode project registration issues. -// ============================================================================= - -extension LibraryConflict { - var severityColor: Color { - severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow - } - var severityIcon: String { - severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" - } - var typeIcon: String { - switch type { - case "duplicate_album": return "rectangle.on.rectangle.fill" - case "missing_files": return "doc.badge.questionmark" - case "picard_legacy_tags": return "tag.slash.fill" - case "orphaned_tracks": return "link.badge.plus" - case "duplicate_track": return "music.note.list" - case "stale_companion_paths": return "externaldrive.badge.xmark" - default: return "exclamationmark.circle" - } - } -} - -struct LibraryConflictsView: View { - @StateObject private var manager = ConflictManager.shared - private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) - - var body: some View { - List { - if manager.isLoading { - Section { - HStack { - ProgressView().tint(accentPink) - Text("Scanning library for issues…") - .font(.system(size: 14)) - .foregroundColor(.gray) - .padding(.leading, 8) - } - .padding(.vertical, 4) - } - } else if let error = manager.lastError { - Section { - Text(error) - .font(.system(size: 13)) - .foregroundColor(.red) - } - } else if let conflicts = manager.conflicts { - if conflicts.total == 0 { - Section { - HStack(spacing: 12) { - Image(systemName: "checkmark.seal.fill") - .font(.system(size: 28)) - .foregroundColor(.green) - VStack(alignment: .leading, spacing: 2) { - Text("No issues found") - .font(.system(size: 15, weight: .medium)) - Text("Your library looks clean.") - .font(.system(size: 13)) - .foregroundColor(.gray) - } - } - .padding(.vertical, 6) - } - } else { - Section { - HStack(spacing: 16) { - summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink) - summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow) - Spacer() - } - .padding(.vertical, 4) - } header: { Text("Summary") } - - ForEach(conflicts.issues.sorted { - ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) - }) { conflict in - conflictRow(conflict) - } - } - } else { - Section { - Text("Tap Scan to check your library.") - .font(.system(size: 14)) - .foregroundColor(.gray) - } - } - } - .navigationTitle("Issues & Conflicts") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { Task { await manager.refresh() } }) { - if manager.isLoading { - ProgressView().tint(accentPink).scaleEffect(0.8) - } else { - Image(systemName: "arrow.clockwise").foregroundColor(accentPink) - } - } - .disabled(manager.isLoading) - } - } - .task { - if manager.conflicts == nil { await manager.refresh() } - } - } - - @ViewBuilder - private func conflictRow(_ conflict: LibraryConflict) -> some View { - Section { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: conflict.typeIcon) - .font(.system(size: 16)) - .foregroundColor(conflict.severityColor) - Text(conflict.title) - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.white) - Spacer() - Image(systemName: conflict.severityIcon) - .font(.system(size: 12)) - .foregroundColor(conflict.severityColor) - } - Text(conflict.detail) - .font(.system(size: 12)) - .foregroundColor(.gray) - .fixedSize(horizontal: false, vertical: true) - if !conflict.affected_paths.isEmpty { - VStack(alignment: .leading, spacing: 2) { - ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in - Text(path) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(.white.opacity(0.45)) - .lineLimit(1) - } - if conflict.affected_paths.count > 3 { - Text("+ \(conflict.affected_paths.count - 3) more") - .font(.system(size: 10)) - .foregroundColor(.gray.opacity(0.6)) - } - } - .padding(6) - .background(Color.white.opacity(0.05)) - .cornerRadius(6) - } - if let action = conflict.fix_action { - HStack { - Spacer() - Button(action: { Task { await manager.fix(conflict) } }) { - if manager.isFixing == conflict.id { - HStack(spacing: 6) { - ProgressView().tint(.white).scaleEffect(0.75) - Text("Fixing…").font(.system(size: 13)) - } - .padding(.horizontal, 16).padding(.vertical, 7) - .background(Color.gray.opacity(0.3)) - .cornerRadius(8) - } else { - Text(fixButtonLabel(action)) - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 16).padding(.vertical, 7) - .background(conflict.severityColor.opacity(0.25)) - .foregroundColor(conflict.severityColor) - .cornerRadius(8) - } - } - .buttonStyle(.plain) - .disabled(manager.isFixing != nil) - } - } - } - .padding(.vertical, 4) - } - } - - private func summaryBadge(count: Int, label: String, color: Color) -> some View { - VStack(spacing: 2) { - Text("\(count)") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(count > 0 ? color : .gray) - Text(label) - .font(.system(size: 11)) - .foregroundColor(.gray) - } - } - - private func fixButtonLabel(_ action: String) -> String { - switch action { - case "fix_duplicate_album": return "Merge Duplicate" - case "fix_missing_files": return "Remove from DB" - case "fix_picard_tags": return "Fix Tags" - case "fix_stale_paths": return "Rescan Library" - case "fix_orphaned_tracks": return "Trigger Rescan" - default: return "Fix" - } - } -} diff --git a/iOS/Views/Library/MyMusicView.swift b/iOS/Views/Library/MyMusicView.swift index fb4e1d4..ae6c97d 100644 --- a/iOS/Views/Library/MyMusicView.swift +++ b/iOS/Views/Library/MyMusicView.swift @@ -1136,7 +1136,6 @@ 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 0ba8e9a..a73164c 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", albumArtist: nil, + artist: station.homePageUrl ?? "Internet Radio", 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 3a4a1dc..7e6dcec 100644 --- a/iOS/Views/NowPlaying/NowPlayingView.swift +++ b/iOS/Views/NowPlaying/NowPlayingView.swift @@ -1162,7 +1162,6 @@ 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, @@ -1539,7 +1538,7 @@ struct RadioRecordingsView: View { let song = Song( id: rec.id.uuidString, parent: nil, isDir: nil, title: rec.name, album: "Recording", - artist: stationName, albumArtist: nil, + artist: stationName, track: nil, year: nil, genre: nil, coverArt: nil, size: nil, contentType: nil, suffix: nil, transcodedContentType: nil, transcodedSuffix: nil,