NavidromeApp/iOS/Views/Companion/LibraryConflictsView.swift
2026-04-11 01:36:13 -07:00

312 lines
12 KiB
Swift

import SwiftUI
// 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)" }
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]
}
// AnyCodable wrapper for heterogeneous fix_data dict
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 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 // currently fixing conflict id
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)
// Refresh after fix
try? await Task.sleep(for: .seconds(2))
await refresh()
} catch {
lastError = error.localizedDescription
}
isFixing = nil
}
}
// 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")
}
// Group by severity errors first
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) {
// Header
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)
// Affected paths (collapsed, show first 3)
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)
}
// Fix button
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"
}
}
}