NavidromeApp/iOS/Views/Companion/CompanionSettingsView.swift
2026-04-12 20:18:29 -07:00

373 lines
17 KiB
Swift

import SwiftUI
struct CompanionSettingsView: View {
@ObservedObject private var settings = CompanionSettings.shared
@State private var testStatus: TestStatus = .idle
@State private var hostInput: String = ""
@State private var portInput: String = ""
@State private var analyzeStatus: AnalyzeStatus = .idle
@State private var analyzeMessage: String?
@State private var fixLibraryStatus: AnalyzeStatus = .idle
@State private var fixLibraryMessage: String?
@State private var cleanTagsStatus: AnalyzeStatus = .idle
@State private var cleanTagsMessage: String?
@State private var djFetchStatus: AnalyzeStatus = .idle
@State private var djFetchMessage: String?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
enum TestStatus: Equatable {
case idle, testing, success, failed(String)
}
enum AnalyzeStatus {
case idle, analyzing, done, failed
}
init() {
_hostInput = State(initialValue: CompanionSettings.shared.host)
_portInput = State(initialValue: "\(CompanionSettings.shared.port)")
}
var body: some View {
List {
// Connection
Section {
Toggle("Enable Companion API", isOn: $settings.isEnabled)
.tint(accentPink)
if settings.isEnabled {
HStack {
Text("Host")
.foregroundColor(.gray)
.frame(width: 50, alignment: .leading)
TextField("192.168.1.100", text: $hostInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.numbersAndPunctuation)
.multilineTextAlignment(.trailing)
.onChange(of: hostInput) { _, val in
settings.host = val
}
}
HStack {
Text("Port")
.foregroundColor(.gray)
.frame(width: 50, alignment: .leading)
TextField("8000", text: $portInput)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.onChange(of: portInput) { _, val in
settings.port = Int(val) ?? 8000
}
}
Button(action: testConnection) {
HStack {
switch testStatus {
case .idle:
Image(systemName: "bolt.horizontal")
.foregroundColor(accentPink)
Text("Test Connection")
.foregroundColor(accentPink)
case .testing:
ProgressView()
.scaleEffect(0.8)
Text("Testing...")
.foregroundColor(.gray)
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Connected")
.foregroundColor(.green)
case .failed(let msg):
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
Text(msg)
.foregroundColor(.red)
.lineLimit(2)
}
}
.font(.system(size: 14))
}
.disabled(testStatus == .testing)
}
} header: {
Text("Companion API")
} footer: {
Text("The Companion API provides advanced features: file uploads, ID3 tag editing, and Smart DJ audio analysis.")
}
// Smart DJ available with or without Companion API
Section {
} header: { Text("Smart DJ") } footer: {
Text("Smart DJ and Crossfade settings have moved to Settings → Smart DJ & Visualizer.")
}
if settings.isEnabled {
Section {
NavigationLink(destination: BatchUploadView()) {
HStack {
Image(systemName: "icloud.and.arrow.up").foregroundColor(accentPink)
Text("Upload Music")
}
}
} header: { Text("Upload") } footer: {
Text("Import a .zip archive of audio files, batch-tag them, and upload to your server via the Companion API.")
}
Section {
Button(action: triggerScan) {
HStack {
Image(systemName: "arrow.triangle.2.circlepath").foregroundColor(accentPink)
Text("Trigger Navidrome Scan").foregroundColor(.white)
}
}
Button(action: triggerCleanTags) {
HStack {
Image(systemName: "tag.slash")
.foregroundColor(cleanTagsStatus == .done ? .green : cleanTagsStatus == .failed ? .red : .orange)
.symbolEffect(.pulse, isActive: cleanTagsStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Clean Picard Tags").foregroundColor(.white)
Text("Remove MusicBrainz IDs, AcoustID, and other Picard-only tags from every file.")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(cleanTagsStatus == .analyzing)
if let msg = cleanTagsMessage {
Text(msg)
.font(.caption)
.foregroundColor(cleanTagsStatus == .done ? .green : cleanTagsStatus == .failed ? .red : .gray)
}
Button(action: triggerFixLibrary) {
HStack {
Image(systemName: "folder.badge.gearshape")
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .orange)
.symbolEffect(.pulse, isActive: fixLibraryStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Fix Library Structure").foregroundColor(.white)
Text("Only run once all tags are correct — moves every file to match its current tags.")
.font(.caption).foregroundColor(.orange.opacity(0.8))
}
}
}
.disabled(fixLibraryStatus == .analyzing)
if let msg = fixLibraryMessage {
Text(msg)
.font(.caption)
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .gray)
}
} header: { Text("Server Actions") }
.onReceive(NotificationCenter.default.publisher(for: .companionTagsCleaned)) { _ in
cleanTagsStatus = .done
cleanTagsMessage = "✓ Tag cleaning complete"
}
Section {
Button(action: fetchAllDJProfiles) {
HStack {
Image(systemName: djFetchStatus == .analyzing ? "arrow.down.circle" : "arrow.down.circle.fill")
.foregroundColor(accentPink)
.symbolEffect(.pulse, isActive: djFetchStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Fetch All DJ Profiles").foregroundColor(.white)
Text("Download crossfade + volume data for your entire library")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(djFetchStatus == .analyzing)
if let msg = djFetchMessage {
Text(msg).font(.caption)
.foregroundColor(djFetchStatus == .done ? .green : djFetchStatus == .failed ? .red : .gray)
}
HStack {
Text("Cached DJ Profiles")
Spacer()
Text("\(SmartDJCache.shared.cachedCount)")
.foregroundColor(.gray)
}
} header: { Text("Cache Management") } footer: {
Text("Fetches all Smart DJ profiles from the server in one request. Speeds up crossfade — no per-song API calls needed.")
}
Section {
Button(action: { triggerPreAnalyze(force: false) }) {
HStack {
Image(systemName: analyzeStatus == .analyzing ? "waveform" : "waveform.badge.magnifyingglass")
.foregroundColor(accentPink)
.symbolEffect(.pulse, isActive: analyzeStatus == .analyzing)
VStack(alignment: .leading, spacing: 2) {
Text("Pre-Compute Missing Visualizer Frames").foregroundColor(.white)
Text("Generate Mitsuha FFT frames on the server for un-cached songs")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
Button(action: { triggerPreAnalyze(force: true) }) {
HStack {
Image(systemName: "arrow.clockwise").foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Re-Compute All Visualizer Frames").foregroundColor(.white)
Text("Force regenerate server-side frames for every track")
.font(.caption).foregroundColor(.gray)
}
}
}
.disabled(analyzeStatus == .analyzing)
if let msg = analyzeMessage {
Text(msg).font(.caption)
.foregroundColor(analyzeStatus == .done ? .green : analyzeStatus == .failed ? .red : .gray)
}
} header: { Text("Server Analysis (Runs on Pi)") } footer: {
Text("These commands run on your Companion API server. Large libraries may take a few minutes.")
}
}
}
.navigationTitle("Companion API")
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Clean Picard Tags
private func triggerCleanTags() {
cleanTagsStatus = .analyzing
cleanTagsMessage = "Cleaning tags on server… this may take a minute."
Task {
do {
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("library/clean-tags"))
req.httpMethod = "POST"
req.timeoutInterval = 15
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Started"
await MainActor.run {
cleanTagsStatus = .analyzing
cleanTagsMessage = "\(msg)"
}
DebugLogger.shared.log("Clean Picard tags triggered", category: "Companion")
} catch {
await MainActor.run {
cleanTagsStatus = .failed
cleanTagsMessage = "\(error.localizedDescription)"
}
}
}
}
// MARK: - Trigger Server Scan
private func triggerScan() {
Task {
do {
try await CompanionAPIService.shared.triggerBulkFix()
DebugLogger.shared.log("Triggered Navidrome scan", category: "Companion", level: .info)
} catch {
DebugLogger.shared.log("Scan failed: \(error.localizedDescription)", category: "Companion", level: .warning)
}
}
}
// MARK: - Fix Library Structure
private func triggerFixLibrary() {
fixLibraryStatus = .analyzing
fixLibraryMessage = "Restructuring library on server..."
Task {
do {
try await CompanionAPIService.shared.triggerBulkFix()
await MainActor.run {
fixLibraryStatus = .done
fixLibraryMessage = "Library restructure started — check server logs for progress."
}
DebugLogger.shared.log("Fix library triggered", category: "Companion", level: .info)
} catch {
await MainActor.run {
fixLibraryStatus = .failed
fixLibraryMessage = "Failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - DJ Profile Bulk Fetch
private func fetchAllDJProfiles() {
djFetchStatus = .analyzing
djFetchMessage = "Fetching profiles from server..."
Task {
do {
let api = CompanionAPIService.shared
let profiles = try await api.fetchAllProfiles()
SmartDJCache.shared.bulkImport(profiles)
await MainActor.run {
djFetchStatus = .done
djFetchMessage = "\(profiles.count) profiles cached"
}
} catch {
await MainActor.run {
djFetchStatus = .failed
djFetchMessage = "\(error.localizedDescription)"
}
}
}
}
// MARK: - Server Visualizer Precompute
private func triggerPreAnalyze(force: Bool) {
analyzeStatus = .analyzing
analyzeMessage = force ? "Requesting full re-compute on server..." : "Requesting precompute on server..."
Task {
do {
guard let base = CompanionSettings.shared.baseURL else { return }
var req = URLRequest(url: base.appendingPathComponent("visualizer/precompute"))
req.httpMethod = "POST"
req.timeoutInterval = 10
let (data, _) = try await URLSession.shared.data(for: req)
let msg = (try? JSONDecoder().decode([String: String].self, from: data))?["message"] ?? "Started"
await MainActor.run { analyzeStatus = .done; analyzeMessage = "\(msg)" }
} catch {
await MainActor.run { analyzeStatus = .failed; analyzeMessage = "\(error.localizedDescription)" }
}
try? await Task.sleep(for: .seconds(5))
await MainActor.run { if analyzeStatus != .analyzing { analyzeStatus = .idle; analyzeMessage = nil } }
}
}
// MARK: - Test Connection
private func testConnection() {
testStatus = .testing
Task {
do {
let ok = try await CompanionAPIService.shared.healthCheck()
await MainActor.run { testStatus = ok ? .success : .failed("Unhealthy") }
} catch let error as URLError {
await MainActor.run {
testStatus = .failed(error.code == .timedOut ? "Timeout" : error.localizedDescription)
}
} catch {
await MainActor.run { testStatus = .failed(error.localizedDescription) }
}
try? await Task.sleep(for: .seconds(3))
await MainActor.run {
if case .success = testStatus { testStatus = .idle }
if case .failed = testStatus { testStatus = .idle }
}
}
}
}