quick fix
This commit is contained in:
parent
b4d2a5ce92
commit
3ea57fa99b
12 changed files with 560 additions and 95 deletions
|
|
@ -114,6 +114,7 @@ struct Album: Codable, Identifiable {
|
|||
let name: String
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let albumArtist: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Int?
|
||||
|
|
@ -129,6 +130,7 @@ struct AlbumWithSongs: Codable, Identifiable {
|
|||
let name: String
|
||||
let artist: String?
|
||||
let artistId: String?
|
||||
let albumArtist: String?
|
||||
let coverArt: String?
|
||||
let songCount: Int?
|
||||
let duration: Int?
|
||||
|
|
@ -152,6 +154,7 @@ struct Song: Codable, Identifiable {
|
|||
let title: String
|
||||
let album: String?
|
||||
let artist: String?
|
||||
let albumArtist: String?
|
||||
let track: Int?
|
||||
let year: Int?
|
||||
let genre: String?
|
||||
|
|
@ -351,6 +354,7 @@ struct CompanionSong: Codable, Identifiable {
|
|||
title: title,
|
||||
album: album,
|
||||
artist: artist,
|
||||
albumArtist: album_artist,
|
||||
track: track_number,
|
||||
year: year,
|
||||
genre: genre.isEmpty ? nil : genre,
|
||||
|
|
@ -390,18 +394,19 @@ struct CompanionAlbum: Codable, Identifiable {
|
|||
|
||||
func toAlbum() -> Album {
|
||||
Album(
|
||||
id: id,
|
||||
name: album,
|
||||
artist: album_artist,
|
||||
artistId: nil,
|
||||
coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" },
|
||||
songCount: track_count,
|
||||
duration: nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: year,
|
||||
genre: nil
|
||||
id: id,
|
||||
name: album,
|
||||
artist: album_artist,
|
||||
artistId: nil,
|
||||
albumArtist: album_artist,
|
||||
coverArt: cover_art_url.map { extractCompanionId($0) }.map { "companion:\($0)" },
|
||||
songCount: track_count,
|
||||
duration: nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: year,
|
||||
genre: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ class LibraryCache: ObservableObject {
|
|||
try? encoded.write(to: cacheURL(for: key), options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(key: String) {
|
||||
try? FileManager.default.removeItem(at: cacheURL(for: key))
|
||||
}
|
||||
|
||||
/// Remove all album detail caches (keyed as "album_ALBUMID") so stale
|
||||
/// song paths from before a file restructure aren't served to the Companion.
|
||||
func removeAlbumDetails() {
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(
|
||||
at: cacheDir, includingPropertiesForKeys: nil) else { return }
|
||||
for url in files where url.lastPathComponent.hasPrefix("album_") {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
func load<T: Decodable>(_ type: T.Type, key: String) -> T? {
|
||||
let url = cacheURL(for: key)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class SyncEngine: ObservableObject {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.debounce(for: .seconds(2), scheduler: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
// Paths may have changed due to file restructuring — invalidate song caches
|
||||
// so next fetch returns the fresh Navidrome paths instead of stale ones
|
||||
LibraryCache.shared.remove(key: "all_songs_sorted")
|
||||
LibraryCache.shared.removeAlbumDetails()
|
||||
self?.lastSyncTimestamp = 0
|
||||
self?.syncIfNeeded()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ struct BatchAlbumEditorSheet: View {
|
|||
init(album: AlbumWithSongs) {
|
||||
self.album = album
|
||||
_albumName = State(initialValue: album.name)
|
||||
_albumArtist = State(initialValue: album.artist ?? "")
|
||||
_artist = State(initialValue: album.artist ?? "")
|
||||
_albumArtist = State(initialValue: album.albumArtist ?? album.artist ?? "")
|
||||
_artist = State(initialValue: album.albumArtist ?? album.artist ?? "")
|
||||
_genre = State(initialValue: album.genre ?? "")
|
||||
_year = State(initialValue: album.year != nil ? "\(album.year!)" : "")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -528,12 +528,15 @@ class CompanionPushClient: ObservableObject {
|
|||
case "batch_metadata_updated":
|
||||
NotificationCenter.default.post(name: .companionMetadataUpdated, object: nil, userInfo: msg.data)
|
||||
case "cover_art_updated":
|
||||
// Clear image cache so updated cover art loads immediately
|
||||
ImageCache.shared.clearAll()
|
||||
NotificationCenter.default.post(name: .companionCoverArtUpdated, object: nil, userInfo: msg.data)
|
||||
case "artist_photo_updated":
|
||||
ImageCache.shared.clearAll()
|
||||
NotificationCenter.default.post(name: .companionArtistPhotoUpdated, object: nil, userInfo: msg.data)
|
||||
case "conflicts_updated":
|
||||
// Notify ConflictManager to refresh — use NotificationCenter to avoid
|
||||
// a cross-module dependency between Foundation and SwiftUI layers.
|
||||
NotificationCenter.default.post(name: .companionConflictsUpdated, object: nil, userInfo: msg.data)
|
||||
case "profile":
|
||||
if let path = msg.data?["path"] as? String,
|
||||
let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]),
|
||||
|
|
@ -585,11 +588,114 @@ struct PushMessage: Codable {
|
|||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
|
||||
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
|
||||
static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated")
|
||||
static let companionMetadataUpdated = Notification.Name("companionMetadataUpdated")
|
||||
static let companionTrackUploaded = Notification.Name("companionTrackUploaded")
|
||||
static let companionCoverArtUpdated = Notification.Name("companionCoverArtUpdated")
|
||||
static let companionArtistPhotoUpdated = Notification.Name("companionArtistPhotoUpdated")
|
||||
static let companionLibraryChanged = Notification.Name("companionLibraryChanged")
|
||||
static let companionLibraryChanged = Notification.Name("companionLibraryChanged")
|
||||
static let companionConflictsUpdated = Notification.Name("companionConflictsUpdated")
|
||||
}
|
||||
|
||||
// MARK: - Conflict Models
|
||||
// Defined here so CompanionAPIService can reference them without a separate file.
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) { self.value = value }
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let v = try? container.decode([String].self) { value = v; return }
|
||||
if let v = try? container.decode([String: String].self) { value = v; return }
|
||||
if let v = try? container.decode(String.self) { value = v; return }
|
||||
if let v = try? container.decode(Int.self) { value = v; return }
|
||||
value = ""
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch value {
|
||||
case let v as [String]: try container.encode(v)
|
||||
case let v as [String: String]: try container.encode(v)
|
||||
case let v as String: try container.encode(v)
|
||||
case let v as Int: try container.encode(v)
|
||||
default: try container.encode("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryConflict: Codable, Identifiable {
|
||||
let type: String
|
||||
let severity: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let affected_paths: [String]
|
||||
let fix_action: String?
|
||||
let fix_data: [String: AnyCodable]?
|
||||
|
||||
var id: String { "\(type)|\(title)" }
|
||||
}
|
||||
|
||||
struct ConflictsResponse: Codable {
|
||||
let total: Int
|
||||
let errors: Int
|
||||
let warnings: Int
|
||||
let issues: [LibraryConflict]
|
||||
}
|
||||
|
||||
// MARK: - Conflict Manager
|
||||
// Defined here (in CompanionAPIService.swift) so it compiles before
|
||||
// DownloadsSettingsView.swift and LibraryConflictsView.swift reference it.
|
||||
|
||||
@MainActor
|
||||
class ConflictManager: ObservableObject {
|
||||
static let shared = ConflictManager()
|
||||
|
||||
@Published var conflicts: ConflictsResponse?
|
||||
@Published var isLoading = false
|
||||
@Published var lastError: String?
|
||||
@Published var isFixing: String? = nil
|
||||
|
||||
private let api = CompanionAPIService()
|
||||
|
||||
var badgeCount: Int { conflicts?.errors ?? 0 }
|
||||
var totalCount: Int { conflicts?.total ?? 0 }
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .companionConflictsUpdated,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { await self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard CompanionSettings.shared.isEnabled else { return }
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
do {
|
||||
conflicts = try await api.fetchConflicts()
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func fix(_ conflict: LibraryConflict) async {
|
||||
guard let action = conflict.fix_action else { return }
|
||||
isFixing = conflict.id
|
||||
do {
|
||||
try await api.fixConflict(action: action, fixData: conflict.fix_data)
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
await refresh()
|
||||
} catch {
|
||||
lastError = error.localizedDescription
|
||||
}
|
||||
isFixing = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Library Fetch (Phase 1)
|
||||
|
|
@ -693,6 +799,37 @@ extension CompanionAPIService {
|
|||
return CompanionSettings.shared.baseURL?
|
||||
.appendingPathComponent("library/artist-photo/\(encoded)")
|
||||
}
|
||||
|
||||
/// Fetch all library conflicts and issues.
|
||||
func fetchConflicts() async throws -> ConflictsResponse {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("library/conflicts")
|
||||
let (data, response) = try await session.data(from: url)
|
||||
try validateResponse(response)
|
||||
return try JSONDecoder().decode(ConflictsResponse.self, from: data)
|
||||
}
|
||||
|
||||
/// Fix a specific conflict by action type.
|
||||
func fixConflict(action: String, fixData: [String: AnyCodable]?) async throws {
|
||||
let base = try baseURL()
|
||||
let url = base.appendingPathComponent("library/fix-conflict")
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
// Build body — AnyCodable.value can be String, Int, or [String]
|
||||
var bodyFix: [String: Any] = [:]
|
||||
if let fd = fixData {
|
||||
for (k, v) in fd {
|
||||
bodyFix[k] = v.value
|
||||
}
|
||||
}
|
||||
let body: [String: Any] = ["action": action, "fix_data": bodyFix]
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (_, response) = try await session.data(for: req)
|
||||
try validateResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
|
|
|||
|
|
@ -250,8 +250,8 @@ struct MultiAlbumEditorSheet: View {
|
|||
// Pre-fill from first album
|
||||
if let first = loaded.first {
|
||||
if albumName.isEmpty { albumName = first.name }
|
||||
if albumArtist.isEmpty { albumArtist = first.artist ?? "" }
|
||||
if artist.isEmpty { artist = first.artist ?? "" }
|
||||
if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" }
|
||||
if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" }
|
||||
if genre.isEmpty { genre = first.genre ?? "" }
|
||||
if year.isEmpty, let y = first.year { year = "\(y)" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,97 @@
|
|||
import SwiftUI
|
||||
|
||||
// MARK: - MusicBrainz Models
|
||||
|
||||
struct MBRecording: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let artist: String
|
||||
let album: String
|
||||
let albumArtist: String
|
||||
let year: String
|
||||
let trackNumber: String
|
||||
let discNumber: String
|
||||
let genre: String
|
||||
let country: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
// MARK: - MusicBrainz Service
|
||||
|
||||
struct MusicBrainzService {
|
||||
static let baseURL = "https://musicbrainz.org/ws/2"
|
||||
static let userAgent = "NavidromePlayer/1.0 (ca.dallasgroot.navidromeplayer.app)"
|
||||
|
||||
static func search(title: String, artist: String) async -> [MBRecording] {
|
||||
let query = "recording:\"\(title)\" AND artist:\"\(artist)\""
|
||||
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "\(baseURL)/recording?query=\(encoded)&limit=10&fmt=json") else {
|
||||
return []
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
req.timeoutInterval = 10
|
||||
|
||||
guard let (data, _) = try? await URLSession.shared.data(for: req),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let recordings = json["recordings"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
var results: [MBRecording] = []
|
||||
for rec in recordings {
|
||||
let id = rec["id"] as? String ?? UUID().uuidString
|
||||
let recTitle = rec["title"] as? String ?? ""
|
||||
|
||||
// Artist credit
|
||||
let credits = rec["artist-credit"] as? [[String: Any]] ?? []
|
||||
let artistName = credits.compactMap {
|
||||
($0["artist"] as? [String: Any])?["name"] as? String
|
||||
}.joined(separator: ", ")
|
||||
|
||||
// First release
|
||||
let releases = rec["releases"] as? [[String: Any]] ?? []
|
||||
let release = releases.first
|
||||
let albumTitle = release?["title"] as? String ?? ""
|
||||
let country = release?["country"] as? String ?? ""
|
||||
let releaseDate = release?["date"] as? String ?? ""
|
||||
let year = String(releaseDate.prefix(4))
|
||||
|
||||
// Label
|
||||
let labelInfo = (release?["label-info"] as? [[String: Any]])?.first
|
||||
let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? ""
|
||||
|
||||
// Track number within release
|
||||
let media = release?["media"] as? [[String: Any]]
|
||||
let track = (media?.first?["tracks"] as? [[String: Any]])?.first
|
||||
let trackPos = (track?["number"] as? String) ?? (track?["position"].map { "\($0)" } ?? "")
|
||||
let discPos = media?.first?["position"].map { "\($0)" } ?? ""
|
||||
|
||||
// Album artist from release artist-credit
|
||||
let releaseCredits = release?["artist-credit"] as? [[String: Any]] ?? []
|
||||
let releaseArtist = releaseCredits.compactMap {
|
||||
($0["artist"] as? [String: Any])?["name"] as? String
|
||||
}.joined(separator: ", ")
|
||||
|
||||
results.append(MBRecording(
|
||||
id: id, title: recTitle,
|
||||
artist: artistName,
|
||||
album: albumTitle,
|
||||
albumArtist: releaseArtist.isEmpty ? artistName : releaseArtist,
|
||||
year: year,
|
||||
trackNumber: trackPos,
|
||||
discNumber: discPos == "1" ? "" : discPos,
|
||||
genre: "",
|
||||
country: country,
|
||||
label: labelName
|
||||
))
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TrackEditorView
|
||||
|
||||
struct TrackEditorView: View {
|
||||
let song: Song
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
|
@ -24,6 +116,13 @@ struct TrackEditorView: View {
|
|||
@State private var editTrackNumber = false
|
||||
@State private var editDiscNumber = false
|
||||
|
||||
// MusicBrainz
|
||||
@State private var isSearchingMB = false
|
||||
@State private var mbResults: [MBRecording] = []
|
||||
@State private var selectedMB: MBRecording?
|
||||
@State private var showMBPicker = false
|
||||
@State private var mbError: String?
|
||||
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccess = false
|
||||
|
|
@ -40,11 +139,11 @@ struct TrackEditorView: View {
|
|||
|
||||
/// 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 aa = albumArtist
|
||||
let alb = album
|
||||
let t = title
|
||||
let tn = Int(trackNumber) ?? (song.track ?? 0)
|
||||
let dn = Int(discNumber) ?? (song.discNumber ?? 0)
|
||||
let ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac")
|
||||
|
||||
let prefix: String
|
||||
|
|
@ -75,10 +174,7 @@ struct TrackEditorView: View {
|
|||
_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 ?? "")
|
||||
_albumArtist = State(initialValue: song.albumArtist ?? song.artist ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -114,12 +210,42 @@ struct TrackEditorView: View {
|
|||
Text("File Info")
|
||||
}
|
||||
|
||||
// MusicBrainz suggestion banner
|
||||
if let mb = selectedMB {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.system(size: 13))
|
||||
Text("MusicBrainz: \(mb.album)")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Button("Change") { showMBPicker = true }
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(accentPink)
|
||||
}
|
||||
if !mb.label.isEmpty {
|
||||
Text("\(mb.country) · \(mb.label)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
} header: {
|
||||
Text("MusicBrainz Match")
|
||||
} footer: {
|
||||
Text("Tap ↓ next to any field to apply the suggestion.")
|
||||
}
|
||||
}
|
||||
|
||||
// Editable fields
|
||||
Section {
|
||||
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)
|
||||
mbCheckField("Title", text: $title, isEnabled: $editTitle, mbValue: selectedMB?.title)
|
||||
mbCheckField("Artist", text: $artist, isEnabled: $editArtist, mbValue: selectedMB?.artist)
|
||||
mbCheckField("Album", text: $album, isEnabled: $editAlbum, mbValue: selectedMB?.album)
|
||||
mbCheckField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist, mbValue: selectedMB?.albumArtist)
|
||||
} header: {
|
||||
Text("Basic Info")
|
||||
} footer: {
|
||||
|
|
@ -127,15 +253,15 @@ struct TrackEditorView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
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)
|
||||
mbCheckField("Genre", text: $genre, isEnabled: $editGenre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
|
||||
mbCheckField("Year", text: $year, isEnabled: $editYear, mbValue: selectedMB?.year, keyboard: .numberPad)
|
||||
mbCheckField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, mbValue: selectedMB?.trackNumber, keyboard: .numberPad)
|
||||
mbCheckField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, mbValue: selectedMB?.discNumber.isEmpty == false ? selectedMB?.discNumber : nil, keyboard: .numberPad)
|
||||
} header: {
|
||||
Text("Details")
|
||||
}
|
||||
|
||||
// Path preview — always visible
|
||||
// Path preview
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("New path")
|
||||
|
|
@ -170,20 +296,42 @@ struct TrackEditorView: View {
|
|||
.foregroundColor(.gray)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if settings.isEnabled {
|
||||
Button(action: save) {
|
||||
if isSaving {
|
||||
ProgressView().tint(accentPink)
|
||||
HStack(spacing: 14) {
|
||||
// MusicBrainz search button
|
||||
Button(action: searchMusicBrainz) {
|
||||
if isSearchingMB {
|
||||
ProgressView().tint(.white).scaleEffect(0.8)
|
||||
} else {
|
||||
Text("Save")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(hasSelection ? accentPink : .gray)
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(selectedMB != nil ? .green : .white)
|
||||
}
|
||||
}
|
||||
.disabled(isSaving || !hasSelection || song.path == nil)
|
||||
.disabled(isSearchingMB)
|
||||
|
||||
if settings.isEnabled {
|
||||
Button(action: save) {
|
||||
if isSaving {
|
||||
ProgressView().tint(accentPink)
|
||||
} else {
|
||||
Text("Save")
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(hasSelection ? accentPink : .gray)
|
||||
}
|
||||
}
|
||||
.disabled(isSaving || !hasSelection || song.path == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showMBPicker) {
|
||||
MusicBrainzPickerSheet(
|
||||
results: mbResults,
|
||||
onSelect: { rec in
|
||||
selectedMB = rec
|
||||
showMBPicker = false
|
||||
}
|
||||
)
|
||||
}
|
||||
.overlay {
|
||||
if showSuccess {
|
||||
VStack(spacing: 12) {
|
||||
|
|
@ -209,21 +357,47 @@ struct TrackEditorView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - MusicBrainz Search
|
||||
|
||||
private func searchMusicBrainz() {
|
||||
isSearchingMB = true
|
||||
mbError = nil
|
||||
Task {
|
||||
let results = await MusicBrainzService.search(title: title, artist: artist)
|
||||
await MainActor.run {
|
||||
isSearchingMB = false
|
||||
if results.isEmpty {
|
||||
mbError = "No results found on MusicBrainz"
|
||||
errorMessage = mbError
|
||||
} else {
|
||||
mbResults = results
|
||||
showMBPicker = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)"
|
||||
guard let path = song.path,
|
||||
let baseURL = CompanionSettings.shared.baseURL else { return }
|
||||
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false)
|
||||
components?.queryItems = [URLQueryItem(name: "album", value: song.album ?? "")]
|
||||
guard let url = components?.url,
|
||||
let (data, _) = try? await URLSession.shared.data(from: url),
|
||||
let response = try? JSONDecoder().decode(CompanionLibraryResponse.self, from: data),
|
||||
let songs = response.songs else { return }
|
||||
|
||||
if let match = songs.first(where: { $0.relative_path == path || path.hasSuffix($0.relative_path) }) {
|
||||
await MainActor.run {
|
||||
if albumArtist.isEmpty || albumArtist == (song.artist ?? "") {
|
||||
albumArtist = match.album_artist
|
||||
}
|
||||
if discNumber.isEmpty, let dn = match.disc_number {
|
||||
discNumber = "\(dn)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -250,7 +424,6 @@ struct TrackEditorView: View {
|
|||
|
||||
try await api.editMetadata(request)
|
||||
|
||||
// Notify the watch so it can update its offline store metadata
|
||||
WatchConnectivityManager.shared.notifyTagsUpdated(
|
||||
songId: song.id,
|
||||
title: request.title,
|
||||
|
|
@ -288,28 +461,123 @@ struct TrackEditorView: View {
|
|||
.font(.system(size: 14))
|
||||
}
|
||||
|
||||
// MARK: - Checkmark Field
|
||||
// MARK: - Checkmark Field with optional MusicBrainz suggestion
|
||||
|
||||
private func checkField(_ label: String, text: Binding<String>, isEnabled: Binding<Bool>, keyboard: UIKeyboardType = .default) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { isEnabled.wrappedValue.toggle() }) {
|
||||
Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4))
|
||||
private func mbCheckField(
|
||||
_ label: String,
|
||||
text: Binding<String>,
|
||||
isEnabled: Binding<Bool>,
|
||||
mbValue: String?,
|
||||
keyboard: UIKeyboardType = .default
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { isEnabled.wrappedValue.toggle() }) {
|
||||
Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 20))
|
||||
.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)
|
||||
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
|
||||
.disabled(!isEnabled.wrappedValue)
|
||||
.keyboardDoneButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 13))
|
||||
|
||||
Text(label)
|
||||
.foregroundColor(isEnabled.wrappedValue ? .white : .gray)
|
||||
.frame(width: 85, alignment: .leading)
|
||||
|
||||
TextField(label, text: text)
|
||||
.keyboardType(keyboard)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
|
||||
.disabled(!isEnabled.wrappedValue)
|
||||
.keyboardDoneButton()
|
||||
// MusicBrainz suggestion pill — only show if different from current value
|
||||
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
|
||||
Button(action: {
|
||||
text.wrappedValue = mb
|
||||
isEnabled.wrappedValue = true
|
||||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
Text(mb)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.blue.opacity(0.35))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 30)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MusicBrainz Picker Sheet
|
||||
|
||||
struct MusicBrainzPickerSheet: View {
|
||||
let results: [MBRecording]
|
||||
let onSelect: (MBRecording) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(results) { rec in
|
||||
Button(action: { onSelect(rec) }) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(rec.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.white)
|
||||
Text(rec.artist)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.gray)
|
||||
HStack(spacing: 8) {
|
||||
if !rec.album.isEmpty {
|
||||
Text(rec.album)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
}
|
||||
if !rec.year.isEmpty {
|
||||
Text(rec.year)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
}
|
||||
if !rec.country.isEmpty {
|
||||
Text(rec.country)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white.opacity(0.4))
|
||||
}
|
||||
if !rec.trackNumber.isEmpty {
|
||||
Text("Track \(rec.trackNumber)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.white.opacity(0.4))
|
||||
}
|
||||
}
|
||||
if !rec.label.isEmpty {
|
||||
Text(rec.label)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.gray.opacity(0.7))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.navigationTitle("MusicBrainz Results")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -526,20 +526,22 @@ struct AlbumDetailView: View {
|
|||
let stdSongs = songs.map { $0.toSong() }
|
||||
let coverArt = songs.first.map { "companion:\($0.id)" }
|
||||
let totalDuration = stdSongs.compactMap { $0.duration }.reduce(0, +)
|
||||
let albumArtist = songs.first?.album_artist ?? artist
|
||||
return AlbumWithSongs(
|
||||
id: albumId,
|
||||
name: name,
|
||||
artist: artist,
|
||||
artistId: nil,
|
||||
coverArt: coverArt,
|
||||
songCount: songs.count,
|
||||
duration: totalDuration > 0 ? totalDuration : nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: songs.first?.year,
|
||||
genre: songs.first?.genre,
|
||||
song: stdSongs
|
||||
id: albumId,
|
||||
name: name,
|
||||
artist: artist,
|
||||
artistId: nil,
|
||||
albumArtist: albumArtist,
|
||||
coverArt: coverArt,
|
||||
songCount: songs.count,
|
||||
duration: totalDuration > 0 ? totalDuration : nil,
|
||||
playCount: nil,
|
||||
created: nil,
|
||||
starred: nil,
|
||||
year: songs.first?.year,
|
||||
genre: songs.first?.genre,
|
||||
song: stdSongs
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -622,6 +624,7 @@ struct AlbumGridView: View {
|
|||
result.append(Album(
|
||||
id: album.id, name: album.name,
|
||||
artist: "Various Artists", artistId: nil,
|
||||
albumArtist: "Various Artists",
|
||||
coverArt: album.coverArt, songCount: album.songCount,
|
||||
duration: album.duration, playCount: album.playCount,
|
||||
created: album.created, starred: album.starred,
|
||||
|
|
|
|||
|
|
@ -502,9 +502,11 @@ struct SettingsView: View {
|
|||
@EnvironmentObject var serverManager: ServerManager
|
||||
@ObservedObject var quality = StreamingQuality.shared
|
||||
@ObservedObject var debugLogger = DebugLogger.shared
|
||||
|
||||
|
||||
@State private var showVisualizerSettings = false
|
||||
@State private var cacheSizeText = "..."
|
||||
@State private var conflictErrorCount = 0
|
||||
@State private var conflictTotalCount = 0
|
||||
|
||||
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||||
|
||||
|
|
@ -546,6 +548,30 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if CompanionSettings.shared.isEnabled {
|
||||
NavigationLink {
|
||||
LibraryConflictsView()
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Issues & Conflicts")
|
||||
Spacer()
|
||||
if conflictErrorCount > 0 {
|
||||
Text("\(conflictErrorCount)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color(red: 1, green: 0.176, blue: 0.333))
|
||||
.clipShape(Capsule())
|
||||
} else if conflictTotalCount > 0 {
|
||||
Text("\(conflictTotalCount)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi Streaming
|
||||
|
|
@ -712,6 +738,12 @@ struct SettingsView: View {
|
|||
}
|
||||
.onAppear {
|
||||
cacheSizeText = computeCacheSize()
|
||||
conflictErrorCount = ConflictManager.shared.badgeCount
|
||||
conflictTotalCount = ConflictManager.shared.totalCount
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .companionConflictsUpdated)) { _ in
|
||||
conflictErrorCount = ConflictManager.shared.badgeCount
|
||||
conflictTotalCount = ConflictManager.shared.totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1136,6 +1136,7 @@ struct MyMusicView: View {
|
|||
let grouped = Album(
|
||||
id: album.id, name: album.name,
|
||||
artist: "Various Artists", artistId: nil,
|
||||
albumArtist: "Various Artists",
|
||||
coverArt: album.coverArt, songCount: album.songCount,
|
||||
duration: album.duration, playCount: album.playCount,
|
||||
created: album.created, starred: album.starred,
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ struct RadioView: View {
|
|||
let radioSong = Song(
|
||||
id: station.id, parent: nil, isDir: nil,
|
||||
title: station.name, album: "Radio",
|
||||
artist: station.homePageUrl ?? "Internet Radio",
|
||||
artist: station.homePageUrl ?? "Internet Radio", albumArtist: nil,
|
||||
track: nil, year: nil, genre: nil, coverArt: nil,
|
||||
size: nil, contentType: nil, suffix: nil,
|
||||
transcodedContentType: nil, transcodedSuffix: nil,
|
||||
|
|
|
|||
|
|
@ -1162,6 +1162,7 @@ struct NowPlayingView: View {
|
|||
let updated = Song(
|
||||
id: song.id, parent: song.parent, isDir: song.isDir,
|
||||
title: song.title, album: song.album, artist: song.artist,
|
||||
albumArtist: song.albumArtist,
|
||||
track: song.track, year: song.year, genre: song.genre,
|
||||
coverArt: song.coverArt, size: song.size,
|
||||
contentType: song.contentType, suffix: song.suffix,
|
||||
|
|
@ -1538,7 +1539,7 @@ struct RadioRecordingsView: View {
|
|||
let song = Song(
|
||||
id: rec.id.uuidString, parent: nil, isDir: nil,
|
||||
title: rec.name, album: "Recording",
|
||||
artist: stationName,
|
||||
artist: stationName, albumArtist: nil,
|
||||
track: nil, year: nil, genre: nil, coverArt: nil,
|
||||
size: nil, contentType: nil, suffix: nil,
|
||||
transcodedContentType: nil, transcodedSuffix: nil,
|
||||
|
|
|
|||
Loading…
Reference in a new issue