From fdd3a098a8418d23d52b358ac910f1c81cbd9e8c Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Fri, 10 Apr 2026 13:00:45 -0700 Subject: [PATCH] changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Companion/CompanionSettingsView.swift | 44 +++++ iOS/Views/Companion/TrackEditorView.swift | 187 +++++++++++++----- 2 files changed, 177 insertions(+), 54 deletions(-) diff --git a/iOS/Views/Companion/CompanionSettingsView.swift b/iOS/Views/Companion/CompanionSettingsView.swift index a090dae..a59285f 100644 --- a/iOS/Views/Companion/CompanionSettingsView.swift +++ b/iOS/Views/Companion/CompanionSettingsView.swift @@ -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) { diff --git a/iOS/Views/Companion/TrackEditorView.swift b/iOS/Views/Companion/TrackEditorView.swift index 197bd06..6a74c46 100644 --- a/iOS/Views/Companion/TrackEditorView.swift +++ b/iOS/Views/Companion/TrackEditorView.swift @@ -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, isEnabled: Binding, 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)