quick fix
This commit is contained in:
parent
d3434f1911
commit
a9acd65001
3 changed files with 91 additions and 354 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1026,10 +1026,50 @@ extension LibraryConflict {
|
|||
|
||||
struct LibraryConflictsView: View {
|
||||
@StateObject private var manager = ConflictManager.shared
|
||||
@State private var selectedTab = 0 // 0 = Active, 1 = Ignored
|
||||
@AppStorage("ignoredConflictIDs") private var ignoredIDsRaw = ""
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
private var ignoredIDs: Set<String> {
|
||||
Set(ignoredIDsRaw.split(separator: "|||").map(String.init))
|
||||
}
|
||||
|
||||
private func ignore(_ conflict: LibraryConflict) {
|
||||
var ids = ignoredIDs
|
||||
ids.insert(conflict.id)
|
||||
ignoredIDsRaw = ids.joined(separator: "|||")
|
||||
}
|
||||
|
||||
private func restore(_ conflict: LibraryConflict) {
|
||||
var ids = ignoredIDs
|
||||
ids.remove(conflict.id)
|
||||
ignoredIDsRaw = ids.joined(separator: "|||")
|
||||
}
|
||||
|
||||
private var activeIssues: [LibraryConflict] {
|
||||
guard let all = manager.conflicts?.issues else { return [] }
|
||||
return all
|
||||
.filter { !ignoredIDs.contains($0.id) }
|
||||
.sorted { ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) }
|
||||
}
|
||||
|
||||
private var ignoredIssues: [LibraryConflict] {
|
||||
guard let all = manager.conflicts?.issues else { return [] }
|
||||
return all.filter { ignoredIDs.contains($0.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
// Tab picker
|
||||
Section {
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Active (\(activeIssues.count))").tag(0)
|
||||
Text("Ignored (\(ignoredIssues.count))").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
if manager.isLoading {
|
||||
Section {
|
||||
HStack {
|
||||
|
|
@ -1047,15 +1087,16 @@ struct LibraryConflictsView: View {
|
|||
.font(.system(size: 13))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
} else if let conflicts = manager.conflicts {
|
||||
if conflicts.total == 0 {
|
||||
} else if selectedTab == 0 {
|
||||
// ── Active tab ──────────────────────────────────────────────
|
||||
if activeIssues.isEmpty {
|
||||
Section {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.green)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("No issues found")
|
||||
Text("No active issues")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
Text("Your library looks clean.")
|
||||
.font(.system(size: 13))
|
||||
|
|
@ -1065,26 +1106,54 @@ struct LibraryConflictsView: View {
|
|||
.padding(.vertical, 6)
|
||||
}
|
||||
} else {
|
||||
let errors = activeIssues.filter { $0.severity == "error" }.count
|
||||
let warnings = activeIssues.filter { $0.severity == "warning" }.count
|
||||
Section {
|
||||
HStack(spacing: 16) {
|
||||
summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink)
|
||||
summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow)
|
||||
summaryBadge(count: errors, label: "Errors", color: accentPink)
|
||||
summaryBadge(count: 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
|
||||
ForEach(activeIssues) { conflict in
|
||||
conflictRow(conflict)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
withAnimation { ignore(conflict) }
|
||||
} label: {
|
||||
Label("Ignore", systemImage: "eye.slash")
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text("Tap Scan to check your library.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
// ── Ignored tab ─────────────────────────────────────────────
|
||||
if ignoredIssues.isEmpty {
|
||||
Section {
|
||||
Text("No ignored issues.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text("Swipe left to restore an issue to the Active list.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
ForEach(ignoredIssues) { conflict in
|
||||
conflictRow(conflict, dimmed: true)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
withAnimation { restore(conflict) }
|
||||
} label: {
|
||||
Label("Restore", systemImage: "arrow.uturn.left")
|
||||
}
|
||||
.tint(accentPink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1107,21 +1176,23 @@ struct LibraryConflictsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Conflict Row
|
||||
|
||||
@ViewBuilder
|
||||
private func conflictRow(_ conflict: LibraryConflict) -> some View {
|
||||
private func conflictRow(_ conflict: LibraryConflict, dimmed: Bool = false) -> some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: conflict.typeIcon)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(conflict.severityColor)
|
||||
.foregroundColor(dimmed ? .gray : conflict.severityColor)
|
||||
Text(conflict.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(dimmed ? .gray : .white)
|
||||
Spacer()
|
||||
Image(systemName: conflict.severityIcon)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(conflict.severityColor)
|
||||
.foregroundColor(dimmed ? .gray : conflict.severityColor)
|
||||
}
|
||||
Text(conflict.detail)
|
||||
.font(.system(size: 12))
|
||||
|
|
@ -1145,7 +1216,7 @@ struct LibraryConflictsView: View {
|
|||
.background(Color.white.opacity(0.05))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
if let action = conflict.fix_action {
|
||||
if !dimmed, let action = conflict.fix_action {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: { Task { await manager.fix(conflict) } }) {
|
||||
|
|
@ -1175,6 +1246,8 @@ struct LibraryConflictsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
Text("\(count)")
|
||||
|
|
@ -1197,3 +1270,4 @@ struct LibraryConflictsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue