changes
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:
parent
36d5151cdb
commit
fdd3a098a8
2 changed files with 177 additions and 54 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue