fix artwork
This commit is contained in:
1 parent
ea1669babf
commit
9ee5a70266
6 files changed
+146
-178
No files matched your search
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
Reference in new issue
Block a user