fix artwork

This commit is contained in:
Nathan Rashleigh committed 2026-06-21 22:27:11 +10:00
1 parent ea1669babf
commit 9ee5a70266
6 files changed
+146 -178

No files matched your search

+7 -4
View File
@@ -59,9 +59,12 @@ def extract_artwork(mp3: Path, usb_root: Path, next_id: int, cache: dict[str, in
abs_small = usb_root / rel_small.lstrip("/") abs_small = usb_root / rel_small.lstrip("/")
abs_large = usb_root / rel_large.lstrip("/") abs_large = usb_root / rel_large.lstrip("/")
abs_small.parent.mkdir(parents=True, exist_ok=True) abs_small.parent.mkdir(parents=True, exist_ok=True)
if not abs_small.exists(): # Always overwrite — the artwork_id↔file mapping is recomputed every
abs_small.write_bytes(_resize_jpeg(data, SMALL)) # export, and a stale a<id>.jpg left over from a previous run would
if not abs_large.exists(): # silently render the wrong cover on the CDJ. The intra-run dedup
abs_large.write_bytes(_resize_jpeg(data, LARGE)) # (cache hit at the top of this function) already prevents wasteful
# rewrites within one export.
abs_small.write_bytes(_resize_jpeg(data, SMALL))
abs_large.write_bytes(_resize_jpeg(data, LARGE))
cache[sha] = aid cache[sha] = aid
return aid, rel_small return aid, rel_small
+1 -1
View File
@@ -316,7 +316,7 @@ def build(input_dir: Path, usb_root: Path, with_playlists: bool = True) -> tuple
logger.warning( logger.warning(
"{} validation issue(s) across {} track(s)", "{} validation issue(s) across {} track(s)",
len(validation_issues), len(validation_issues),
len({(i.artist, i.title) for i in validation_issues}), len({i.track.display_name for i in validation_issues}),
) )
log_issues(validation_issues, report=False) log_issues(validation_issues, report=False)
else: else:
+1 -1
View File
@@ -164,7 +164,7 @@ async def main_async(argv: list[str]) -> int:
) )
for issue in new_issues: for issue in new_issues:
logger.warning( logger.warning(
"{} - {}: {}", issue.artist, issue.title, issue.reason, "{}: {}: {}", issue.track.display_name, issue.field, issue.reason,
) )
issues.extend(new_issues) issues.extend(new_issues)
for pl, _ in affected: for pl, _ in affected:
+1 -1
View File
@@ -261,7 +261,7 @@ class Collection:
bound.info("ok") bound.info("ok")
return return
for i in issues: for i in issues:
bound.warning(f"[{i.playlist}] {i.artist} - {i.title}: {i.reason}") bound.warning(f"{i.track.display_name}: {i.field}: {i.reason}")
# --- indexing ---------------------------------------------------------- # # --- indexing ---------------------------------------------------------- #
+117 -37
View File
@@ -5,21 +5,9 @@ A *collection* is a sequence of `Track`s persisted under
rekordbox USB at `data/usb/`. This dataclass is the bridge: any code that rekordbox USB at `data/usb/`. This dataclass is the bridge: any code that
talks about "a track" in maple should hold one of these. talks about "a track" in maple should hold one of these.
Field semantics: `Track.validate()` is the project's single source of truth for what makes
- Identity-ish fields (`isrc`, `title`, `artists`, `album`) start populated a track "exportable". Every check that used to be scattered across
from whichever source first knows the track — Spotify, a file's existing `maple/validate.py` lives here now; both pipelines call `track.validate()`.
tags, or a manual entry.
- Spotify-only fields (`spotify_track_id`, `release_date`, …) stay empty
for tracks that didn't come through the Spotify path.
- On-disk fields (`path`, `bitrate`, …) are populated once the audio is
actually on disk somewhere.
- Analytical fields (`bpm`, `beats`, …) populate during the audio-analysis
pass and are cached under `<collection>/_analysis/<isrc>.json`.
The existing `maple.model.Track` is a *denormalised pdb row* — it carries
artist_id/album_id/genre_id FKs that only exist in the context of a
specific export. `Track.to_pdb_row(...)` projects this canonical Track
into that shape using caller-supplied FK indices.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,11 +17,25 @@ from pathlib import Path
from maple import model from maple import model
@dataclass
class TrackIssue:
"""A single failed validation criterion, bound to the `Track` it
came from. Repr-friendly via `__str__`."""
track: "Track"
field: str
reason: str
def __str__(self) -> str:
return f"{self.track.display_name}: {self.field}: {self.reason}"
@dataclass @dataclass
class Track: class Track:
"""Canonical track. Defaults intentionally permissive so partially-filled """Canonical track metadata. Every field has a concrete default so the
instances flow through the pipeline; validation (`maple.validate`) type is always populated — "missing" data shows up as `""`/`0`/`False`
decides what counts as "complete enough to export".""" and is caught by `validate()`, not by Optional-handling at every call
site."""
# ---- identity --------------------------------------------------------- # # ---- identity --------------------------------------------------------- #
isrc: str = "" isrc: str = ""
@@ -53,14 +55,18 @@ class Track:
duration_ms: int = 0 duration_ms: int = 0
# ---- on-disk audio --------------------------------------------------- # # ---- on-disk audio --------------------------------------------------- #
path: Path | None = None # current best file location # `path` is the only legitimately-Optional field — a Track may exist
# purely as a Spotify entry before the audio has been downloaded.
path: Path | None = None
file_size: int = 0 file_size: int = 0
bitrate: int = 0 # kbps bitrate: int = 0 # kbps
sample_rate: int = 0 # Hz sample_rate: int = 0 # Hz
sample_depth: int = 0 # bits per sample sample_depth: int = 0 # bits per sample
has_artwork: bool = False # embedded cover-art presence
has_cached_analysis: bool = False # collection/_analysis/<isrc>.json exists
# ---- analytical ------------------------------------------------------ # # ---- analytical ------------------------------------------------------ #
bpm: float | None = None # median-of-intervals tempo bpm: float = 0.0 # 0.0 = unknown
# ---- provenance ------------------------------------------------------ # # ---- provenance ------------------------------------------------------ #
source: str = "" # "spotify", "local", "bandcamp", … source: str = "" # "spotify", "local", "bandcamp", …
@@ -120,10 +126,13 @@ class Track:
) )
@classmethod @classmethod
def from_file(cls, path: Path) -> "Track": def from_file(cls, path: Path, *, analysis_cache_dir: Path | None = None) -> "Track":
"""Construct from on-disk audio via mutagen. Fields not in the file's """Construct from on-disk audio via mutagen.
tags stay at their defaults; the caller can `merge_spotify` later to
fill them in canonically.""" When `analysis_cache_dir` is provided, also sets
`has_cached_analysis` from `<cache_dir>/<isrc>.json` so
`.validate()` can check it without a separate call site.
"""
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
out = cls(path=path, source="local") out = cls(path=path, source="local")
@@ -149,9 +158,9 @@ class Track:
from maple.cli import split_genre_tags from maple.cli import split_genre_tags
out.genres = split_genre_tags(gtag) out.genres = split_genre_tags(gtag)
try: try:
out.bpm = float(first("bpm")) if first("bpm") else None out.bpm = float(first("bpm")) if first("bpm") else 0.0
except ValueError: except ValueError:
out.bpm = None out.bpm = 0.0
try: try:
out.track_number = int(first("tracknumber").split("/")[0]) out.track_number = int(first("tracknumber").split("/")[0])
except ValueError: except ValueError:
@@ -167,14 +176,20 @@ class Track:
out.file_size = path.stat().st_size out.file_size = path.stat().st_size
except OSError: except OSError:
pass pass
# Presence flags so validate() can decide without extra plumbing.
from maple.collection.validate import has_embedded_art
out.has_artwork = has_embedded_art(path)
if analysis_cache_dir is not None and out.isrc:
from maple.analyze import cache_path_for
cp = cache_path_for(analysis_cache_dir, out.isrc)
out.has_cached_analysis = cp is not None and cp.exists()
return out return out
# ---- enrichment ------------------------------------------------------ # # ---- enrichment ------------------------------------------------------ #
def merge_spotify(self, d: dict) -> "Track": def merge_spotify(self, d: dict) -> "Track":
"""Overlay Spotify-canonical fields onto this Track *in place* — used """Overlay Spotify-canonical fields onto this Track in place. Empty
when we have a local file but Spotify carries better identity / genre Spotify fields are NOT applied (no clobbering)."""
/ release date. Empty Spotify fields are NOT applied (no clobbering)."""
if d.get("title"): if d.get("title"):
self.title = d["title"] self.title = d["title"]
if d.get("artists"): if d.get("artists"):
@@ -207,10 +222,81 @@ class Track:
def merge_analysis(self, analysis: dict) -> "Track": def merge_analysis(self, analysis: dict) -> "Track":
"""Merge fields from `maple.analyze.analyze_audio`'s output.""" """Merge fields from `maple.analyze.analyze_audio`'s output."""
if analysis.get("bpm") is not None: if analysis.get("bpm"):
self.bpm = float(analysis["bpm"]) self.bpm = float(analysis["bpm"])
return self return self
# ---- validation ------------------------------------------------------ #
def validate(self) -> list[TrackIssue]:
"""Return the list of failed criteria. Empty list = ready to export.
The single place every "is this track exportable?" check lives.
Both `just collection` and `just export` call this — there is no
separate validation entrypoint.
"""
issues: list[TrackIssue] = []
def fail(field_name: str, reason: str) -> None:
issues.append(TrackIssue(self, field_name, reason))
# Required text tags — the spotify-canonical set.
if not self.title:
fail("title", "tag missing")
if not self.artists:
fail("artist", "tag missing")
if not self.album:
fail("album", "tag missing")
if not self.release_date:
fail("date", "tag missing")
if not self.genres:
fail("genre", "tag missing")
if not self.isrc:
fail("isrc", "tag missing")
# Analytical.
if self.bpm <= 0:
fail("bpm", "tag missing")
# On-disk.
if not self.has_artwork:
fail("artwork", "no embedded cover art")
if not self.has_cached_analysis:
fail("analysis", "no cached analysis")
# Sanity — only check when we have something to check.
if self.path is not None and self.duration_ms <= 0:
fail("duration", "≤ 0s — corrupt file or unreadable container")
return issues
def compare(self, canonical: "Track", *, fuzzy_threshold: float = 0.5) -> list[TrackIssue]:
"""Diff this Track against a canonical one (typically a Spotify
`from_spotify` Track). Returns mismatch issues — empty when the
two agree on every populated field."""
from maple.collection.index import first_artist, fuzzy_ratio, norm, norm_loose
out: list[TrackIssue] = []
def diff(field_name: str, sv: object, fv: object) -> None:
out.append(TrackIssue(self, field_name, f"spotify={sv!r} file={fv!r}"))
sp_artist = ", ".join(canonical.artists)
f_artist = ", ".join(self.artists)
if sp_artist and fuzzy_ratio(norm(first_artist(sp_artist)), norm(first_artist(f_artist))) < fuzzy_threshold:
diff("artist", sp_artist, f_artist)
if canonical.title and fuzzy_ratio(norm_loose(canonical.title), norm_loose(self.title)) < fuzzy_threshold:
diff("title", canonical.title, self.title)
if canonical.album and self.album and fuzzy_ratio(norm_loose(canonical.album), norm_loose(self.album)) < fuzzy_threshold:
diff("album", canonical.album, self.album)
elif canonical.album and not self.album:
diff("album", canonical.album, self.album)
if canonical.isrc and self.isrc and canonical.isrc.upper() != self.isrc.upper():
diff("isrc", canonical.isrc, self.isrc)
if canonical.release_date and self.release_date and canonical.release_date[:4] != self.release_date[:4]:
diff("date", canonical.release_date, self.release_date)
if canonical.duration_ms and self.duration_ms:
delta = abs(canonical.duration_ms - self.duration_ms) / 1000.0
if delta > 5.0:
diff("duration", f"{canonical.duration_s}s", f"{self.duration_s}s")
return out
# ---- export adapter -------------------------------------------------- # # ---- export adapter -------------------------------------------------- #
def to_pdb_row( def to_pdb_row(
@@ -226,13 +312,7 @@ class Track:
date_added: str = "", date_added: str = "",
tempo_x100: int | None = None, tempo_x100: int | None = None,
) -> model.Track: ) -> model.Track:
"""Project this canonical Track into the denormalised pdb row shape. """Project this canonical Track into the denormalised pdb row shape."""
FK ids (`artist_id`, `album_id`, …) and the per-export `file_path` /
`analyze_path` are caller-supplied because they only exist in the
context of a specific export. `tempo_x100` defaults to `round(bpm *
100)` when we have a BPM; pass through otherwise.
"""
if tempo_x100 is None: if tempo_x100 is None:
tempo_x100 = round(self.bpm * 100) if self.bpm else 0 tempo_x100 = round(self.bpm * 100) if self.bpm else 0
return model.Track( return model.Track(
+19 -134
View File
@@ -1,72 +1,22 @@
"""Cross-cutting per-track validator. """Thin compatibility layer over `Track.validate()`.
A track is "valid" iff every export criterion holds. Both `just collection` Validation logic lives on `maple.track.Track` itself — this module just
(`Collection.process_track`) and `just export` (`cli.build`) must invoke re-exports `TrackIssue` and offers helpers so callers don't all need to
this — there is no separate "collection-only" or "export-only" validation construct `Track` objects inline.
in maple. Add new criteria in one place; both pipelines pick them up.
Criteria (extend as they emerge): The `validate_track(path, ...)` wrapper is kept for the few sites that
- ffprobe-decodable hand around `Path`s rather than `Track`s; new code should prefer
- duration > 0 `Track.from_file(path).validate()`.
- title / artist / album / date / genre / isrc / bpm tags present
- embedded cover art present
- analytical BPM cached or fresh (cache key = ISRC)
- if `spotify_track` supplied: tags within fuzzy match of Spotify truth
`validate_track` returns a list of `TrackIssue`. Empty list = passing.
Callers decide whether to warn-and-continue (the default) or fail.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
from mutagen import File as MutagenFile
from maple.analyze import cache_path_for from maple.track import Track, TrackIssue
from maple.collection.index import (
first_artist,
fuzzy_ratio,
norm,
norm_loose,
)
from maple.collection.validate import (
DURATION_TOLERANCE_S,
ffprobe_ok,
file_duration,
has_embedded_art,
)
__all__ = ["Track", "TrackIssue", "validate_track", "log_issues"]
@dataclass
class TrackIssue:
"""A single validation finding.
Shape is (playlist, artist, title, reason) — `reason` is a
`"<field>: <detail>"` string so structured fields and free-text
descriptions share one container.
"""
playlist: str
artist: str
title: str
reason: str
REQUIRED_TAGS = ("title", "artist", "album", "date", "genre", "bpm", "isrc")
def _file_tags(path: Path) -> dict[str, str] | None:
try:
audio = MutagenFile(path, easy=True)
except Exception as e:
logger.warning("could not open {} for validation: {}", path.name, e)
return None
if audio is None or audio.tags is None:
return {k: "" for k in REQUIRED_TAGS}
tags = audio.tags
return {k: (tags.get(k) or [""])[0] if hasattr(tags, "get") else "" for k in REQUIRED_TAGS}
def validate_track( def validate_track(
@@ -76,87 +26,22 @@ def validate_track(
analysis_cache_dir: Path | None = None, analysis_cache_dir: Path | None = None,
playlist: str = "", playlist: str = "",
) -> list[TrackIssue]: ) -> list[TrackIssue]:
"""Run every criterion against `path`. Returns the list of failed checks. """Construct a `Track` from `path` and run its validator.
Optional context broadens the checks: `playlist` is unused — kept in the signature for API compatibility
- `spotify_track`: enables fuzzy tag comparison against Spotify truth. with callers that still pass it.
- `analysis_cache_dir`: enables the "analysis cached" check via ISRC.
- `playlist`: tags every issue with playlist context (collection flow).
""" """
tags = _file_tags(path) del playlist # not used; TrackIssue carries the track now
if tags is None: track = Track.from_file(path, analysis_cache_dir=analysis_cache_dir)
return [TrackIssue(playlist, "", path.name, "tags: could not read file")] issues = track.validate()
artist = tags.get("artist") or (
", ".join(spotify_track.get("artists") or []) if spotify_track else ""
)
title = tags.get("title") or (spotify_track.get("title") if spotify_track else "") or path.stem
issues: list[TrackIssue] = []
def fail(field: str, detail: str) -> None:
issues.append(TrackIssue(playlist, artist, title, f"{field}: {detail}"))
# ---- audio integrity --------------------------------------------------- #
if not ffprobe_ok(path):
fail("decode", f"ffprobe rejected {path.name}")
duration = file_duration(path)
if duration <= 0:
fail("duration", "≤ 0s — corrupt file or unreadable container")
# ---- required tags ----------------------------------------------------- #
for key in REQUIRED_TAGS:
if not (tags.get(key) or "").strip():
fail(key, "tag missing")
# ---- artwork ----------------------------------------------------------- #
if not has_embedded_art(path):
fail("artwork", "no embedded cover art")
# ---- analysis cached --------------------------------------------------- #
isrc = (tags.get("isrc") or "").strip()
if analysis_cache_dir is not None and isrc:
cp = cache_path_for(analysis_cache_dir, isrc)
if cp is None or not cp.exists():
fail("analysis", f"no cached analysis at {cp}")
# ---- Spotify reconciliation -------------------------------------------- #
if spotify_track is not None: if spotify_track is not None:
sp_artist = ", ".join(spotify_track.get("artists") or []) issues.extend(track.compare(Track.from_spotify(spotify_track)))
sp_title = (spotify_track.get("title") or "").strip()
sp_album = (spotify_track.get("album") or "").strip()
sp_isrc = (spotify_track.get("isrc") or "").strip()
sp_duration = (spotify_track.get("duration_ms") or 0) / 1000.0
sp_date = (spotify_track.get("release_date") or "").strip()
f_artist = tags.get("artist") or ""
f_title = tags.get("title") or ""
f_album = tags.get("album") or ""
f_isrc = tags.get("isrc") or ""
f_date = tags.get("date") or ""
if sp_artist and fuzzy_ratio(norm(first_artist(sp_artist)), norm(first_artist(f_artist))) < 0.5:
fail("artist", f"spotify={sp_artist!r} file={f_artist!r}")
if sp_title and fuzzy_ratio(norm_loose(sp_title), norm_loose(f_title)) < 0.5:
fail("title", f"spotify={sp_title!r} file={f_title!r}")
if sp_album and f_album and fuzzy_ratio(norm_loose(sp_album), norm_loose(f_album)) < 0.5:
fail("album", f"spotify={sp_album!r} file={f_album!r}")
elif sp_album and not f_album:
fail("album", f"spotify={sp_album!r} file=''")
if sp_isrc and f_isrc and sp_isrc.upper() != f_isrc.upper():
fail("isrc", f"spotify={sp_isrc!r} file={f_isrc!r}")
if sp_date and f_date and sp_date[:4] != f_date[:4]:
fail("date", f"spotify={sp_date!r} file={f_date!r}")
if sp_duration > 0 and abs(duration - sp_duration) > DURATION_TOLERANCE_S:
fail("duration", f"file {duration:.1f}s vs spotify {sp_duration:.1f}s")
return issues return issues
def log_issues(issues: list[TrackIssue], *, report: bool = True) -> None: def log_issues(issues: list[TrackIssue], *, report: bool = True) -> None:
"""Emit every issue via loguru.warning. When `report=True`, also bind """Emit every issue via `loguru.warning`. With `report=True`, also
`extra["report"]` so any attached file-sink (Collection.attach_report_sink binds `extra["report"]=True` so a report-file sink can capture them."""
or the export-side equivalent) captures the line."""
bound = logger.bind(report=True) if report else logger bound = logger.bind(report=True) if report else logger
for i in issues: for i in issues:
prefix = f"[{i.playlist}] " if i.playlist else "" bound.warning("{}", i)
bound.warning("{}{} - {}: {}", prefix, i.artist, i.title, i.reason)