mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
* fix(sloppak): the full mix is a stem — drop the invented `original_audio` key (#933) Core read, served, and depended on `original_audio:` — a top-level manifest key this repo invented in #583 that the feedpak spec never defined. The format already had a home for the pre-separation mixdown: it is a stem. feedpak 1.15.0 (feedpak-spec#53) RESERVES the id `full` for it, so read it from there. The key existed to work around a bug in our own reader. The packer's comment said so plainly: "we must NOT list the full mix as a playable stem — the player sums every entry in `stems` and does not gate playback on `default`, so a listed full mix plays on top of the stems". Faced with a reader that would double the song, the packer put the mixdown outside `stems` and invented a key to point at it. The fix belongs in the reader, and that is what this is. load_song() now partitions the stem list: `full` comes out as LoadedSloppak.full_mix, the instruments stay in .stems. Nothing that sums stems or draws one fader per stem can see the mixdown, so retaining it is safe — which is what lets the packer put it where the format says it goes. - ws_highway: `song_info` gains full_mix_url / has_full_mix. The old original_audio_url / has_original_audio remain as deprecated aliases for one release so an older stems plugin keeps working (#945). - `stems` on the wire, and stem_ids / stem_count in the library index, are now INSTRUMENT stems only — a separated pack that retains its mixdown no longer advertises a bogus "full" chip or an inflated stem count. - enrichment: fingerprint against the mixdown wherever it lives. This widens coverage — _song_audio_file() previously returned None for any pack without the invented key, so fingerprinting silently did nothing for nearly every pack. - sloppak: `original_audio:` is still READ as a deprecated fallback, because every pack in the wild carries it and would otherwise lose its pristine mix. tools/migrate_full_mix_stem.py rewrites those packs into the spec shape (original/full.ogg -> stems/full.ogg, add the `full` stem at default:off, drop the key); the fallback and the aliases die with #945. The spec gate keeps the debt honest: the grandfather entry now tracks #945, and the gate fails if it goes stale. Verified: spec gate OK (4/4, incl. ingesting the spec's new example pack that retains `full`); 2493 python tests, 995 js tests; migrator round-tripped over real packs from the library and the results pass the spec's reference validator. * fix(migrate): discover directory-form packs instead of silently skipping them iter_packs() searched only files, so a directory-form pack (`song.sloppak/`, the authoring shape) was walked INTO and never yielded — silently missed by a run that's meant to be exhaustive. Discover suffix-named directories too (yielded whole, not descended into), and route packs through migrate_pack/verify_pack. Directory packs are REPORTED as `dir-form-unsupported`, not rewritten in place: a single-file pack is replaced atomically (a fully-built temp archive swapped in with one os.replace), but a populated directory can't be swapped that way, so an interrupted in-place rewrite could leave an authoring pack half-migrated. The status is a problem status, so it counts against the run's exit code and shows in the summary — the operator re-packs or migrates it as a `.feedpak` instead of it vanishing from the report. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> * fix(migrate): verify requires an explicit `off` on a retained full mix verify_zip accepted any non-truthy `default` on a multi-stem `full` (missing, empty, boolean, `false`/`no`/`0`, malformed) as "ok". But core defaults an ABSENT `default` to True — ON (lib/sloppak.py: `s.get("default", True)`) — and treats an empty/unrecognized string as ON too, so a migrated-shape pack whose `full` stem has a missing or blank default beside instrument stems would actually play the mixdown on open and double the song. verify was certifying that as safe. Require an explicit normalized `off` beside instrument stems: `on`-ish values are reported `full-stem-default-on` (actively plays), everything that is not a normalized `off` is reported `full-stem-default-not-off`. The migrator already writes the literal `off`, so its own output is unaffected; this also certifies the pack is in the tool's canonical, most-portable shape. The len>1 gate is kept, so a sole `full` stem (which IS the audio) is not policed. Adds parametrized coverage for missing / empty / boolean / off-ish / malformed defaults, and a sole-full-stem case. Addresses a CodeRabbit review finding. Signed-off-by: Kris Anderson <topkoa@gmail.com> --------- Signed-off-by: Kris Anderson <topkoa@gmail.com> Co-authored-by: Kris Anderson <topkoa@gmail.com>
1123 lines
49 KiB
Python
1123 lines
49 KiB
Python
"""The metadata-enrichment subsystem: MusicBrainz/AcoustID/Cover-Art-Archive
|
||
transport, the match scorer glue, and the background enrichment worker (R3).
|
||
|
||
Moved verbatim out of server.py. The only changes are the seam reads — the
|
||
singletons and the two shared art helpers that stay in server.py are reached
|
||
through appstate at call time:
|
||
|
||
meta_db -> appstate.meta_db
|
||
CONFIG_DIR -> appstate.config_dir
|
||
SLOPPAK_CACHE_DIR -> appstate.sloppak_cache_dir
|
||
ART_CACHE_DIR -> appstate.art_cache_dir
|
||
_song_pack_art_exists / _art_override_paths -> appstate.<callable>
|
||
the User-Agent VERSION lookup: Path(__file__).parent -> .resolve().parents[1]
|
||
(lib/enrichment.py -> app root; VERSION ships at the app root everywhere).
|
||
|
||
server.py drives the worker through the public names here (import enrichment;
|
||
enrichment._kick_enrich(), enrichment._enrich_thread, the routes call the
|
||
matchers/transport). Tests that faked the network on `server` now patch the
|
||
same names on `enrichment` (the module attribute is resolved at call time, so a
|
||
setattr(enrichment, "_mb_http_get", ...) reaches both the routes and the
|
||
worker's internal callers).
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from fastapi.responses import JSONResponse
|
||
|
||
import acoustid_match
|
||
import appstate
|
||
import loosefolder as loosefolder_mod
|
||
import mb_match
|
||
import sloppak as sloppak_mod
|
||
from appconfig import _load_config
|
||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||
from env_compat import env_flag_compat as _env_flag
|
||
from metadata_db import _artist_title_from_filename
|
||
|
||
import logging
|
||
log = logging.getLogger("feedBack.server")
|
||
|
||
_enrich_thread: threading.Thread | None = None
|
||
|
||
|
||
_enrich_kick_lock = threading.Lock()
|
||
|
||
|
||
_enrich_pending_pass = False
|
||
|
||
|
||
# processed = phase-1 stubs stamped this pass (legacy field). total/matched =
|
||
# the phase-2 MATCHING progress the "Refresh Metadata" batch bar reads (the
|
||
# slow, rate-limited part worth a progress readout); current = the song being
|
||
# matched right now, which drives the per-tile "working" badge.
|
||
_enrich_status = {"running": False, "processed": 0, "last_pass_at": None,
|
||
"total": 0, "matched": 0, "current": None}
|
||
|
||
|
||
# Cooperative cancel for the Stop button: the matching/art loops check it
|
||
# between songs (an in-flight ≤1/s lookup can't be interrupted, but no new one
|
||
# is started). Set by /api/enrichment/cancel, cleared when a fresh pass kicks.
|
||
_enrich_cancel = threading.Event()
|
||
|
||
|
||
# Minimum spacing between EXTERNAL lookups (design: ≤1 req/s + local cache).
|
||
_ENRICH_MIN_INTERVAL = 1.1
|
||
|
||
|
||
_enrich_last_fetch = 0.0
|
||
|
||
|
||
# Serializes throttling across the background daemon thread AND the sync
|
||
# /api/enrichment/search route (FastAPI runs sync routes in a threadpool).
|
||
_enrich_throttle_lock = threading.Lock()
|
||
|
||
|
||
def _enrichment_art_dir() -> Path:
|
||
"""The size-capped art cache dir (populated by the Cover Art slice; the
|
||
LRU cap policy lands with it). Under appstate.config_dir so Settings backup/restore
|
||
and the docker volume already cover it."""
|
||
d = appstate.config_dir / "art_cache"
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
return d
|
||
|
||
|
||
def _enrich_throttle():
|
||
"""Block until an external lookup is allowed. Matchers MUST call this
|
||
before every network request — and must NOT hold appstate.meta_db._lock across the
|
||
request (fetch outside the lock, write inside)."""
|
||
global _enrich_last_fetch
|
||
# Hold the lock across the read, sleep, and write so concurrent callers
|
||
# serialize instead of all reading the same stale timestamp and firing
|
||
# together (which would burst past MusicBrainz's 1 req/s limit).
|
||
with _enrich_throttle_lock:
|
||
wait = _ENRICH_MIN_INTERVAL - (time.monotonic() - _enrich_last_fetch)
|
||
if wait > 0:
|
||
time.sleep(wait)
|
||
_enrich_last_fetch = time.monotonic()
|
||
|
||
|
||
class EnrichTransportError(Exception):
|
||
"""Network-level enrichment failure — offline, DNS, MusicBrainz down or
|
||
rate-limiting. Pauses the current pass (rows keep their state and no
|
||
attempt is consumed); the next kick (scan-complete / the 5-min periodic
|
||
rescan) retries naturally."""
|
||
|
||
|
||
_MB_API_ROOT = "https://musicbrainz.org/ws/2"
|
||
|
||
|
||
_enrich_ua_cache: str | None = None
|
||
|
||
|
||
def _enrich_user_agent() -> str:
|
||
"""MusicBrainz etiquette requires a real identifying User-Agent
|
||
(app/version + contact URL); anonymous defaults get throttled/blocked."""
|
||
global _enrich_ua_cache
|
||
if _enrich_ua_cache is None:
|
||
version = "unknown"
|
||
try:
|
||
vf = Path(__file__).resolve().parents[1] / "VERSION"
|
||
if vf.exists():
|
||
version = vf.read_text().strip() or "unknown"
|
||
except (OSError, UnicodeDecodeError):
|
||
pass
|
||
_enrich_ua_cache = f"feedBack/{version} (https://github.com/got-feedback/feedBack)"
|
||
return _enrich_ua_cache
|
||
|
||
|
||
def _enrich_network_enabled() -> bool:
|
||
"""False = the matcher runs local-only (hash stamping, cache copies) and
|
||
never opens a socket. FEEDBACK_ENRICH_OFFLINE is the explicit user
|
||
kill-switch (privacy / air-gapped installs); FEEDBACK_SKIP_STARTUP_TASKS
|
||
marks the test/CI environment, where pytest must never reach the network
|
||
no matter what a test triggers."""
|
||
return not (_env_flag("FEEDBACK_ENRICH_OFFLINE")
|
||
or _env_flag("FEEDBACK_SKIP_STARTUP_TASKS"))
|
||
|
||
|
||
def _mb_http_get(path: str, params: dict) -> dict | None:
|
||
"""The ONE place enrichment touches the network (tests fake exactly this
|
||
seam). Throttled (≤1 req/s via _enrich_throttle), identified (real
|
||
User-Agent), offline-guarded. Returns the parsed JSON body, or None for
|
||
a 404 lookup; raises EnrichTransportError for anything network-shaped.
|
||
NEVER call this while holding appstate.meta_db._lock — fetch outside, write
|
||
inside."""
|
||
if not _enrich_network_enabled():
|
||
raise EnrichTransportError("enrichment network disabled")
|
||
import requests # declared in requirements.txt; lazy so tests never need it
|
||
_enrich_throttle()
|
||
try:
|
||
resp = requests.get(
|
||
f"{_MB_API_ROOT}/{path.lstrip('/')}",
|
||
params={**params, "fmt": "json"},
|
||
headers={"User-Agent": _enrich_user_agent()},
|
||
timeout=10,
|
||
)
|
||
except requests.RequestException as e:
|
||
raise EnrichTransportError(str(e)) from e
|
||
if resp.status_code == 404:
|
||
return None
|
||
if resp.status_code == 503:
|
||
# MusicBrainz signals rate-limit pressure with 503 — back the whole
|
||
# pass off rather than hammering on.
|
||
raise EnrichTransportError("musicbrainz 503 (rate limited)")
|
||
if resp.status_code != 200:
|
||
raise EnrichTransportError(f"musicbrainz HTTP {resp.status_code}")
|
||
try:
|
||
return resp.json()
|
||
except ValueError as e:
|
||
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
||
|
||
|
||
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||
"""Text search (tier 2–4): denoised Lucene query over /recording. The strict
|
||
query drops live-only recordings and the ranker rewards the studio take, so a
|
||
slightly larger default result set gives the re-ranker room to surface the
|
||
canonical version.
|
||
|
||
Runs the strict field-phrase query first (high precision); if it finds
|
||
nothing, retries ONCE with a loose term query. The strict phrase only matches
|
||
MusicBrainz's *primary* artist/title, so a recording stored under a non-Latin
|
||
primary name (大橋純子) whose romanized form ("Junko Ohashi") is only an alias
|
||
is invisible to it — the loose query searches aliases and rescues it. The
|
||
retry spends a second throttled request only on a miss; results are re-scored
|
||
by rank_candidates, so the looser recall doesn't lower match quality
|
||
(auto-accept still needs the per-field floors)."""
|
||
query = mb_match.build_recording_query(artist, title)
|
||
cands: list[dict] = []
|
||
if query:
|
||
body = _mb_http_get("recording", {"query": query, "limit": limit})
|
||
cands = mb_match.parse_search_response(body or {})
|
||
if not cands:
|
||
loose = mb_match.build_recording_query(artist, title, loose=True)
|
||
if loose and loose != query:
|
||
body = _mb_http_get("recording", {"query": loose, "limit": limit})
|
||
cands = mb_match.parse_search_response(body or {})
|
||
return cands
|
||
|
||
|
||
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
|
||
"""Text search /release-group for the Change-cover picker: albums matching a
|
||
free query, each mapped to its Cover Art Archive front thumb. One request;
|
||
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
|
||
cover be found even for a song with no metadata match (the city-pop pile)."""
|
||
q = (query or "").strip()
|
||
if not q:
|
||
return []
|
||
body = _mb_http_get("release-group", {"query": q, "limit": limit})
|
||
out: list[dict] = []
|
||
for rg in ((body or {}).get("release-groups") or []):
|
||
rid = rg.get("id")
|
||
if not rid:
|
||
continue
|
||
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
|
||
# collaborations) — reconstruct the credited name.
|
||
artist = "".join(
|
||
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
|
||
for c in (rg.get("artist-credit") or [])
|
||
).strip()
|
||
title = rg.get("title") or ""
|
||
year = (rg.get("first-release-date") or "")[:4]
|
||
out.append({
|
||
"id": rid,
|
||
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
|
||
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
|
||
})
|
||
return out
|
||
|
||
|
||
_ACOUSTID_MAX_UPLOAD_BYTES = 256 * 1024 * 1024 # 256 MB — an uncompressed master
|
||
# Slack for the multipart envelope on the Content-Length pre-parse guard, shared
|
||
# by the AcoustID-identify route (server.py) and the song-upload route
|
||
# (routers/song.py). The real per-file cap is the streaming check downstream.
|
||
_MULTIPART_OVERHEAD_SLACK = 1024 * 1024 # 1 MiB
|
||
|
||
|
||
def _fpcalc_bin() -> str | None:
|
||
"""Locate the Chromaprint `fpcalc` binary: $FPCALC override, else PATH."""
|
||
import shutil
|
||
cand = os.environ.get("FPCALC")
|
||
if cand and Path(cand).exists():
|
||
return cand
|
||
return shutil.which("fpcalc")
|
||
|
||
|
||
def _acoustid_settings() -> "tuple[bool, str]":
|
||
"""(enabled, api_key) for AcoustID, resolved from settings with an env-var
|
||
fallback for the key. Opt-in: `acoustid_enabled` defaults off. The key lives
|
||
in settings so a user can set it themselves in the UI; $ACOUSTID_API_KEY is a
|
||
server-wide fallback for a headless deploy."""
|
||
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||
enabled = cfg.get("acoustid_enabled", False) is True
|
||
key = cfg.get("acoustid_api_key")
|
||
if not isinstance(key, str) or not key.strip():
|
||
key = os.environ.get("ACOUSTID_API_KEY", "")
|
||
return enabled, (key or "").strip()
|
||
|
||
|
||
def _acoustid_available() -> bool:
|
||
"""True only when the user opted in, a key is set (settings or env), the
|
||
network is on, AND fpcalc exists."""
|
||
enabled, key = _acoustid_settings()
|
||
return (enabled
|
||
and _enrich_network_enabled()
|
||
and acoustid_match.is_configured(key)
|
||
and _fpcalc_bin() is not None)
|
||
|
||
|
||
def _fpcalc(path: str) -> "tuple[int, str] | None":
|
||
"""Fingerprint a local audio file → (duration_seconds, fingerprint). None on
|
||
any failure (missing binary/file, decode error, timeout)."""
|
||
binp = _fpcalc_bin()
|
||
if not binp or not Path(path).exists():
|
||
return None
|
||
import subprocess
|
||
import json as _json
|
||
try:
|
||
pr = subprocess.run([binp, "-json", str(path)],
|
||
capture_output=True, timeout=30)
|
||
except Exception:
|
||
return None
|
||
if pr.returncode != 0:
|
||
return None
|
||
try:
|
||
data = _json.loads(pr.stdout.decode("utf-8", "replace"))
|
||
dur = int(round(float(data.get("duration"))))
|
||
fp = str(data.get("fingerprint") or "")
|
||
except Exception:
|
||
return None
|
||
if not fp or dur <= 0:
|
||
return None
|
||
return dur, fp
|
||
|
||
|
||
def _acoustid_lookup(duration: int, fingerprint: str) -> list[dict]:
|
||
"""Look a fingerprint up on AcoustID → candidate dicts (mb_match shape).
|
||
Throttled + offline-guarded like the MusicBrainz path. [] when unavailable
|
||
or no hit; raises EnrichTransportError for network-shaped failures."""
|
||
_, key = _acoustid_settings()
|
||
if not key or not _enrich_network_enabled():
|
||
return []
|
||
import requests
|
||
_enrich_throttle()
|
||
try:
|
||
# POST, not GET: a fingerprint is multi-KB (a 3.5-min track is ~3.5k
|
||
# chars), so a GET crams it into the URL and a long song overflows the
|
||
# server's URL limit → a spurious failure. AcoustID accepts the same
|
||
# params form-encoded in the body.
|
||
resp = requests.post(
|
||
f"{acoustid_match.ACOUSTID_API_ROOT}/lookup",
|
||
data={
|
||
"client": key, "format": "json",
|
||
"meta": acoustid_match.LOOKUP_META,
|
||
"duration": duration, "fingerprint": fingerprint,
|
||
},
|
||
headers={"User-Agent": _enrich_user_agent()},
|
||
timeout=10,
|
||
)
|
||
except requests.RequestException as e:
|
||
raise EnrichTransportError(str(e)) from e
|
||
if resp.status_code == 429:
|
||
raise EnrichTransportError("acoustid 429 (rate limited)")
|
||
if resp.status_code != 200:
|
||
raise EnrichTransportError(f"acoustid HTTP {resp.status_code}")
|
||
try:
|
||
body = resp.json()
|
||
except ValueError as e:
|
||
raise EnrichTransportError("bad JSON from acoustid") from e
|
||
return acoustid_match.parse_lookup_response(body)
|
||
|
||
|
||
def _identify_by_fingerprint(path: str) -> list[dict]:
|
||
"""fpcalc + AcoustID lookup for a local audio file. [] if fingerprinting is
|
||
unavailable, the file can't be read, or nothing matched. Available to the
|
||
library-enrichment pipeline as well as the /identify endpoint."""
|
||
if not _acoustid_available():
|
||
return []
|
||
fp = _fpcalc(path)
|
||
if not fp:
|
||
return []
|
||
return _acoustid_lookup(fp[0], fp[1])
|
||
|
||
|
||
def _acoustid_gate() -> "JSONResponse | None":
|
||
"""Shared availability gate for the identify endpoints: None when ready,
|
||
else a 412 needs_setup (opt-in off / no key → the UI re-prompts) or a 503
|
||
(set up but fpcalc/network missing). Never lets a caller pretend a
|
||
fingerprint ran."""
|
||
if _acoustid_available():
|
||
return None
|
||
enabled, key = _acoustid_settings()
|
||
if not enabled or not key:
|
||
return JSONResponse(
|
||
{"error": "audio fingerprinting not set up", "needs_setup": True,
|
||
"detail": "Turn on AcoustID and add a free API key to identify by audio — "
|
||
"it reads the recording itself, far more reliable than text search."},
|
||
status_code=412)
|
||
return JSONResponse(
|
||
{"error": "audio fingerprinting unavailable", "needs_setup": False,
|
||
"detail": "the fpcalc (Chromaprint) binary was not found on the server"},
|
||
status_code=503)
|
||
|
||
|
||
def _song_audio_file(filename: str) -> "str | None":
|
||
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
|
||
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
|
||
when the song can't be found or carries no mixdown (a pack that kept only its
|
||
separated stems — an acoustic fingerprint of one re-summed from them would not
|
||
match the recording, so we decline rather than submit a lossy reconstruction).
|
||
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
|
||
read outside DLC_DIR / the pack."""
|
||
dlc = _get_dlc_dir()
|
||
if not dlc:
|
||
return None
|
||
resolved = _resolve_dlc_path(dlc, filename)
|
||
if resolved is None or not resolved.exists():
|
||
return None
|
||
if sloppak_mod.is_sloppak(resolved):
|
||
try:
|
||
canon = resolved.relative_to(dlc.resolve()).as_posix()
|
||
except ValueError:
|
||
return None
|
||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
|
||
# fingerprinting wants it even when it is the pack's ONLY stem — a
|
||
# single-mix pack is exactly the master audio we want to fingerprint —
|
||
# so this asks find_full_mix() rather than partition_stems().
|
||
stems = manifest.get("stems") or []
|
||
full = sloppak_mod.find_full_mix(
|
||
[s for s in stems if isinstance(s, dict)]
|
||
)
|
||
rel = full.get("file") if full else None
|
||
# DEPRECATED fallback: packs written before the spec reserved `full` put
|
||
# the mixdown behind a top-level `original_audio:` key instead (#933).
|
||
if not isinstance(rel, str) or not rel.strip():
|
||
rel = manifest.get("original_audio")
|
||
if not isinstance(rel, str) or not rel.strip():
|
||
return None
|
||
src = sloppak_mod.get_cached_source_dir(canon)
|
||
if src is None:
|
||
try:
|
||
src = sloppak_mod.resolve_source_dir(canon, dlc, appstate.sloppak_cache_dir)
|
||
except Exception:
|
||
return None
|
||
target = (src / rel.strip()).resolve()
|
||
try:
|
||
target.relative_to(src.resolve())
|
||
except ValueError:
|
||
return None
|
||
return str(target) if target.is_file() else None
|
||
try:
|
||
audio = loosefolder_mod.find_audio(resolved)
|
||
except Exception:
|
||
audio = None
|
||
return str(audio) if audio and Path(str(audio)).is_file() else None
|
||
|
||
|
||
def _mb_lookup_recording(mbid: str) -> dict | None:
|
||
"""Direct lookup for a manifest-carried recording MBID (tier 0)."""
|
||
body = _mb_http_get(
|
||
f"recording/{mbid}",
|
||
{"inc": "artist-credits+releases+release-groups+isrcs+genres"})
|
||
return mb_match.parse_recording_doc(body) if body else None
|
||
|
||
|
||
def _mb_lookup_isrc(isrc: str) -> list[dict]:
|
||
"""Recordings registered under a manifest-carried ISRC (tier 1)."""
|
||
body = _mb_http_get(
|
||
f"isrc/{isrc}", {"inc": "artist-credits+releases+release-groups"})
|
||
if not body:
|
||
return []
|
||
docs = body.get("recordings") or []
|
||
return [c for c in (mb_match.parse_recording_doc(d) for d in docs) if c]
|
||
|
||
|
||
# Strict shapes for the manifest's optional identity keys (feedpak spec §5.1).
|
||
# Validated before use — the mbid is interpolated into a URL path, so junk or
|
||
# hostile manifest values must never reach the request line.
|
||
_MBID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||
|
||
|
||
_ISRC_RE = re.compile(r"^[A-Z]{2}[A-Z0-9]{3}[0-9]{7}$")
|
||
|
||
|
||
# ── Alias-aware scoring ───────────────────────────────────────────────────────
|
||
# MusicBrainz stores many artists under a non-Latin PRIMARY name (大橋純子) with
|
||
# the romanized form ("Junko Ohashi") only as an ALIAS. A recording search
|
||
# returns the primary name in its artist-credit, never the aliases — so scoring
|
||
# a romanized reference against the primary gives 0 and the match can't confirm.
|
||
# We fetch the artist's aliases (one throttled lookup, process-cached) and hand
|
||
# them to the scorer, but ONLY for a promising near-miss (title already agrees,
|
||
# artist doesn't) so a normal pass spends no extra requests.
|
||
_ALIAS_ENRICH_MAX = 3 # cap alias lookups per song/search (each is ≤1/s)
|
||
|
||
|
||
_artist_alias_cache: dict[str, list[str]] = {}
|
||
|
||
|
||
def _mb_artist_aliases(artist_id: str) -> list[str]:
|
||
"""Romanized/alternate names for a MusicBrainz artist, process-cached (an
|
||
artist recurs across a whole discography, so a library of one artist costs
|
||
ONE lookup). Returns [] for an unknown/aliasless artist. Raises
|
||
EnrichTransportError on a network failure so the caller pauses the pass
|
||
(nothing is cached on failure → retried next pass)."""
|
||
aid = str(artist_id or "")
|
||
if aid in _artist_alias_cache:
|
||
return _artist_alias_cache[aid]
|
||
if not _MBID_RE.match(aid):
|
||
return []
|
||
body = _mb_http_get(f"artist/{aid}", {"inc": "aliases"})
|
||
names: list[str] = []
|
||
if body:
|
||
sort_name = str(body.get("sort-name") or "").strip()
|
||
if sort_name:
|
||
names.append(sort_name) # often the romanized form for JP artists
|
||
for al in body.get("aliases") or []:
|
||
if isinstance(al, dict) and al.get("name"):
|
||
names.append(str(al["name"]))
|
||
seen: set[str] = set()
|
||
out: list[str] = []
|
||
for n in names:
|
||
k = n.casefold()
|
||
if k and k not in seen:
|
||
seen.add(k)
|
||
out.append(n)
|
||
out = out[:12]
|
||
_artist_alias_cache[aid] = out
|
||
return out
|
||
|
||
|
||
def _alias_enrich(ref: dict, cands: list[dict]) -> None:
|
||
"""Attach `artist_aliases` in place to candidates that look like the
|
||
non-Latin-primary case — title agrees with the reference but the primary
|
||
artist doesn't — so the scorer can confirm them via a romanized alias.
|
||
Bounded by _ALIAS_ENRICH_MAX + the process cache; a no-op when the
|
||
reference has no artist or nothing is aliasable."""
|
||
ref_artist = (ref.get("artist") or "").strip()
|
||
if not ref_artist:
|
||
return
|
||
spent = 0
|
||
for c in cands:
|
||
if spent >= _ALIAS_ENRICH_MAX:
|
||
break
|
||
if not isinstance(c, dict) or c.get("artist_aliases") is not None:
|
||
continue
|
||
aid = c.get("artist_id")
|
||
if not aid:
|
||
continue
|
||
# Only spend a lookup on a promising near-miss: the title already
|
||
# matches, but the primary artist doesn't (that's the alias signature).
|
||
if mb_match.similarity(ref.get("title"), c.get("title")) < mb_match.AUTO_TITLE_MIN:
|
||
continue
|
||
if mb_match.similarity(ref_artist, c.get("artist"), artist=True) >= mb_match.AUTO_ARTIST_MIN:
|
||
continue
|
||
c["artist_aliases"] = _mb_artist_aliases(aid) # cached; attach [] to avoid refetch
|
||
spent += 1
|
||
|
||
|
||
def _manifest_exact_ids(filename: str) -> dict:
|
||
"""Optional `mbid`/`isrc` from the pack manifest — the spec's additive
|
||
identity keys. Feature-detected: packs published before that spec
|
||
revision simply lack them and fall through to text matching. READ-only:
|
||
enrichment never writes anything into pack files."""
|
||
try:
|
||
dlc = _get_dlc_dir()
|
||
if not dlc:
|
||
return {}
|
||
p = _resolve_dlc_path(dlc, filename)
|
||
if p is None or not p.exists() or not sloppak_mod.is_sloppak(p):
|
||
return {}
|
||
manifest = sloppak_mod.load_manifest(p) or {}
|
||
except Exception:
|
||
return {}
|
||
out = {}
|
||
mbid = str(manifest.get("mbid", "") or "").strip().lower()
|
||
if _MBID_RE.match(mbid):
|
||
out["mbid"] = mbid
|
||
isrc = str(manifest.get("isrc", "") or "").strip().upper()
|
||
# Spec 1.14.0: the stored form is the bare 12-char code, but ISRCs
|
||
# circulate hyphenated in the wild (US-ABC-24-00001) — the separators
|
||
# are presentation, not part of the code, so a hand-authored display
|
||
# form still matches (consumers SHOULD strip before comparing).
|
||
isrc = isrc.replace("-", "").replace(" ", "")
|
||
if _ISRC_RE.match(isrc):
|
||
out["isrc"] = isrc
|
||
return out
|
||
|
||
|
||
# Failed-row retry backoff: 1 h after the first failed attempt, doubling per
|
||
# attempt, capped at a week — a permanently-unmatchable obscure chart must
|
||
# not re-hammer MusicBrainz on every scan kick.
|
||
_ENRICH_BACKOFF_BASE = 3600.0
|
||
|
||
|
||
_ENRICH_BACKOFF_CAP = 7 * 86400.0
|
||
|
||
|
||
def _enrich_backoff_elapsed(attempts, last_attempt_at, now: float) -> bool:
|
||
if not last_attempt_at:
|
||
return True
|
||
delay = min(_ENRICH_BACKOFF_BASE * (2 ** max(0, int(attempts or 1) - 1)),
|
||
_ENRICH_BACKOFF_CAP)
|
||
return (now - float(last_attempt_at)) >= delay
|
||
|
||
|
||
# Review tier keeps a short ranked candidate list for the drawer; more than a
|
||
# handful is noise the user has to scroll past.
|
||
_ENRICH_MAX_CANDIDATES = 5
|
||
|
||
|
||
# ── Cover art (R3/P9) ─────────────────────────────────────────────────────────
|
||
# The art cache dir (appstate.config_dir/art_cache) holds two kinds of file:
|
||
# {safe_name}.png / .gif — USER OVERRIDES (upload or URL-fetch; never
|
||
# evicted, removed only with the song or by the
|
||
# explicit remove-override route)
|
||
# caa_{release_mbid}.jpg — COVER ART ARCHIVE fetches, keyed by release so
|
||
# every chart of the same release shares one file;
|
||
# size-capped LRU (evictions reset the enrichment
|
||
# rows so a later pass may re-fetch)
|
||
_CAA_CACHE_CAP_BYTES = 200 * 1024 * 1024
|
||
|
||
|
||
# Per-cover cap on a single CAA fetch. The 500px thumbnail is normally tens of
|
||
# KB; this bounds any one response independently of the aggregate LRU cap so a
|
||
# single oversized (or misbehaving) release can't blow up memory/disk.
|
||
_CAA_MAX_BYTES = 10 * 1024 * 1024
|
||
|
||
|
||
# A release MBID is a UUID; before interpolating it into a cache-file path we
|
||
# require a conservative token (alphanumerics, hyphen, underscore only) so no
|
||
# separator or '.' can ever appear — blocks path traversal. Defence in depth:
|
||
# cheap even though the DB only ever holds MusicBrainz UUIDs. (Distinct name
|
||
# from the strict recording-MBID _MBID_RE above — this only gates a filename.)
|
||
_CAA_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||
|
||
|
||
def _caa_http_get(release_id: str) -> bytes | None:
|
||
"""Fetch a release's front cover from the Cover Art Archive — the one
|
||
network seam of the art layer (tests fake exactly this). Same etiquette
|
||
as the MusicBrainz client: throttled, identified, offline-guarded.
|
||
Returns the image bytes, None when the release has no cover (404), and
|
||
raises EnrichTransportError for anything network-shaped."""
|
||
if not _enrich_network_enabled():
|
||
raise EnrichTransportError("enrichment network disabled")
|
||
import requests
|
||
_enrich_throttle()
|
||
try:
|
||
with requests.get(
|
||
f"https://coverartarchive.org/release/{release_id}/front-500",
|
||
headers={"User-Agent": _enrich_user_agent()},
|
||
timeout=15, allow_redirects=True, stream=True,
|
||
) as resp:
|
||
if resp.status_code == 404:
|
||
return None
|
||
if resp.status_code != 200:
|
||
raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}")
|
||
# Stream with a per-file cap so a huge response never fully downloads.
|
||
data = b""
|
||
for chunk in resp.iter_content(65536):
|
||
data += chunk
|
||
if len(data) > _CAA_MAX_BYTES:
|
||
# Not network-shaped: settle just this row as 'error' (the
|
||
# art loop's generic handler) rather than pausing the pass.
|
||
raise ValueError("cover art exceeds size cap")
|
||
return data
|
||
except requests.RequestException as e:
|
||
raise EnrichTransportError(str(e)) from e
|
||
|
||
|
||
def _caa_release_index(release_id: str) -> dict | None:
|
||
"""Fetch a release's Cover Art Archive INDEX (json — image METADATA, not
|
||
image bytes): the cover picker's one network seam (tests fake exactly
|
||
this). Same etiquette as _caa_http_get: throttled, identified,
|
||
offline-guarded. Returns the parsed index dict, None when the archive
|
||
has no art for the release (404), and raises EnrichTransportError for
|
||
anything network-shaped."""
|
||
if not _enrich_network_enabled():
|
||
raise EnrichTransportError("enrichment network disabled")
|
||
import requests
|
||
_enrich_throttle()
|
||
try:
|
||
resp = requests.get(
|
||
f"https://coverartarchive.org/release/{release_id}",
|
||
headers={"User-Agent": _enrich_user_agent(),
|
||
"Accept": "application/json"},
|
||
timeout=15, allow_redirects=True)
|
||
if resp.status_code == 404:
|
||
return None
|
||
if resp.status_code != 200:
|
||
raise EnrichTransportError(f"cover art archive HTTP {resp.status_code}")
|
||
body = resp.json()
|
||
return body if isinstance(body, dict) else None
|
||
except requests.RequestException as e:
|
||
raise EnrichTransportError(str(e)) from e
|
||
except ValueError as e:
|
||
# Non-JSON body — treat as a transport blip (nothing gets cached, a
|
||
# later picker-open retries) rather than caching an empty index.
|
||
raise EnrichTransportError(f"cover art archive returned non-JSON: {e}") from e
|
||
|
||
|
||
# Per-release lock so two concurrent /art/candidates opens for the SAME
|
||
# release serialise their read→fetch→write (the "index cached, no second
|
||
# fetch" invariant). Different releases still fetch in parallel; the guard
|
||
# lock only protects the tiny registry lookup.
|
||
_caa_index_locks: dict[str, threading.Lock] = {}
|
||
|
||
|
||
_caa_index_locks_guard = threading.Lock()
|
||
|
||
|
||
def _caa_index_lock(release_id: str) -> threading.Lock:
|
||
with _caa_index_locks_guard:
|
||
lock = _caa_index_locks.get(release_id)
|
||
if lock is None:
|
||
lock = _caa_index_locks[release_id] = threading.Lock()
|
||
return lock
|
||
|
||
|
||
def _caa_index_cached(release_id: str) -> list[dict]:
|
||
"""A release's CAA index images through a TTL-less on-disk cache
|
||
(`caa_index_{id}.json` beside the cover files — indexes are stable, and
|
||
a 404 is cached as an empty index so a coverless release is never
|
||
re-asked). Outside the network seam on purpose: tests fake
|
||
_caa_release_index and still exercise this cache. Raises
|
||
EnrichTransportError on a cache-miss network failure (the caller stops
|
||
asking for further releases); malformed ids/bodies yield []."""
|
||
if not _CAA_ID_RE.match(str(release_id or "")):
|
||
return []
|
||
cache_file = _enrichment_art_dir() / f"caa_index_{release_id}.json"
|
||
# Hold the per-id lock across the check→fetch→write so a concurrent open
|
||
# for the same release finds the freshly-written cache instead of racing a
|
||
# second fetch. (The network fetch sleeps in _enrich_throttle under a
|
||
# different lock — no deadlock; a different release is never blocked.)
|
||
with _caa_index_lock(str(release_id)):
|
||
if cache_file.is_file():
|
||
try:
|
||
body = json.loads(cache_file.read_text(encoding="utf-8"))
|
||
imgs = body.get("images") if isinstance(body, dict) else None
|
||
if isinstance(imgs, list):
|
||
return imgs
|
||
except (OSError, ValueError):
|
||
pass # unreadable/corrupt cache → refetch below
|
||
body = _caa_release_index(release_id)
|
||
if body is None or not isinstance(body.get("images"), list):
|
||
body = {"images": []}
|
||
try:
|
||
cache_file.write_text(json.dumps(body), encoding="utf-8")
|
||
except OSError:
|
||
pass # cache is best-effort; the response still serves
|
||
return body["images"]
|
||
|
||
|
||
def _prune_caa_cache() -> None:
|
||
"""Keep the CAA side of the art cache under its size cap: evict the
|
||
oldest caa_* files (mtime LRU) and reset the enrichment rows that pointed
|
||
at them. User-override files are never touched."""
|
||
try:
|
||
files = sorted(appstate.art_cache_dir.glob("caa_*.jpg"), key=lambda p: p.stat().st_mtime)
|
||
total = sum(p.stat().st_size for p in files)
|
||
evicted: list[str] = []
|
||
while files and total > _CAA_CACHE_CAP_BYTES:
|
||
victim = files.pop(0)
|
||
try:
|
||
total -= victim.stat().st_size
|
||
victim.unlink()
|
||
evicted.append(str(victim))
|
||
except OSError:
|
||
break
|
||
if evicted:
|
||
appstate.meta_db.clear_enrichment_art_paths(evicted)
|
||
log.info("art cache: evicted %d cover(s) to stay under the cap", len(evicted))
|
||
except Exception:
|
||
log.exception("art cache prune failed")
|
||
|
||
|
||
def _enrich_art_one(row: dict) -> bool:
|
||
"""Resolve one matched song's cover-art situation (art worker, phase 3).
|
||
Returns True when a cover was actually fetched. Every outcome writes an
|
||
art_state so the row never re-queues:
|
||
'pack' — the song ships its own art (it wins; nothing to do)
|
||
'user' — an override exists (it wins; nothing to do)
|
||
'caa' — front cover cached (possibly deduped from an earlier fetch
|
||
of the same release — no network on that path)
|
||
'none' — the Cover Art Archive has no cover for this release
|
||
Network errors raise EnrichTransportError → the pass pauses and the row
|
||
stays unevaluated for the next kick."""
|
||
fn, release_id = row["filename"], row["mb_release_id"]
|
||
if not release_id or not _CAA_ID_RE.match(str(release_id)):
|
||
# Malformed release id — never build a cache path from it. Settle the
|
||
# row as 'error' so it isn't re-queued every pass.
|
||
appstate.meta_db.set_enrichment_art(fn, None, "error")
|
||
return False
|
||
if appstate.song_pack_art_exists(fn):
|
||
appstate.meta_db.set_enrichment_art(fn, None, "pack")
|
||
return False
|
||
if appstate.art_override_paths(fn):
|
||
appstate.meta_db.set_enrichment_art(fn, None, "user")
|
||
return False
|
||
cache_file = _enrichment_art_dir() / f"caa_{release_id}.jpg"
|
||
if cache_file.is_file():
|
||
appstate.meta_db.set_enrichment_art(fn, str(cache_file), "caa")
|
||
return False
|
||
data = _caa_http_get(release_id)
|
||
if data is None:
|
||
appstate.meta_db.set_enrichment_art(fn, None, "none")
|
||
return False
|
||
cache_file.write_bytes(data)
|
||
appstate.meta_db.set_enrichment_art(fn, str(cache_file), "caa")
|
||
_prune_caa_cache()
|
||
return True
|
||
|
||
|
||
_ENRICH_APPLY_FIELDS = {
|
||
# Per-field auto-apply toggle → the candidate fields it governs. The
|
||
# MusicBrainz ids + isrc are deliberately NOT here: they're identity,
|
||
# not display — the art fetch and any future re-match need them stamped
|
||
# even when every display field is toggled off.
|
||
"enrich_apply_names": ("artist", "title", "album", "artist_sort"),
|
||
"enrich_apply_year": ("year",),
|
||
"enrich_apply_genres": ("genres",),
|
||
}
|
||
|
||
|
||
def _enrich_blocked_apply_keys(cfg: dict) -> frozenset:
|
||
"""The per-field auto-apply toggle keys that are currently OFF (suppressed).
|
||
Its complement (`_ENRICH_APPLY_FIELDS` minus these) is what an automatic
|
||
match may canonicalize."""
|
||
return frozenset(k for k in _ENRICH_APPLY_FIELDS if cfg.get(k, True) is False)
|
||
|
||
|
||
def _enrich_apply_mask(cfg: dict) -> str:
|
||
"""Canonical marker of the suppressed apply keys, persisted on each
|
||
automatic match so re-enabling a field re-queues the row for backfill
|
||
(enrichment_pending) and a partial match can't seed siblings
|
||
(enrichment_cache_lookup). '' = nothing suppressed (the default)."""
|
||
return ",".join(sorted(_enrich_blocked_apply_keys(cfg)))
|
||
|
||
|
||
def _enrich_field_filter(cfg: dict):
|
||
"""Build the cand filter for AUTOMATIC matches from the per-field
|
||
auto-apply settings: strips the display fields whose toggle is off
|
||
before they're stamped as canonical. Returns None when everything is
|
||
on (the default) so the common path stays zero-copy. Review candidates
|
||
and user-confirmed picks bypass this — a match the user confirms in
|
||
the modal applies in full."""
|
||
blocked = {f for key in _enrich_blocked_apply_keys(cfg)
|
||
for f in _ENRICH_APPLY_FIELDS[key]}
|
||
if not blocked:
|
||
return None
|
||
return lambda cand: {k: v for k, v in cand.items() if k not in blocked}
|
||
|
||
|
||
# A per-song LOCK (Fix-metadata popup) → the candidate display keys it
|
||
# suppresses on an AUTOMATIC match. Identity keys (recording/release/artist ids,
|
||
# isrc) are deliberately absent: a locked DISPLAY field still gets matched for
|
||
# art + future re-match, it just isn't re-canonicalized behind the user's back.
|
||
_LOCK_FIELD_TO_CAND = {
|
||
"artist": ("artist", "artist_sort"),
|
||
"title": ("title",),
|
||
"album": ("album",),
|
||
"year": ("year",),
|
||
"genre": ("genres",),
|
||
}
|
||
|
||
|
||
def _compose_lock_filter(base_filter, locked_fields):
|
||
"""Wrap the pass's global per-field apply-filter with a per-song filter that
|
||
also strips the song's LOCKED display fields, so an automatic match never
|
||
re-canonicalizes a field the user pinned. Returns base_filter unchanged when
|
||
the song has no relevant lock (the common path)."""
|
||
blocked = {ck for f in locked_fields for ck in _LOCK_FIELD_TO_CAND.get(f, ())}
|
||
if not blocked:
|
||
return base_filter
|
||
|
||
def lock_filter(cand):
|
||
c = base_filter(cand) if base_filter else cand
|
||
return {k: v for k, v in c.items() if k not in blocked}
|
||
return lock_filter
|
||
|
||
|
||
def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||
apply_mask: str = "") -> None:
|
||
"""The matcher (P8; replaces P7's no-op). Precedence per design §5:
|
||
|
||
1. local match-cache by content_hash — another chart of the same
|
||
recording already matched/pinned → copy it, NO network;
|
||
2. manifest `mbid` (tier 0) / `isrc` (tier 1) exact keys → direct
|
||
lookup, auto;
|
||
3. text search → scored tiers: auto (high) / review (medium — a human
|
||
confirms before anything canonicalizes) / failed (low, retried on
|
||
backoff).
|
||
|
||
`auto_min` is the user's auto-apply confidence setting (None → the
|
||
engine default); it moves only the auto/review boundary of step 3 —
|
||
the per-field floors and exact-key tiers are unaffected. `field_filter`
|
||
(from _enrich_field_filter) strips per-field-disabled display values
|
||
from every AUTOMATIC stamp — all three steps here are automatic, so it
|
||
applies to each; the review tier stores candidates unfiltered because
|
||
accepting one is a user action. Never touches a `manual` row (the
|
||
writer enforces it). `apply_mask` (the suppressed keys, from
|
||
_enrich_apply_mask) is stamped on each AUTOMATIC match so a later
|
||
re-enable re-queues the row for backfill and a partial match can't seed
|
||
siblings. Network errors raise EnrichTransportError so the pass pauses
|
||
instead of burning attempts while offline."""
|
||
fn, chash = row["filename"], row["content_hash"]
|
||
# Respect per-song field LOCKS (Fix-metadata popup): an automatic match must
|
||
# not re-canonicalize a field the user pinned. Compose the lock filter onto
|
||
# the pass's global apply-filter — both the cache-copy and text-match auto
|
||
# paths run their candidate through it. (Review/manual picks bypass the
|
||
# filter, so confirming a match in the modal is an explicit override.)
|
||
locked = appstate.meta_db.locked_fields(fn)
|
||
if locked:
|
||
field_filter = _compose_lock_filter(field_filter, locked)
|
||
|
||
cached = appstate.meta_db.enrichment_cache_lookup(chash, exclude_filename=fn)
|
||
if cached:
|
||
score = cached.pop("score", None)
|
||
if field_filter:
|
||
cached = field_filter(cached)
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "matched", source="cache",
|
||
score=score, cand=cached, apply_mask=apply_mask)
|
||
return
|
||
|
||
ids = _manifest_exact_ids(fn)
|
||
if ids.get("mbid"):
|
||
cand = _mb_lookup_recording(ids["mbid"])
|
||
if cand:
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "matched", source="mbid",
|
||
score=1.0, apply_mask=apply_mask,
|
||
cand=field_filter(cand) if field_filter else cand)
|
||
return
|
||
# A 404'd mbid (typo'd manifest) falls through to the text tiers.
|
||
# A pack that left `artist` blank can't be text-matched (search needs an
|
||
# artist, and the per-field floor rejects a blank one) — so when it's blank,
|
||
# seed the query/scoring from the filename's Artist_Song convention. Seed
|
||
# only: fn/chash and the stored row are untouched, and the DISPLAYED values
|
||
# still come from the confirmed match. The exact-key tiers above don't need
|
||
# it (mbid/isrc identify without text).
|
||
ref = row
|
||
if not (row.get("artist") or "").strip():
|
||
derived = _artist_title_from_filename(fn)
|
||
if derived:
|
||
ref = {**row, **derived}
|
||
|
||
if ids.get("isrc"):
|
||
cands = mb_match.rank_candidates(ref, _mb_lookup_isrc(ids["isrc"]))
|
||
if cands:
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "matched", source="isrc",
|
||
score=1.0, apply_mask=apply_mask,
|
||
cand=field_filter(cands[0]) if field_filter else cands[0])
|
||
return
|
||
|
||
cands = _mb_search_recordings(ref.get("artist"), ref.get("title"))
|
||
# Alias-enrich promising near-misses (title agrees, primary artist doesn't)
|
||
# so a non-Latin-primary artist can confirm via its romanized alias, then
|
||
# rank once with the aliases in hand. `ref` carries any filename-derived
|
||
# artist seed, so alias scoring runs against the searched identity.
|
||
_alias_enrich(ref, cands)
|
||
ranked = mb_match.rank_candidates(ref, cands)
|
||
best = ranked[0] if ranked else None
|
||
tier = mb_match.classify(ref, best, best["score"], auto_min=auto_min) if best else "none"
|
||
if tier == "auto":
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "matched", source="text",
|
||
score=best["score"], apply_mask=apply_mask,
|
||
cand=field_filter(best) if field_filter else best)
|
||
elif tier == "review":
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "review", source="text",
|
||
score=best["score"],
|
||
candidates=ranked[:_ENRICH_MAX_CANDIDATES])
|
||
else:
|
||
appstate.meta_db.apply_enrichment_match(fn, chash, "failed", source="text",
|
||
score=(best["score"] if best else None),
|
||
candidates=ranked[:_ENRICH_MAX_CANDIDATES] or None,
|
||
bump_attempts=True)
|
||
|
||
|
||
def _background_enrich():
|
||
"""One bounded pass, two phases. Phase 1 stamps/refreshes identity-hash
|
||
stubs for every song whose identity is new or changed — pure-local, so
|
||
hashes stay fresh (and stale matches drop back to `unscanned`) even
|
||
fully offline. Phase 2 runs the matcher over those rows plus any
|
||
`failed` rows whose backoff has elapsed; a transport failure pauses it
|
||
(state untouched, no attempt burned) and the next kick retries. Offline
|
||
(kill-switch or the test env) skips phase 2 entirely. Never drains in a
|
||
loop — a dead network would make that spin forever. Between songs it
|
||
honours the Stop button's cancel flag (phases 2 and 3), so a long trickle
|
||
can be halted without waiting for the whole queue to drain."""
|
||
_enrich_status["processed"] = 0
|
||
_enrich_status["total"] = 0
|
||
_enrich_status["matched"] = 0
|
||
_enrich_status["current"] = None
|
||
# User settings gate the BACKGROUND matcher only (the review modal's
|
||
# manual search/fix stays available when it's off); read once per pass,
|
||
# up front so the pending query can honour the per-field apply mask
|
||
# (a re-enabled field re-queues its `matched` rows for backfill).
|
||
cfg = _load_config(appstate.config_dir / "config.json") or {}
|
||
allowed_keys = frozenset(_ENRICH_APPLY_FIELDS) - _enrich_blocked_apply_keys(cfg)
|
||
apply_mask = _enrich_apply_mask(cfg)
|
||
try:
|
||
pending = appstate.meta_db.enrichment_pending(limit=100000, allowed_keys=allowed_keys)
|
||
except Exception:
|
||
log.exception("enrichment: pending query failed")
|
||
return
|
||
for row in pending:
|
||
try:
|
||
appstate.meta_db.upsert_enrichment_stub(row["filename"], row["content_hash"])
|
||
except Exception as e:
|
||
log.warning("enrichment stub failed for %s: %s", row.get("filename"), e)
|
||
_enrich_status["processed"] += 1
|
||
_enrich_status["last_pass_at"] = time.time()
|
||
|
||
if cfg.get("enrich_enabled", True) is False:
|
||
if pending:
|
||
log.info("Enrichment pass: %d rows stamped (matching disabled in Settings)", len(pending))
|
||
return
|
||
try:
|
||
auto_min = float(cfg.get("enrich_auto_threshold", 0.9))
|
||
except (TypeError, ValueError):
|
||
auto_min = 0.9
|
||
|
||
if not _enrich_network_enabled():
|
||
if pending:
|
||
log.info("Enrichment pass: %d rows stamped (network disabled — matching skipped)", len(pending))
|
||
return
|
||
|
||
# Scraper options (R1), read from the same per-pass cfg: `mb_on` gates
|
||
# the matcher (phase 2), `art_on` the cover-art fetch (phase 3 — the
|
||
# Cover Art Archive is the only automatic art source today, so the
|
||
# source toggle and the cover-art apply toggle both have to be on).
|
||
mb_on = cfg.get("enrich_src_musicbrainz", True) is not False
|
||
art_on = (cfg.get("enrich_src_caa", True) is not False
|
||
and cfg.get("enrich_apply_art", True) is not False)
|
||
field_filter = _enrich_field_filter(cfg)
|
||
|
||
now = time.time()
|
||
retriable = []
|
||
if mb_on:
|
||
try:
|
||
retriable = [r for r in appstate.meta_db.enrichment_failed_rows(limit=100000)
|
||
if _enrich_backoff_elapsed(r.get("attempts"), r.get("last_attempt_at"), now)]
|
||
except Exception:
|
||
log.exception("enrichment: failed-row query failed")
|
||
elif pending:
|
||
log.info("Enrichment pass: %d rows stamped (MusicBrainz source disabled in Settings)", len(pending))
|
||
matched = 0
|
||
# A `failed` row with a changed identity hash can surface in BOTH lists;
|
||
# de-dup by filename so each row consumes the rate budget only once.
|
||
seen_filenames = set()
|
||
queue = []
|
||
for row in (pending + retriable) if mb_on else []:
|
||
fn = row.get("filename")
|
||
if fn in seen_filenames:
|
||
continue
|
||
seen_filenames.add(fn)
|
||
queue.append(row)
|
||
_enrich_status["total"] = len(queue)
|
||
for row in queue:
|
||
if _enrich_cancel.is_set():
|
||
log.info("enrichment: pass cancelled by user after %d matched", matched)
|
||
break
|
||
_enrich_status["current"] = row.get("filename")
|
||
try:
|
||
_enrich_one(row, auto_min=auto_min, field_filter=field_filter,
|
||
apply_mask=apply_mask)
|
||
matched += 1
|
||
_enrich_status["matched"] = matched
|
||
except EnrichTransportError as e:
|
||
log.info("enrichment: network unavailable, pass paused (%s)", e)
|
||
break
|
||
except Exception as e:
|
||
log.warning("enrichment failed for %s: %s", row.get("filename"), e)
|
||
try:
|
||
# Park the row on the failure backoff instead of retrying a
|
||
# poisoned input every pass.
|
||
appstate.meta_db.apply_enrichment_match(
|
||
row["filename"], row["content_hash"], "failed",
|
||
source="error", bump_attempts=True)
|
||
except Exception:
|
||
pass
|
||
_enrich_status["current"] = None
|
||
if mb_on and (pending or retriable):
|
||
log.info("Enrichment pass: %d rows stamped, %d matched", len(pending), matched)
|
||
|
||
# Phase 3 — cover art (R3/P9). For freshly-matched songs, resolve the art
|
||
# situation once: songs with their own pack art (or a user override) are
|
||
# marked and skipped; the rest fetch the release's front cover from the
|
||
# Cover Art Archive into the size-capped cache. Same pause-on-transport-
|
||
# error rule as matching — a dead network never burns a row's evaluation.
|
||
# Rows skipped here stay art_state NULL, so re-enabling the toggles picks
|
||
# them up on the next pass — nothing is permanently forfeited.
|
||
if not art_on:
|
||
return
|
||
try:
|
||
art_rows = appstate.meta_db.enrichment_art_pending(limit=100000)
|
||
except Exception:
|
||
log.exception("enrichment: art-pending query failed")
|
||
return
|
||
fetched = 0
|
||
for row in art_rows:
|
||
if _enrich_cancel.is_set():
|
||
log.info("enrichment: art pass cancelled by user after %d fetched", fetched)
|
||
break
|
||
try:
|
||
fetched += 1 if _enrich_art_one(row) else 0
|
||
except EnrichTransportError as e:
|
||
log.info("enrichment: network unavailable, art pass paused (%s)", e)
|
||
break
|
||
except Exception as e:
|
||
log.warning("enrichment art failed for %s: %s", row.get("filename"), e)
|
||
try:
|
||
appstate.meta_db.set_enrichment_art(row["filename"], None, "error")
|
||
except Exception:
|
||
pass
|
||
if art_rows:
|
||
log.info("Enrichment art pass: %d evaluated, %d covers fetched", len(art_rows), fetched)
|
||
|
||
|
||
def _kick_enrich() -> bool:
|
||
"""Request an enrichment pass, single-flight + coalescing (the _kick_scan
|
||
contract): True = a worker thread was started, False = one is running and
|
||
a follow-up pass was queued."""
|
||
global _enrich_pending_pass, _enrich_thread
|
||
with _enrich_kick_lock:
|
||
if _enrich_status["running"]:
|
||
_enrich_pending_pass = True
|
||
return False
|
||
# A fresh pass supersedes any prior Stop — clear the flag so the new
|
||
# pass isn't cancelled the instant it checks (a stale set() from a
|
||
# cancelled-then-re-kicked run would otherwise abort it immediately).
|
||
_enrich_cancel.clear()
|
||
_enrich_status["running"] = True
|
||
_enrich_thread = threading.Thread(target=_enrich_runner, daemon=True)
|
||
_enrich_thread.start()
|
||
return True
|
||
|
||
|
||
def _enrich_runner():
|
||
global _enrich_pending_pass
|
||
while True:
|
||
try:
|
||
_background_enrich()
|
||
except Exception:
|
||
log.exception("background enrichment failed unexpectedly")
|
||
with _enrich_kick_lock:
|
||
_enrich_status["current"] = None
|
||
if _enrich_cancel.is_set():
|
||
# Stop: abandon any coalesced follow-up and clear the flag so the
|
||
# next kick starts clean. The current pass already broke out of
|
||
# its loop between songs (see _background_enrich).
|
||
_enrich_pending_pass = False
|
||
_enrich_cancel.clear()
|
||
_enrich_status["running"] = False
|
||
return
|
||
if not _enrich_pending_pass:
|
||
_enrich_status["running"] = False
|
||
return
|
||
_enrich_pending_pass = False
|