feat(enrichment): AcoustID audio-fingerprint identification (opt-in) - #759
Conversation
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>
📝 WalkthroughWalkthroughAdds AcoustID fingerprint parsing, server-side identify endpoints, and match-review UI/settings support for audio-based matching. ChangesAcoustID Fingerprint Matching
Estimated code review effort: 4 (Complex) | ~45 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
server.py (1)
6268-6338: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNo 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, andPOST /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.runandrequests.getto 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
📒 Files selected for processing (3)
lib/acoustid_match.pyserver.pytests/test_acoustid_match.py
…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.
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
lib/acoustid_match.pyserver.pystatic/tailwind.min.cssstatic/v3/match-review.jstests/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
| return JSONResponse( | ||
| {"error": "audio fingerprinting unavailable", "needs_setup": False, | ||
| "detail": "the fpcalc (Chromaprint) binary was not found on the server"}, | ||
| status_code=503) |
There was a problem hiding this comment.
📐 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.
| 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>
|
Review + fix pass (Claude Code). Backend is solid —
Two follow-ups left for you:
|
- /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>
|
Follow-up: implemented both items I flagged (10 acoustid tests green; tailwind stays fresh).
|
There was a problem hiding this comment.
🧹 Nitpick comments (3)
static/v3/index.html (1)
774-780: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueSettings wiring matches JS contract.
Ids (
acoustid-enabled,acoustid-api-key) line up exactly withwireSettingsCard'soptInToggles/acoustidKeyElbinding and save handlers inmatch-review.js. No issues found.Two optional nits, non-blocking:
- The
acoustid.org/new-applicationmention 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; considertype="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 valuePrefer
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 winNarrow the multipart exception handling. Catch the size-overflow case explicitly and return
413, but let otherRequest.form()parse failures return400so 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
📒 Files selected for processing (3)
server.pystatic/v3/index.htmlstatic/v3/match-review.js
🚧 Files skipped from review as they are similar to previous changes (1)
- static/v3/match-review.js
… (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>
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>
…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>
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 asmb_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), andPOST /api/enrichment/identify(upload the master audio → candidates in the/searchshape).Opt-in + graceful
Absent the
fpcalcbinary or anACOUSTID_API_KEY, the whole path is a no-op /503and the text matcher runs unchanged. Nothing regresses.Setup to enable
fpcalc(Chromaprint) onPATHor at$FPCALC.$ACOUSTID_API_KEY.Tests
tests/test_acoustid_match.py— 8 passing (parsing, studio detection, dedupe, ranking, config gating). Thefpcalc+ 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