From 22c4f58646040724c845b8c92ffed0b82783ed41 Mon Sep 17 00:00:00 2001 From: Philip Guzman Date: Thu, 2 Jul 2026 16:17:11 -0700 Subject: [PATCH] Add defensible orphaned audio detection --- ROADMAP.md | 2 +- docs/design/orphaned-audio.md | 34 +++++++++++++++++++++++++++ serato_doctor/health.py | 17 ++++++++++++-- serato_doctor/models/health.py | 1 + serato_doctor/web.py | 42 ++++++++++++++++++++++++++++++---- serato_doctor/webui/app.js | 12 +++++++++- serato_doctor/webui/index.html | 5 ++-- tests/test_health.py | 28 +++++++++++++++++++++++ tests/test_web.py | 3 +++ 9 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 docs/design/orphaned-audio.md diff --git a/ROADMAP.md b/ROADMAP.md index 8f9f94d..f907b36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ - [x] Duplicate filename detection - [x] Duplicate audio hash detection - [x] Broken symlink detection -- [ ] Orphaned audio detection +- [x] Orphaned audio detection - [ ] OneDrive rename detection - [x] Crate classification: static vs smart/dynamic - [x] Library health score diff --git a/docs/design/orphaned-audio.md b/docs/design/orphaned-audio.md new file mode 100644 index 0000000..3b390fb --- /dev/null +++ b/docs/design/orphaned-audio.md @@ -0,0 +1,34 @@ +# Orphaned audio detection + +## Problem + +“Not in a crate” does not mean unused. Many DJs keep active tracks in Serato's +main library without placing them in a crate. Treating those files as orphans +would create dangerous deletion pressure. + +## Design + +Serato Doctor now separates two read-only findings: + +- **Not in crates**: scanned tracks whose normalized filename is absent from all + loaded crate references. These may still be ordinary Serato library tracks. +- **Possible orphan files**: tracks absent by normalized filename from both + loaded crates and database V2. + +When database V2 is unavailable, orphan status is reported as not assessed—not +zero. Orphan candidates include preview and Finder controls but no delete or +repair action. + +## Edge cases + +- Filename comparison is case- and Unicode-normalized. +- A track in several folders may still require duplicate review separately. +- Smart-crate materialized references count as crate evidence. +- Missing database V2 prevents a defensible orphan conclusion. + +## Tests + +- Tracks known only to database V2 are excluded from orphan candidates. +- Tracks in crates are excluded even if absent from database V2. +- A track absent from both sources is counted once. +- No database produces a not-assessed result. diff --git a/serato_doctor/health.py b/serato_doctor/health.py index ff46437..5dc6544 100644 --- a/serato_doctor/health.py +++ b/serato_doctor/health.py @@ -36,9 +36,13 @@ def analyze_health(library: Library) -> HealthReport: for group in duplicate_groups if group.kind is DuplicateKind.CLOUD_CONFLICT ] - referenced_names = {reference.filename for reference in library.references} + referenced_names = { + normalize(reference.filename) for reference in library.references + } unused_count = sum( - 1 for track in library.tracks if track.filename not in referenced_names + 1 + for track in library.tracks + if normalize(track.filename) not in referenced_names ) matcher = MatchingEngine(library.tracks) @@ -73,6 +77,15 @@ def analyze_health(library: Library) -> HealthReport: group.extra_files for group in cloud_conflicts ), unused_tracks=unused_count, + orphan_candidates=( + sum( + normalize(track.filename) not in referenced_names + and normalize(track.filename) not in database_names + for track in library.tracks + ) + if library.database + else None + ), suggested_matches=suggested_count, broken_symlinks=len(library.broken_symlinks), static_crates=sum( diff --git a/serato_doctor/models/health.py b/serato_doctor/models/health.py index 9fae79a..525ac7b 100644 --- a/serato_doctor/models/health.py +++ b/serato_doctor/models/health.py @@ -18,6 +18,7 @@ class HealthReport: suspected_cloud_conflict_groups: int suspected_cloud_conflict_files: int unused_tracks: int + orphan_candidates: Optional[int] suggested_matches: int broken_symlinks: int static_crates: int diff --git a/serato_doctor/web.py b/serato_doctor/web.py index d76799c..ad0de72 100644 --- a/serato_doctor/web.py +++ b/serato_doctor/web.py @@ -143,10 +143,24 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict: database_filename_counts = Counter( normalize(track.filename) for track in missing_database_tracks ) - referenced_names = {reference.filename for reference in library.references} + referenced_names = { + normalize(reference.filename) for reference in library.references + } + database_names = {normalize(track.filename) for track in database_tracks} unused_tracks = [ - track for track in library.tracks if track.filename not in referenced_names + track + for track in library.tracks + if normalize(track.filename) not in referenced_names ] + orphan_candidates = ( + [ + track + for track in unused_tracks + if normalize(track.filename) not in database_names + ] + if library.database + else None + ) return { "database_missing_tracks": { @@ -246,10 +260,10 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict: ], }, "unused_tracks": { - "title": "Unused tracks", + "title": "Not in crates", "summary": ( - "These scanned files were not referenced by any loaded crate. " - "That does not mean they should be deleted." + "These files are not in any loaded crate, but may still be " + "normal tracks in Serato's main library." ), "total": len(unused_tracks), "items": [ @@ -257,6 +271,24 @@ def diagnostic_details(library: Library, limit: int = DETAIL_LIMIT) -> dict: for track in unused_tracks[:limit] ], }, + "orphan_candidates": { + "title": "Possible orphan files", + "summary": ( + "These files were found in the music folder but not by filename " + "in loaded crates or Serato's database. Review only; this is " + "never an automatic deletion recommendation." + ), + "total": len(orphan_candidates) if orphan_candidates is not None else None, + "assessed": orphan_candidates is not None, + "items": [ + { + "filename": track.filename, + "path": _display_path(track.path), + **_file_preview(track.path), + } + for track in (orphan_candidates or ())[:limit] + ], + }, } diff --git a/serato_doctor/webui/app.js b/serato_doctor/webui/app.js index d96f01d..c3ae609 100644 --- a/serato_doctor/webui/app.js +++ b/serato_doctor/webui/app.js @@ -111,6 +111,11 @@ function detailLines(item) { return lines.map((line) => `
  • ${escapeHtml(line)}
  • `).join(''); } +function detailActions(item) { + if (!item.audio_url || !item.reveal_token) return ''; + return `
    `; +} + function renderDetail(key) { const detail = latestAnalysis?.details?.[key]; if (!detail) return; @@ -119,7 +124,7 @@ function renderDetail(key) { trigger.classList.toggle('selected', trigger.dataset.detail === key); }); drilldownTitle.textContent = detail.title; - drilldownCount.textContent = `${detail.total ?? 0} found`; + drilldownCount.textContent = detail.assessed === false ? 'Not assessed' : `${detail.total ?? 0} found`; drilldownSummary.textContent = detail.summary; repairPanel.hidden = true; @@ -127,6 +132,10 @@ function renderDetail(key) { previewedRepair = null; latestBackup = null; restoreRepairButton.hidden = true; + if (detail.assessed === false) { + drilldownList.innerHTML = '
    Serato’s database V2 was not available, so orphan status cannot be assessed safely.
    '; + return; + } if (!detail.items?.length) { drilldownList.innerHTML = '
    Nothing to review here. Tiny victory parade, very tasteful.
    '; return; @@ -143,6 +152,7 @@ function renderDetail(key) {
    ${escapeHtml(item.filename || item.path || 'Untitled item')} + ${detailActions(item)}
    `).join(''); } diff --git a/serato_doctor/webui/index.html b/serato_doctor/webui/index.html index aeb6058..cbf0f66 100644 --- a/serato_doctor/webui/index.html +++ b/serato_doctor/webui/index.html @@ -69,7 +69,7 @@
    Tracks scannedaudio files found
    Missing tracks in Serato unique filenames · click for list
    -
    Unused tracksnot referenced by crates · click for list
    +
    Possible orphan filesoutside crates and database · click for list
    Suggested matchesexplainable candidates · click for list
    @@ -82,6 +82,7 @@

    Possible cloud conflicts groups · extra files · click for list

    Broken shortcuts unresolved symbolic links · click for list

    Old crate references appearances · unique filenames · click for list

    +

    Not in crates tracks still potentially in Serato · click for list

    Database coverage Serato entries · scanned tracks absent

    @@ -130,6 +131,6 @@ - + diff --git a/tests/test_health.py b/tests/test_health.py index 7528444..fb4562c 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -2,6 +2,7 @@ from pathlib import Path from serato_doctor.health import analyze_health from serato_doctor.models.crate import Crate, CrateKind +from serato_doctor.models.database import DatabaseTrack, SeratoDatabase from serato_doctor.models.filesystem import BrokenSymlink from serato_doctor.models.library import Library from serato_doctor.models.reference import TrackReference @@ -49,6 +50,7 @@ def test_health_report_exposes_each_metric(): assert report.duplicate_filename_groups == 1 assert report.duplicate_files == 1 assert report.unused_tracks == 3 + assert report.orphan_candidates is None assert report.suggested_matches == 1 @@ -60,6 +62,32 @@ def test_empty_library_has_no_health_score(): assert report.scored_references == 0 assert not report.database_present assert report.tracks_missing_from_database == 0 + assert report.orphan_candidates is None + + +def test_orphan_candidates_exclude_tracks_known_to_serato_or_crates(): + tracks = [ + track("Crated.mp3"), + track("LibraryOnly.mp3"), + track("OutsideEverything.mp3"), + ] + database = SeratoDatabase( + Path("database V2"), + "test", + ( + DatabaseTrack( + Path("/new/House/LibraryOnly.mp3"), "LibraryOnly.mp3" + ), + ), + ) + library = Library.build( + [reference("Crated.mp3")], tracks, database=database + ) + + report = analyze_health(library) + + assert report.unused_tracks == 2 + assert report.orphan_candidates == 1 def test_smart_crate_references_are_reported_but_not_scored(): diff --git a/tests/test_web.py b/tests/test_web.py index 18b223a..cba0de7 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -37,6 +37,8 @@ def test_web_analysis_uses_production_health_pipeline(tmp_path): assert result["details"]["old_crate_references"]["items"][0]["crate"] assert result["details"]["suggested_matches"]["total"] == 0 assert result["details"]["unused_tracks"]["total"] == 1 + assert result["orphan_candidates"] == 0 + assert result["details"]["orphan_candidates"]["assessed"] is True def test_web_analysis_rejects_missing_folders(tmp_path): @@ -58,6 +60,7 @@ def test_web_static_assets_are_declared_and_packaged(): html = (asset_root / "index.html").read_text(encoding="utf-8") assert "Missing tracks in Serato" in html assert "Old crate references" in html + assert "Possible orphan files" in html assert "Choose a diagnostic" in html assert "Backup recovery" in html assert "Here’s exactly what will happen" in html