261 lines
9.8 KiB
Swift
261 lines
9.8 KiB
Swift
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: - 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
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
}
|