TrackEditorView — added Disc # field, album artist now auto-populates
from the Companion DB on sheet open (fetches /library/song/{id}), live
path preview shows exactly where the file will end up as you type,
success message now says “File moved to new path”.
CompanionSettingsView — “Fix Library Structure” button added under
Server Actions in orange with the warning “Only run once all tags are
correct”. It won’t do damage right now since touching it just
restructures based on whatever tags exist, but the warning makes it
clear it’s a final-step tool.
This commit is contained in:
Dallas Groot 2026-04-10 13:00:45 -07:00
parent 36d5151cdb
commit fdd3a098a8
2 changed files with 177 additions and 54 deletions

View file

@ -8,6 +8,8 @@ struct CompanionSettingsView: View {
@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?
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
@ -119,6 +121,26 @@ struct CompanionSettingsView: View {
Text("Trigger Navidrome Scan").foregroundColor(.white)
}
}
Button(action: triggerFixLibrary) {
HStack {
Image(systemName: "folder.badge.gearshape")
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .orange)
.symbolEffect(.pulse, isActive: fixLibraryStatus == .running)
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 == .running)
if let msg = fixLibraryMessage {
Text(msg)
.font(.caption)
.foregroundColor(fixLibraryStatus == .done ? .green : fixLibraryStatus == .failed ? .red : .gray)
}
} header: { Text("Server Actions") }
Section {
@ -174,6 +196,28 @@ struct CompanionSettingsView: View {
}
}
// MARK: - Fix Library Structure
private func triggerFixLibrary() {
fixLibraryStatus = .running
fixLibraryMessage = "Restructuring library on server..."
Task {
do {
try await CompanionAPIService().triggerBulkFix()
await MainActor.run {
fixLibraryStatus = .done
fixLibraryMessage = "Library restructure started — check server logs for progress."
}
DebugLogger.shared.log("Fix library triggered", category: "Companion")
} catch {
await MainActor.run {
fixLibraryStatus = .failed
fixLibraryMessage = "Failed: \(error.localizedDescription)"
}
}
}
}
// MARK: - Server Visualizer Precompute
private func triggerPreAnalyze(force: Bool) {

View file

@ -3,7 +3,7 @@ import SwiftUI
struct TrackEditorView: View {
let song: Song
@Environment(\.dismiss) private var dismiss
// Field values
@State private var title: String
@State private var artist: String
@ -12,40 +12,75 @@ struct TrackEditorView: View {
@State private var genre: String
@State private var year: String
@State private var trackNumber: String
@State private var discNumber: String
// Checkmarks only checked fields get sent to the API
@State private var editTitle = false
@State private var editArtist = false
@State private var editAlbum = false
@State private var editTitle = false
@State private var editArtist = false
@State private var editAlbum = false
@State private var editAlbumArtist = false
@State private var editGenre = false
@State private var editYear = false
@State private var editGenre = false
@State private var editYear = false
@State private var editTrackNumber = false
@State private var isSaving = false
@State private var editDiscNumber = false
@State private var isSaving = false
@State private var errorMessage: String?
@State private var showSuccess = false
@State private var showSuccess = false
@ObservedObject private var settings = CompanionSettings.shared
private let api = CompanionAPIService()
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private var hasSelection: Bool {
editTitle || editArtist || editAlbum || editAlbumArtist || editGenre || editYear || editTrackNumber
editTitle || editArtist || editAlbum || editAlbumArtist ||
editGenre || editYear || editTrackNumber || editDiscNumber
}
/// Live preview of where the file will end up after restructure.
private var pathPreview: String {
let aa = editAlbumArtist ? albumArtist : albumArtist
let alb = editAlbum ? album : album
let t = editTitle ? title : title
let tn = editTrackNumber ? (Int(trackNumber) ?? 0) : (song.track ?? 0)
let dn = editDiscNumber ? (Int(discNumber) ?? 0) : (song.discNumber ?? 0)
let ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac")
let prefix: String
if dn > 1 {
prefix = String(format: "%02d-%02d", dn, tn)
} else if tn > 0 {
prefix = String(format: "%02d", tn)
} else {
prefix = ""
}
let sanitized = { (s: String) -> String in
s.replacingOccurrences(of: "[/\\\\:*?\"<>|]", with: "",
options: .regularExpression).trimmingCharacters(in: .whitespaces)
}
let folder = "\(sanitized(aa.isEmpty ? "Unknown Artist" : aa))/\(sanitized(alb.isEmpty ? "Unknown Album" : alb))"
let file = prefix.isEmpty ? "\(sanitized(t)).\(ext)" : "\(prefix) \(sanitized(t)).\(ext)"
return "\(folder)/\(file)"
}
init(song: Song) {
self.song = song
_title = State(initialValue: song.title)
_artist = State(initialValue: song.artist ?? "")
_album = State(initialValue: song.album ?? "")
_albumArtist = State(initialValue: "")
_genre = State(initialValue: song.genre ?? "")
_year = State(initialValue: song.year != nil ? "\(song.year!)" : "")
_trackNumber = State(initialValue: song.track != nil ? "\(song.track!)" : "")
_title = State(initialValue: song.title)
_artist = State(initialValue: song.artist ?? "")
_album = State(initialValue: song.album ?? "")
_genre = State(initialValue: song.genre ?? "")
_year = State(initialValue: song.year.map { "\($0)" } ?? "")
_trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "")
_discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "")
// Pre-populate album artist from companion cover art ID if available
// Falls back to artist; will be overwritten by fetchCompanionDetails on appear
_albumArtist = State(initialValue: song.artist ?? "")
}
var body: some View {
NavigationStack {
List {
@ -78,27 +113,46 @@ struct TrackEditorView: View {
} header: {
Text("File Info")
}
// Editable fields with checkmarks
// Editable fields
Section {
checkField("Title", text: $title, isEnabled: $editTitle)
checkField("Artist", text: $artist, isEnabled: $editArtist)
checkField("Album", text: $album, isEnabled: $editAlbum)
checkField("Title", text: $title, isEnabled: $editTitle)
checkField("Artist", text: $artist, isEnabled: $editArtist)
checkField("Album", text: $album, isEnabled: $editAlbum)
checkField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist)
} header: {
Text("Basic Info")
} footer: {
Text("Check the fields you want to change. Only checked fields will be updated.")
Text("Check fields you want to change. Only checked fields are updated.")
}
Section {
checkField("Genre", text: $genre, isEnabled: $editGenre)
checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad)
checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad)
checkField("Genre", text: $genre, isEnabled: $editGenre)
checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad)
checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad)
checkField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, keyboard: .numberPad)
} header: {
Text("Details")
}
// Path preview always visible
Section {
VStack(alignment: .leading, spacing: 4) {
Text("New path")
.font(.system(size: 11))
.foregroundColor(.gray)
Text(pathPreview)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.lineLimit(3)
}
.padding(.vertical, 4)
} header: {
Text("After Save")
} footer: {
Text("The file will be moved to this path on the server after tags are saved.")
}
if let error = errorMessage {
Section {
Text(error)
@ -139,6 +193,9 @@ struct TrackEditorView: View {
Text("Tags Updated")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
Text("File moved to new path")
.font(.system(size: 13))
.foregroundColor(.gray)
}
.padding(30)
.background(.ultraThinMaterial)
@ -146,40 +203,62 @@ struct TrackEditorView: View {
.transition(.scale.combined(with: .opacity))
}
}
.task {
await fetchCompanionDetails()
}
}
}
// MARK: - Fetch companion details to populate album artist + disc number
private func fetchCompanionDetails() async {
guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return }
let companionId = String(coverArt.dropFirst("companion:".count))
guard let baseURL = CompanionSettings.shared.baseURL else { return }
let url = baseURL.appendingPathComponent("library/song/\(companionId)")
guard let (data, _) = try? await URLSession.shared.data(from: url),
let json = try? JSONDecoder().decode(CompanionSong.self, from: data) else { return }
await MainActor.run {
if albumArtist.isEmpty || albumArtist == artist {
albumArtist = json.album_artist
}
if discNumber.isEmpty, let dn = json.disc_number {
discNumber = "\(dn)"
}
}
}
// MARK: - Save
private func save() {
guard let path = song.path else { return }
isSaving = true
errorMessage = nil
Task {
do {
let request = MetadataEditRequest(
relativePath: path,
title: editTitle ? (title.isEmpty ? nil : title) : nil,
artist: editArtist ? (artist.isEmpty ? nil : artist) : nil,
album: editAlbum ? (album.isEmpty ? nil : album) : nil,
albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil,
genre: editGenre ? (genre.isEmpty ? nil : genre) : nil,
year: editYear ? Int(year) : nil,
trackNumber: editTrackNumber ? Int(trackNumber) : nil
relativePath: path,
title: editTitle ? (title.isEmpty ? nil : title) : nil,
artist: editArtist ? (artist.isEmpty ? nil : artist) : nil,
album: editAlbum ? (album.isEmpty ? nil : album) : nil,
albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil,
genre: editGenre ? (genre.isEmpty ? nil : genre) : nil,
year: editYear ? Int(year) : nil,
trackNumber: editTrackNumber ? Int(trackNumber) : nil
)
try await api.editMetadata(request)
await MainActor.run {
isSaving = false
withAnimation { showSuccess = true }
DebugLogger.shared.log("Tags updated: \(title)\(artist)", category: "Companion")
}
try? await Task.sleep(for: .seconds(1.2))
await MainActor.run { dismiss() }
} catch {
await MainActor.run {
isSaving = false
@ -188,9 +267,9 @@ struct TrackEditorView: View {
}
}
}
// MARK: - Checkmark Field
private func checkField(_ label: String, text: Binding<String>, isEnabled: Binding<Bool>, keyboard: UIKeyboardType = .default) -> some View {
HStack(spacing: 10) {
Button(action: { isEnabled.wrappedValue.toggle() }) {
@ -199,11 +278,11 @@ struct TrackEditorView: View {
.foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4))
}
.buttonStyle(.plain)
Text(label)
.foregroundColor(isEnabled.wrappedValue ? .white : .gray)
.frame(width: 85, alignment: .leading)
TextField(label, text: text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
@ -212,7 +291,7 @@ struct TrackEditorView: View {
}
.font(.system(size: 14))
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label).foregroundColor(.gray)