# Phase 2B: Collection Doctor / Library Health - Detailed Design > **Tasks**: T-403 to T-405 > **Branch**: `experimental/brainz` > **Dependencies**: Phase 1 (MusicBrainz/Chromaprint), T-400 to T-402 (Canonical Scoring) --- ## Overview The Collection Doctor ("Library Health") system scans user libraries, identifies quality issues, and provides automated remediation via multi-swarm downloads. This transforms slskdn from a download tool into a library management system. --- ## 1. Library Health Issues Taxonomy ### 1.1. Issue Types ```csharp namespace slskd.LibraryHealth { public enum LibraryIssueType { SuspectedTranscode, // Lossless file likely encoded from lossy source NonCanonicalVariant, // File exists but better canonical variant available TrackNotInTaggedRelease, // File's MBID doesn't match its tagged album MissingTrackInRelease, // Album incomplete per MusicBrainz tracklist CorruptedFile, // File can't be read or has integrity issues MissingMetadata, // File has no MusicBrainz ID resolvable via fingerprint MultipleVariants, // Duplicate recording with different quality levels WrongDuration // File duration differs significantly from MB metadata } /// /// Represents a single library health issue. /// public class LibraryIssue { public string IssueId { get; set; } // Unique ID public LibraryIssueType Type { get; set; } public LibraryIssueSeverity Severity { get; set; } // Affected entities public string FilePath { get; set; } public string MusicBrainzRecordingId { get; set; } public string MusicBrainzReleaseId { get; set; } public string Artist { get; set; } public string Album { get; set; } public string Title { get; set; } // Issue details public string Reason { get; set; } // Human-readable explanation public Dictionary Metadata { get; set; } // Structured details // Remediation public bool CanAutoFix { get; set; } public string SuggestedAction { get; set; } public string RemediationJobId { get; set; } // If fix job spawned // Status public LibraryIssueStatus Status { get; set; } public DateTimeOffset DetectedAt { get; set; } public DateTimeOffset? ResolvedAt { get; set; } public string ResolvedBy { get; set; } // "user_dismissed" | "auto_fixed" | "manual_fix" } public enum LibraryIssueSeverity { Info, // FYI, no action needed Low, // Minor quality issue Medium, // Noticeable quality or completeness issue High, // Significant problem (transcode, corruption) Critical // File unreadable or major data loss } public enum LibraryIssueStatus { Detected, // New issue Acknowledged, // User has seen it Ignored, // User explicitly ignored Fixing, // Remediation job in progress Resolved, // Fixed successfully Failed // Fix attempt failed } } ``` ### 1.2. Database Schema ```sql CREATE TABLE LibraryHealthIssues ( issue_id TEXT PRIMARY KEY, type TEXT NOT NULL, severity TEXT NOT NULL, file_path TEXT, mb_recording_id TEXT, mb_release_id TEXT, artist TEXT, album TEXT, title TEXT, reason TEXT, metadata TEXT, -- JSON can_auto_fix BOOLEAN DEFAULT FALSE, suggested_action TEXT, remediation_job_id TEXT, status TEXT DEFAULT 'detected', detected_at INTEGER NOT NULL, resolved_at INTEGER, resolved_by TEXT, FOREIGN KEY (remediation_job_id) REFERENCES LibraryHealthJobs(job_id) ); CREATE INDEX idx_issues_status ON LibraryHealthIssues(status); CREATE INDEX idx_issues_type ON LibraryHealthIssues(type); CREATE INDEX idx_issues_severity ON LibraryHealthIssues(severity); CREATE INDEX idx_issues_release ON LibraryHealthIssues(mb_release_id); CREATE INDEX idx_issues_file ON LibraryHealthIssues(file_path); -- Scan history CREATE TABLE LibraryHealthScans ( scan_id TEXT PRIMARY KEY, library_path TEXT NOT NULL, started_at INTEGER NOT NULL, completed_at INTEGER, status TEXT DEFAULT 'running', -- running | completed | failed | cancelled files_scanned INTEGER DEFAULT 0, issues_detected INTEGER DEFAULT 0, error_message TEXT ); CREATE INDEX idx_scans_path ON LibraryHealthScans(library_path); CREATE INDEX idx_scans_completed ON LibraryHealthScans(completed_at DESC); ``` --- ## 2. Library Scan Service (T-403) ### 2.1. Scan Service Interface ```csharp namespace slskd.LibraryHealth { public interface ILibraryHealthService { /// /// Start a library health scan. /// Task StartScanAsync(LibraryHealthScanRequest request, CancellationToken ct = default); /// /// Get scan status. /// Task GetScanStatusAsync(string scanId, CancellationToken ct = default); /// /// Get all issues for a library path. /// Task> GetIssuesAsync(LibraryHealthIssueFilter filter, CancellationToken ct = default); /// /// Mark issue as ignored/resolved. /// Task UpdateIssueStatusAsync(string issueId, LibraryIssueStatus newStatus, CancellationToken ct = default); /// /// Create remediation job for issue(s). /// Task CreateRemediationJobAsync(List issueIds, CancellationToken ct = default); /// /// Get library health summary. /// Task GetSummaryAsync(string libraryPath, CancellationToken ct = default); } public class LibraryHealthScanRequest { public string LibraryPath { get; set; } public bool IncludeSubdirectories { get; set; } = true; public List FileExtensions { get; set; } = new() { ".flac", ".mp3", ".m4a", ".ogg" }; public bool SkipPreviouslyScanned { get; set; } = false; public int MaxConcurrentFiles { get; set; } = 4; } public class LibraryHealthIssueFilter { public string LibraryPath { get; set; } public List Types { get; set; } public List Severities { get; set; } public List Statuses { get; set; } public string MusicBrainzReleaseId { get; set; } public int Limit { get; set; } = 100; public int Offset { get; set; } = 0; } } ``` ### 2.2. Scan Service Implementation ```csharp namespace slskd.LibraryHealth { public class LibraryHealthService : ILibraryHealthService { private readonly IHashDbService hashDb; private readonly IFingerprintExtractionService fingerprintService; private readonly IAcoustIdClient acoustIdClient; private readonly IMusicBrainzClient musicBrainzClient; private readonly ICanonicalStatsService canonicalStats; private readonly ILogger log; private readonly ConcurrentDictionary activeScans = new(); public async Task StartScanAsync(LibraryHealthScanRequest request, CancellationToken ct) { var scanId = Ulid.NewUlid().ToString(); var scan = new LibraryHealthScan { ScanId = scanId, LibraryPath = request.LibraryPath, StartedAt = DateTimeOffset.UtcNow, Status = ScanStatus.Running }; activeScans[scanId] = scan; // Persist scan record await PersistScanAsync(scan, ct); // Start background scan task _ = Task.Run(() => PerformScanAsync(scanId, request, ct), ct); return scanId; } private async Task PerformScanAsync(string scanId, LibraryHealthScanRequest request, CancellationToken ct) { var scan = activeScans[scanId]; try { log.Information("[LH] Starting library health scan: {Path}", request.LibraryPath); // Enumerate audio files var files = Directory.EnumerateFiles( request.LibraryPath, "*.*", request.IncludeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) .Where(f => request.FileExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .ToList(); log.Information("[LH] Found {Count} audio files to scan", files.Count); // Process files in parallel with concurrency limit var semaphore = new SemaphoreSlim(request.MaxConcurrentFiles); var tasks = files.Select(async file => { await semaphore.WaitAsync(ct); try { await ScanFileAsync(file, scan, ct); Interlocked.Increment(ref scan.FilesScanned); } finally { semaphore.Release(); } }); await Task.WhenAll(tasks); scan.Status = ScanStatus.Completed; scan.CompletedAt = DateTimeOffset.UtcNow; log.Information("[LH] Scan completed: {Files} files, {Issues} issues", scan.FilesScanned, scan.IssuesDetected); } catch (Exception ex) { log.Error(ex, "[LH] Scan failed for {Path}", request.LibraryPath); scan.Status = ScanStatus.Failed; scan.ErrorMessage = ex.Message; } finally { await PersistScanAsync(scan, ct); } } private async Task ScanFileAsync(string filePath, LibraryHealthScan scan, CancellationToken ct) { try { // Step 1: Extract metadata and fingerprint using var tagFile = TagLib.File.Create(filePath); var props = tagFile.Properties; var tags = tagFile.Tag; var fingerprint = await fingerprintService.ExtractFingerprintAsync(filePath, ct); // Step 2: Resolve MusicBrainz ID string recordingId = null; if (!string.IsNullOrEmpty(fingerprint?.Fingerprint)) { var acoustIdResult = await acoustIdClient.LookupAsync( fingerprint.Fingerprint, fingerprint.SampleRate, fingerprint.DurationSeconds, ct); recordingId = acoustIdResult?.Recordings?.FirstOrDefault()?.Id; } if (recordingId == null) { // Issue: Missing metadata (can't resolve MBID) await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.MissingMetadata, Severity = LibraryIssueSeverity.Low, FilePath = filePath, Artist = tags.FirstPerformer, Album = tags.Album, Title = tags.Title, Reason = "Unable to resolve MusicBrainz Recording ID via fingerprint", CanAutoFix = false }, scan, ct); return; } // Step 3: Create AudioVariant for quality analysis var variant = await CreateVariantFromFileAsync(filePath, recordingId, tagFile, ct); // Step 4: Check for transcodes await CheckForTranscodeAsync(variant, scan, ct); // Step 5: Check for canonical variants await CheckForCanonicalUpgradeAsync(variant, scan, ct); // Step 6: Check release completeness await CheckReleaseCompletenessAsync(variant, tags, scan, ct); // Step 7: Check duration mismatch await CheckDurationMismatchAsync(variant, recordingId, scan, ct); } catch (Exception ex) { log.Warning(ex, "[LH] Failed to scan file: {Path}", filePath); // Issue: Corrupted file await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.CorruptedFile, Severity = LibraryIssueSeverity.Critical, FilePath = filePath, Reason = $"File cannot be read: {ex.Message}", CanAutoFix = true, SuggestedAction = "Re-download from Soulseek or mesh overlay" }, scan, ct); } } private async Task CheckForTranscodeAsync(AudioVariant variant, LibraryHealthScan scan, CancellationToken ct) { var detector = new TranscodeDetector(); var (isSuspect, reason) = detector.DetectTranscode(variant); if (isSuspect) { await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.SuspectedTranscode, Severity = LibraryIssueSeverity.High, FilePath = variant.FilePath, MusicBrainzRecordingId = variant.MusicBrainzRecordingId, Artist = variant.Artist, Title = variant.Title, Reason = reason, Metadata = new Dictionary { ["quality_score"] = variant.QualityScore, ["codec"] = variant.Codec, ["bitrate"] = variant.BitrateKbps, ["dynamic_range"] = variant.DynamicRangeDR }, CanAutoFix = true, SuggestedAction = "Replace with verified lossless variant from canonical sources" }, scan, ct); } } private async Task CheckForCanonicalUpgradeAsync(AudioVariant variant, LibraryHealthScan scan, CancellationToken ct) { var candidates = await canonicalStats.GetCanonicalVariantCandidatesAsync( variant.MusicBrainzRecordingId, ct); if (candidates.Count == 0) return; var bestCanonical = candidates.First(); // Check if current variant is significantly worse than canonical double qualityGap = bestCanonical.QualityScore - variant.QualityScore; if (qualityGap > 0.2) // Significant quality gap { await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.NonCanonicalVariant, Severity = qualityGap > 0.4 ? LibraryIssueSeverity.High : LibraryIssueSeverity.Medium, FilePath = variant.FilePath, MusicBrainzRecordingId = variant.MusicBrainzRecordingId, Artist = variant.Artist, Title = variant.Title, Reason = $"Canonical variant available with quality score {bestCanonical.QualityScore:F2} vs current {variant.QualityScore:F2}", Metadata = new Dictionary { ["current_quality"] = variant.QualityScore, ["canonical_quality"] = bestCanonical.QualityScore, ["canonical_codec"] = bestCanonical.Codec, ["canonical_bitrate"] = bestCanonical.BitrateKbps }, CanAutoFix = true, SuggestedAction = "Download canonical variant and replace current file" }, scan, ct); } } private async Task CheckReleaseCompletenessAsync(AudioVariant variant, TagLib.Tag tags, LibraryHealthScan scan, CancellationToken ct) { // Extract MusicBrainz Release ID from tags (if present) string releaseId = tags.MusicBrainzReleaseId; if (string.IsNullOrEmpty(releaseId)) { // Try to infer from recording ID var releases = await musicBrainzClient.GetReleasesForRecordingAsync(variant.MusicBrainzRecordingId, ct); releaseId = releases?.FirstOrDefault(); } if (string.IsNullOrEmpty(releaseId)) return; // Get album target from HashDb var albumTarget = await hashDb.GetAlbumTargetAsync(releaseId, ct); if (albumTarget == null) return; // Check if all tracks in release are present in library var tracks = await hashDb.GetAlbumTracksAsync(releaseId, ct); var libraryPath = Path.GetDirectoryName(variant.FilePath); var missingTracks = new List(); foreach (var track in tracks) { var matches = await hashDb.LookupHashesByRecordingIdAsync(track.RecordingId, ct); // Check if any match is in the same directory (same album) bool foundInAlbum = matches.Any(m => { var matchPath = GetFilePathForHash(m.FlacKey); // Would need reverse lookup return matchPath != null && Path.GetDirectoryName(matchPath) == libraryPath; }); if (!foundInAlbum) { missingTracks.Add(track); } } if (missingTracks.Count > 0) { await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.MissingTrackInRelease, Severity = missingTracks.Count > tracks.Count / 2 ? LibraryIssueSeverity.High : LibraryIssueSeverity.Medium, FilePath = libraryPath, // Directory, not specific file MusicBrainzReleaseId = releaseId, Artist = albumTarget.Artist, Album = albumTarget.Title, Reason = $"Album incomplete: {missingTracks.Count}/{tracks.Count} tracks missing", Metadata = new Dictionary { ["missing_tracks"] = missingTracks.Select(t => new { position = t.Position, title = t.Title, recording_id = t.RecordingId }).ToList() }, CanAutoFix = true, SuggestedAction = "Download missing tracks via MusicBrainz Release job" }, scan, ct); } } private async Task CheckDurationMismatchAsync(AudioVariant variant, string recordingId, LibraryHealthScan scan, CancellationToken ct) { // Get MusicBrainz canonical duration var recording = await musicBrainzClient.GetRecordingAsync(recordingId, ct); if (recording?.DurationMs == null) return; int durationDiff = Math.Abs(variant.DurationMs - recording.DurationMs.Value); // Allow 3 second tolerance if (durationDiff > 3000) { await EmitIssueAsync(new LibraryIssue { Type = LibraryIssueType.WrongDuration, Severity = LibraryIssueSeverity.Low, FilePath = variant.FilePath, MusicBrainzRecordingId = recordingId, Artist = variant.Artist, Title = variant.Title, Reason = $"Duration mismatch: file {variant.DurationMs / 1000}s vs MB {recording.DurationMs / 1000}s", Metadata = new Dictionary { ["file_duration_ms"] = variant.DurationMs, ["mb_duration_ms"] = recording.DurationMs }, CanAutoFix = false, SuggestedAction = "Verify this is the correct recording; may be live/alternate version" }, scan, ct); } } private async Task EmitIssueAsync(LibraryIssue issue, LibraryHealthScan scan, CancellationToken ct) { issue.IssueId = Ulid.NewUlid().ToString(); issue.DetectedAt = DateTimeOffset.UtcNow; issue.Status = LibraryIssueStatus.Detected; // Persist to database await PersistIssueAsync(issue, ct); Interlocked.Increment(ref scan.IssuesDetected); log.Information("[LH] Detected issue: {Type} - {Reason}", issue.Type, issue.Reason); } } } ``` --- ## 3. Library Health UI/API (T-404) ### 3.1. API Endpoints ```csharp namespace slskd.LibraryHealth.API { [ApiController] [Route("api/library/health")] [Produces("application/json")] public class LibraryHealthController : ControllerBase { private readonly ILibraryHealthService healthService; /// /// Start a library health scan. /// [HttpPost("scan")] public async Task StartScan([FromBody] LibraryHealthScanRequest request, CancellationToken ct) { var scanId = await healthService.StartScanAsync(request, ct); return Ok(new { scan_id = scanId }); } /// /// Get scan status. /// [HttpGet("scan/{scanId}")] public async Task GetScanStatus(string scanId, CancellationToken ct) { var scan = await healthService.GetScanStatusAsync(scanId, ct); if (scan == null) return NotFound(); return Ok(scan); } /// /// Get library health summary. /// [HttpGet("summary")] public async Task GetSummary([FromQuery] string path, CancellationToken ct) { var summary = await healthService.GetSummaryAsync(path, ct); return Ok(summary); } /// /// Get issues with filtering. /// [HttpGet("issues")] public async Task GetIssues([FromQuery] LibraryHealthIssueFilter filter, CancellationToken ct) { var issues = await healthService.GetIssuesAsync(filter, ct); return Ok(new { issues }); } /// /// Update issue status (ignore, acknowledge, etc). /// [HttpPatch("issues/{issueId}")] public async Task UpdateIssue(string issueId, [FromBody] UpdateIssueRequest request, CancellationToken ct) { await healthService.UpdateIssueStatusAsync(issueId, request.Status, ct); return NoContent(); } /// /// Create remediation job for one or more issues. /// [HttpPost("issues/fix")] public async Task FixIssues([FromBody] FixIssuesRequest request, CancellationToken ct) { var jobId = await healthService.CreateRemediationJobAsync(request.IssueIds, ct); return Ok(new { job_id = jobId }); } } public class UpdateIssueRequest { public LibraryIssueStatus Status { get; set; } } public class FixIssuesRequest { public List IssueIds { get; set; } } } ``` ### 3.2. Frontend UI Components (React) ```jsx // src/web/src/components/LibraryHealth/LibraryHealthDashboard.jsx import React, { useState, useEffect } from 'react'; import { Segment, Header, Statistic, Button, Progress, List, Label } from 'semantic-ui-react'; import api from '../../lib/api'; const LibraryHealthDashboard = ({ libraryPath }) => { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); const [scanning, setScanning] = useState(false); useEffect(() => { loadSummary(); }, [libraryPath]); const loadSummary = async () => { try { const data = await api.get('/api/library/health/summary', { params: { path: libraryPath } }); setSummary(data); } finally { setLoading(false); } }; const startScan = async () => { setScanning(true); try { const { scan_id } = await api.post('/api/library/health/scan', { library_path: libraryPath, include_subdirectories: true }); // Poll for scan completion const pollInterval = setInterval(async () => { const scan = await api.get(`/api/library/health/scan/${scan_id}`); if (scan.status === 'completed' || scan.status === 'failed') { clearInterval(pollInterval); setScanning(false); loadSummary(); } }, 2000); } catch (err) { setScanning(false); } }; if (loading) return
Loading...
; return (
Library Health Dashboard
{summary?.suspected_transcodes || 0} Suspected Transcodes {summary?.non_canonical_variants || 0} Non-Canonical Variants {summary?.incomplete_releases || 0} Incomplete Albums 0.85 ? 'green' : 'orange'}> {(summary?.health_score * 100).toFixed(0)}% Health Score {summary?.last_scan && (

Last scan: {new Date(summary.last_scan).toLocaleString()}

)}
Quick Actions
); }; export default LibraryHealthDashboard; ``` --- ## 4. Fix via Multi-Swarm (T-405) ### 4.1. Remediation Service ```csharp namespace slskd.LibraryHealth { public interface ILibraryHealthRemediationService { Task CreateRemediationJobAsync(List issueIds, CancellationToken ct = default); Task LinkJobToIssuesAsync(string jobId, List issueIds, CancellationToken ct = default); } public class LibraryHealthRemediationService : ILibraryHealthRemediationService { private readonly ILibraryHealthService healthService; private readonly IMusicBrainzClient musicBrainzClient; private readonly IMultiSourceDownloadService multiSourceDownloads; private readonly ILogger log; public async Task CreateRemediationJobAsync(List issueIds, CancellationToken ct) { // Group issues by remediation type var issues = new List(); foreach (var issueId in issueIds) { var issue = await healthService.GetIssueAsync(issueId, ct); if (issue != null && issue.CanAutoFix) { issues.Add(issue); } } if (issues.Count == 0) { throw new InvalidOperationException("No fixable issues provided"); } // Determine remediation strategy var strategy = DetermineRemediationStrategy(issues); string jobId = null; switch (strategy) { case RemediationStrategy.RedownloadTracks: jobId = await CreateTrackRedownloadJobAsync(issues, ct); break; case RemediationStrategy.CompleteAlbum: jobId = await CreateAlbumCompletionJobAsync(issues, ct); break; case RemediationStrategy.ReplaceWithCanonical: jobId = await CreateCanonicalReplacementJobAsync(issues, ct); break; default: throw new NotSupportedException($"Remediation strategy {strategy} not supported"); } // Link job to issues await LinkJobToIssuesAsync(jobId, issueIds, ct); return jobId; } private async Task CreateTrackRedownloadJobAsync(List issues, CancellationToken ct) { // Create multi-source download job for each recording var recordingIds = issues .Where(i => !string.IsNullOrEmpty(i.MusicBrainzRecordingId)) .Select(i => i.MusicBrainzRecordingId) .Distinct() .ToList(); // Determine target directory (parent of first issue file) string targetDir = Path.GetDirectoryName(issues.First().FilePath); var job = new MultiSourceDownloadJob { TargetDirectory = targetDir, TargetRecordingIds = recordingIds, Constraints = new DownloadConstraints { PreferCanonical = true, PreferredCodecs = new[] { "FLAC" }, AllowLossy = false }, ReplaceExisting = true // Important: replace bad files }; var jobId = await multiSourceDownloads.CreateJobAsync(job, ct); log.Information("[LH] Created track redownload job {JobId} for {Count} recordings", jobId, recordingIds.Count); return jobId; } private async Task CreateAlbumCompletionJobAsync(List issues, CancellationToken ct) { // Extract unique release IDs var releaseIds = issues .Where(i => !string.IsNullOrEmpty(i.MusicBrainzReleaseId)) .Select(i => i.MusicBrainzReleaseId) .Distinct() .ToList(); if (releaseIds.Count != 1) { throw new InvalidOperationException("Album completion requires all issues to be from the same release"); } string releaseId = releaseIds.First(); // Get missing track IDs from issue metadata var missingRecordingIds = new List(); foreach (var issue in issues) { if (issue.Metadata.TryGetValue("missing_tracks", out var missingObj)) { var missing = (List)missingObj; foreach (var track in missing) { var trackDict = (Dictionary)track; if (trackDict.TryGetValue("recording_id", out var recId)) { missingRecordingIds.Add(recId.ToString()); } } } } // Create MusicBrainz Release job for missing tracks string targetDir = issues.First().FilePath; // Directory path var jobId = await musicBrainzClient.CreateReleaseJobAsync(new MBReleaseJobRequest { ReleaseId = releaseId, TargetDir = targetDir, Tracks = missingRecordingIds, // Only download missing tracks Constraints = new DownloadConstraints { PreferCanonical = true, PreferredCodecs = new[] { "FLAC" } } }, ct); log.Information("[LH] Created album completion job {JobId} for release {ReleaseId}, {Count} missing tracks", jobId, releaseId, missingRecordingIds.Count); return jobId; } private async Task CreateCanonicalReplacementJobAsync(List issues, CancellationToken ct) { // Similar to track redownload but with explicit canonical preference return await CreateTrackRedownloadJobAsync(issues, ct); } public async Task LinkJobToIssuesAsync(string jobId, List issueIds, CancellationToken ct) { foreach (var issueId in issueIds) { await healthService.UpdateIssueAsync(issueId, new LibraryIssueUpdate { Status = LibraryIssueStatus.Fixing, RemediationJobId = jobId }, ct); } // Subscribe to job completion to auto-resolve issues // (Implementation would use event bus or polling) } } } ``` --- ## 5. Implementation Checklist ### T-403: Library scan service - [x] Define issue taxonomy enums and models - [x] Create database schema for `LibraryHealthIssues` and `LibraryHealthScans` - [x] Implement `ILibraryHealthService` interface - [x] Implement `LibraryHealthService.ScanFileAsync()` core logic - [x] Implement transcode detection in scan - [x] Implement canonical upgrade detection - [x] Implement release completeness checking - [x] Add background scan scheduling - [x] Add unit tests for scan logic - [x] Add integration tests with sample library ### T-404: Library health UI/API - [x] Create `LibraryHealthController` API endpoints - [x] Implement summary aggregation (issue counts, health score) - [x] Create React `LibraryHealthDashboard` component - [x] Create React `IssueListView` component with filtering - [x] Create React `IssueDetailView` component - [x] Add real-time scan progress updates (SignalR or polling) - [x] Add issue status management (ignore, acknowledge) - [x] Add unit tests for API endpoints - [x] Add E2E tests for UI workflows ### T-405: Fix via multi-swarm - [x] Implement `ILibraryHealthRemediationService` interface - [x] Implement `CreateTrackRedownloadJobAsync()` logic - [x] Implement `CreateAlbumCompletionJobAsync()` logic - [x] Implement `CreateCanonicalReplacementJobAsync()` logic - [x] Add job-to-issue linking - [x] Subscribe to job completion events to auto-resolve issues - [x] Add "Fix" buttons to UI issue views - [x] Add bulk fix operations - [x] Add unit tests for remediation logic - [x] Add integration tests for job creation --- ## 6. Configuration Options ```yaml library_health: enabled: true # Automatic scan scheduling auto_scan: enabled: false interval_hours: 168 # Weekly library_paths: - "/music/library1" - "/music/library2" # Issue detection thresholds thresholds: quality_gap_for_upgrade: 0.2 # Min quality difference to flag non-canonical duration_tolerance_ms: 3000 # Max duration mismatch before flagging # Remediation defaults remediation: replace_existing: true # Replace bad files with fixes backup_before_replace: true # Move old file to .bak before replacing auto_fix_transcodes: false # Automatically fix detected transcodes ``` --- This comprehensive design provides everything Codex needs to implement the Library Health system!