Skip to content

Enrichment: MusicBrainz + Cover Art Archive by default, Discogs opt-in (ADR-0016) - #45

Merged
cole-hackman merged 2 commits into
mainfrom
claude/lexicon-enrichment
Aug 8, 2026
Merged

cole-hackman merged 2 commits into
mainfrom
claude/lexicon-enrichment

Conversation

@cole-hackman

@cole-hackman cole-hackman commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Turns crates/enrichment from a ten-line stub into a real crate, closing the tag half of docs/lexicon/07-health.md §Find Tags & Album Art — the last large missing row.

Why these providers

Neither of Lexicon's own sources was available to copy: SonoVault is not a public API, and Spotify's audio-features was deprecated on 2024-11-27 and returns 403 to applications registered since. So the sources had to be chosen. ADR-0016 records it.

  • MusicBrainz is the default — no account, no key, no registration, CC0 data. A default source the user must go and register for is not a default; it's a dead feature with a settings page.
  • Cover Art Archive pairs with it: keyed by release MBID, so a metadata match already carries what an art lookup needs and no second search happens.
  • Discogs is opt-in with a personal access token in the OS keychain (discogs_token, via the existing get_api_key plumbing). It earns its slot where MusicBrainz is weakest — label, catalogue and year for dance records, which is most of a Rekordbox library.
  • No Spotify, and no faked equivalents. Energy comes from our own analysis (ADR-0015). Danceability, Popularity and Happiness have no honest source and are not invented.

The privacy rule, made structural

This is the first feature where the library leaves the machine, so CLAUDE.md's rule had to stop being aspirational:

  • Every outbound request goes through the enrichment::http::Http trait — grep its implementors and you have the entire network surface. Today that's one type, in one file, behind an optional reqwest feature.
  • Service::lookup consults the cache before any provider. The test asserts on the request log, not the returned value — returning the same answer twice would not prove the network was skipped.
  • Only an artist and a title are sent. A test pins that no path, library identifier or volume name ever reaches a request URL.
  • A no-match is cached too. Without it a library of bootlegs pays the full rate-limited round trip on every re-run — the case most likely to be re-run.

Rate limits are terms of use

MusicBrainz documents one request per second and a User-Agent identifying the app with contact info; ignoring either gets you blocked, and a blocked client means the feature stops working with no visible cause. Both are enforced in the crate rather than at call sites, because a limit each call site must remember is one a call site will forget.

The limiter holds its lock across the sleep on purpose: releasing it first would let every waiter compute the same wake time and fire together, which is exactly the burst the limit forbids. Tested with a paused clock — five callers take four intervals, and a naturally-spaced caller is never delayed.

Behaviour worth calling out

  • Backfill, never overwrite. A field the library already holds is never proposed over — including when it holds whitespace, which Rekordbox libraries are full of, and treating " " as curation would make the feature do nothing on a real collection.
  • Every proposal names its provider (ADR-0008), and that attribution survives into the staged change's reason, so a diff reviewed a week later still says who claimed it.
  • The genre split is the manual's own good idea: main genre to the Genre field, everything else to Custom Tags. Zero-vote MusicBrainz tags are dropped so one person's typo can't become somebody's Genre; Discogs styles beat its broad genres, because "Deep House" is more useful than "Electronic".
  • Filename-shaped titles are split. A track whose artist is blank and whose title is Artist - Title is the normal state of a downloaded library, and searching that whole string as a title matches nothing.

Two things deliberately not shipped

No album-art option in the UI. The downloader works — MIME sniffed from magic bytes rather than a header (an ID3 picture frame with a wrong MIME is one players silently refuse to show), WAV refused up front per the manual's own caveat. But crates/audio-tags has no picture support, so a checkbox would download an image and discard it: exactly the stub logic CLAUDE.md forbids in production paths. A test asserts the option is absent, with the reason.

Not verified against live responses. The container's network policy denies musicbrainz.org (403 on CONNECT). Both providers' field paths are written against documented schemas. Every parse is tolerant by construction, so schema drift costs proposals, never a wrong value written into a library — which is why this ships unverified and ANLZ writing does not: there the failure mode is a corrupted user file. GAPS.md carries the one curl that closes it.

While writing that up I found the existing GAPS bullet claiming these providers could not be written here either. That was wrong, and the design is the disproof — making the transport a seam left everything but the wire testable. Corrected in place.

Reach

Track context menu → Find tags & album art…, and library_find_tags in chat, MCP and the CLI. The agent-tools path needed care: AgentToolService::execute is synchronous and the lookup is not, and the MCP HTTP server calls execute from inside a Tokio runtime where block_on panics — so the future runs on a fresh thread that has no ambient runtime to nest in.

Verification

