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)