Skip to content

fix(lyrics): strip Genius header + track real provider + user-driven refetch (closes #284)#287

Merged
InstaZDLL merged 7 commits into
mainfrom
fix/genius-lyrics-header-artifact
Jun 20, 2026
Merged

fix(lyrics): strip Genius header + track real provider + user-driven refetch (closes #284)#287
InstaZDLL merged 7 commits into
mainfrom
fix/genius-lyrics-header-artifact

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Draft pour test manuel. Trois commits cohérents qui répondent à toutes les frictions de l'issue #284.

Closes #284

Le bug d'origine

Quand LRCLIB miss, la cascade tombait sur NetEase / Megalobiz / Genius automatiquement. Trois problèmes empilés :

  1. Le parser Genius dump le header artifact : la page Genius nest le badge contributeurs + le ` Lyrics` heading DANS le même `data-lyrics-container` que les paroles. Une fois le HTML decoder appliqué, on obtient un préfixe comme `"17 ContributorsFall on Me Lyrics"` collé au début des paroles. Pour les chansons cataloguées sans transcription (image 1 de l'issue : "Solamente una vez"), la page ne contient QUE ce header — donc l'utilisateur voit littéralement `"2 ContributorsSolamente Una Vez Lyrics"` comme "paroles".
  2. Le badge dit "LRCLIB" alors que le contenu vient d'ailleurs : le label était hardcodé et ne tenait pas compte du provider qui avait réellement répondu. Quand Genius cachait du junk après un miss LRCLIB, ça ressemblait à un bug LRCLIB.
  3. Aucun moyen de récupérer : si Genius cachait un mauvais hit, "Clear + Refetch" ré-exécutait la même cascade et retombait sur le même hit Genius. Boucle fermée.

Les trois fixes

1. `fix(lyrics): strip Genius header artifact + treat stub pages as miss` (`5e889c4`)

Ajout de `strip_genius_header` dans crates/syncedlyrics/src/providers/genius.rs : pattern `^\d+ Contributors.*? Lyrics` strippé en début de body. Si après strip le contenu est vide, le candidate retourne None — la cascade passe au provider suivant au lieu de cacher le header brut.

7 nouveaux tests unitaires : happy path, stub page (Solamente Una Vez), multibyte title (Déjà Vu), 4 cas no-op (whitespace prefix, no digits, no "Contributors" token, titre commençant par un nombre genre "99 Red Balloons").

2. `fix(lyrics): track real provider per row + add user-driven refetch` (`5e8f7c4`)

Migration `20260620120000_lyrics_provider.sql` : nouvelle colonne `lyrics.provider TEXT NULL`. NULL = inconnu (rows pré-1.5.1 + tiers non-API).

Provider plumbing :

  • `LyricsPayload` gagne `provider: Option` (skip-if-none au JSON wire)
  • `Provider::as_str()` + `Provider::from_str()` ajoutés dans `waveflow_syncedlyrics`, snake_case round-trip
  • `upsert_lyrics` prend un 6e paramètre `provider: Option<&str>` ; tous les call sites updated (embedded/sidecar/manual = None ; LRCLIB = `Some("lrclib")` ; cache_external_lyrics dérive du `result.provider`)
  • `read_cached` SELECT la nouvelle colonne
  • Empty miss rows écrivent `provider = None` (attribution à un provider serait mensongère)

Nouvelle commande `refetch_lyrics(track_id, provider: Option)` :

  • Drop le row caché d'abord
  • `provider = None` → re-run la full waterfall (identique au "Clear + Refetch" legacy)
  • `provider = Some(id)` → bypass embedded/sidecar, query UNIQUEMENT ce provider via `external_lyrics_search`. Miss → cache empty row attribué au provider demandé (le badge reflète ce que l'user vient de tenter)
  • Honore `musixmatch_enabled` + offline mode aux mêmes chokepoints que la waterfall

Registrée dans `lib.rs::generate_handler![]`.

3. `fix(lyrics): expose real provider in source badge + add picker dropdown` (`71b05ff`)

Badge : `sourceLabel` lit `payload.provider` et rend le brand name réel ("LRCLIB" / "Genius" / "NetEase" / "Megalobiz" / "Musixmatch"). Fallback générique "Online source" localisé quand provider = NULL.

Picker : quand row est API-sourced, le label devient un chip-button avec chevron. Click → dropdown des 5 providers avec checkmark sur l'actif. Click sur un provider → `refetchLyrics(track_id, provider)`. `role="menu"` + `menuitemradio`, fermé par click outside.

Embedded/sidecar/manual rows : label statique sans chevron (picker n'a aucun sens pour un tag embedded).

Backend command swap : `handleRefetch` passe du legacy `clearLyrics + fetchLyrics` au nouveau single-call `refetchLyrics`.

i18n sur les 17 locales :

  • `lyrics.source.api` réécrit de "LRCLIB" (verbatim) → "Source en ligne" / "Online source" / etc. (fallback générique)
  • `lyrics.source.pickerHint` (tooltip "Changer de source")
  • `lyrics.provider.{lrclib,genius,net_ease,megalobiz,musixmatch}` — brand tokens verbatim partout per la convention CLAUDE.md (comme "Daily Mix" / "On Repeat")

Tests

  • 7 tests Genius strip passent
  • 34 tests syncedlyrics passent
  • 84 tests desktop unit passent
  • `cargo check --workspace` clean
  • `bun run typecheck` clean
  • Test manuel : ouvrir lyrics panel sur un track LRCLIB cache hit → badge dit "LRCLIB"
  • Test manuel : ouvrir un track où LRCLIB miss → cascade fall on Genius → badge dit "Genius"
  • Test manuel : click sur badge "Genius" → dropdown avec 5 providers, Genius coché → click "LRCLIB" → refetch lance, badge passe à "LRCLIB"
  • Test manuel : Solamente Una Vez ou autre track sans paroles transcrites → cascade complète miss → empty state "Pas de paroles", PAS le header artifact
  • Test manuel : 17 locales le label "Online source" s'affiche correctement sur un row sans provider connu (e.g. pré-1.5.1 cache)
  • Test manuel : Musixmatch via picker quand opt-in OFF → erreur claire ou skip silencieux

Summary by CodeRabbit

Nouvelles fonctionnalités et améliorations

  • New Features
    • Affichage du fournisseur pour les paroles en ligne, avec un sélecteur (menu) et un indicateur “chip”.
    • Rechargement forcé des paroles ciblant un fournisseur spécifique.
  • Améliorations
    • La source du fournisseur est désormais mémorisée et propagée pour mieux refléter l’origine des paroles, y compris dans le cache.
  • Correctifs
    • Les pages Genius “stub” sont mieux gérées (elles ne produisent plus de faux résultats).
  • Localization
    • Mise à jour des traductions pour prendre en charge la sélection et l’affichage des fournisseurs.

Genius recently moved the contributor badge + `<song title> Lyrics`
heading inside the same `data-lyrics-container` div that holds the
actual verses. Once our HTML decoder flattens the tags we end up
with a glued-together prefix like `"17 ContributorsFall on Me Lyrics"`
sitting at the head of every result, plus — for songs that have
been catalogued on Genius without any transcribed lyrics yet — pages
whose ONLY visible content is that bare header.

Image 1 in issue #284 shows the bare-header case ("Solamente una
vez": panel renders `"2 ContributorsSolamente Una Vez Lyrics"` and
nothing else); image 2 shows the prefix-glue case ("Fall on Me":
header runs into the chorus).

Add `strip_genius_header` to pattern-match the
`^\d+ Contributors.*? Lyrics` shape and drop it before returning the
candidate. Pure prefix surgery: anything that doesn't match the
shape (no leading digits, no " Contributors" token, no trailing
" Lyrics" closer) is returned untouched, so a future Genius layout
change won't silently lose the first line. After stripping, an empty
body is treated as a miss — the host then falls through to the next
provider in the chain (NetEase / Megalobiz) instead of caching the
bare header as the user-visible lyrics.

Seven new unit tests cover the happy path, the stub-page case, the
multi-byte title path (Déjà Vu), and four no-op shapes (leading
whitespace, no digit prefix, missing " Contributors", missing
" Lyrics", title starting with a number à la "99 Red Balloons").
Backend half of the fix for issue #284. Two problems being addressed:

1. The lyrics panel hard-codes "LRCLIB" as the source badge no matter
   which provider actually responded. After the LRCLIB miss, the
   waterfall falls through to NetEase / Megalobiz / Genius — and when
   Genius hands back junk (see the sibling Genius header-strip commit
   in this branch), the user has no way to know what they're looking
   at or how to retry from a cleaner source.
2. The auto-waterfall has no manual override. If Genius caches a bad
   result, the only escape today is "Clear" + "Refetch" which just
   re-runs the same waterfall and lands on the same Genius hit.

## Migration

`20260620120000_lyrics_provider.sql` adds a nullable `provider` column
to `app.lyrics`. NULL means "unknown" — covers pre-1.5.1 cached rows
plus every non-API tier (embedded / sidecar / manual) where the broad
`source` is already meaningful attribution. No CHECK constraint on the
allowed set: provider strings come from `Provider::as_str()` at the
single write site, and a CHECK that hard-codes the list would force
another migration every time the SDK gains a provider.

## Provider plumbing

- `LyricsPayload` gains `provider: Option<String>`, serialised via
  `skip_serializing_if = "Option::is_none"` so the JSON wire shape
  stays compact on tiers where the field is meaningless.
- `Provider::as_str()` + `Provider::from_str()` added to
  `waveflow_syncedlyrics`, matching the existing
  `serde(rename_all = "snake_case")` ids
  (`"lrclib"` / `"genius"` / `"net_ease"` / `"megalobiz"` /
  `"musixmatch"`). Single canonical id format across DB rows, payload
  field, and the frontend ↔ backend boundary.
- `upsert_lyrics` takes a 6th `provider: Option<&str>` parameter.
  Every call site updated: embedded / sidecar / manual write `None`;
  LRCLIB direct + LRCLIB instrumental cache + LRCLIB pre-cache in
  `fetch_lyrics` and the prefetch path all write `Some("lrclib")`;
  `cache_external_lyrics` derives the provider id from
  `LyricsResult::provider.as_str()` so NetEase / Megalobiz / Genius /
  Musixmatch all attribute correctly.
- `read_cached` SELECTs the new column and surfaces it on the payload.
- Empty miss rows (after a full waterfall failure) write `provider =
  None` — attribution to a specific provider would be misleading when
  every provider returned nothing.

## refetch_lyrics command

New `#[tauri::command] pub async fn refetch_lyrics(track_id,
provider: Option<String>)`. Drops the cached row first so the
waterfall (or single-provider query below) is forced to re-query,
then two modes:

- `provider = None` → re-runs the full waterfall by calling
  `fetch_lyrics` directly. Identical to the legacy "Clear + Refetch"
  flow.
- `provider = Some(id)` → bypasses local tiers (embedded / sidecar)
  and queries ONLY the named provider via `external_lyrics_search`.
  Honours the Musixmatch translation lang and the offline-mode
  short-circuit on the same chokepoints as the waterfall. A miss
  caches an empty row attributed to the requested provider so the
  UI badge reflects what the user just tried — picking a different
  provider and trying again is then the natural next step.

Registered alongside `fetch_lyrics` in `lib.rs::generate_handler![]`.

84 desktop unit tests + workspace `cargo check --all-targets` still
pass with the migration applied to a fresh `app.db`.

Frontend wiring (provider badge + dropdown picker) lands in the next
commit.
Frontend half of the fix for issue #284. Pairs with the migration +
backend plumbing in the prior commit.

## Provider badge

`sourceLabel` now reads `payload.provider` and renders the canonical
brand name when the row is API-sourced (`"LRCLIB"` / `"Genius"` /
`"NetEase"` / `"Megalobiz"` / `"Musixmatch"`). Falls back to the
generic localised "Online source" label when the provider is `null`
— covers pre-1.5.1 cached rows and the empty-miss rows the backend
deliberately leaves unattributed.

The badge text was previously hardcoded as "LRCLIB" regardless of
which provider actually responded, which made the auto-waterfall's
Genius fallback look like a LRCLIB bug. With this change the user
can read what actually ran.

## Provider picker

When the row is API-sourced, the source label becomes a chip-button
with a chevron. Clicking opens a dropdown of all five providers; the
currently-active one carries a check mark. Picking any provider
calls the new `refetch_lyrics(track_id, provider)` Tauri command,
which drops the cached row + queries ONLY that provider — the
escape hatch users need when the auto-waterfall caches a low-quality
hit they can't recover from with the existing "Clear + Refetch".

Picker is anchored to the source label via a `<span class="relative">`
wrapper so the menu floats above the footer instead of pushing it.
Closed by an outside click + by `handleRefetch` itself after the new
fetch lands. `role="menu"` + `menuitemradio` items for WAI-ARIA.

Embedded / sidecar / manual rows render the label as static text —
the picker would have nothing meaningful to do for a tag-embedded
lyric, and the chevron would just be noise.

## Backend command swap

`handleRefetch` switches from the legacy `clearLyrics + fetchLyrics`
two-call gesture to the new single-call `refetchLyrics`, which does
the cache drop + the re-query atomically on the backend. The
parameter-less variant (clicked from the existing refresh button)
re-runs the full waterfall identical to the legacy flow; the
provider-bearing variant (from the picker) targets one source.

## i18n

Two new keys + one rewrite across all 17 locales:

- `lyrics.source.api` rewritten from the verbatim "LRCLIB" to a
  localised "Online source" — the generic fallback when the actual
  provider is unknown.
- `lyrics.source.pickerHint` added — tooltip on the chip-button
  ("Switch source").
- `lyrics.provider.{lrclib,genius,net_ease,megalobiz,musixmatch}`
  added — provider display names. Brand tokens stay verbatim per
  the CLAUDE.md "Daily Mix / On Repeat" precedent (Spotify keeps
  product names untranslated, we do the same for the providers
  themselves).

`bun run typecheck` clean.
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7f95b880-ae64-481b-86cf-7632db282476

📥 Commits

Reviewing files that changed from the base of the PR and between 01eff6d and ccf5379.

📒 Files selected for processing (1)
  • src/components/layout/LyricsPanel.tsx

📝 Walkthrough

Walkthrough

Ce PR ajoute l'attribution du fournisseur de paroles (LRCLIB, Genius, NetEase, Megalobiz, Musixmatch) depuis une nouvelle colonne SQL provider jusqu'à l'UI, via un champ provider dans LyricsPayload. Il expose une commande refetch_lyrics ciblant un provider spécifique, ajoute un dropdown de sélection dans LyricsPanel, corrige les pages Genius stub et uniformise toutes les locales.

Changes

Traçage du fournisseur de paroles

Layer / File(s) Résumé
Schéma et contrats provider
src-tauri/migrations/app/20260620120000_lyrics_provider.sql, src-tauri/crates/syncedlyrics/src/providers/mod.rs, src-tauri/crates/app/src/commands/lyrics.rs, src/lib/tauri/lyrics.ts
Ajoute la colonne SQL nullable provider, les méthodes Provider::as_str/from_str pour les mappages stables, le champ provider: Option<String> dans LyricsPayload Rust, et le type LyricsProvider avec la liste LYRICS_PROVIDERS côté TypeScript.
Propagation backend dans cache, fetch et prefetch
src-tauri/crates/app/src/commands/lyrics.rs
Étend upsert_lyrics avec un paramètre provider, met à jour read_cached pour sélectionner ce champ, et propage Some(provider) ou None sur tous les chemins : cache_external_lyrics, fetch_lyrics (Embedded, Sidecar, instrumental, LRCLIB), prefetch_library_lyrics, import_lrc_file, save_lyrics.
Refetch ciblé par provider
src-tauri/crates/app/src/commands/lyrics.rs, src-tauri/crates/app/src/lib.rs, src/lib/tauri/lyrics.ts
Met à jour refetch_lyrics pour attribuer le provider demandé sur les hits et les miss, enregistre la commande Tauri dans generate_handler!, et ajoute refetchLyrics(trackId, provider?) côté frontend.
Nettoyage des pages Genius stub
src-tauri/crates/syncedlyrics/src/providers/genius.rs
Ajoute strip_genius_header pour supprimer le préfixe "N Contributors … Lyrics" et traite les pages entête-seule comme des miss (aucun Candidate produit), avec tests unitaires complets.
Sélecteur provider dans LyricsPanel
src/components/layout/LyricsPanel.tsx
Ajoute un dropdown de sélection de provider pour source === "api", refactorise handleImport et handleRefetch avec paramétrisation du provider, gère la détection de clic extérieur via pickerRef, et adapte sourceLabel pour afficher le provider précis ou un libellé générique.
Traductions des labels de provider
src/i18n/locales/*.json (18 locales)
Uniformise les 18 locales : remplace la source API spécifique à LRCLIB par un libellé générique, ajoute le hint pickerHint de changement de source, et introduit le sous-objet provider avec les labels de chaque fournisseur supporté.

Sequence Diagram(s)

sequenceDiagram
  participant Utilisateur
  participant LyricsPanel as LyricsPanel (UI)
  participant refetchLyrics_TS as refetchLyrics (TS)
  participant refetch_lyrics_Rust as refetch_lyrics (Rust)
  participant upsert_lyrics
  participant DB as lyrics (DB)

  Utilisateur->>LyricsPanel: Sélectionne un provider dans le dropdown
  LyricsPanel->>refetchLyrics_TS: refetchLyrics(trackId, provider)
  refetchLyrics_TS->>refetch_lyrics_Rust: invoke("refetch_lyrics", {trackId, provider})
  refetch_lyrics_Rust->>upsert_lyrics: provider = Some(provider_str) ou None (miss)
  upsert_lyrics->>DB: INSERT/UPDATE provider
  DB-->>refetch_lyrics_Rust: OK
  refetch_lyrics_Rust-->>LyricsPanel: LyricsPayload { provider }
  LyricsPanel->>LyricsPanel: Affiche badge provider + coche active
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#207: Modifie la même logique multi-provider dans lyrics.rs et syncedlyrics ; ce PR la complète en persistant l'attribution provider jusqu'à l'UI.
  • InstaZDLL/WaveFlow#273: Touche la gestion des erreurs de fetch_lyrics et le flux refetch dans LyricsPanel.tsx, chevauchant directement les modifications de ce PR.

Poem

🎵 La source était LRCLIB, unique et figée,
Désormais Genius, NetEase, Musixmatch — libérés !
Un badge précis, un dropdown qui s'ouvre,
Et les stubs Genius ne piègent plus... aucune œuvre.
provider: Some(🎶) — le mystère est levé ! 🏷️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit les Conventional Commits avec un scope valide et décrit précisément les trois changements majeurs (strip header Genius, suivi du provider réel, refetch utilisateur).
Description check ✅ Passed La description est très détaillée avec contexte, trois commits distincts documentés, tests validés, mais le template PR ne sera pas entièrement rempli (résumé/tests/checklist sous forme narrative).
Linked Issues check ✅ Passed Tous les objectifs de #284 sont adressés : suppression du header Genius, attribution réelle du provider, mécanisme de refetch utilisateur, et affichage gracieux des cas sans paroles.
Out of Scope Changes check ✅ Passed Les changements sont précisément alignés avec #284 : i18n sur 17 locales (requis pour le badge provider), migration DB, commandes backend, et UI correspondante. Aucune dérive détectée.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 fix/genius-lyrics-header-artifact

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

@InstaZDLL InstaZDLL added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: i18n Translations (src/i18n/) type: fix Bug fix size: xl > 500 lines labels Jun 20, 2026
@InstaZDLL InstaZDLL self-assigned this Jun 20, 2026
@InstaZDLL InstaZDLL marked this pull request as ready for review June 20, 2026 20:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@src-tauri/crates/syncedlyrics/src/providers/genius.rs`:
- Around line 113-118: The use of find(" Lyrics") at line 113 in the lyrics
extraction logic is fragile because it locates the first occurrence of the
string " Lyrics", but if the song title itself contains "Lyrics" (such as "No
Lyrics Needed"), the code will cut at the wrong position and leave header
artifacts. Replace the find() method with rfind() to search for the last
occurrence of " Lyrics" instead, which will correctly identify the actual lyrics
section header separator rather than any occurrence within the title text.

In `@src/components/layout/LyricsPanel.tsx`:
- Line 71: The pickerRef variable is declared with the wrong TypeScript type. It
is currently typed as HTMLDivElement but is being attached to a span element in
the JSX. Change the type annotation of pickerRef from useRef<HTMLDivElement |
null>(null) to useRef<HTMLSpanElement | null>(null) to match the actual HTML
element it references.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e8f15fb1-258e-4f91-9660-ced73fddcde2

📥 Commits

Reviewing files that changed from the base of the PR and between f75b321 and fd78fe5.

📒 Files selected for processing (24)
  • src-tauri/crates/app/src/commands/lyrics.rs
  • src-tauri/crates/app/src/lib.rs
  • src-tauri/crates/syncedlyrics/src/providers/genius.rs
  • src-tauri/crates/syncedlyrics/src/providers/mod.rs
  • src-tauri/migrations/app/20260620120000_lyrics_provider.sql
  • src/components/layout/LyricsPanel.tsx
  • src/i18n/locales/ar.json
  • src/i18n/locales/de.json
  • src/i18n/locales/en.json
  • src/i18n/locales/es.json
  • src/i18n/locales/fr.json
  • src/i18n/locales/hi.json
  • src/i18n/locales/id.json
  • src/i18n/locales/it.json
  • src/i18n/locales/ja.json
  • src/i18n/locales/ko.json
  • src/i18n/locales/nl.json
  • src/i18n/locales/pt-BR.json
  • src/i18n/locales/pt.json
  • src/i18n/locales/ru.json
  • src/i18n/locales/tr.json
  • src/i18n/locales/zh-CN.json
  • src/i18n/locales/zh-TW.json
  • src/lib/tauri/lyrics.ts

Comment thread src-tauri/crates/syncedlyrics/src/providers/genius.rs Outdated
Comment thread src/components/layout/LyricsPanel.tsx Outdated
Two findings flagged on PR #287.

## Genius header strip anchors on newline (not first/last match)

CodeRabbit pointed out that `find(" Lyrics")` cuts at the WRONG
position when the song title itself contains "Lyrics" (e.g.
"No Lyrics Needed" by The Avalanches): the first match lands inside
the title rather than at the closer, and the strip leaves
" Needed Lyrics\n…" stuck to the body.

The suggested fix (swap `find` for `rfind`) trades one bug for
another: a body line like "Some Lyrics" would then match LAST and
the strip would eat past the closer into the verses. Neither
extreme is correct — the header closer is the FIRST " Lyrics"
immediately followed by a newline or end-of-string, since Genius
always writes either `<title> Lyrics\n[Verse…]` (when transcribed)
or `<title> Lyrics` at end-of-string (stub catalogue entry).

Iterate via `match_indices(" Lyrics")` and take the first hit whose
tail starts with `\n` / `\r` / is empty. Two new tests pin both
edge cases:
- `strip_header_picks_closer_when_title_contains_lyrics_word`
- `strip_header_does_not_swallow_body_containing_lyrics_word`

## pickerRef typed as HTMLDivElement attached to a span

`LyricsPanel`'s provider picker hangs off a `<span class="relative
inline-flex">` wrapper around the source label, so `useRef`'s
parameter must be `HTMLSpanElement`. The mismatch compiled under
React's loose ref typing today, but any future code that reads
`pickerRef.current.classList` or similar would see a stale
`DOMTokenList | undefined` shape against the actual span.
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai Both findings addressed in c9c1b4c.

Finding 1 (genius.rs strip) — flagged the issue accurately, but rfind would have introduced a symmetric bug: a body line like "Some Lyrics" would then match LAST and the strip would eat past the closer into the verses. The right anchor is newline-after-token: Genius always writes the closer as <title> Lyrics\n<body> or <title> Lyrics at EOF, never <title> Lyrics <something>. Switched to match_indices(" Lyrics") + filter on tail starting with \n / \r / empty. Two new tests pin both edges:

  • strip_header_picks_closer_when_title_contains_lyrics_word (your example: "No Lyrics Needed")
  • strip_header_does_not_swallow_body_containing_lyrics_word (the would-be rfind failure mode)

Finding 2 (pickerRef type) — straight fix, useRef<HTMLSpanElement | null>(null) to match the <span class="relative inline-flex"> it attaches to.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL The match_indices(" Lyrics") + tail-anchor approach is the right call — rfind would have been a regression waiting to happen as soon as a verse contained the word "Lyrics". The two pinning tests cover both failure modes cleanly. Both fixes confirmed. ✅

🐇 ✓

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/layout/LyricsPanel.tsx (1)

172-194: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Évite l’écrasement par une réponse refetch obsolète.

Si la piste change pendant await refetchLyrics(...), la réponse de l’ancienne piste peut écraser payload de la nouvelle. Ajoute un garde (id de requête ou ref du trackId courant) avant setPayload(next).

Correctif minimal proposé
+  const latestTrackIdRef = useRef<typeof trackId>(trackId);
+  useEffect(() => {
+    latestTrackIdRef.current = trackId;
+  }, [trackId]);
+
   const handleRefetch = async (provider?: LyricsProvider) => {
     if (trackId == null) return;
+    const requestTrackId = trackId;
     try {
       setIsFetching(true);
       setPickerOpen(false);
       const next = await refetchLyrics(trackId, provider);
+      if (latestTrackIdRef.current !== requestTrackId) return;
       setPayload(next);
       setError(null);
🤖 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 `@src/components/layout/LyricsPanel.tsx` around lines 172 - 194, The
handleRefetch function has a race condition where if the trackId changes while
awaiting refetchLyrics, the old response could overwrite the payload of the new
track. Capture the trackId at the start of handleRefetch before the await call,
then add a guard condition before setPayload(next) to verify that the trackId
hasn't changed since the refetch was initiated. Only proceed with setPayload if
the captured trackId matches the current trackId, ensuring stale responses from
previous tracks do not overwrite the current payload.
🤖 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 `@src-tauri/crates/syncedlyrics/src/providers/genius.rs`:
- Around line 106-108: The strip_prefix call in the genius provider only handles
the plural form " Contributors" but Genius can also return the singular form "
Contributor". Modify the strip_prefix logic to handle both singular and plural
cases. You can achieve this by checking if after_digits starts with either "
Contributor" or " Contributors", handling both possibilities before continuing
with the text processing.

---

Outside diff comments:
In `@src/components/layout/LyricsPanel.tsx`:
- Around line 172-194: The handleRefetch function has a race condition where if
the trackId changes while awaiting refetchLyrics, the old response could
overwrite the payload of the new track. Capture the trackId at the start of
handleRefetch before the await call, then add a guard condition before
setPayload(next) to verify that the trackId hasn't changed since the refetch was
initiated. Only proceed with setPayload if the captured trackId matches the
current trackId, ensuring stale responses from previous tracks do not overwrite
the current payload.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cd168db3-0c18-4ea6-931a-82f798971805

📥 Commits

Reviewing files that changed from the base of the PR and between fd78fe5 and c9c1b4c.

📒 Files selected for processing (2)
  • src-tauri/crates/syncedlyrics/src/providers/genius.rs
  • src/components/layout/LyricsPanel.tsx

Comment thread src-tauri/crates/syncedlyrics/src/providers/genius.rs Outdated
Two new CodeRabbit findings on PR #287.

## Singular "Contributor" badge

Genius pluralises the contributor badge conditionally — a track
with a single transcriber renders "1 Contributor" (no trailing
's'). The previous `strip_prefix(" Contributors")` missed this
shape and a 1-transcriber track shipped the bare header to the
user. Strip the singular `" Contributor"` first and consume an
optional `'s'` to cover both forms in a single pass instead of two
near-identical branches.

New test `strip_header_handles_singular_contributor_badge` pins
the singular case.

## handleRefetch stale-response race

A refetch on track A that takes longer than the user's switch to
track B would land its result into B's payload — the same
trap the initial-fetch useEffect already avoided with its
per-render `cancelled` flag. User-triggered handlers can't rely on
effect cleanup for the same job, so add a `trackIdRef` synced via
a sibling effect and have `handleRefetch` capture the requested
trackId at call time and guard every setter (`setPayload`,
`setError`, `setIsFetching`) against it.

The `setIsFetching(false)` guard is what kept this from being a
simple one-line capture — clearing the spinner after the user
switched would race the new track's useEffect which already
flipped it to `true` for its own request.
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai Both round-2 findings addressed in 01eff6d.

Finding 1 (singular "Contributor" badge) — valid. Strip " Contributor" first then consume an optional trailing 's' so both "1 Contributor…" and "17 Contributors…" collapse in a single pass. New test strip_header_handles_singular_contributor_badge pins it.

Finding 2 (handleRefetch race) — valid. Captured requestedTrackId at the call site and added a sibling effect that mirrors trackId into a trackIdRef so the async handler can compare against the LIVE value (the closure's own trackId would always equal requestedTrackId since they came from the same render-time scope). Guarded every setter — setPayload, setError, AND setIsFetching — because clearing the spinner after a switch would race the new track's useEffect that already flipped it to true.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Les deux fixes sont solides.

  • Contributor badge : strip-then-optional-s est plus propre qu'un | en regex — un seul chemin de code pour les deux formes, et le test couvre le cas manquant. ✅
  • Race condition : trackIdRef + guard sur les trois setters est exactement le bon pattern React. Important d'avoir guardé setIsFetching aussi — sans ça, le spinner d'une nouvelle piste pouvait être éteint par le callback d'une piste précédente. ✅

🐇 ✓

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/layout/LyricsPanel.tsx (2)

485-513: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Le menu dropdown ne gère pas la touche Escape pour fermer.

Le picker utilise role="menu" ce qui implique une navigation clavier selon les pratiques ARIA. Actuellement, seul le clic extérieur ferme le menu. Ajouter Escape améliorerait l'UX clavier.

♿ Suggestion d'amélioration

Ajouter un handler onKeyDown sur le conteneur du menu ou étendre l'effet click-outside :

 useEffect(() => {
   if (!pickerOpen) return;
   const handler = (e: MouseEvent) => {
     if (
       pickerRef.current &&
       e.target instanceof Node &&
       pickerRef.current.contains(e.target)
     ) {
       return;
     }
     setPickerOpen(false);
   };
+  const keyHandler = (e: KeyboardEvent) => {
+    if (e.key === "Escape") {
+      setPickerOpen(false);
+    }
+  };
   document.addEventListener("mousedown", handler);
+  document.addEventListener("keydown", keyHandler);
   return () => {
     document.removeEventListener("mousedown", handler);
+    document.removeEventListener("keydown", keyHandler);
   };
 }, [pickerOpen]);
🤖 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 `@src/components/layout/LyricsPanel.tsx` around lines 485 - 513, The menu
dropdown with role="menu" lacks keyboard support for closing via the Escape key,
which is expected behavior for ARIA menus. Add an onKeyDown event handler to the
div element with role="menu" that checks if the pressed key is Escape, and if
so, calls a function to close the picker (set pickerOpen to false). This handler
should be added alongside the existing className and aria-label attributes to
provide proper keyboard navigation support.

165-183: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

handleImport a la même vulnérabilité de race condition que handleRefetch mais n'est pas protégé.

Le PR corrige explicitement la race condition dans handleRefetch (un refetch sur track A qui atterrit dans le payload de track B après un switch). handleImport suit exactement le même pattern async : l'utilisateur peut ouvrir le file picker sur track A, changer de track pendant que le picker est ouvert, puis les lyrics importées écrasent le payload de track B.

🐛 Correction proposée
 const handleImport = async () => {
   if (trackId == null) return;
+  const requestedTrackId = trackId;
   try {
     const path = await pickFile(
       ["lrc", "elrc", "ttml", "xml", "txt"],
       t("lyrics.importTitle"),
     );
     if (!path) return;
-    const next = await importLrcFile(trackId, path);
+    const next = await importLrcFile(requestedTrackId, path);
+    if (requestedTrackId !== trackIdRef.current) return;
     setPayload(next);
-    // Drop any error left from a prior failed fetch — otherwise the
-    // error-vs-notFound conditional below would mask the freshly
-    // imported lyrics behind the stale error state.
     setError(null);
   } catch (err) {
     console.error("[LyricsPanel] import failed", err);
+    if (requestedTrackId !== trackIdRef.current) return;
     setError(String(err));
   }
 };
🤖 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 `@src/components/layout/LyricsPanel.tsx` around lines 165 - 183, The
handleImport function has a race condition where the trackId can change after
the async file picker operation starts but before setPayload is called, causing
imported lyrics from one track to overwrite another track's payload. Capture the
current trackId value at the beginning of the handleImport function before
calling pickFile, and add a guard check after the importLrcFile call completes
to verify the trackId has not changed (compare the captured value with the
current trackId). Only call setPayload if the trackIds match, preventing stale
imports from overwriting the payload of a different track after the user has
switched tracks during the picker operation.
🤖 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.

Outside diff comments:
In `@src/components/layout/LyricsPanel.tsx`:
- Around line 485-513: The menu dropdown with role="menu" lacks keyboard support
for closing via the Escape key, which is expected behavior for ARIA menus. Add
an onKeyDown event handler to the div element with role="menu" that checks if
the pressed key is Escape, and if so, calls a function to close the picker (set
pickerOpen to false). This handler should be added alongside the existing
className and aria-label attributes to provide proper keyboard navigation
support.
- Around line 165-183: The handleImport function has a race condition where the
trackId can change after the async file picker operation starts but before
setPayload is called, causing imported lyrics from one track to overwrite
another track's payload. Capture the current trackId value at the beginning of
the handleImport function before calling pickFile, and add a guard check after
the importLrcFile call completes to verify the trackId has not changed (compare
the captured value with the current trackId). Only call setPayload if the
trackIds match, preventing stale imports from overwriting the payload of a
different track after the user has switched tracks during the picker operation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cdb89c78-9556-4d08-9e78-afb78d0dcb5d

📥 Commits

Reviewing files that changed from the base of the PR and between c9c1b4c and 01eff6d.

📒 Files selected for processing (2)
  • src-tauri/crates/syncedlyrics/src/providers/genius.rs
  • src/components/layout/LyricsPanel.tsx

Two CodeRabbit round-3 findings on PR #287.

## Escape closes the provider picker

The picker dropdown carried `role="menu"` but only closed on
outside-click — the WAI-ARIA APG dismissal pattern also requires
Escape regardless of where focus sits. Extended the existing
mousedown-listener useEffect to also bind a document-level keydown
handler for Escape (catches focus-on-trigger AND focus-on-item
cases in one place), plus mirrored the handler on the menu div
itself with `e.stopPropagation()` so the event doesn't bubble into
any ancestor panel that might also listen for Escape.

## handleImport stale-response race

Same shape as the handleRefetch race fixed in the previous commit:
the user can switch tracks during the file picker (which can sit
on screen for a while) and again during the `importLrcFile` disk
+ DB work. Without a guard the stale payload would clobber the new
track's UI.

The fix mirrors handleRefetch: capture `requestedTrackId` at the
call site, compare against `trackIdRef.current` after each await,
skip the UI updates when stale. Crucially the `importLrcFile`
call itself is NOT cancelled when the user switches away — the
write goes to the captured track's DB row, which IS what the user
asked for; only the UI updates skip when no longer current.
@InstaZDLL

Copy link
Copy Markdown
Owner Author

@coderabbitai Both round-3 findings addressed in ccf5379.

Finding 1 (Escape key on menu) — valid per WAI-ARIA APG. Extended the existing mousedown useEffect to ALSO bind a document-level keydown for Escape (catches focus-on-trigger AND focus-on-item without depending on event bubbling through the menu div), plus mirrored the handler on the menu div itself with e.stopPropagation() so the event doesn't bubble into ancestor panels that might also listen for Escape. Document-level listener subsumes the menu onKeyDown in practice but keeping both makes the menu's a11y contract self-contained on the element.

Finding 2 (handleImport race) — valid, same shape as handleRefetch from round 2. Captured requestedTrackId at the call site, guarded setPayload + setError against trackIdRef.current. Note: the importLrcFile call itself is NOT cancelled when the user switches away — the disk + DB write goes to the captured track's row, which IS what the user asked for; only the UI updates skip when no longer current. Cancelling the write would silently lose the user's intent.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL Les deux fixes sont corrects.

  • Escape key : double-binding (document-level + onKeyDown sur le menu div avec stopPropagation) est exactement le pattern WAI-ARIA APG recommandé — le listener document capture tous les cas de focus, et stopPropagation isole le menu des panels ancêtres. ✅
  • handleImport race : la distinction entre "ne pas annuler l'écriture" et "ignorer la mise à jour UI" est la bonne lecture — l'écriture disque/BDD cible la bonne ligne et représente l'intention explicite de l'utilisateur, seul le rendu est guardé par trackIdRef. ✅

🐇 ✓

@InstaZDLL InstaZDLL merged commit 4a1f67f into main Jun 20, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the fix/genius-lyrics-header-artifact branch June 20, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: backend Rust/Tauri backend (src-tauri/) scope: frontend React/Vite frontend (src/) scope: i18n Translations (src/i18n/) size: xl > 500 lines type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: some issues with lyrics fetch from webs.

1 participant