cargo fmt --all -- --check
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings   # clean, incl. decks-desktop
pnpm test        # 831 passed
pnpm typecheck
pnpm lint
pnpm e2e         # 59 passed

85 new tests in crates/enrichment (query escaping, original-release re-ranking, tolerant parsing of every optional field, cache-before-network on the request log, provider isolation on failure, rate-limit queueing under a paused clock, WAV refusal, MIME sniffing), 7 in crates/cache, 10 in enrich.rs, 9 in EnrichPanel.test.tsx.

Parity: 61 done / 21 partial / 12 missing / 2 blocked / 16 deferred.


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Added “Find Tags & Album Art” from the track context menu.
    • Preview and selectively apply metadata proposals from MusicBrainz, with optional Discogs lookups.
    • Backfill missing metadata and add genre-based custom tags without overwriting existing values.
    • Added album-art discovery and validation; embedding is not yet available.
    • Added cached provider results and cache-clearing support.
    • Added agent-tool support for finding tags on individual tracks.
  • Documentation

    • Documented enrichment behavior, privacy safeguards, provider support, and current album-art limitations.

`crates/enrichment` was a ten-line stub and Find Tags & Album Art was the last
large `missing` row. Neither of Lexicon's own sources was available to copy —
SonoVault is not a public API, and Spotify's `audio-features` is deprecated and
403s for applications registered since 2024-11-27 — so the providers had to be
chosen. ADR-0016 records the choice.

MusicBrainz is the default because it needs no account, key or registration; a
default source the user must register for is a dead feature with a settings
page. Cover Art Archive pairs with it, keyed by the release MBID a metadata
match already carries. Discogs is opt-in with a keychain token, earning its slot
on label, catalogue and year for dance records.

This is the first feature where the library leaves the machine, so the CLAUDE.md
privacy rule is structural rather than aspirational:

- every request goes through one `Http` trait, so grepping its implementors
  enumerates the whole network surface
- the cache is consulted before any provider, asserted on the request log rather
  than the returned value
- only an artist and a title are sent; a test pins that no path, library id or
  volume name reaches a URL
- a no-match is cached too, so a library of bootlegs does not pay the full
  rate-limited round trip on every re-run

Rate limits are enforced in the crate: MusicBrainz's one-per-second and its
User-Agent requirement are conditions of use, not courtesies. The limiter holds
its lock across the sleep, or every waiter wakes at once and bursts.

Enrichment backfills and never overwrites — including over whitespace, which
real libraries are full of. Every proposal names its provider, and that reaches
the staged change's reason (ADR-0008). The genre split follows the manual: main
genre to the Genre field, the rest to Custom Tags; zero-vote MusicBrainz tags
dropped so a typo cannot become somebody's Genre, and Discogs styles preferred
over its broad genres.

Album art fetches but does not embed, and no UI option offers it: `audio-tags`
has no picture support, so a checkbox would download an image and discard it.
Asserted absent by test.

Reachable from the track context menu and from `library_find_tags` in chat, MCP
and the CLI. The agent-tools path runs the async lookup on a fresh thread rather
than `block_on`, since the MCP HTTP server calls `execute` from inside a runtime.

Not verified against live responses: the container's network policy denies
`musicbrainz.org`. Parsing is tolerant by construction, so schema drift costs
proposals rather than wrong values — recorded in GAPS.md with the check that
closes it.

Parity: 61 done / 21 partial / 12 missing / 2 blocked / 16 deferred.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Gn43w2xFL3JRBRkMv3vRo
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3c7a86c-7369-4aff-b698-b64e166d0a04

📥 Commits

Reviewing files that changed from the base of the PR and between 7043be1 and ee364d7.

📒 Files selected for processing (32)
  • apps/desktop/src-tauri/Cargo.toml
  • apps/desktop/src-tauri/src/enrich.rs
  • apps/desktop/src-tauri/src/lib.rs
  • apps/desktop/src/App.tsx
  • apps/desktop/src/components/EnrichPanel.test.tsx
  • apps/desktop/src/components/EnrichPanel.tsx
  • apps/desktop/src/hooks/useTrackContextActions.tsx
  • apps/desktop/src/ipc.ts
  • apps/desktop/src/types.ts
  • crates/agent-tools/Cargo.toml
  • crates/agent-tools/src/mcp.rs
  • crates/agent-tools/src/service.rs
  • crates/agent-tools/src/types.rs
  • crates/cache/src/migrations.rs
  • crates/cache/src/store.rs
  • crates/enrichment/Cargo.toml
  • crates/enrichment/src/cover_art.rs
  • crates/enrichment/src/discogs.rs
  • crates/enrichment/src/http.rs
  • crates/enrichment/src/lib.rs
  • crates/enrichment/src/merge.rs
  • crates/enrichment/src/musicbrainz.rs
  • crates/enrichment/src/rate_limit.rs
  • crates/enrichment/src/reqwest_http.rs
  • crates/enrichment/src/title.rs
  • crates/enrichment/src/types.rs
  • docs/DECISIONS.md
  • docs/JOURNAL.md
  • docs/STATUS.md
  • docs/lexicon/07-health.md
  • docs/lexicon/GAPS.md
  • docs/lexicon/PARITY.md

