Skip to content

feat(enrichment): AcoustID audio-fingerprint identification (opt-in) - #759

Merged
byrongamatos merged 8 commits into
mainfrom
feat/acoustid-fingerprint
Jul 4, 2026
Merged

feat(enrichment): AcoustID audio-fingerprint identification (opt-in)#759
byrongamatos merged 8 commits into
mainfrom
feat/acoustid-fingerprint

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Why

Text search can only guess the version (see #758 for the ranking mitigation). The definitive fix is content-based: fingerprint the actual audio with Chromaprint (fpcalc) and look it up on AcoustID, which maps the fingerprint to the exact MusicBrainz recording — the approach Lidarr uses. This eliminates the studio-vs-live ambiguity entirely.

What

  • lib/acoustid_match.py — pure response parsing + config gating (unit-tested). Normalizes AcoustID hits into the same candidate shape as mb_match, so the review UI and the editor's Match popup render fingerprint and text hits identically. Flags the studio take (reuses the secondary-type logic).
  • server.py_fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled, offline-guarded HTTP), _identify_by_fingerprint (also callable from the library-enrichment pipeline), and POST /api/enrichment/identify (upload the master audio → candidates in the /search shape).

Opt-in + graceful

Absent the fpcalc binary or an ACOUSTID_API_KEY, the whole path is a no-op / 503 and the text matcher runs unchanged. Nothing regresses.

Setup to enable

Tests

tests/test_acoustid_match.py — 8 passing (parsing, studio detection, dedupe, ranking, config gating). The fpcalc + live-lookup path needs the two deps above to exercise end-to-end.

Follow-up

Wire an "Identify from my audio" button into the editor's Match popup (posts the staged master track to /identify); trivial once this endpoint lands + deps are configured.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added “Identify by audio” to match review for both uploaded audio and library songs, returning enriched candidates (title, artist, album, year, duration, confidence).
    • Added optional AcoustID audio fingerprinting to Settings → Metadata matching (enable toggle + API key).
  • Bug Fixes
    • Improved candidate ranking and de-duplication, with more accurate studio album selection and clearer handling when identification isn’t available or setup is incomplete.
  • Tests
    • Added unit tests covering AcoustID response parsing and configuration gating.

Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.

- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
  normalizes AcoustID hits into the same candidate shape as mb_match so the
  review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
  offline-guarded HTTP), _identify_by_fingerprint (also available to the
  library-enrichment pipeline), and POST /api/enrichment/identify (upload the
  master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
  the whole path is a no-op / 503 and the text matcher runs unchanged.

Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds AcoustID fingerprint parsing, server-side identify endpoints, and match-review UI/settings support for audio-based matching.

Changes

AcoustID Fingerprint Matching

Layer / File(s) Summary
Config and selection helpers
lib/acoustid_match.py
Defines AcoustID lookup constants, API key resolution and gating, studio classification, earliest-year extraction, and release-group selection helpers.
Lookup parsing and normalization
lib/acoustid_match.py, tests/test_acoustid_match.py
Parses AcoustID lookup responses into flat candidates, de-duplicates recordings, maps confidence scores, sorts the results, and validates the parser and gating helpers with new tests.
Fingerprint lookup pipeline and API
server.py
Adds AcoustID server integration, demo-mode blocking, fingerprinting utilities, identify endpoints, and AcoustID settings defaults and validation.
Match review identify action
static/v3/index.html, static/v3/match-review.js
Adds the audio-identify button, wires the new request flow, and renders fingerprint matches or status messages in the match review modal, plus the matching settings controls.

Estimated code review effort: 4 (Complex) | ~45 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: opt-in AcoustID audio-fingerprint identification for enrichment.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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 feat/acoustid-fingerprint

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
server.py (1)

6268-6338: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for the server-side fingerprinting pipeline or the new endpoint.

Only lib/acoustid_match.py's pure parsing is unit-tested. _fpcalc, _acoustid_lookup, _identify_by_fingerprint, and POST /api/enrichment/identify (subprocess/network/503 paths) have no tests, despite being the layer that wires user input (uploaded file, subprocess, HTTP) together.

Consider mocking subprocess.run and requests.get to cover: fpcalc success/failure/timeout, AcoustID 429/non-200/bad-JSON → EnrichTransportError → 503, and the _acoustid_available() gating (missing key / missing binary) returning 503 from the endpoint.

Also applies to: 7531-7562

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.py` around lines 6268 - 6338, Add tests for the server-side
fingerprinting and identify flow, since _fpcalc, _acoustid_lookup,
_identify_by_fingerprint, and POST /api/enrichment/identify are currently
untested. Mock subprocess.run and requests.get to cover _fpcalc success,
failure, and timeout, plus _acoustid_lookup handling of 429, non-200, and
bad-JSON cases that should surface as EnrichTransportError and map to 503 at the
endpoint. Also verify _acoustid_available() gating in the identify path,
including missing API key and missing fpcalc binary returning 503, using the
_identify_by_fingerprint and API handler symbols to locate the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server.py`:
- Around line 7546-7548: The upload handling currently reads the entire file
into memory without a local size check, so add a hard byte cap before or during
the read in this code path. Update the upload logic around file.file.read() to
follow the same size-limit approach used by the song upload flow, or switch to
chunked reading with an enforced maximum, and keep the empty-upload validation
after the bounded read.

---

Nitpick comments:
In `@server.py`:
- Around line 6268-6338: Add tests for the server-side fingerprinting and
identify flow, since _fpcalc, _acoustid_lookup, _identify_by_fingerprint, and
POST /api/enrichment/identify are currently untested. Mock subprocess.run and
requests.get to cover _fpcalc success, failure, and timeout, plus
_acoustid_lookup handling of 429, non-200, and bad-JSON cases that should
surface as EnrichTransportError and map to 503 at the endpoint. Also verify
_acoustid_available() gating in the identify path, including missing API key and
missing fpcalc binary returning 503, using the _identify_by_fingerprint and API
handler symbols to locate the code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 78f1fa89-cc93-463d-9952-03ba260d0dec

📥 Commits

Reviewing files that changed from the base of the PR and between b6169af and 0bdf0f1.

📒 Files selected for processing (3)
  • lib/acoustid_match.py
  • server.py
  • tests/test_acoustid_match.py

Comment thread server.py Outdated
…in settings

Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
  - acoustid_enabled (bool, default OFF — opt-in)
  - acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)

_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.

Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).
A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.
…g metadata)

The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.
…fingerprint

AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.
… tooling

Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server.py`:
- Around line 6378-6381: The 503 response in the audio fingerprinting failure
branch is using a single fpcalc-missing message for every _acoustid_available()
failure, which mislabels offline deployments. Update the response logic around
the audio fingerprinting check in server.py to distinguish whether
_enrich_network_enabled() is disabled versus fpcalc/Chromaprint being absent,
and set the JSONResponse detail accordingly while keeping the existing 503
status and needs_setup flag behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2068b947-1ece-44f3-86d7-fe499e82ea7b

📥 Commits

Reviewing files that changed from the base of the PR and between 0bdf0f1 and 2c8176f.

📒 Files selected for processing (5)
  • lib/acoustid_match.py
  • server.py
  • static/tailwind.min.css
  • static/v3/match-review.js
  • tests/test_acoustid_match.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_acoustid_match.py
  • lib/acoustid_match.py

Comment thread server.py
Comment on lines +6378 to +6381
return JSONResponse(
{"error": "audio fingerprinting unavailable", "needs_setup": False,
"detail": "the fpcalc (Chromaprint) binary was not found on the server"},
status_code=503)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

503 detail misattributes the cause when the network is off.

_acoustid_available() is False when either fpcalc is missing or _enrich_network_enabled() is off. This branch always reports "the fpcalc (Chromaprint) binary was not found on the server", so an offline (FEEDBACK_ENRICH_OFFLINE) deploy with fpcalc present surfaces a misleading reason. Consider distinguishing the two.

💡 Proposed disambiguation
+    detail = ("the fpcalc (Chromaprint) binary was not found on the server"
+              if _fpcalc_bin() is None
+              else "audio identification is disabled while the server is offline")
     return JSONResponse(
         {"error": "audio fingerprinting unavailable", "needs_setup": False,
-         "detail": "the fpcalc (Chromaprint) binary was not found on the server"},
+         "detail": detail},
         status_code=503)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return JSONResponse(
{"error": "audio fingerprinting unavailable", "needs_setup": False,
"detail": "the fpcalc (Chromaprint) binary was not found on the server"},
status_code=503)
detail = ("the fpcalc (Chromaprint) binary was not found on the server"
if _fpcalc_bin() is None
else "audio identification is disabled while the server is offline")
return JSONResponse(
{"error": "audio fingerprinting unavailable", "needs_setup": False,
"detail": detail},
status_code=503)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.py` around lines 6378 - 6381, The 503 response in the audio
fingerprinting failure branch is using a single fpcalc-missing message for every
_acoustid_available() failure, which mislabels offline deployments. Update the
response logic around the audio fingerprinting check in server.py to distinguish
whether _enrich_network_enabled() is disabled versus fpcalc/Chromaprint being
absent, and set the JSONResponse detail accordingly while keeping the existing
503 status and needs_setup flag behavior.

- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
  regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
  writing it; stream it to the temp file with a 256 MB cap (413 over) so an
  oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos

Copy link
Copy Markdown
Contributor

Review + fix pass (Claude Code). Backend is solid — _fpcalc uses list-form subprocess args (no injection), _song_audio_file mirrors the sloppak containment guards, opt-in gating (412/503) is thorough, both endpoints are demo-blocked, 10 acoustid tests green. Pushed 2 fixes:

  1. ci/tailwind-fresh was red — committed static/tailwind.min.css was stale vs a fresh rebuild. Regenerated with the pinned tailwindcss@3.4.19 (verified byte-stable across rebuilds), so the CI check now passes.
  2. Identify upload read the whole body into memory (file.file.read()). Now streams to the temp file in 1 MB chunks with a 256 MB cap (413 over) — fpcalc reads from the temp file anyway.

Two follow-ups left for you:

  • Pre-parse upload guard: my cap runs after FastAPI has already spooled the multipart body. For full defense-in-depth, mirror upload_song's pre-parse Content-Length check + request.form(max_part_size=…) (requires making the route async and running the blocking fpcalc/HTTP via run_in_executor). Low priority under the single-trusted-user model (Principle I), but it's the complete fix.
  • Settings UI to enable AcoustID: the backend/API is complete (save_settings validates acoustid_enabled/acoustid_api_key, both in _default_settings), but the v3 settings card exposes no toggle/key input, so the 'Identify by audio' button 412s for a normal user until they POST settings or hand-edit config. Worth a small settings-card addition (a follow-up, since it'd also need a tailwind rebuild if it adds classes).

- /api/enrichment/identify is now async: a pre-parse Content-Length check +
  request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
  the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
  fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
  toggle (acoustid_enabled, default OFF) + an AcoustID key input
  (acoustid_api_key), wired in match-review.js — so the advertised feature is
  reachable from the UI instead of only via a manual settings POST. Reuses
  existing classes only; committed tailwind.min.css stays fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos

Copy link
Copy Markdown
Contributor

Follow-up: implemented both items I flagged (10 acoustid tests green; tailwind stays fresh).

  1. Pre-parse upload guard. /api/enrichment/identify is now async: a pre-parse Content-Length check + request.form(max_part_size=…) reject an oversized body before Starlette spools the multipart to temp disk (mirrors upload_song), and the blocking fpcalc subprocess + AcoustID HTTP now run off the event loop via run_in_executor (the route was previously sync-in-threadpool; async keeps that non-blocking). Gate still fires before any parse (verified: unconfigured→412, configured-but-no-fpcalc→503).
  2. Settings UI to enable AcoustID. Added an 'Identify by audio' opt-in toggle (acoustid_enabled, default OFF) + an AcoustID key input (acoustid_api_key) to the v3 Metadata-matching card, wired in match-review.js (loads current values, saves on change) — so the feature is reachable from the UI, not just a manual settings POST. Reused only existing classes, so static/tailwind.min.css stays fresh (ci/tailwind-fresh green).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
static/v3/index.html (1)

774-780: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Settings wiring matches JS contract.

Ids (acoustid-enabled, acoustid-api-key) line up exactly with wireSettingsCard's optInToggles/acoustidKeyEl binding and save handlers in match-review.js. No issues found.

Two optional nits, non-blocking:

  • The acoustid.org/new-application mention is plain text; the sibling Demucs block above (Line 623) uses a real <a href> link for its external reference — consider doing the same for consistency/clickability.
  • The API key input is type="text", so the key is visible in plaintext on screen; consider type="password" if this should be treated as a secret.
    [optional_optional_placeholder]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@static/v3/index.html` around lines 774 - 780, Update the AcoustID settings
block in the HTML to match the existing Demucs pattern by turning the
acoustid.org/new-application reference into a clickable link, and consider
changing the acoustid-api-key input in the same block to a password field if the
key should be treated as sensitive. Keep the existing ids (`acoustid-enabled`,
`acoustid-api-key`) unchanged so `wireSettingsCard` in match-review.js continues
to bind correctly.
server.py (2)

7669-7670: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer asyncio.get_running_loop() inside the coroutine.

get_event_loop() is discouraged within a running coroutine and emits a DeprecationWarning in some contexts; get_running_loop() is the idiomatic choice here.

♻️ Proposed tweak
-        cands = await asyncio.get_event_loop().run_in_executor(
-            None, _identify_by_fingerprint, tmp)
+        cands = await asyncio.get_running_loop().run_in_executor(
+            None, _identify_by_fingerprint, tmp)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.py` around lines 7669 - 7670, The coroutine currently uses
asyncio.get_event_loop() in the fingerprint-identification path, which should be
updated to the running loop API. In the code around the _identify_by_fingerprint
call, replace the loop lookup with asyncio.get_running_loop() and keep using
run_in_executor on that loop so the async flow remains unchanged. Use the
existing coroutine context and the cands assignment as the locator when making
the change.

7643-7646: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Narrow the multipart exception handling. Catch the size-overflow case explicitly and return 413, but let other Request.form() parse failures return 400 so malformed uploads aren’t reported as “audio upload too large”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server.py` around lines 7643 - 7646, The multipart error handling around
Request.form() is too broad and maps every failure to an upload-too-large
response. Update the form parsing block in the upload path so it only catches
the specific size-overflow exception and returns 413, while other parsing
failures fall through to a 400 response for malformed uploads. Use the existing
request.form call and the surrounding upload handler logic to distinguish the
exception types without changing the normal success path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server.py`:
- Around line 7669-7670: The coroutine currently uses asyncio.get_event_loop()
in the fingerprint-identification path, which should be updated to the running
loop API. In the code around the _identify_by_fingerprint call, replace the loop
lookup with asyncio.get_running_loop() and keep using run_in_executor on that
loop so the async flow remains unchanged. Use the existing coroutine context and
the cands assignment as the locator when making the change.
- Around line 7643-7646: The multipart error handling around Request.form() is
too broad and maps every failure to an upload-too-large response. Update the
form parsing block in the upload path so it only catches the specific
size-overflow exception and returns 413, while other parsing failures fall
through to a 400 response for malformed uploads. Use the existing request.form
call and the surrounding upload handler logic to distinguish the exception types
without changing the normal success path.

In `@static/v3/index.html`:
- Around line 774-780: Update the AcoustID settings block in the HTML to match
the existing Demucs pattern by turning the acoustid.org/new-application
reference into a clickable link, and consider changing the acoustid-api-key
input in the same block to a password field if the key should be treated as
sensitive. Keep the existing ids (`acoustid-enabled`, `acoustid-api-key`)
unchanged so `wireSettingsCard` in match-review.js continues to bind correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 934cf88d-74d3-4c61-a0b3-c09c98b33711

📥 Commits

Reviewing files that changed from the base of the PR and between c401423 and ad6d363.

📒 Files selected for processing (3)
  • server.py
  • static/v3/index.html
  • static/v3/match-review.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • static/v3/match-review.js

@byrongamatos
byrongamatos merged commit 73c5ab1 into main Jul 4, 2026
3 of 4 checks passed
byrongamatos pushed a commit that referenced this pull request Jul 5, 2026
… (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos pushed a commit that referenced this pull request Jul 5, 2026
The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
byrongamatos pushed a commit that referenced this pull request Jul 5, 2026
…ver picker, MusicBrainz + AcoustID (#777)

* feat(library): metadata override + lock store, enforced by enrichment (popup slices 1–2)

Backend foundation for the Fix-metadata popup. Not yet surfaced in the UI (the
display + 3-tab popup are the next slices); no PR until it's user-visible.

Slice 1 — the store:
- `song_field_override(filename, field, value, locked)` table: a reversible
  DISPLAY overlay (never written to the pack), filename-keyed so it survives a
  rescan (never purged by delete_missing) and is dropped only with the song.
- DB methods (partial upsert that drops empty+unlocked rows; batch map) +
  `GET`/`PUT /api/song/{fn}/overrides` (field allowlist title/artist/album/
  year/genre; clearing rides PUT since DELETE /api/song/{path} shadows sub-
  routes; PUT demo-blocked).

Slice 2 — locks respected by enrichment:
- The auto-matcher composes a per-song `_compose_lock_filter` onto the global
  apply-filter, so a match still applies IDENTITY (mbid/release → art) but never
  re-canonicalizes a LOCKED display field.
- Gap-fill (write-to-file) skips locked album/year/genre — writing the matched
  value would be exactly the clobber the lock exists to prevent.
- Review/manual picks bypass the filter (an explicit confirm overrides a lock).

Tests: store semantics + rescan-survival + API; the lock filter + reader; an
auto-match leaving a locked field un-canonicalized; gap-fill excluding locked
keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): show per-song overrides in the grid (popup slice 3)

The grid now displays the user's per-song title/artist/album/year override
in place of the pack value ("grid shows only overrides") — a matched
MusicBrainz canon never silently re-titles a card; canon stays in the
Details drawer + art. Overlaid in Python over the visible window, keyset-safe
like the P4 artist-alias re-label: the seek still runs on the raw column, and
the one overridable keyset column (title) stashes its raw value for the cursor
so paging never skips/dupes. The private stash is dropped from the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(library): 3-tab Fix-metadata popup — Details / Cover art / Match (slice 4)

Turns the thin single-song fix-match modal into the Plex-style metadata
editor reached from a card's "Fix metadata…" menu:

- Details tab: type + lock the displayed title/artist/album/year. Values
  ride the reversible override store (GET/PUT /api/song/{fn}/overrides); each
  field sits on its pack value (Yours/Pack provenance + revert-to-pack), a lock
  pins it against auto-match, and Save repaints the grid via library:changed
  (slice-3 overlay). This is the real tool for the blank-artist city-pop pile
  MusicBrainz can't surface — you just type the right title.
- Cover art tab: hands off to the shared image picker (its own modal); the
  pick refreshes the thumbnail everywhere.
- Match tab: the existing MusicBrainz search + candidate/pick flow, refactored
  into shared body/footer helpers (the queue-review flow is untouched).

Backend: GET /overrides now also returns the pack baseline so the Details tab
can pre-fill + show provenance. tailwind.min.css regenerated (build-tailwind.sh)
for the popup's new utility classes.

Identify-by-audio (AcoustID) is deferred: it lives in unmerged PR #759, off
main — the Match tab gains the button once #759 lands and this branch rebases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): wire "Identify by audio" in the tabbed popup's Match tab

The AcoustID Identify button (#759) merged in referencing an out-of-scope
`panel` in the wiring — a leftover from the pre-popup fix-match modal that my
tab refactor renamed to `root`. Under strict mode that threw, so the handler
never attached and the button did nothing. Scope it to `root` (the tab body),
which is where the search-results area it renders into lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): make "Identify by audio" outcomes unmistakable

An empty AcoustID result read the same as a broken button. Each state now says
plainly which outcome it is — ✓ fingerprinted-but-no-match vs no-audio vs off vs
unavailable — and, in the popup, points at the manual fallback (Search, or set
the album in Details + cover in Cover art by hand). A ✓ marks the states that
actually ran, so "worked, found nothing" no longer looks like a failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

3 participants