1169 lines
30 KiB
Python
1169 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
album_coverage.py
|
|
|
|
Compact terminal-friendly album deletion/coverage checker for beets.
|
|
|
|
Default output:
|
|
- compact summary
|
|
- album list
|
|
- relationship map showing candidate track -> best match/reason
|
|
- hides exact-covered tracks unless --show-covered is used
|
|
- avoids long paths in terminal output
|
|
|
|
Verbose/detail output:
|
|
--details shows compact candidate/match blocks
|
|
--verbose shows forensic match categories
|
|
--paths includes relative paths in details/verbose output
|
|
|
|
Layout assumption:
|
|
/music/
|
|
beets/
|
|
audit/
|
|
album_coverage.py
|
|
album_coverage.sh
|
|
library/
|
|
|
|
Audit output defaults to /music/beets/audit.
|
|
Displayed paths, when enabled, are relative to /music.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import unicodedata
|
|
from collections import Counter
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
|
|
US = "\x1f"
|
|
DIVIDER = "-" * 100
|
|
|
|
LOSSLESS_FORMATS = {
|
|
"FLAC",
|
|
"ALAC",
|
|
"WAV",
|
|
"AIFF",
|
|
"AIF",
|
|
"APE",
|
|
"WavPack",
|
|
"WV",
|
|
"DSF",
|
|
"DFF",
|
|
}
|
|
|
|
TRACK_FIELDS = [
|
|
"album_id",
|
|
"id",
|
|
"albumartist",
|
|
"album",
|
|
"artist",
|
|
"title",
|
|
"disc",
|
|
"track",
|
|
"tracktotal",
|
|
"format",
|
|
"bitdepth",
|
|
"samplerate",
|
|
"bitrate",
|
|
"path",
|
|
]
|
|
|
|
ALBUM_FIELDS = [
|
|
"id",
|
|
"albumartist",
|
|
"album",
|
|
"year",
|
|
"albumtype",
|
|
"albumtotal",
|
|
"mb_albumid",
|
|
"mb_releasegroupid",
|
|
"albumdisambig",
|
|
"label",
|
|
"catalognum",
|
|
"country",
|
|
"media",
|
|
"path",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Album:
|
|
id: str
|
|
albumartist: str
|
|
album: str
|
|
year: str
|
|
albumtype: str
|
|
albumtotal: str
|
|
mb_albumid: str
|
|
mb_releasegroupid: str
|
|
albumdisambig: str
|
|
label: str
|
|
catalognum: str
|
|
country: str
|
|
media: str
|
|
path: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Track:
|
|
album_id: str
|
|
id: str
|
|
albumartist: str
|
|
album: str
|
|
artist: str
|
|
title: str
|
|
disc: str
|
|
track: str
|
|
tracktotal: str
|
|
format: str
|
|
bitdepth: str
|
|
samplerate: str
|
|
bitrate: str
|
|
path: str
|
|
|
|
@property
|
|
def exact_title_key(self) -> str:
|
|
return normalize_title_exact(self.title)
|
|
|
|
@property
|
|
def loose_title_key(self) -> str:
|
|
return normalize_title_loose(self.title)
|
|
|
|
@property
|
|
def artist_keys(self) -> set[str]:
|
|
return artist_keys(self.artist, self.albumartist)
|
|
|
|
|
|
@dataclass
|
|
class TrackCoverage:
|
|
candidate: Track
|
|
status: str
|
|
exact_related: list[Track]
|
|
exact_other: list[Track]
|
|
loose_related: list[Track]
|
|
loose_other: list[Track]
|
|
|
|
@property
|
|
def exact_match_count(self) -> int:
|
|
return len(self.exact_related) + len(self.exact_other)
|
|
|
|
@property
|
|
def loose_match_count(self) -> int:
|
|
return len(self.loose_related) + len(self.loose_other)
|
|
|
|
@property
|
|
def is_exactly_covered(self) -> bool:
|
|
return bool(self.exact_related)
|
|
|
|
@property
|
|
def is_attention_needed(self) -> bool:
|
|
return self.status != "EXACT_RELATED"
|
|
|
|
|
|
def default_paths() -> tuple[Path, Path, Path]:
|
|
script_path = Path(__file__).resolve()
|
|
audit_dir = script_path.parent
|
|
beets_dir = audit_dir.parent
|
|
music_root = beets_dir.parent
|
|
library_dir = music_root / "library"
|
|
return audit_dir, music_root, library_dir
|
|
|
|
|
|
def terminal_width(cli_width: int | None) -> int:
|
|
if cli_width:
|
|
return max(72, cli_width)
|
|
|
|
detected = shutil.get_terminal_size(fallback=(100, 24)).columns
|
|
return max(72, min(detected, 120))
|
|
|
|
|
|
def ellipsize(value: str, width: int) -> str:
|
|
value = value or ""
|
|
if width <= 0:
|
|
return ""
|
|
if len(value) <= width:
|
|
return value
|
|
if width == 1:
|
|
return "…"
|
|
return value[: width - 1] + "…"
|
|
|
|
|
|
def relpath(path_value: str, music_root: Path) -> str:
|
|
if not path_value:
|
|
return ""
|
|
|
|
path = Path(path_value)
|
|
|
|
if not path.is_absolute():
|
|
return path_value
|
|
|
|
try:
|
|
return str(path.relative_to(music_root))
|
|
except ValueError:
|
|
return path_value
|
|
|
|
|
|
def run_beet_ls(
|
|
fields: list[str],
|
|
query: list[str] | None = None,
|
|
*,
|
|
album_mode: bool = False,
|
|
) -> list[dict[str, str]]:
|
|
query = query or []
|
|
fmt = US.join(f"${field}" for field in fields)
|
|
|
|
cmd = ["beet", "ls"]
|
|
|
|
if album_mode:
|
|
cmd.append("-a")
|
|
|
|
cmd.extend(["-f", fmt])
|
|
cmd.extend(query)
|
|
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
check=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
except FileNotFoundError:
|
|
print("ERROR: could not find 'beet' on PATH.", file=sys.stderr)
|
|
raise SystemExit(2)
|
|
except subprocess.CalledProcessError as exc:
|
|
print("ERROR: beet command failed:", file=sys.stderr)
|
|
print(" ".join(cmd), file=sys.stderr)
|
|
if exc.stderr:
|
|
print(exc.stderr, file=sys.stderr)
|
|
raise SystemExit(exc.returncode)
|
|
|
|
rows: list[dict[str, str]] = []
|
|
|
|
for line_number, line in enumerate(proc.stdout.splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
|
|
parts = line.split(US)
|
|
|
|
if len(parts) != len(fields):
|
|
print(
|
|
f"ERROR: could not parse beet output line {line_number}. "
|
|
f"Expected {len(fields)} fields, got {len(parts)}.",
|
|
file=sys.stderr,
|
|
)
|
|
print("Raw line:", repr(line), file=sys.stderr)
|
|
print("Command:", " ".join(cmd), file=sys.stderr)
|
|
raise SystemExit(2)
|
|
|
|
rows.append(dict(zip(fields, parts)))
|
|
|
|
return rows
|
|
|
|
|
|
def load_album(album_id: str) -> Album:
|
|
rows = run_beet_ls(ALBUM_FIELDS, [f"id:{album_id}"], album_mode=True)
|
|
|
|
if not rows:
|
|
raise SystemExit(f"ERROR: no album found for album id {album_id!r}")
|
|
|
|
return Album(**rows[0])
|
|
|
|
|
|
def load_tracks_for_album(album_id: str) -> list[Track]:
|
|
rows = run_beet_ls(TRACK_FIELDS, [f"album_id:{album_id}"], album_mode=False)
|
|
return [Track(**row) for row in rows]
|
|
|
|
|
|
def load_all_tracks() -> list[Track]:
|
|
rows = run_beet_ls(TRACK_FIELDS, [], album_mode=False)
|
|
return [Track(**row) for row in rows]
|
|
|
|
|
|
def normalize_common(value: str) -> str:
|
|
value = unicodedata.normalize("NFKC", value or "")
|
|
|
|
replacements = {
|
|
"\u2018": "'",
|
|
"\u2019": "'",
|
|
"\u201a": "'",
|
|
"\u201b": "'",
|
|
"\u2032": "'",
|
|
"\u2035": "'",
|
|
"\u201c": '"',
|
|
"\u201d": '"',
|
|
"\u201e": '"',
|
|
"\u2033": '"',
|
|
"\u2036": '"',
|
|
"\u2010": "-",
|
|
"\u2011": "-",
|
|
"\u2012": "-",
|
|
"\u2013": "-",
|
|
"\u2014": "-",
|
|
"\u2015": "-",
|
|
"\u2212": "-",
|
|
"\u2026": "...",
|
|
"\u00a0": " ",
|
|
}
|
|
|
|
for old, new in replacements.items():
|
|
value = value.replace(old, new)
|
|
|
|
value = value.casefold()
|
|
value = re.sub(r"\s+", " ", value)
|
|
return value.strip()
|
|
|
|
|
|
def normalize_censored_words(value: str) -> str:
|
|
value = re.sub(r"\bf[\W_]*\*[\W_]*\*[\W_]*k\b", "fuck", value)
|
|
value = re.sub(r"\bf[\W_]*_[\W_]*_[\W_]*k\b", "fuck", value)
|
|
value = re.sub(r"\bf[\W_]*x[\W_]*x[\W_]*k\b", "fuck", value)
|
|
return value
|
|
|
|
|
|
def normalize_title_exact(title: str) -> str:
|
|
title = normalize_common(title)
|
|
title = normalize_censored_words(title)
|
|
return title
|
|
|
|
|
|
def normalize_title_loose(title: str) -> str:
|
|
title = normalize_title_exact(title)
|
|
|
|
previous = None
|
|
while previous != title:
|
|
previous = title
|
|
title = re.sub(r"\s*[\(\[\{(【].*?[\)\]\})】]\s*", " ", title)
|
|
|
|
title = re.sub(
|
|
r"\s+-\s+(remaster(?:ed)?|radio edit|single version|album version|edit|remix).*$",
|
|
" ",
|
|
title,
|
|
)
|
|
|
|
title = title.replace("_", " ")
|
|
title = re.sub(r"[^\w\s']", " ", title, flags=re.UNICODE)
|
|
title = re.sub(r"\s+", " ", title)
|
|
return title.strip()
|
|
|
|
|
|
def artist_name_common(value: str) -> str:
|
|
value = normalize_common(value)
|
|
value = value.replace("&", " and ")
|
|
value = re.sub(r"\b(featuring|feat\.?|ft\.?)\b", " feat ", value)
|
|
value = re.sub(r"\bwith\b", " with ", value)
|
|
value = re.sub(r"\s+", " ", value)
|
|
return value.strip()
|
|
|
|
|
|
def artist_keys(*names: str) -> set[str]:
|
|
keys: set[str] = set()
|
|
|
|
for raw in names:
|
|
if not raw:
|
|
continue
|
|
|
|
cleaned = artist_name_common(raw)
|
|
|
|
if cleaned:
|
|
keys.add(cleaned)
|
|
|
|
split_ready = cleaned
|
|
split_ready = re.sub(r"\b(feat|with|and|x)\b", ",", split_ready)
|
|
split_ready = re.sub(r"[,/;+|]", ",", split_ready)
|
|
|
|
for part in split_ready.split(","):
|
|
part = part.strip()
|
|
part = re.sub(r"\s+", " ", part)
|
|
|
|
if not part:
|
|
continue
|
|
|
|
if len(part) < 2:
|
|
continue
|
|
|
|
keys.add(part)
|
|
|
|
return keys
|
|
|
|
|
|
def tracks_are_related(candidate: Track, other: Track) -> bool:
|
|
candidate_keys = candidate.artist_keys
|
|
other_keys = other.artist_keys
|
|
|
|
if candidate_keys & other_keys:
|
|
return True
|
|
|
|
for c in candidate_keys:
|
|
for o in other_keys:
|
|
if len(c) >= 5 and len(o) >= 5 and (c in o or o in c):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def parse_intish(value: str) -> int:
|
|
if not value:
|
|
return 0
|
|
|
|
match = re.search(r"\d+", value)
|
|
|
|
if not match:
|
|
return 0
|
|
|
|
return int(match.group(0))
|
|
|
|
|
|
def parse_samplerate_hz(value: str) -> int:
|
|
value = (value or "").strip()
|
|
|
|
if not value:
|
|
return 0
|
|
|
|
match = re.search(r"(\d+(?:\.\d+)?)\s*k\s*hz", value, flags=re.I)
|
|
|
|
if match:
|
|
return int(float(match.group(1)) * 1000)
|
|
|
|
match = re.search(r"(\d+)", value)
|
|
|
|
if match:
|
|
number = int(match.group(1))
|
|
if number < 1000:
|
|
return number * 1000
|
|
return number
|
|
|
|
return 0
|
|
|
|
|
|
def parse_bitrate_kbps(value: str) -> int:
|
|
value = (value or "").strip()
|
|
|
|
if not value:
|
|
return 0
|
|
|
|
match = re.search(r"(\d+(?:\.\d+)?)\s*kbps", value, flags=re.I)
|
|
|
|
if match:
|
|
return int(float(match.group(1)))
|
|
|
|
match = re.search(r"(\d+)", value)
|
|
|
|
if match:
|
|
return int(match.group(1))
|
|
|
|
return 0
|
|
|
|
|
|
def is_lossless(track: Track) -> bool:
|
|
return track.format in LOSSLESS_FORMATS
|
|
|
|
|
|
def quality_score(track: Track) -> tuple[int, int, int, int]:
|
|
return (
|
|
1 if is_lossless(track) else 0,
|
|
parse_intish(track.bitdepth),
|
|
parse_samplerate_hz(track.samplerate),
|
|
parse_bitrate_kbps(track.bitrate),
|
|
)
|
|
|
|
|
|
def quality_short(track: Track) -> str:
|
|
fmt = track.format or "?"
|
|
bitdepth = parse_intish(track.bitdepth)
|
|
samplerate = parse_samplerate_hz(track.samplerate)
|
|
bitrate = parse_bitrate_kbps(track.bitrate)
|
|
|
|
parts = [fmt]
|
|
|
|
if bitdepth > 0:
|
|
parts.append(f"{bitdepth}b")
|
|
|
|
if samplerate > 0:
|
|
parts.append(f"{round(samplerate / 1000)}kHz")
|
|
|
|
if bitrate > 0:
|
|
parts.append(f"{bitrate}k")
|
|
|
|
return " ".join(parts)
|
|
|
|
|
|
def natural_track_number(value: str) -> int:
|
|
return parse_intish(value)
|
|
|
|
|
|
def track_position(track: Track) -> str:
|
|
disc = parse_intish(track.disc)
|
|
number = parse_intish(track.track)
|
|
|
|
if disc:
|
|
return f"{disc:02d}-{number:02d}" if number else f"{disc:02d}-??"
|
|
|
|
return f"{number:02d}" if number else "??"
|
|
|
|
|
|
def sort_matches(matches: Iterable[Track]) -> list[Track]:
|
|
return sorted(
|
|
matches,
|
|
key=lambda t: (
|
|
t.albumartist.casefold(),
|
|
t.album.casefold(),
|
|
-quality_score(t)[0],
|
|
-quality_score(t)[1],
|
|
-quality_score(t)[2],
|
|
-quality_score(t)[3],
|
|
natural_track_number(t.disc),
|
|
natural_track_number(t.track),
|
|
t.title.casefold(),
|
|
),
|
|
)
|
|
|
|
|
|
def classify_track(candidate: Track, outside_tracks: list[Track]) -> TrackCoverage:
|
|
exact_related: list[Track] = []
|
|
exact_other: list[Track] = []
|
|
loose_related: list[Track] = []
|
|
loose_other: list[Track] = []
|
|
|
|
candidate_exact = candidate.exact_title_key
|
|
candidate_loose = candidate.loose_title_key
|
|
|
|
for other in outside_tracks:
|
|
if not candidate_exact and not candidate_loose:
|
|
continue
|
|
|
|
related = tracks_are_related(candidate, other)
|
|
|
|
exact_match = candidate_exact and candidate_exact == other.exact_title_key
|
|
loose_match = candidate_loose and candidate_loose == other.loose_title_key
|
|
|
|
if exact_match:
|
|
if related:
|
|
exact_related.append(other)
|
|
else:
|
|
exact_other.append(other)
|
|
|
|
if loose_match and not exact_match:
|
|
if related:
|
|
loose_related.append(other)
|
|
else:
|
|
loose_other.append(other)
|
|
|
|
exact_related = sort_matches(exact_related)
|
|
exact_other = sort_matches(exact_other)
|
|
loose_related = sort_matches(loose_related)
|
|
loose_other = sort_matches(loose_other)
|
|
|
|
if exact_related:
|
|
status = "EXACT_RELATED"
|
|
elif loose_related:
|
|
status = "LOOSE_RELATED"
|
|
elif exact_other:
|
|
status = "EXACT_OTHER_ARTIST"
|
|
elif loose_other:
|
|
status = "LOOSE_OTHER_ARTIST"
|
|
else:
|
|
status = "UNCOVERED"
|
|
|
|
return TrackCoverage(
|
|
candidate=candidate,
|
|
status=status,
|
|
exact_related=exact_related,
|
|
exact_other=exact_other,
|
|
loose_related=loose_related,
|
|
loose_other=loose_other,
|
|
)
|
|
|
|
|
|
def status_short(status: str) -> str:
|
|
return {
|
|
"EXACT_RELATED": "EXACT",
|
|
"LOOSE_RELATED": "LOOSE",
|
|
"EXACT_OTHER_ARTIST": "OTHER",
|
|
"LOOSE_OTHER_ARTIST": "OTHER",
|
|
"UNCOVERED": "MISS",
|
|
}.get(status, status[:5])
|
|
|
|
|
|
def status_reason(status: str) -> str:
|
|
return {
|
|
"EXACT_RELATED": "exact title + related artist",
|
|
"LOOSE_RELATED": "base title + related artist",
|
|
"EXACT_OTHER_ARTIST": "same exact title, different artist",
|
|
"LOOSE_OTHER_ARTIST": "same base title, different artist",
|
|
"UNCOVERED": "no title match elsewhere",
|
|
}.get(status, "")
|
|
|
|
|
|
def best_match_for(coverage: TrackCoverage) -> Track | None:
|
|
if coverage.status == "EXACT_RELATED":
|
|
return coverage.exact_related[0] if coverage.exact_related else None
|
|
|
|
if coverage.status == "LOOSE_RELATED":
|
|
return coverage.loose_related[0] if coverage.loose_related else None
|
|
|
|
if coverage.status == "EXACT_OTHER_ARTIST":
|
|
return coverage.exact_other[0] if coverage.exact_other else None
|
|
|
|
if coverage.status == "LOOSE_OTHER_ARTIST":
|
|
return coverage.loose_other[0] if coverage.loose_other else None
|
|
|
|
return None
|
|
|
|
|
|
def candidate_ref(track: Track) -> str:
|
|
return f"A{track.album_id} {track_position(track)} {track.title}"
|
|
|
|
|
|
def match_ref(track: Track) -> str:
|
|
return (
|
|
f"A{track.album_id} {track_position(track)} "
|
|
f"{track.artist} - {track.title} [{track.album}]"
|
|
)
|
|
|
|
|
|
def album_line(album: Album, width: int, music_root: Path, include_paths: bool) -> str:
|
|
parts = []
|
|
|
|
if album.year:
|
|
parts.append(album.year)
|
|
|
|
if album.albumtype:
|
|
parts.append(album.albumtype)
|
|
|
|
if album.albumtotal:
|
|
parts.append(f"{album.albumtotal} tracks")
|
|
|
|
if album.media:
|
|
parts.append(album.media)
|
|
|
|
if album.country:
|
|
parts.append(album.country)
|
|
|
|
if album.albumdisambig:
|
|
parts.append(album.albumdisambig)
|
|
|
|
meta = " | ".join(parts)
|
|
base = f"A{album.id} {album.albumartist} - {album.album}"
|
|
|
|
if meta:
|
|
base += f" ({meta})"
|
|
|
|
if include_paths and album.path:
|
|
base += f" | {relpath(album.path, music_root)}"
|
|
|
|
return ellipsize(base, width)
|
|
|
|
|
|
def build_summary_lines(
|
|
album_ids: list[str],
|
|
albums: list[Album],
|
|
candidate_tracks: list[Track],
|
|
outside_tracks: list[Track],
|
|
coverages: list[TrackCoverage],
|
|
music_root: Path,
|
|
*,
|
|
width: int,
|
|
include_paths: bool,
|
|
) -> list[str]:
|
|
counts = Counter(c.status for c in coverages)
|
|
total = len(candidate_tracks)
|
|
|
|
exact = counts["EXACT_RELATED"]
|
|
loose = counts["LOOSE_RELATED"]
|
|
other = counts["EXACT_OTHER_ARTIST"] + counts["LOOSE_OTHER_ARTIST"]
|
|
miss = counts["UNCOVERED"]
|
|
attention = total - exact
|
|
|
|
safe_delete = "YES" if attention == 0 else "NO"
|
|
|
|
lines: list[str] = []
|
|
|
|
lines.append("SUMMARY")
|
|
lines.append(f" albums: {' '.join(album_ids)}")
|
|
lines.append(f" tracks: {total}")
|
|
lines.append(f" searched elsewhere: {len(outside_tracks)} tracks")
|
|
lines.append(
|
|
f" exact={exact} loose={loose} other-artist={other} missing={miss}"
|
|
)
|
|
lines.append(f" safe-delete-by-exact-coverage: {safe_delete} ({attention} need review)")
|
|
lines.append("")
|
|
lines.append("LEGEND")
|
|
lines.append(" EXACT = same title, related artist; usually safe duplicate coverage")
|
|
lines.append(" LOOSE = same base title, related artist; review version/remix/edit")
|
|
lines.append(" OTHER = same title, unrelated artist; not useful deletion coverage")
|
|
lines.append(" MISS = no title match elsewhere")
|
|
lines.append("")
|
|
lines.append("ALBUMS")
|
|
|
|
for album in albums:
|
|
lines.append(" " + album_line(album, max(10, width - 2), music_root, include_paths))
|
|
|
|
return lines
|
|
|
|
|
|
def build_relationship_map(
|
|
coverages: list[TrackCoverage],
|
|
*,
|
|
width: int,
|
|
show_covered: bool,
|
|
) -> list[str]:
|
|
rows = [
|
|
coverage
|
|
for coverage in coverages
|
|
if show_covered or coverage.status != "EXACT_RELATED"
|
|
]
|
|
|
|
lines: list[str] = []
|
|
lines.append("")
|
|
lines.append("RELATIONSHIP MAP")
|
|
|
|
if not rows:
|
|
lines.append(" all candidate tracks have exact related coverage elsewhere")
|
|
return lines
|
|
|
|
hidden_exact = sum(1 for c in coverages if c.status == "EXACT_RELATED")
|
|
|
|
if hidden_exact and not show_covered:
|
|
lines.append(
|
|
f" {hidden_exact} EXACT rows hidden; use --show-covered to include them"
|
|
)
|
|
|
|
lines.append("")
|
|
|
|
status_w = 5
|
|
gap = 2
|
|
|
|
if width >= 100:
|
|
candidate_w = 40
|
|
elif width >= 86:
|
|
candidate_w = 34
|
|
else:
|
|
candidate_w = 28
|
|
|
|
right_w = width - status_w - gap - candidate_w - gap
|
|
right_w = max(24, right_w)
|
|
|
|
header = (
|
|
f"{'ST':<{status_w}}"
|
|
f"{' ' * gap}"
|
|
f"{'CANDIDATE':<{candidate_w}}"
|
|
f"{' ' * gap}"
|
|
f"BEST MATCH / REASON"
|
|
)
|
|
|
|
lines.append(ellipsize(header, width))
|
|
lines.append(ellipsize("-" * width, width))
|
|
|
|
for coverage in rows:
|
|
candidate = coverage.candidate
|
|
best = best_match_for(coverage)
|
|
|
|
left = candidate_ref(candidate)
|
|
|
|
if best is None:
|
|
right = status_reason(coverage.status)
|
|
else:
|
|
right = f"-> {match_ref(best)}"
|
|
|
|
row = (
|
|
f"{status_short(coverage.status):<{status_w}}"
|
|
f"{' ' * gap}"
|
|
f"{ellipsize(left, candidate_w):<{candidate_w}}"
|
|
f"{' ' * gap}"
|
|
f"{ellipsize(right, right_w)}"
|
|
)
|
|
|
|
lines.append(row)
|
|
|
|
return lines
|
|
|
|
|
|
def compact_detail_block(
|
|
coverage: TrackCoverage,
|
|
music_root: Path,
|
|
*,
|
|
width: int,
|
|
include_paths: bool,
|
|
max_matches: int,
|
|
verbose: bool,
|
|
) -> list[str]:
|
|
lines: list[str] = []
|
|
candidate = coverage.candidate
|
|
|
|
lines.append(ellipsize("-" * width, width))
|
|
lines.append(f"{status_short(coverage.status)} | {status_reason(coverage.status)}")
|
|
lines.append(ellipsize(f"CAND {full_track_ref(candidate)}", width))
|
|
|
|
if include_paths:
|
|
lines.append(ellipsize(f"PATH {relpath(candidate.path, music_root)}", width))
|
|
|
|
lines.append(
|
|
"CNT "
|
|
f"exact_rel={len(coverage.exact_related)} "
|
|
f"exact_other={len(coverage.exact_other)} "
|
|
f"loose_rel={len(coverage.loose_related)} "
|
|
f"loose_other={len(coverage.loose_other)}"
|
|
)
|
|
|
|
categories = [
|
|
("EXACT RELATED", coverage.exact_related, "REL"),
|
|
("EXACT OTHER", coverage.exact_other, "OTH"),
|
|
("LOOSE RELATED", coverage.loose_related, "REL"),
|
|
("LOOSE OTHER", coverage.loose_other, "OTH"),
|
|
]
|
|
|
|
for heading, matches, label in categories:
|
|
if not matches:
|
|
continue
|
|
|
|
lines.append(f"{heading}")
|
|
|
|
shown = matches if verbose else matches[:max_matches]
|
|
|
|
for match in shown:
|
|
lines.append(ellipsize(f" {label} {full_track_ref(match)}", width))
|
|
if include_paths:
|
|
lines.append(ellipsize(f" {relpath(match.path, music_root)}", width))
|
|
|
|
hidden = len(matches) - len(shown)
|
|
if hidden > 0:
|
|
lines.append(f" ... {hidden} more omitted")
|
|
|
|
return lines
|
|
|
|
|
|
def full_track_ref(track: Track) -> str:
|
|
return (
|
|
f"A{track.album_id} {track_position(track)} i{track.id} | "
|
|
f"{track.artist} - {track.title} | "
|
|
f"{track.album} | {quality_short(track)}"
|
|
)
|
|
|
|
|
|
def build_details_output(
|
|
coverages: list[TrackCoverage],
|
|
music_root: Path,
|
|
*,
|
|
width: int,
|
|
show_covered: bool,
|
|
include_paths: bool,
|
|
max_matches: int,
|
|
verbose: bool,
|
|
) -> list[str]:
|
|
rows = [
|
|
coverage
|
|
for coverage in coverages
|
|
if show_covered or verbose or coverage.status != "EXACT_RELATED"
|
|
]
|
|
|
|
lines: list[str] = []
|
|
lines.append("")
|
|
lines.append("DETAILS")
|
|
|
|
if not rows:
|
|
lines.append(" no detail rows; all tracks are exact-covered")
|
|
return lines
|
|
|
|
for coverage in rows:
|
|
lines.extend(
|
|
compact_detail_block(
|
|
coverage,
|
|
music_root,
|
|
width=width,
|
|
include_paths=include_paths,
|
|
max_matches=max_matches,
|
|
verbose=verbose,
|
|
)
|
|
)
|
|
|
|
return lines
|
|
|
|
|
|
def write_tsv(path: Path, coverages: list[TrackCoverage], music_root: Path) -> None:
|
|
columns = [
|
|
"candidate_album_id",
|
|
"candidate_item_id",
|
|
"status",
|
|
"status_reason",
|
|
"exact_related_count",
|
|
"exact_other_count",
|
|
"loose_related_count",
|
|
"loose_other_count",
|
|
"candidate_albumartist",
|
|
"candidate_album",
|
|
"candidate_artist",
|
|
"candidate_title",
|
|
"candidate_disc",
|
|
"candidate_track",
|
|
"candidate_format",
|
|
"candidate_bitdepth",
|
|
"candidate_samplerate",
|
|
"candidate_bitrate",
|
|
"candidate_path",
|
|
"best_match_album_id",
|
|
"best_match_item_id",
|
|
"best_match_albumartist",
|
|
"best_match_album",
|
|
"best_match_artist",
|
|
"best_match_title",
|
|
"best_match_format",
|
|
"best_match_bitdepth",
|
|
"best_match_samplerate",
|
|
"best_match_bitrate",
|
|
"best_match_path",
|
|
]
|
|
|
|
with path.open("w", encoding="utf-8", newline="") as f:
|
|
writer = csv.DictWriter(f, fieldnames=columns, delimiter="\t")
|
|
writer.writeheader()
|
|
|
|
for coverage in coverages:
|
|
candidate = coverage.candidate
|
|
best = best_match_for(coverage)
|
|
|
|
row = {
|
|
"candidate_album_id": candidate.album_id,
|
|
"candidate_item_id": candidate.id,
|
|
"status": coverage.status,
|
|
"status_reason": status_reason(coverage.status),
|
|
"exact_related_count": len(coverage.exact_related),
|
|
"exact_other_count": len(coverage.exact_other),
|
|
"loose_related_count": len(coverage.loose_related),
|
|
"loose_other_count": len(coverage.loose_other),
|
|
"candidate_albumartist": candidate.albumartist,
|
|
"candidate_album": candidate.album,
|
|
"candidate_artist": candidate.artist,
|
|
"candidate_title": candidate.title,
|
|
"candidate_disc": candidate.disc,
|
|
"candidate_track": candidate.track,
|
|
"candidate_format": candidate.format,
|
|
"candidate_bitdepth": candidate.bitdepth,
|
|
"candidate_samplerate": candidate.samplerate,
|
|
"candidate_bitrate": candidate.bitrate,
|
|
"candidate_path": relpath(candidate.path, music_root),
|
|
"best_match_album_id": best.album_id if best else "",
|
|
"best_match_item_id": best.id if best else "",
|
|
"best_match_albumartist": best.albumartist if best else "",
|
|
"best_match_album": best.album if best else "",
|
|
"best_match_artist": best.artist if best else "",
|
|
"best_match_title": best.title if best else "",
|
|
"best_match_format": best.format if best else "",
|
|
"best_match_bitdepth": best.bitdepth if best else "",
|
|
"best_match_samplerate": best.samplerate if best else "",
|
|
"best_match_bitrate": best.bitrate if best else "",
|
|
"best_match_path": relpath(best.path, music_root) if best else "",
|
|
}
|
|
|
|
writer.writerow(row)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
audit_dir, music_root, library_dir = default_paths()
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Check whether beets album tracks are covered elsewhere."
|
|
)
|
|
|
|
parser.add_argument(
|
|
"album_ids",
|
|
nargs="+",
|
|
help="Beets album IDs to evaluate, e.g. 454 or 371 372.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--audit-dir",
|
|
default=str(audit_dir),
|
|
help=f"Directory for audit output. Default: {audit_dir}",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--music-root",
|
|
default=str(music_root),
|
|
help=f"Root used for relative paths. Default: {music_root}",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--library-dir",
|
|
default=str(library_dir),
|
|
help=f"Expected library directory. Default: {library_dir}",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--width",
|
|
type=int,
|
|
default=None,
|
|
help="Report width. Default: detected terminal width, capped at 120.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--no-write",
|
|
action="store_true",
|
|
help="Print report but do not write .txt/.tsv files.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--show-covered",
|
|
action="store_true",
|
|
help="Include EXACT rows in the relationship map/details.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--details",
|
|
action="store_true",
|
|
help="Show compact detail blocks for attention rows.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--verbose",
|
|
action="store_true",
|
|
help="Show forensic match blocks. Implies --details.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--paths",
|
|
action="store_true",
|
|
help="Include relative paths in detail/verbose output.",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"--matches",
|
|
type=int,
|
|
default=3,
|
|
help="Max matches per category in details mode. Default: 3.",
|
|
)
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
|
|
album_ids = [str(album_id).strip() for album_id in args.album_ids]
|
|
album_ids = [album_id for album_id in album_ids if album_id]
|
|
|
|
invalid = [album_id for album_id in album_ids if not album_id.isdigit()]
|
|
|
|
if invalid:
|
|
print(
|
|
f"ERROR: album IDs should be numeric. Invalid: {', '.join(invalid)}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
audit_dir = Path(args.audit_dir).resolve()
|
|
music_root = Path(args.music_root).resolve()
|
|
library_dir = Path(args.library_dir).resolve()
|
|
width = terminal_width(args.width)
|
|
|
|
if not library_dir.exists():
|
|
print(
|
|
f"WARNING: expected library directory does not exist: {library_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
albums = [load_album(album_id) for album_id in album_ids]
|
|
|
|
candidate_tracks: list[Track] = []
|
|
|
|
for album_id in album_ids:
|
|
candidate_tracks.extend(load_tracks_for_album(album_id))
|
|
|
|
if not candidate_tracks:
|
|
print(
|
|
f"ERROR: no tracks found for album IDs: {' '.join(album_ids)}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
candidate_album_id_set = set(album_ids)
|
|
|
|
all_tracks = load_all_tracks()
|
|
|
|
outside_tracks = [
|
|
track for track in all_tracks if track.album_id not in candidate_album_id_set
|
|
]
|
|
|
|
candidate_tracks = sorted(
|
|
candidate_tracks,
|
|
key=lambda t: (
|
|
parse_intish(t.album_id),
|
|
natural_track_number(t.disc),
|
|
natural_track_number(t.track),
|
|
t.title.casefold(),
|
|
),
|
|
)
|
|
|
|
coverages = [
|
|
classify_track(candidate, outside_tracks)
|
|
for candidate in candidate_tracks
|
|
]
|
|
|
|
lines: list[str] = []
|
|
|
|
lines.extend(
|
|
build_summary_lines(
|
|
album_ids,
|
|
albums,
|
|
candidate_tracks,
|
|
outside_tracks,
|
|
coverages,
|
|
music_root,
|
|
width=width,
|
|
include_paths=args.paths,
|
|
)
|
|
)
|
|
|
|
lines.extend(
|
|
build_relationship_map(
|
|
coverages,
|
|
width=width,
|
|
show_covered=args.show_covered,
|
|
)
|
|
)
|
|
|
|
if args.details or args.verbose:
|
|
lines.extend(
|
|
build_details_output(
|
|
coverages,
|
|
music_root,
|
|
width=width,
|
|
show_covered=args.show_covered,
|
|
include_paths=args.paths,
|
|
max_matches=args.matches,
|
|
verbose=args.verbose,
|
|
)
|
|
)
|
|
|
|
suffix = "-".join(album_ids)
|
|
txt_path = audit_dir / f"audit.album-coverage-{suffix}.txt"
|
|
tsv_path = audit_dir / f"audit.album-coverage-{suffix}.tsv"
|
|
|
|
if not args.no_write:
|
|
audit_dir.mkdir(parents=True, exist_ok=True)
|
|
txt_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
write_tsv(tsv_path, coverages, music_root)
|
|
|
|
output_lines = list(lines)
|
|
|
|
if not args.no_write:
|
|
output_lines.append("")
|
|
output_lines.append(f"Wrote: {txt_path}")
|
|
output_lines.append(f"Wrote: {tsv_path}")
|
|
|
|
print("\n".join(output_lines))
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|