Compare commits
2 Commits
c5449a278a
...
251ab4d090
| Author | SHA1 | Date | |
|---|---|---|---|
| 251ab4d090 | |||
| b8ea17450b |
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, ...]
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 == ()
|
||||
|
||||
Reference in New Issue
Block a user