📝 Walkthrough

Walkthrough

Adds a complete metadata enrichment workflow. MusicBrainz is the default provider, Discogs is optional, and Cover Art Archive supports art retrieval. Results are cached, reviewed in the desktop UI or agent tool, and staged as attributed metadata changes.

Changes

Metadata enrichment

Layer / File(s) Summary
Enrichment contracts and transport
crates/enrichment/Cargo.toml, crates/enrichment/src/types.rs, crates/enrichment/src/title.rs, crates/enrichment/src/http.rs, crates/enrichment/src/reqwest_http.rs
Defines provider candidates, track proposals, normalized queries, title parsing, HTTP abstractions, and the reqwest transport.
Provider lookup and proposal merging
crates/enrichment/src/musicbrainz.rs, crates/enrichment/src/discogs.rs, crates/enrichment/src/cover_art.rs, crates/enrichment/src/rate_limit.rs, crates/enrichment/src/lib.rs, crates/enrichment/src/merge.rs
Adds MusicBrainz, Discogs, and Cover Art Archive lookups with rate limiting, cache-first orchestration, privacy checks, and backfill-only proposal merging.
Shared enrichment response cache
crates/cache/src/migrations.rs, crates/cache/src/store.rs, apps/desktop/src-tauri/src/enrich.rs, crates/agent-tools/src/service.rs
Adds the SQLite cache schema and adapters with 30-day expiry, provider/query isolation, empty-result caching, replacement, and clearing.
Desktop preview and staging commands
apps/desktop/src-tauri/src/enrich.rs, apps/desktop/src-tauri/src/lib.rs, apps/desktop/src-tauri/Cargo.toml, apps/desktop/src/types.ts, apps/desktop/src/ipc.ts
Adds Tauri commands for previewing enrichment, staging accepted proposals, reading the optional Discogs keychain token, and clearing cached responses.
Agent-tool enrichment preview
crates/agent-tools/src/mcp.rs, crates/agent-tools/src/types.rs, crates/agent-tools/src/service.rs, crates/agent-tools/Cargo.toml
Adds the library_find_tags MCP tool and asynchronous single-track proposal execution.
Desktop enrichment review workflow
apps/desktop/src/App.tsx, apps/desktop/src/components/EnrichPanel.tsx, apps/desktop/src/components/EnrichPanel.test.tsx, apps/desktop/src/hooks/useTrackContextActions.tsx
Adds the track-menu action, selection-preserving modal, provider and proposal displays, selectable fields and tags, staging controls, and UI tests.
Enrichment documentation and status
docs/DECISIONS.md, docs/JOURNAL.md, docs/STATUS.md, docs/lexicon/*
Documents provider behavior, privacy constraints, verification limits, album-art limitations, and updated parity status.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TrackMenu
  participant EnrichPanel
  participant Tauri
  participant EnrichmentService
  participant CacheDb
  participant RekordboxLibrary
  TrackMenu->>EnrichPanel: Open enrichment for selected tracks
  EnrichPanel->>Tauri: Call enrichPreview
  Tauri->>RekordboxLibrary: Load selected track metadata
  Tauri->>EnrichmentService: Lookup provider candidates
  EnrichmentService->>CacheDb: Read or update cached responses
  EnrichmentService-->>Tauri: Return merged proposals
  Tauri-->>EnrichPanel: Display proposals and provider errors
  EnrichPanel->>Tauri: Call enrichStage with accepted proposals
  Tauri-->>EnrichPanel: Return staged change IDs
Loading

Possibly related PRs

Suggested reviewers: claude

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/lexicon-enrichment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`strip_version`'s loop-with-let-else is a `while let`, and current stable
clippy says so. Pure syntax; the tests are unchanged and still pass.

Worth recording *why this got past me*: `rust-toolchain.toml` pins `stable`,
which is 1.97.1 on CI, while this container's image had 1.94.1. So the local
clippy is systematically an older lint set than the gate, and "clippy is clean
here" was never the same claim as "clippy is clean on CI". Installed the current
stable and re-ran the whole definition of done against it — fmt, the workspace
tests and `clippy --workspace --all-targets -D warnings` are all clean on the
toolchain CI actually uses, which is what makes this a fix for the class rather
than for the one line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Gn43w2xFL3JRBRkMv3vRo
@cole-hackman
cole-hackman marked this pull request as ready for review August 8, 2026 22:54
@cole-hackman
cole-hackman merged commit 30e4f11 into main Aug 8, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants