Compare commits

..

2 Commits

Author SHA1 Message Date
Philip Guzman b8ea17450b Add broken symlink diagnostics 2026-07-01 07:32:07 -07:00
Philip Guzman c5449a278a Merge feature/duplicate-filenames into develop 2026-07-01 07:30:50 -07:00
11 changed files with 145 additions and 9 deletions
+1 -1
View File
@@ -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
+27
View File
@@ -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.
+5 -2
View File
@@ -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(
+1
View File
@@ -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
),
+3
View File
@@ -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",
+21
View File
@@ -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, ...]
+1
View File
@@ -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
+18 -3
View File
@@ -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}
+20 -2
View File
@@ -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)
+14
View File
@@ -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
+34 -1
View File
@@ -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 == ()