- ${detailLines(item)}
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) => `
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