From db9d79f023f8e126f7d5d2ec557cb3c2d8963d75 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Sat, 11 Apr 2026 01:36:13 -0700 Subject: [PATCH] safeguards to id3 tagging and mismatch info --- companion-api/docker-compose.yml | 2 + companion-api/main.py | 346 +++++++++++++++++- iOS/Views/Companion/CompanionAPIService.swift | 35 +- .../Companion/LibraryConflictsView.swift | 312 ++++++++++++++++ iOS/Views/Library/DownloadsSettingsView.swift | 28 +- 5 files changed, 719 insertions(+), 4 deletions(-) create mode 100644 iOS/Views/Companion/LibraryConflictsView.swift diff --git a/companion-api/docker-compose.yml b/companion-api/docker-compose.yml index c2c3e9e..c507d77 100644 --- a/companion-api/docker-compose.yml +++ b/companion-api/docker-compose.yml @@ -24,6 +24,7 @@ services: volumes: - /home/pi/navidrome/music:/music:rw - ./companion_data:/app/data + - /home/pi/docker/navidrome/navidrome_data:/navidrome_data:ro environment: - MUSIC_DIR=/music - DB_PATH=/app/data/smart_dj.db @@ -31,6 +32,7 @@ services: - COVER_ART_DIR=/app/data/cover_art - ARTIST_PHOTO_DIR=/app/data/artist_photos - NAVIDROME_URL=http://navidrome:4533/navidrome + - NAVIDROME_DB_PATH=/navidrome_data/navidrome.db - SUBSONIC_USER=Dallasgroot - SUBSONIC_TOKEN= - SUBSONIC_SALT= diff --git a/companion-api/main.py b/companion-api/main.py index 63c89b1..2493273 100644 --- a/companion-api/main.py +++ b/companion-api/main.py @@ -415,6 +415,11 @@ class BatchMetadataUpdate(BaseModel): genre: Optional[str] = None year: Optional[int] = None +class FixConflictRequest(BaseModel): + action: str + fix_data: dict = {} + + class TrackUploadMeta(BaseModel): filename: str title: str @@ -1348,13 +1353,13 @@ async def batch_edit_metadata(update: BatchMetadataUpdate): results["failed"].append({"path": rp, "error": str(e)}) await trigger_scan() # Wait for Navidrome to finish scanning before the app re-fetches paths. - # Without this, the app gets stale paths from Navidrome's DB before it - # has processed the restructured file locations. await asyncio.sleep(4) await push.broadcast("batch_metadata_updated", {"count": len(results["succeeded"]), "album": update.album or "", "paths_changed": "true"}) + # Run conflict check in background after every batch edit + asyncio.create_task(_run_conflict_check_and_broadcast()) return results @@ -1776,6 +1781,343 @@ async def get_artist_photo(artist_name: str): return FileResponse(row[0], media_type=mt) +async def _run_conflict_check_and_broadcast(): + """Run conflict check in the background and broadcast results.""" + try: + await asyncio.sleep(6) # Wait for Navidrome scan to complete + navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db") + issues = check_library_conflicts(navidrome_db) + error_count = sum(1 for i in issues if i["severity"] == "error") + warning_count = sum(1 for i in issues if i["severity"] == "warning") + if issues: + await push.broadcast("conflicts_updated", { + "total": str(len(issues)), + "errors": str(error_count), + "warnings": str(warning_count) + }) + except Exception as e: + print(f" Background conflict check failed: {e}", flush=True) + + +# ============================================================================= +# LIBRARY CONFLICTS +# ============================================================================= + +def check_library_conflicts(navidrome_db_path: str = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db")) -> list: + """ + Run all conflict checks and return a list of issues. + Each issue is a dict with: type, severity, title, detail, affected_paths, fix_action + """ + issues = [] + + # ── 1. Duplicate albums (same name, multiple album_artist values) ───────── + try: + with sqlite3.connect(navidrome_db_path) as nav: + rows = nav.execute(""" + SELECT name, COUNT(DISTINCT album_artist) as aa_count, + GROUP_CONCAT(id, '|||') as ids, + GROUP_CONCAT(album_artist, '|||') as artists, + GROUP_CONCAT(song_count, '|||') as counts + FROM album + GROUP BY name + HAVING aa_count > 1 + ORDER BY name + """).fetchall() + for name, aa_count, ids, artists, counts in rows: + id_list = ids.split('|||') + artist_list = artists.split('|||') + count_list = counts.split('|||') + issues.append({ + "type": "duplicate_album", + "severity": "error", + "title": f"Duplicate album: {name}", + "detail": f"{aa_count} versions found with different album artists: {', '.join(artist_list)}", + "affected_paths": [], + "fix_action": "fix_duplicate_album", + "fix_data": { + "album_name": name, + "album_ids": id_list, + "album_artists": artist_list, + "song_counts": count_list + } + }) + except Exception as e: + print(f" conflict check 1 failed: {e}", flush=True) + + # ── 2. Missing files (Navidrome knows about them but they're gone) ──────── + try: + with sqlite3.connect(navidrome_db_path) as nav: + rows = nav.execute( + "SELECT path FROM media_file WHERE missing = 1 LIMIT 50" + ).fetchall() + if rows: + issues.append({ + "type": "missing_files", + "severity": "warning", + "title": f"{len(rows)} missing file(s)", + "detail": "Files registered in Navidrome but not found on disk.", + "affected_paths": [r[0] for r in rows], + "fix_action": "fix_missing_files", + "fix_data": {} + }) + except Exception as e: + print(f" conflict check 2 failed: {e}", flush=True) + + # ── 3. Picard legacy tags (FLAC files with conflicting albumartist tags) ── + try: + legacy_files = [] + with sqlite3.connect(DB_PATH) as c: + rows = c.execute("SELECT full_path FROM songs").fetchall() + for (full_path,) in rows: + if not full_path.lower().endswith('.flac'): + continue + if not os.path.isfile(full_path): + continue + try: + from mutagen.flac import FLAC + f = FLAC(full_path) + keys = [k.upper() for k in f.keys()] + has_legacy = any(k in keys for k in ['ALBUM ARTIST', 'ALBUM_ARTIST']) + has_canonical = 'ALBUMARTIST' in keys + if has_legacy and has_canonical: + legacy_files.append(os.path.relpath(full_path, MUSIC_DIR)) + except Exception: + pass + if legacy_files: + issues.append({ + "type": "picard_legacy_tags", + "severity": "error", + "title": f"{len(legacy_files)} file(s) with conflicting album artist tags", + "detail": "These FLAC files have both Picard legacy tags (ALBUM ARTIST/ALBUM_ARTIST) and the canonical ALBUMARTIST tag. Navidrome reads the legacy tag and gets the wrong album artist.", + "affected_paths": legacy_files[:50], + "fix_action": "fix_picard_tags", + "fix_data": {} + }) + except Exception as e: + print(f" conflict check 3 failed: {e}", flush=True) + + # ── 4. Orphaned tracks (album_id points to non-existent album) ──────────── + try: + with sqlite3.connect(navidrome_db_path) as nav: + rows = nav.execute(""" + SELECT mf.path FROM media_file mf + LEFT JOIN album a ON mf.album_id = a.id + WHERE mf.album_id IS NOT NULL AND a.id IS NULL + LIMIT 50 + """).fetchall() + if rows: + issues.append({ + "type": "orphaned_tracks", + "severity": "warning", + "title": f"{len(rows)} orphaned track(s)", + "detail": "Tracks whose album_id points to a non-existent album entry.", + "affected_paths": [r[0] for r in rows], + "fix_action": "fix_orphaned_tracks", + "fix_data": {} + }) + except Exception as e: + print(f" conflict check 4 failed: {e}", flush=True) + + # ── 5. Duplicate tracks (same title+artist+duration appearing >1 time) ─── + try: + with sqlite3.connect(DB_PATH) as c: + rows = c.execute(""" + SELECT title, artist, COUNT(*) as cnt, + GROUP_CONCAT(relative_path, '|||') as paths + FROM songs + WHERE title != '' AND artist != '' + GROUP BY title, artist, CAST(duration AS INT) + HAVING cnt > 1 + LIMIT 30 + """).fetchall() + for title, artist, cnt, paths in rows: + path_list = paths.split('|||') if paths else [] + issues.append({ + "type": "duplicate_track", + "severity": "warning", + "title": f"Duplicate: {title} -- {artist}", + "detail": f"Found {cnt} copies of this track.", + "affected_paths": path_list, + "fix_action": None, + "fix_data": {} + }) + except Exception as e: + print(f" conflict check 5 failed: {e}", flush=True) + + # ── 6. Stale Companion paths (full_path no longer exists on disk) ───────── + try: + stale = [] + with sqlite3.connect(DB_PATH) as c: + rows = c.execute("SELECT relative_path, full_path FROM songs").fetchall() + for rel, full in rows: + if not os.path.isfile(full): + stale.append(rel) + if stale: + issues.append({ + "type": "stale_companion_paths", + "severity": "warning", + "title": f"{len(stale)} stale path(s) in Companion DB", + "detail": "The Companion's song database has entries whose files no longer exist at the recorded path. Run a library scan to fix.", + "affected_paths": stale[:50], + "fix_action": "fix_stale_paths", + "fix_data": {} + }) + except Exception as e: + print(f" conflict check 6 failed: {e}", flush=True) + + print(f" Conflict check complete: {len(issues)} issues found", flush=True) + return issues + + +@app.get("/library/conflicts") +async def library_conflicts(): + """Run all conflict checks and return structured results.""" + navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db") + issues = check_library_conflicts(navidrome_db) + error_count = sum(1 for i in issues if i["severity"] == "error") + warning_count = sum(1 for i in issues if i["severity"] == "warning") + return { + "total": len(issues), + "errors": error_count, + "warnings": warning_count, + "issues": issues + } + + +@app.post("/library/fix-conflict") +async def fix_conflict(request: FixConflictRequest): + """ + Fix a specific conflict by action type. + Request body: {"action": "fix_duplicate_album", "fix_data": {...}} + """ + navidrome_db = os.getenv("NAVIDROME_DB_PATH", "/navidrome_data/navidrome.db") + action = request.action + fix_data = request.fix_data + + if action == "fix_duplicate_album": + # Strategy: rewrite ALBUMARTIST tags on all affected files so Navidrome's + # next scan groups them under one canonical album entry. + # We cannot write to Navidrome's DB directly while it is running. + album_name = fix_data.get("album_name", "") + album_artists = fix_data.get("album_artists", []) + song_counts = fix_data.get("song_counts", []) + + if not album_artists: + raise HTTPException(400, "No album artists provided") + + # Canonical = album artist with the most songs + try: + counts = [int(c) for c in song_counts] + canonical_idx = counts.index(max(counts)) + except Exception: + canonical_idx = 0 + canonical_artist = album_artists[canonical_idx] + + try: + with sqlite3.connect(DB_PATH) as c: + rows = c.execute( + "SELECT full_path FROM songs WHERE album = ?", (album_name,) + ).fetchall() + + fixed = 0 + for (full_path,) in rows: + if not os.path.isfile(full_path): + continue + try: + ext = Path(full_path).suffix.lower() + if ext == '.flac': + from mutagen.flac import FLAC + f = FLAC(full_path) + for variant in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']: + if variant in f: + del f[variant] + f['ALBUMARTIST'] = canonical_artist + f['ALBUM'] = album_name + f.save() + else: + audio = MutagenFile(full_path, easy=True) + if audio: + audio['albumartist'] = canonical_artist + audio['album'] = album_name + audio.save() + update_song_in_db(full_path) + fixed += 1 + except Exception as e: + print(f" fix_duplicate_album: {os.path.basename(full_path)}: {e}", flush=True) + + await trigger_scan() + await asyncio.sleep(4) + await push.broadcast("conflicts_updated", {"action": "fix_duplicate_album", "album": album_name}) + return {"status": "fixed", "album": album_name, "fixed": fixed, "canonical_artist": canonical_artist} + except Exception as e: + raise HTTPException(500, f"Fix failed: {e}") + + elif action == "fix_missing_files": + # Trigger a full Navidrome rescan -- Navidrome will detect and remove + # missing files automatically during a full scan. We cannot write to + # Navidrome's DB directly while it is running (mounted read-only). + try: + await trigger_scan() + return {"status": "triggered_scan", "detail": "Navidrome will remove missing entries on next scan"} + except Exception as e: + raise HTTPException(500, f"Fix failed: {e}") + + elif action == "fix_picard_tags": + fixed = 0 + failed = [] + with sqlite3.connect(DB_PATH) as c: + rows = c.execute("SELECT full_path FROM songs WHERE full_path LIKE '%.flac'").fetchall() + for (full_path,) in rows: + if not os.path.isfile(full_path): + continue + try: + from mutagen.flac import FLAC + f = FLAC(full_path) + keys = list(f.keys()) + upper_keys = [k.upper() for k in keys] + has_legacy = any(k in upper_keys for k in ['ALBUM ARTIST', 'ALBUM_ARTIST']) + if not has_legacy: + continue + # Get canonical value + canonical = None + for k in keys: + if k.upper() == 'ALBUMARTIST': + canonical = f[k][0] if f[k] else None + break + if not canonical: + for k in keys: + if k.upper() in ['ALBUM ARTIST', 'ALBUM_ARTIST']: + canonical = f[k][0] if f[k] else None + break + if not canonical: + continue + # Remove all variants and write canonical + for k in list(f.keys()): + if k.upper() in ['ALBUM ARTIST', 'ALBUM_ARTIST', 'ALBUMARTIST', 'albumartist']: + del f[k] + f['ALBUMARTIST'] = canonical + f.save() + update_song_in_db(full_path) + fixed += 1 + except Exception as e: + failed.append(os.path.relpath(full_path, MUSIC_DIR)) + await trigger_scan() + await push.broadcast("conflicts_updated", {"action": "fix_picard_tags"}) + return {"status": "fixed", "fixed": fixed, "failed": len(failed)} + + elif action == "fix_stale_paths": + count = scan_library(full_rescan=False) + build_file_index() + return {"status": "fixed", "rescanned": count} + + elif action == "fix_orphaned_tracks": + await trigger_scan() + return {"status": "triggered_scan"} + + else: + raise HTTPException(400, f"Unknown action: {action}") + + # ============================================================================= # WebSocket Push # ============================================================================= diff --git a/iOS/Views/Companion/CompanionAPIService.swift b/iOS/Views/Companion/CompanionAPIService.swift index fbe00f1..b5315cd 100644 --- a/iOS/Views/Companion/CompanionAPIService.swift +++ b/iOS/Views/Companion/CompanionAPIService.swift @@ -528,12 +528,14 @@ 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": + // Refresh conflict badge count in the background + Task { await ConflictManager.shared.refresh() } case "profile": if let path = msg.data?["path"] as? String, let jsonData = try? JSONSerialization.data(withJSONObject: msg.data ?? [:]), @@ -693,6 +695,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 diff --git a/iOS/Views/Companion/LibraryConflictsView.swift b/iOS/Views/Companion/LibraryConflictsView.swift new file mode 100644 index 0000000..0ffb62c --- /dev/null +++ b/iOS/Views/Companion/LibraryConflictsView.swift @@ -0,0 +1,312 @@ +import SwiftUI + +// MARK: - Conflict Models + +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)" } + + var severityColor: Color { + severity == "error" ? Color(red: 1, green: 0.176, blue: 0.333) : .yellow + } + + var severityIcon: String { + severity == "error" ? "xmark.circle.fill" : "exclamationmark.triangle.fill" + } + + var typeIcon: String { + switch type { + case "duplicate_album": return "rectangle.on.rectangle.fill" + case "missing_files": return "doc.badge.questionmark" + case "picard_legacy_tags": return "tag.slash.fill" + case "orphaned_tracks": return "link.badge.plus" + case "duplicate_track": return "music.note.list" + case "stale_companion_paths": return "externaldrive.badge.xmark" + default: return "exclamationmark.circle" + } + } +} + +struct ConflictsResponse: Codable { + let total: Int + let errors: Int + let warnings: Int + let issues: [LibraryConflict] +} + +// AnyCodable wrapper for heterogeneous fix_data dict +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("") + } + } +} + +// MARK: - Conflict Manager + +@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 // currently fixing conflict id + + private let api = CompanionAPIService() + + var badgeCount: Int { conflicts?.errors ?? 0 } + var totalCount: Int { conflicts?.total ?? 0 } + + 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) + // Refresh after fix + try? await Task.sleep(for: .seconds(2)) + await refresh() + } catch { + lastError = error.localizedDescription + } + isFixing = nil + } +} + +// MARK: - Issues & Conflicts View + +struct LibraryConflictsView: View { + @StateObject private var manager = ConflictManager.shared + private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) + + var body: some View { + List { + if manager.isLoading { + Section { + HStack { + ProgressView().tint(accentPink) + Text("Scanning library for issues…") + .font(.system(size: 14)) + .foregroundColor(.gray) + .padding(.leading, 8) + } + .padding(.vertical, 4) + } + } else if let error = manager.lastError { + Section { + Text(error) + .font(.system(size: 13)) + .foregroundColor(.red) + } + } else if let conflicts = manager.conflicts { + if conflicts.total == 0 { + Section { + HStack(spacing: 12) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 28)) + .foregroundColor(.green) + VStack(alignment: .leading, spacing: 2) { + Text("No issues found") + .font(.system(size: 15, weight: .medium)) + Text("Your library looks clean.") + .font(.system(size: 13)) + .foregroundColor(.gray) + } + } + .padding(.vertical, 6) + } + } else { + Section { + HStack(spacing: 16) { + summaryBadge(count: conflicts.errors, label: "Errors", color: accentPink) + summaryBadge(count: conflicts.warnings, label: "Warnings", color: .yellow) + Spacer() + } + .padding(.vertical, 4) + } header: { + Text("Summary") + } + + // Group by severity — errors first + let sorted = conflicts.issues.sorted { + ($0.severity == "error" ? 0 : 1) < ($1.severity == "error" ? 0 : 1) + } + + ForEach(sorted) { conflict in + conflictRow(conflict) + } + } + } else { + Section { + Text("Tap Scan to check your library.") + .font(.system(size: 14)) + .foregroundColor(.gray) + } + } + } + .navigationTitle("Issues & Conflicts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + Task { await manager.refresh() } + }) { + if manager.isLoading { + ProgressView().tint(accentPink).scaleEffect(0.8) + } else { + Image(systemName: "arrow.clockwise") + .foregroundColor(accentPink) + } + } + .disabled(manager.isLoading) + } + } + .task { + if manager.conflicts == nil { + await manager.refresh() + } + } + } + + // MARK: - Conflict Row + + @ViewBuilder + private func conflictRow(_ conflict: LibraryConflict) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + // Header + HStack(spacing: 8) { + Image(systemName: conflict.typeIcon) + .font(.system(size: 16)) + .foregroundColor(conflict.severityColor) + Text(conflict.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + Spacer() + Image(systemName: conflict.severityIcon) + .font(.system(size: 12)) + .foregroundColor(conflict.severityColor) + } + + Text(conflict.detail) + .font(.system(size: 12)) + .foregroundColor(.gray) + .fixedSize(horizontal: false, vertical: true) + + // Affected paths (collapsed, show first 3) + if !conflict.affected_paths.isEmpty { + VStack(alignment: .leading, spacing: 2) { + ForEach(conflict.affected_paths.prefix(3), id: \.self) { path in + Text(path) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.white.opacity(0.45)) + .lineLimit(1) + } + if conflict.affected_paths.count > 3 { + Text("+ \(conflict.affected_paths.count - 3) more") + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.6)) + } + } + .padding(6) + .background(Color.white.opacity(0.05)) + .cornerRadius(6) + } + + // Fix button + if let action = conflict.fix_action { + HStack { + Spacer() + Button(action: { + Task { await manager.fix(conflict) } + }) { + if manager.isFixing == conflict.id { + HStack(spacing: 6) { + ProgressView().tint(.white).scaleEffect(0.75) + Text("Fixing…").font(.system(size: 13)) + } + .padding(.horizontal, 16) + .padding(.vertical, 7) + .background(Color.gray.opacity(0.3)) + .cornerRadius(8) + } else { + Text(fixButtonLabel(action)) + .font(.system(size: 13, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 7) + .background(conflict.severityColor.opacity(0.25)) + .foregroundColor(conflict.severityColor) + .cornerRadius(8) + } + } + .buttonStyle(.plain) + .disabled(manager.isFixing != nil) + } + } + } + .padding(.vertical, 4) + } + } + + // MARK: - Helpers + + private func summaryBadge(count: Int, label: String, color: Color) -> some View { + VStack(spacing: 2) { + Text("\(count)") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(count > 0 ? color : .gray) + Text(label) + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } + + private func fixButtonLabel(_ action: String) -> String { + switch action { + case "fix_duplicate_album": return "Merge Duplicate" + case "fix_missing_files": return "Remove from DB" + case "fix_picard_tags": return "Fix Tags" + case "fix_stale_paths": return "Rescan Library" + case "fix_orphaned_tracks": return "Trigger Rescan" + default: return "Fix" + } + } +} diff --git a/iOS/Views/Library/DownloadsSettingsView.swift b/iOS/Views/Library/DownloadsSettingsView.swift index 2db5f93..aeb93c1 100644 --- a/iOS/Views/Library/DownloadsSettingsView.swift +++ b/iOS/Views/Library/DownloadsSettingsView.swift @@ -502,7 +502,8 @@ struct SettingsView: View { @EnvironmentObject var serverManager: ServerManager @ObservedObject var quality = StreamingQuality.shared @ObservedObject var debugLogger = DebugLogger.shared - + @ObservedObject private var conflictManager = ConflictManager.shared + @State private var showVisualizerSettings = false @State private var cacheSizeText = "..." @@ -546,6 +547,31 @@ struct SettingsView: View { } } } + + if CompanionSettings.shared.isEnabled { + NavigationLink { + LibraryConflictsView() + } label: { + HStack { + Text("Issues & Conflicts") + Spacer() + let count = ConflictManager.shared.badgeCount + if count > 0 { + Text("\(count)") + .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 ConflictManager.shared.totalCount > 0 { + Text("\(ConflictManager.shared.totalCount)") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + } + } } // WiFi Streaming