diff --git a/ROADMAP.md b/ROADMAP.md index 77225fa..4c0fa46 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,7 @@ - [x] `serato-doctor analyze` command - [x] Duplicate filename detection - [ ] Duplicate audio hash detection -- [ ] Broken symlink detection +- [x] Broken symlink detection - [ ] Orphaned audio detection - [ ] OneDrive rename detection - [x] Crate classification: static vs smart/dynamic diff --git a/docs/design/broken-symlinks.md b/docs/design/broken-symlinks.md new file mode 100644 index 0000000..f113106 --- /dev/null +++ b/docs/design/broken-symlinks.md @@ -0,0 +1,27 @@ +# Broken Symlink Detection + +## Problem + +Library migrations may leave symbolic links pointing to files or folders that no +longer exist. The filesystem scanner previously skipped those links silently. + +## Architecture + +One read-only filesystem traversal now returns audio tracks and broken symbolic +links. Each finding preserves the link path and its raw target when the operating +system can read it. The library and health report retain only immutable findings. + +Broken links are reported separately and do not affect the reference-integrity +score. The detector does not follow, recreate, remove, or rewrite any link. + +## Edge Cases + +- Relative and absolute link targets. +- Links to missing files and missing directories. +- Link targets that cannot be read due to an operating-system error. +- Valid symlinks, which remain eligible for ordinary audio scanning. + +## Verification + +Tests create synthetic valid and broken links in temporary directories, verify the +raw target, and confirm health integration. Existing scanner behavior is preserved. diff --git a/serato_doctor/cli.py b/serato_doctor/cli.py index 4f82102..0ccdd35 100644 --- a/serato_doctor/cli.py +++ b/serato_doctor/cli.py @@ -6,7 +6,7 @@ from serato_doctor.crate_parser import load_library_crates from serato_doctor.health import analyze_health from serato_doctor.logging import configure_logging from serato_doctor.models.library import Library -from serato_doctor.scanner import scan_audio +from serato_doctor.scanner import scan_filesystem from serato_doctor.report import write_csv, write_missing_report @@ -56,9 +56,11 @@ def main(): logger.debug("Music directory: %s", config.music) crates = load_library_crates(config.serato, config.reference_roots) + filesystem = scan_filesystem(config.music) library = Library.from_crates( crates=crates, - tracks=scan_audio(config.music), + tracks=filesystem.tracks, + broken_symlinks=filesystem.broken_symlinks, ) results = library.reconcile_by_filename() missing_count = sum(1 for result in results if not result.exists_by_filename) @@ -90,6 +92,7 @@ def main(): ) print(f"Unused Tracks: {health.unused_tracks}") print(f"Suggested Matches: {health.suggested_matches}") + print(f"Broken Symlinks: {health.broken_symlinks}") print(f"Static Crates: {health.static_crates}") print(f"Smart Crates: {health.smart_crates}") print( diff --git a/serato_doctor/health.py b/serato_doctor/health.py index be00534..c1cca6f 100644 --- a/serato_doctor/health.py +++ b/serato_doctor/health.py @@ -62,6 +62,7 @@ def analyze_health(library: Library) -> HealthReport: ), unused_tracks=unused_count, suggested_matches=suggested_count, + broken_symlinks=len(library.broken_symlinks), static_crates=sum( crate.kind is CrateKind.STATIC for crate in library.crates ), diff --git a/serato_doctor/models/__init__.py b/serato_doctor/models/__init__.py index 1a2f647..7522360 100644 --- a/serato_doctor/models/__init__.py +++ b/serato_doctor/models/__init__.py @@ -1,5 +1,6 @@ from serato_doctor.models.crate import Crate, CrateKind from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind +from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan from serato_doctor.models.health import HealthReport from serato_doctor.models.library import Library from serato_doctor.models.match import MatchEvidence, TrackMatch @@ -12,6 +13,8 @@ __all__ = [ "DiskTrack", "DuplicateGroup", "DuplicateKind", + "BrokenSymlink", + "FilesystemScan", "HealthReport", "Library", "MatchEvidence", diff --git a/serato_doctor/models/filesystem.py b/serato_doctor/models/filesystem.py new file mode 100644 index 0000000..afbfe4b --- /dev/null +++ b/serato_doctor/models/filesystem.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Tuple + +from serato_doctor.models.track import DiskTrack + + +@dataclass(frozen=True) +class BrokenSymlink: + """A symbolic link whose target cannot be resolved.""" + + path: Path + target: Optional[Path] + + +@dataclass(frozen=True) +class FilesystemScan: + """Read-only findings from one traversal of a music folder.""" + + tracks: Tuple[DiskTrack, ...] + broken_symlinks: Tuple[BrokenSymlink, ...] diff --git a/serato_doctor/models/health.py b/serato_doctor/models/health.py index e6c02cb..8d0bed5 100644 --- a/serato_doctor/models/health.py +++ b/serato_doctor/models/health.py @@ -19,6 +19,7 @@ class HealthReport: suspected_cloud_conflict_files: int unused_tracks: int suggested_matches: int + broken_symlinks: int static_crates: int smart_crates: int unknown_crates: int diff --git a/serato_doctor/models/library.py b/serato_doctor/models/library.py index 037097e..6cae56e 100644 --- a/serato_doctor/models/library.py +++ b/serato_doctor/models/library.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Iterable, Tuple from serato_doctor.models.crate import Crate +from serato_doctor.models.filesystem import BrokenSymlink from serato_doctor.models.reference import ReferenceResult, TrackReference from serato_doctor.models.track import DiskTrack @@ -13,18 +14,27 @@ class Library: references: Tuple[TrackReference, ...] tracks: Tuple[DiskTrack, ...] crates: Tuple[Crate, ...] = () + broken_symlinks: Tuple[BrokenSymlink, ...] = () @classmethod def build( cls, references: Iterable[TrackReference], tracks: Iterable[DiskTrack], + broken_symlinks: Iterable[BrokenSymlink] = (), ) -> "Library": - return cls(tuple(references), tuple(tracks)) + return cls( + tuple(references), + tuple(tracks), + broken_symlinks=tuple(broken_symlinks), + ) @classmethod def from_crates( - cls, crates: Iterable[Crate], tracks: Iterable[DiskTrack] + cls, + crates: Iterable[Crate], + tracks: Iterable[DiskTrack], + broken_symlinks: Iterable[BrokenSymlink] = (), ) -> "Library": crate_tuple = tuple(crates) references = tuple( @@ -32,7 +42,12 @@ class Library: for crate in crate_tuple for reference in crate.references ) - return cls(references, tuple(tracks), crate_tuple) + return cls( + references, + tuple(tracks), + crate_tuple, + tuple(broken_symlinks), + ) def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]: disk_names = {track.filename for track in self.tracks} diff --git a/serato_doctor/scanner.py b/serato_doctor/scanner.py index 319aa7e..8f139ca 100644 --- a/serato_doctor/scanner.py +++ b/serato_doctor/scanner.py @@ -1,14 +1,23 @@ from pathlib import Path +from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan from serato_doctor.models.track import DiskTrack AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"} -def scan_audio(folder: Path) -> list[DiskTrack]: +def scan_filesystem(folder: Path) -> FilesystemScan: tracks = [] + broken_symlinks = [] for path in folder.rglob("*"): + if path.is_symlink() and not path.exists(): + try: + target = path.readlink() + except OSError: + target = None + broken_symlinks.append(BrokenSymlink(path=path, target=target)) + continue if not path.is_file(): continue if path.suffix.lower() not in AUDIO_SUFFIXES: @@ -28,4 +37,13 @@ def scan_audio(folder: Path) -> list[DiskTrack]: ) ) - return tracks + return FilesystemScan( + tracks=tuple(tracks), + broken_symlinks=tuple(broken_symlinks), + ) + + +def scan_audio(folder: Path) -> list[DiskTrack]: + """Scan audio files while preserving the prototype API.""" + + return list(scan_filesystem(folder).tracks) diff --git a/tests/test_health.py b/tests/test_health.py index 0511d8f..2ebf242 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.filesystem import BrokenSymlink from serato_doctor.models.library import Library from serato_doctor.models.reference import TrackReference from serato_doctor.models.track import DiskTrack @@ -104,3 +105,16 @@ def test_health_reports_cloud_conflicts_separately_from_exact_duplicates(): assert report.duplicate_files == 1 assert report.suspected_cloud_conflict_groups == 1 assert report.suspected_cloud_conflict_files == 1 + + +def test_health_reports_broken_symlinks_without_changing_score(): + library = Library.build( + [reference("Found.mp3")], + [track("Found.mp3")], + [BrokenSymlink(Path("/music/Broken.mp3"), Path("missing.mp3"))], + ) + + report = analyze_health(library) + + assert report.score == 100.0 + assert report.broken_symlinks == 1 diff --git a/tests/test_scanner.py b/tests/test_scanner.py index 63484e1..924e7f2 100644 --- a/tests/test_scanner.py +++ b/tests/test_scanner.py @@ -1,4 +1,6 @@ -from serato_doctor.scanner import scan_audio +from pathlib import Path + +from serato_doctor.scanner import scan_audio, scan_filesystem def test_scan_audio_finds_supported_files(tmp_path): @@ -15,3 +17,34 @@ def test_scan_audio_finds_supported_files(tmp_path): first = next(track for track in tracks if track.filename == "First.MP3") assert first.suffix == ".mp3" assert first.size == len(b"synthetic audio") + + +def test_scan_filesystem_reports_broken_symlink(tmp_path): + music = tmp_path / "music" + music.mkdir() + link = music / "Missing.mp3" + link.symlink_to("not-there.mp3") + + result = scan_filesystem(music) + + assert result.tracks == () + assert len(result.broken_symlinks) == 1 + assert result.broken_symlinks[0].path == link + assert result.broken_symlinks[0].target == Path("not-there.mp3") + + +def test_valid_audio_symlink_is_scanned_normally(tmp_path): + music = tmp_path / "music" + music.mkdir() + target = music / "Target.mp3" + target.write_bytes(b"synthetic audio") + link = music / "Linked.mp3" + link.symlink_to(target) + + result = scan_filesystem(music) + + assert {track.filename for track in result.tracks} == { + "Linked.mp3", + "Target.mp3", + } + assert result.broken_symlinks == ()