Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8ea17450b | |||
| c5449a278a | |||
| 6449c7de6a | |||
| d764f910e4 |
+2
-2
@@ -18,9 +18,9 @@
|
|||||||
## v0.2 — Diagnostics
|
## v0.2 — Diagnostics
|
||||||
|
|
||||||
- [x] `serato-doctor analyze` command
|
- [x] `serato-doctor analyze` command
|
||||||
- [ ] Duplicate filename detection
|
- [x] Duplicate filename detection
|
||||||
- [ ] Duplicate audio hash detection
|
- [ ] Duplicate audio hash detection
|
||||||
- [ ] Broken symlink detection
|
- [x] Broken symlink detection
|
||||||
- [ ] Orphaned audio detection
|
- [ ] Orphaned audio detection
|
||||||
- [ ] OneDrive rename detection
|
- [ ] OneDrive rename detection
|
||||||
- [x] Crate classification: static vs smart/dynamic
|
- [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.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Duplicate Filename Detection
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Two files with the same filename may be ordinary copies, while names such as
|
||||||
|
`Track.mp3` and `Track 2.mp3` may indicate a OneDrive conflict. These cases need
|
||||||
|
review, but neither is sufficient evidence for deletion.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The duplicate detector emits immutable groups of two kinds:
|
||||||
|
|
||||||
|
- `exact_name` groups filenames after case and Unicode normalization.
|
||||||
|
- `cloud_conflict` groups names after additionally removing a trailing numeric
|
||||||
|
suffix from the stem.
|
||||||
|
|
||||||
|
Groups contain every path, a stable comparison key, a display name, and the number
|
||||||
|
of files beyond the first. Health analysis reports exact and suspected-conflict
|
||||||
|
counts separately. Neither category changes the health score.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
- Same filename in different folders.
|
||||||
|
- Case-only and Unicode representation differences.
|
||||||
|
- Multiple conflict suffixes such as `Track 2.mp3` and `Track 3.mp3`.
|
||||||
|
- Legitimate numbered titles, which remain explicitly labeled as suspected.
|
||||||
|
- A single file, which is never reported as a duplicate.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Tests cover exact, normalized, conflict-family, unrelated, and deterministic-order
|
||||||
|
behavior, plus integration with health analysis. Detection is read-only.
|
||||||
+13
-2
@@ -6,7 +6,7 @@ from serato_doctor.crate_parser import load_library_crates
|
|||||||
from serato_doctor.health import analyze_health
|
from serato_doctor.health import analyze_health
|
||||||
from serato_doctor.logging import configure_logging
|
from serato_doctor.logging import configure_logging
|
||||||
from serato_doctor.models.library import Library
|
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
|
from serato_doctor.report import write_csv, write_missing_report
|
||||||
|
|
||||||
|
|
||||||
@@ -56,9 +56,11 @@ def main():
|
|||||||
logger.debug("Music directory: %s", config.music)
|
logger.debug("Music directory: %s", config.music)
|
||||||
|
|
||||||
crates = load_library_crates(config.serato, config.reference_roots)
|
crates = load_library_crates(config.serato, config.reference_roots)
|
||||||
|
filesystem = scan_filesystem(config.music)
|
||||||
library = Library.from_crates(
|
library = Library.from_crates(
|
||||||
crates=crates,
|
crates=crates,
|
||||||
tracks=scan_audio(config.music),
|
tracks=filesystem.tracks,
|
||||||
|
broken_symlinks=filesystem.broken_symlinks,
|
||||||
)
|
)
|
||||||
results = library.reconcile_by_filename()
|
results = library.reconcile_by_filename()
|
||||||
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
missing_count = sum(1 for result in results if not result.exists_by_filename)
|
||||||
@@ -80,8 +82,17 @@ def main():
|
|||||||
print(f"Unique Missing Filenames: {health.unique_missing_filenames}")
|
print(f"Unique Missing Filenames: {health.unique_missing_filenames}")
|
||||||
print(f"Duplicate Filename Groups: {health.duplicate_filename_groups}")
|
print(f"Duplicate Filename Groups: {health.duplicate_filename_groups}")
|
||||||
print(f"Duplicate Files: {health.duplicate_files}")
|
print(f"Duplicate Files: {health.duplicate_files}")
|
||||||
|
print(
|
||||||
|
"Suspected Cloud Conflict Groups: "
|
||||||
|
f"{health.suspected_cloud_conflict_groups}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"Suspected Cloud Conflict Files: "
|
||||||
|
f"{health.suspected_cloud_conflict_files}"
|
||||||
|
)
|
||||||
print(f"Unused Tracks: {health.unused_tracks}")
|
print(f"Unused Tracks: {health.unused_tracks}")
|
||||||
print(f"Suggested Matches: {health.suggested_matches}")
|
print(f"Suggested Matches: {health.suggested_matches}")
|
||||||
|
print(f"Broken Symlinks: {health.broken_symlinks}")
|
||||||
print(f"Static Crates: {health.static_crates}")
|
print(f"Static Crates: {health.static_crates}")
|
||||||
print(f"Smart Crates: {health.smart_crates}")
|
print(f"Smart Crates: {health.smart_crates}")
|
||||||
print(
|
print(
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from typing import DefaultDict, Iterable, List, Tuple
|
||||||
|
|
||||||
|
from serato_doctor.matching import cloud_conflict_name, normalize
|
||||||
|
from serato_doctor.models.duplicate import DuplicateGroup, DuplicateKind
|
||||||
|
from serato_doctor.models.track import DiskTrack
|
||||||
|
|
||||||
|
|
||||||
|
def find_duplicate_groups(
|
||||||
|
tracks: Iterable[DiskTrack],
|
||||||
|
) -> Tuple[DuplicateGroup, ...]:
|
||||||
|
"""Find exact-name duplicates and suspected numeric conflict copies."""
|
||||||
|
|
||||||
|
track_tuple = tuple(tracks)
|
||||||
|
by_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
||||||
|
by_conflict_name: DefaultDict[str, List[DiskTrack]] = defaultdict(list)
|
||||||
|
|
||||||
|
for track in track_tuple:
|
||||||
|
by_name[normalize(track.filename)].append(track)
|
||||||
|
by_conflict_name[cloud_conflict_name(track.filename)].append(track)
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for key, matches in by_name.items():
|
||||||
|
if len(matches) > 1:
|
||||||
|
groups.append(_group(DuplicateKind.EXACT_NAME, key, matches))
|
||||||
|
|
||||||
|
for key, matches in by_conflict_name.items():
|
||||||
|
distinct_names = {normalize(track.filename) for track in matches}
|
||||||
|
if len(distinct_names) > 1:
|
||||||
|
groups.append(_group(DuplicateKind.CLOUD_CONFLICT, key, matches))
|
||||||
|
|
||||||
|
return tuple(
|
||||||
|
sorted(groups, key=lambda group: (group.kind.value, group.comparison_key))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _group(
|
||||||
|
kind: DuplicateKind, key: str, tracks: Iterable[DiskTrack]
|
||||||
|
) -> DuplicateGroup:
|
||||||
|
ordered = tuple(sorted(tracks, key=lambda track: str(track.path)))
|
||||||
|
return DuplicateGroup(kind, key, ordered)
|
||||||
+20
-6
@@ -1,7 +1,7 @@
|
|||||||
from collections import Counter
|
from serato_doctor.duplicates import find_duplicate_groups
|
||||||
|
|
||||||
from serato_doctor.matching import MatchingEngine
|
from serato_doctor.matching import MatchingEngine
|
||||||
from serato_doctor.models.crate import CrateKind
|
from serato_doctor.models.crate import CrateKind
|
||||||
|
from serato_doctor.models.duplicate import DuplicateKind
|
||||||
from serato_doctor.models.health import HealthReport
|
from serato_doctor.models.health import HealthReport
|
||||||
from serato_doctor.models.library import Library
|
from serato_doctor.models.library import Library
|
||||||
|
|
||||||
@@ -23,8 +23,17 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
round(healthy_count / len(results) * 100, 1) if results else None
|
round(healthy_count / len(results) * 100, 1) if results else None
|
||||||
)
|
)
|
||||||
|
|
||||||
disk_name_counts = Counter(track.filename for track in library.tracks)
|
duplicate_groups = find_duplicate_groups(library.tracks)
|
||||||
duplicate_counts = [count for count in disk_name_counts.values() if count > 1]
|
exact_duplicates = [
|
||||||
|
group
|
||||||
|
for group in duplicate_groups
|
||||||
|
if group.kind is DuplicateKind.EXACT_NAME
|
||||||
|
]
|
||||||
|
cloud_conflicts = [
|
||||||
|
group
|
||||||
|
for group in duplicate_groups
|
||||||
|
if group.kind is DuplicateKind.CLOUD_CONFLICT
|
||||||
|
]
|
||||||
referenced_names = {reference.filename for reference in library.references}
|
referenced_names = {reference.filename for reference in library.references}
|
||||||
unused_count = sum(
|
unused_count = sum(
|
||||||
1 for track in library.tracks if track.filename not in referenced_names
|
1 for track in library.tracks if track.filename not in referenced_names
|
||||||
@@ -45,10 +54,15 @@ def analyze_health(library: Library) -> HealthReport:
|
|||||||
{result.reference.filename for result in missing}
|
{result.reference.filename for result in missing}
|
||||||
),
|
),
|
||||||
disk_tracks=len(library.tracks),
|
disk_tracks=len(library.tracks),
|
||||||
duplicate_filename_groups=len(duplicate_counts),
|
duplicate_filename_groups=len(exact_duplicates),
|
||||||
duplicate_files=sum(count - 1 for count in duplicate_counts),
|
duplicate_files=sum(group.extra_files for group in exact_duplicates),
|
||||||
|
suspected_cloud_conflict_groups=len(cloud_conflicts),
|
||||||
|
suspected_cloud_conflict_files=sum(
|
||||||
|
group.extra_files for group in cloud_conflicts
|
||||||
|
),
|
||||||
unused_tracks=unused_count,
|
unused_tracks=unused_count,
|
||||||
suggested_matches=suggested_count,
|
suggested_matches=suggested_count,
|
||||||
|
broken_symlinks=len(library.broken_symlinks),
|
||||||
static_crates=sum(
|
static_crates=sum(
|
||||||
crate.kind is CrateKind.STATIC for crate in library.crates
|
crate.kind is CrateKind.STATIC for crate in library.crates
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
from serato_doctor.models.crate import Crate, CrateKind
|
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.health import HealthReport
|
||||||
from serato_doctor.models.library import Library
|
from serato_doctor.models.library import Library
|
||||||
from serato_doctor.models.match import MatchEvidence, TrackMatch
|
from serato_doctor.models.match import MatchEvidence, TrackMatch
|
||||||
@@ -9,6 +11,10 @@ __all__ = [
|
|||||||
"Crate",
|
"Crate",
|
||||||
"CrateKind",
|
"CrateKind",
|
||||||
"DiskTrack",
|
"DiskTrack",
|
||||||
|
"DuplicateGroup",
|
||||||
|
"DuplicateKind",
|
||||||
|
"BrokenSymlink",
|
||||||
|
"FilesystemScan",
|
||||||
"HealthReport",
|
"HealthReport",
|
||||||
"Library",
|
"Library",
|
||||||
"MatchEvidence",
|
"MatchEvidence",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from serato_doctor.models.track import DiskTrack
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateKind(str, Enum):
|
||||||
|
EXACT_NAME = "exact_name"
|
||||||
|
CLOUD_CONFLICT = "cloud_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DuplicateGroup:
|
||||||
|
"""A deterministic group of files that warrants duplicate review."""
|
||||||
|
|
||||||
|
kind: DuplicateKind
|
||||||
|
comparison_key: str
|
||||||
|
tracks: Tuple[DiskTrack, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_files(self) -> int:
|
||||||
|
return max(0, len(self.tracks) - 1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
return min(
|
||||||
|
(track.filename for track in self.tracks),
|
||||||
|
key=lambda name: (len(name), name.casefold()),
|
||||||
|
)
|
||||||
@@ -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, ...]
|
||||||
@@ -15,8 +15,11 @@ class HealthReport:
|
|||||||
disk_tracks: int
|
disk_tracks: int
|
||||||
duplicate_filename_groups: int
|
duplicate_filename_groups: int
|
||||||
duplicate_files: int
|
duplicate_files: int
|
||||||
|
suspected_cloud_conflict_groups: int
|
||||||
|
suspected_cloud_conflict_files: int
|
||||||
unused_tracks: int
|
unused_tracks: int
|
||||||
suggested_matches: int
|
suggested_matches: int
|
||||||
|
broken_symlinks: int
|
||||||
static_crates: int
|
static_crates: int
|
||||||
smart_crates: int
|
smart_crates: int
|
||||||
unknown_crates: int
|
unknown_crates: int
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
|||||||
from typing import Iterable, Tuple
|
from typing import Iterable, Tuple
|
||||||
|
|
||||||
from serato_doctor.models.crate import Crate
|
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.reference import ReferenceResult, TrackReference
|
||||||
from serato_doctor.models.track import DiskTrack
|
from serato_doctor.models.track import DiskTrack
|
||||||
|
|
||||||
@@ -13,18 +14,27 @@ class Library:
|
|||||||
references: Tuple[TrackReference, ...]
|
references: Tuple[TrackReference, ...]
|
||||||
tracks: Tuple[DiskTrack, ...]
|
tracks: Tuple[DiskTrack, ...]
|
||||||
crates: Tuple[Crate, ...] = ()
|
crates: Tuple[Crate, ...] = ()
|
||||||
|
broken_symlinks: Tuple[BrokenSymlink, ...] = ()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def build(
|
def build(
|
||||||
cls,
|
cls,
|
||||||
references: Iterable[TrackReference],
|
references: Iterable[TrackReference],
|
||||||
tracks: Iterable[DiskTrack],
|
tracks: Iterable[DiskTrack],
|
||||||
|
broken_symlinks: Iterable[BrokenSymlink] = (),
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
return cls(tuple(references), tuple(tracks))
|
return cls(
|
||||||
|
tuple(references),
|
||||||
|
tuple(tracks),
|
||||||
|
broken_symlinks=tuple(broken_symlinks),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_crates(
|
def from_crates(
|
||||||
cls, crates: Iterable[Crate], tracks: Iterable[DiskTrack]
|
cls,
|
||||||
|
crates: Iterable[Crate],
|
||||||
|
tracks: Iterable[DiskTrack],
|
||||||
|
broken_symlinks: Iterable[BrokenSymlink] = (),
|
||||||
) -> "Library":
|
) -> "Library":
|
||||||
crate_tuple = tuple(crates)
|
crate_tuple = tuple(crates)
|
||||||
references = tuple(
|
references = tuple(
|
||||||
@@ -32,7 +42,12 @@ class Library:
|
|||||||
for crate in crate_tuple
|
for crate in crate_tuple
|
||||||
for reference in crate.references
|
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, ...]:
|
def reconcile_by_filename(self) -> Tuple[ReferenceResult, ...]:
|
||||||
disk_names = {track.filename for track in self.tracks}
|
disk_names = {track.filename for track in self.tracks}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from serato_doctor.models.filesystem import BrokenSymlink, FilesystemScan
|
||||||
from serato_doctor.models.track import DiskTrack
|
from serato_doctor.models.track import DiskTrack
|
||||||
|
|
||||||
AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"}
|
AUDIO_SUFFIXES = {".mp3", ".m4a", ".wav", ".aif", ".aiff", ".flac"}
|
||||||
|
|
||||||
|
|
||||||
def scan_audio(folder: Path) -> list[DiskTrack]:
|
def scan_filesystem(folder: Path) -> FilesystemScan:
|
||||||
tracks = []
|
tracks = []
|
||||||
|
broken_symlinks = []
|
||||||
|
|
||||||
for path in folder.rglob("*"):
|
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():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
if path.suffix.lower() not in AUDIO_SUFFIXES:
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from serato_doctor.duplicates import find_duplicate_groups
|
||||||
|
from serato_doctor.models.duplicate import DuplicateKind
|
||||||
|
from serato_doctor.models.track import DiskTrack
|
||||||
|
|
||||||
|
|
||||||
|
def track(path):
|
||||||
|
path = Path(path)
|
||||||
|
return DiskTrack(path, path.name, 100, path.suffix.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_names_in_different_folders_form_a_group():
|
||||||
|
groups = find_duplicate_groups(
|
||||||
|
[track("/A/Song.mp3"), track("/B/Song.mp3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert groups[0].kind is DuplicateKind.EXACT_NAME
|
||||||
|
assert groups[0].display_name == "Song.mp3"
|
||||||
|
assert groups[0].extra_files == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_only_difference_is_an_exact_name_duplicate():
|
||||||
|
groups = find_duplicate_groups(
|
||||||
|
[track("/A/SONG.MP3"), track("/B/song.mp3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert groups[0].kind is DuplicateKind.EXACT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_suffixes_form_a_suspected_cloud_conflict_group():
|
||||||
|
groups = find_duplicate_groups(
|
||||||
|
[
|
||||||
|
track("/A/Track.mp3"),
|
||||||
|
track("/B/Track 2.mp3"),
|
||||||
|
track("/C/Track 3.mp3"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert groups[0].kind is DuplicateKind.CLOUD_CONFLICT
|
||||||
|
assert groups[0].display_name == "Track.mp3"
|
||||||
|
assert groups[0].extra_files == 2
|
||||||
|
assert [item.path for item in groups[0].tracks] == [
|
||||||
|
Path("/A/Track.mp3"),
|
||||||
|
Path("/B/Track 2.mp3"),
|
||||||
|
Path("/C/Track 3.mp3"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_and_single_files_are_not_reported():
|
||||||
|
groups = find_duplicate_groups(
|
||||||
|
[track("/A/First.mp3"), track("/B/Second.mp3")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert groups == ()
|
||||||
@@ -2,6 +2,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from serato_doctor.health import analyze_health
|
from serato_doctor.health import analyze_health
|
||||||
from serato_doctor.models.crate import Crate, CrateKind
|
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.library import Library
|
||||||
from serato_doctor.models.reference import TrackReference
|
from serato_doctor.models.reference import TrackReference
|
||||||
from serato_doctor.models.track import DiskTrack
|
from serato_doctor.models.track import DiskTrack
|
||||||
@@ -85,3 +86,35 @@ def test_smart_crate_references_are_reported_but_not_scored():
|
|||||||
assert report.static_crates == 1
|
assert report.static_crates == 1
|
||||||
assert report.smart_crates == 1
|
assert report.smart_crates == 1
|
||||||
assert report.dynamic_references_excluded == 1
|
assert report.dynamic_references_excluded == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_cloud_conflicts_separately_from_exact_duplicates():
|
||||||
|
library = Library.build(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
track("Track.mp3", "Original"),
|
||||||
|
track("Track 2.mp3", "Conflict"),
|
||||||
|
track("Copy.mp3", "First"),
|
||||||
|
track("Copy.mp3", "Second"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
report = analyze_health(library)
|
||||||
|
|
||||||
|
assert report.duplicate_filename_groups == 1
|
||||||
|
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):
|
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")
|
first = next(track for track in tracks if track.filename == "First.MP3")
|
||||||
assert first.suffix == ".mp3"
|
assert first.suffix == ".mp3"
|
||||||
assert first.size == len(b"synthetic audio")
|
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