quick fix

This commit is contained in:
Dallas Groot 2026-04-11 01:39:31 -07:00
parent db9d79f023
commit aae53a17d8
2 changed files with 114 additions and 115 deletions

View file

@ -0,0 +1,114 @@
import SwiftUI
// MARK: - Conflict Models
// Kept in a separate file so both CompanionAPIService and LibraryConflictsView can reference these types.
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]
}
// MARK: - AnyCodable
// Wraps heterogeneous values in the fix_data dictionary (String, Int, or [String]).
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
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
}
}

View file

@ -1,116 +1,5 @@
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 {
@ -165,7 +54,6 @@ struct LibraryConflictsView: View {
Text("Summary")
}
// Group by severity errors first
let sorted = conflicts.issues.sorted {
($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1)
}
@ -212,7 +100,6 @@ struct LibraryConflictsView: View {
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))
@ -231,7 +118,6 @@ struct LibraryConflictsView: View {
.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
@ -251,7 +137,6 @@ struct LibraryConflictsView: View {
.cornerRadius(6)
}
// Fix button
if let action = conflict.fix_action {
HStack {
Spacer()