Skip to content

feat(lyrics): auto-discover local sidecar .lrc / .txt files (#115)#117

Merged
InstaZDLL merged 3 commits into
mainfrom
feat/lyrics-sidecar-autodetect
May 23, 2026
Merged

feat(lyrics): auto-discover local sidecar .lrc / .txt files (#115)#117
InstaZDLL merged 3 commits into
mainfrom
feat/lyrics-sidecar-autodetect

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 23, 2026

Closes #115.

Summary

Many users keep their lyrics as sidecar files next to the audio (`01 Song.mp3` + `01 Song.lrc` / `.txt`) or in a sibling `Lyrics/` subfolder — a very common K-Pop / J-Pop rip layout. Until now those files were invisible to WaveFlow unless the user pressed "Import File" per track, which doesn't scale beyond a handful of songs.

Slot a new tier between the embedded-tag read and the LRCLIB network call in the `fetch_lyrics` waterfall.

New waterfall

  1. DB cache
  2. Embedded tag (USLT / TXXX / Vorbis / MP4 ©lyr)
  3. NEW — Sidecar file
  4. LRCLIB

Sidecar lookup rules

  • Probes `{audio_dir}/{stem}.{lrc|txt}` (same folder, primary)
  • Then `{audio_dir}/Lyrics/{stem}.{lrc|txt}` (sibling subfolder)
  • Case-insensitive at every layer (subfolder name, stem, extension) so `Song.MP3` matches `song.lrc` on Linux and `lyrics/` matches `Lyrics/`
  • `.lrc` wins over `.txt` at every probed directory because it carries timing info
  • Same-folder hits beat `Lyrics/` hits when both exist
  • Whitespace-only files fall through (no empty cache row pollution)

The library-wide `prefetch_library_lyrics` walks the same new waterfall, so the "Prefetch lyrics" button now also surfaces local sidecars without network traffic.

Why `save_lyrics` was left alone

`save_lyrics` keeps writing only to the embedded tag. The sidecar file stays read-only on purpose: the file's authoritative copy becomes the embedded one after any in-app edit (the new file hash invalidates the cache row, the next fetch sees the embedded tag, the sidecar is silently superseded). This avoids two confusing failure modes:

  • Writing back to `Song.lrc` would surprise users who maintain those files with external tools
  • Writing both creates a divergence problem the next time they edit one

Test plan

Backend

  • 9 new unit tests added in `commands::lyrics::tests` (all 17 tests in the module pass)
  • `cargo check --all-targets` ✅

Frontend

  • `bun run lint` ✅
  • `bun run typecheck` ✅

Manual

  • Drop a `01 Track.lrc` next to a `.mp3` track with no embedded lyrics → open the lyrics panel → synced lyrics appear without clicking Import
  • Replace the `.lrc` with a same-named `.txt` → "Refetch" (clears cache) → reopen panel → plain text appears
  • Put both at the same time → `.lrc` is preferred
  • Move the sidecar into a `Lyrics/` subfolder next to the audio → "Refetch" → still found
  • On Linux: try `lyrics/` (lowercase) — also found
  • On Linux with `Song.MP3` + `song.lrc` (case mismatch) — found
  • No sidecar, no embedded, online → LRCLIB still consulted as before (sidecar tier is invisible to the existing fallback)
  • No sidecar, no embedded, offline → returns None (sidecar runs even in offline mode since it's local I/O — confirmed)
  • Edit lyrics via the in-app editor → save → reopen panel → updated content shown (from embedded tag, sidecar superseded)

Docs

Summary by CodeRabbit

Notes de version

  • Nouvelles fonctionnalités

    • Recherche et prise en charge des fichiers de paroles sidecar (.lrc/.txt) à côté des pistes ou dans un dossier Lyrics (recherche insensible à la casse, priorité .lrc > .txt, ignore les répertoires homonymes).
  • Comportement

    • Sidecar vide/whitespace est rejeté; lecture sidecar court-circuite la requête réseau et met le cache à jour avec source=LrcFile.
    • Sauvegarde n’écrit que l’embedded; un nouvel embedded supplante silencieusement l’ancienne sidecar en cache.
  • Documentation

    • Ajout du niveau “Sidecar” et précisions sur priorités, cache et import_lrc_file (écrasement du cache).
  • Tests

    • Tests unitaires couvrant découverte, priorité, casse et fichiers vides.

Review Change Stack

Many users keep their lyrics as sidecar files next to the audio
(\`01 Song.mp3\` + \`01 Song.lrc\` / \`.txt\`) or in a sibling
\`Lyrics/\` subfolder — common K-Pop / J-Pop rip layout. Until now
those files were invisible to WaveFlow unless the user pressed
"Import File" per track, which doesn't scale to large libraries.

Slot a new tier between the embedded-tag read and the LRCLIB
network call in the \`fetch_lyrics\` waterfall:

  1. DB cache
  2. Embedded tag (USLT / TXXX / Vorbis / MP4 ©lyr)
  3. NEW: Sidecar file
  4. LRCLIB

The sidecar lookup probes:
  - {audio_dir}/{stem}.{lrc|txt} (same folder, primary)
  - {audio_dir}/Lyrics/{stem}.{lrc|txt} (sibling subfolder)

Both stem and extension matching are case-insensitive so a
mixed-case rip like \`Song.MP3\` finds \`song.lrc\` cleanly on
case-sensitive filesystems. \`.lrc\` wins over \`.txt\` at every
probed directory because it carries timing info, and same-folder
hits beat \`Lyrics/\` hits. Whitespace-only files fall through
instead of caching an empty entry.

The library-wide \`prefetch_library_lyrics\` walks the same new
waterfall, so the "Prefetch lyrics" button now also surfaces
local sidecars without network traffic.

\`save_lyrics\` still writes only to the embedded tag, so the
sidecar file stays read-only: the file's authoritative copy
becomes the embedded one after any in-app edit (the new file
hash invalidates the cache row, the next fetch sees the embedded
tag, sidecar is silently superseded).

9 new unit tests cover the helper's matching rules
(same-folder, subfolder case-insensitive, stem case-insensitive,
.lrc-wins-over-.txt, same-folder-beats-subfolder, empty file,
missing file, etc). All 17 \`commands::lyrics\` tests pass.

Docs updated: CLAUDE.md playback catalogue and
docs/features/integrations.md LRCLIB waterfall section.
@InstaZDLL InstaZDLL added scope: backend Rust/Tauri backend (src-tauri/) scope: docs Docs, README, assets type: feat New feature size: l 200-500 lines labels May 23, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 23, 2026

📝 Walkthrough

Walkthrough

Ce PR ajoute la lecture de fichiers lyrics locaux (.lrc / .txt) situés à côté des fichiers audio ou dans un dossier Lyrics/ comme 4ème niveau du lookup (avant l'API LRCLIB), avec intégration dans fetch/prefetch et documentation mise à jour.

Changes

Support des fichiers lyrics sidecar locaux

Layer / File(s) Résumé
Documentation et commentaires
CLAUDE.md, docs/features/integrations.md, src-tauri/src/commands/lyrics.rs
Mise à jour du flux 4-niveaux (cache → tag embarqué → sidecar → LRCLIB), clarification que save_lyrics n'écrit que dans embedded et que le sidecar reste en lecture seule, et précision que import_lrc_file écrase la ligne cache indépendamment du niveau précédent.
Lecteur de fichiers sidecar
src-tauri/src/commands/lyrics.rs
Fonction read_sidecar_lyrics + helpers : recherche {stem}.lrc/.txt (case-insensitive) d'abord dans le même dossier, puis dans Lyrics/, priorité .lrc, trim, rejet des contenus vides, et protection contre les dossiers homonymes.
Intégration dans fetch (requête utilisateur)
src-tauri/src/commands/lyrics.rs
fetch_lyrics intercale la lecture sidecar via spawn_blocking, détecte le format, persiste en cache (source = LrcFile) et retourne immédiatement sans appeler LRCLIB pour les hits sidecar.
Intégration dans préfetch (opération bulk)
src-tauri/src/commands/lyrics.rs
Pipeline de prefetch_library_lyrics mis à jour : après le tag embarqué, charge les sidecars en spawn_blocking, met à jour le cache en LrcFile, incrémente hits/failed, puis passe au titre suivant avant toute tentative LRCLIB.
Tests unitaires
src-tauri/src/commands/lyrics.rs
Tests pour read_sidecar_lyrics : même dossier, priorité .lrc sur .txt, fallback .txt, lecture depuis Lyrics/ insensible à la casse, matching du stem insensible à la casse, priorité dossier courant, et retours None pour fichiers manquants ou vides.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant fetch_lyrics
  participant DBCache
  participant EmbeddedTags
  participant SidecarReader
  participant LRCLib
  Client->>fetch_lyrics: request lyrics(track)
  fetch_lyrics->>DBCache: lookup by track.file_hash
  alt cache hit
    DBCache-->>fetch_lyrics: cached payload
    fetch_lyrics-->>Client: return cached payload
  else cache miss
    fetch_lyrics->>EmbeddedTags: read embedded tag (USLT/TXXX)
    alt embedded hit
      EmbeddedTags-->>fetch_lyrics: embedded payload
      fetch_lyrics-->>DBCache: upsert(source=Embedded)
      fetch_lyrics-->>Client: return embedded payload
    else embedded miss
      fetch_lyrics->>SidecarReader: spawn_blocking read_sidecar_lyrics(path)
      alt sidecar hit
        SidecarReader-->>fetch_lyrics: sidecar payload / format
        fetch_lyrics-->>DBCache: upsert(source=LrcFile)
        fetch_lyrics-->>Client: return sidecar payload
      else sidecar miss
        fetch_lyrics->>LRCLib: network lookup
        LRCLib-->>fetch_lyrics: remote payload
        fetch_lyrics-->>DBCache: upsert(source=LRCLib)
        fetch_lyrics-->>Client: return remote payload
      end
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • InstaZDLL/WaveFlow#44: Les changements du pipeline de préfetch de ce PR sont liés (modifications de run_prefetch et gestion des résultats/progress).

Suggested labels

size: m

Poem

🎶 Des mots collés aux fichiers sonores,
.lrc et .txt cherchent leur aurore,
Dans Lyrics/ ou près du titre, sans bruit,
Quatre niveaux s'alignent pour la nuit.
Un cache, un tag, un fichier — puis le bruit.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre suit les conventions Conventional Commits avec scope kebab-case et résume clairement l'ajout principal : découverte automatique des fichiers sidecar .lrc/.txt.
Linked Issues check ✅ Passed Le PR implémente l'intégralité des objectifs de l'issue #115 : découverte auto des sidecars .lrc/.txt, support du dossier Lyrics/, correspondance insensible à la casse, préférence .lrc sur .txt, et aucune interaction manuelle requise.
Out of Scope Changes check ✅ Passed Tous les changements restent dans le scope : modifications des commandes lyrics, mise à jour de la documentation (CLAUDE.md, integrations.md) et ajout de tests unitaires, sans altérations non liées.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Description check ✅ Passed La description suit le modèle de repository avec titre en Conventional Commits, résumé détaillé, plan de test complet et checklist de validation.

✏️ 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 feat/lyrics-sidecar-autodetect

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

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 `@src-tauri/src/commands/lyrics.rs`:
- Around line 380-399: The loop over entries currently treats directory names
like "Song.lrc" as candidates and later fails when trying to read them; update
the loop that sets lrc_match and txt_match (the for entry in entries.flatten()
block that uses path, file_stem, lrc_match, txt_match and produces chosen) to
skip non-regular files before evaluating file_stem and extension — e.g., check
that the entry/path is a file (via entry.file_type()/metadata() or
path.is_file()) and continue if not, so only real files are considered for
.lrc/.txt selection.
🪄 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: 65c75f2c-2549-4689-a771-bd2681b808f1

📥 Commits

Reviewing files that changed from the base of the PR and between cbf3805 and d5d6e55.

📒 Files selected for processing (3)
  • CLAUDE.md
  • docs/features/integrations.md
  • src-tauri/src/commands/lyrics.rs

Comment thread src-tauri/src/commands/lyrics.rs Outdated
@InstaZDLL InstaZDLL self-assigned this May 23, 2026
CodeRabbit caught a real edge case: a directory named `Song.lrc`
next to a track would be selected into `lrc_match`,
`read_to_string` on it would fail, and a legitimate `Song.txt`
fallback in the same directory would be silently masked.

Skip non-file entries up front with `path.is_file()` so only
real files are considered. `is_file` follows symlinks so a
symlinked sidecar (one master `lyrics/` folder linked from
album directories) still works.

Added a regression test that reproduces the original failure
(directory `Song.lrc` shadowing valid `Song.txt`) and asserts
the fallback now wins.
@InstaZDLL
Copy link
Copy Markdown
Owner Author

Applied in 78d9af6. Real edge case caught — a directory named Song.lrc would shadow a valid Song.txt sidecar in the same folder (the directory got selected into lrc_match, read_to_string failed, and the fallback was silently lost).

Fix: if !path.is_file() { continue; } at the top of the loop. is_file follows symlinks so a symlinked sidecar (one master lyrics/ folder linked from album directories) still works.

Added a regression test sidecar_skips_directory_named_like_a_sidecar that reproduces the original failure and asserts the fallback wins — all 18 commands::lyrics tests pass.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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 (1)
src-tauri/src/commands/lyrics.rs (1)

382-413: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ne laisse pas un .lrc vide masquer un .txt valide.

Le is_file() corrige bien le cas du dossier, mais la sélection reste faite avant lecture. Du coup, si Song.lrc existe mais ne contient que des espaces, la fonction renvoie None au lieu de retomber sur Song.txt, alors que les sidecars vides sont censés être ignorés.

Patch proposé
-    let chosen = lrc_match.or(txt_match)?;
-    let raw = std::fs::read_to_string(&chosen).ok()?;
-    let trimmed = raw.trim();
-    if trimmed.is_empty() {
-        None
-    } else {
-        Some(trimmed.to_string())
-    }
+    for candidate in [lrc_match, txt_match].into_iter().flatten() {
+        let raw = match std::fs::read_to_string(&candidate) {
+            Ok(raw) => raw,
+            Err(_) => continue,
+        };
+        let trimmed = raw.trim();
+        if !trimmed.is_empty() {
+            return Some(trimmed.to_string());
+        }
+    }
+    None

Je rajouterais aussi un test de régression pour le couple .lrc vide + .txt valide.

🤖 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-tauri/src/commands/lyrics.rs` around lines 382 - 413, The current
selection picks lrc_match over txt_match before reading, so an .lrc file that is
empty (only whitespace) will mask a valid .txt; change the logic to prefer a
non-empty sidecar: try reading lrc_match first and if reading succeeds but
trimmed.is_empty() then fall back to reading txt_match (or vice‑versa if you
prefer txt priority), i.e., after computing lrc_match and txt_match, attempt
read_to_string(&lrc_match) -> trimmed and only return it if non-empty, otherwise
attempt read_to_string(&txt_match) and return that if non-empty; update the code
paths around chosen/raw/trimmed to perform this fallback and add a regression
test that creates an empty Song.lrc and a valid Song.txt to assert the .txt is
used.
🤖 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-tauri/src/commands/lyrics.rs`:
- Around line 382-413: The current selection picks lrc_match over txt_match
before reading, so an .lrc file that is empty (only whitespace) will mask a
valid .txt; change the logic to prefer a non-empty sidecar: try reading
lrc_match first and if reading succeeds but trimmed.is_empty() then fall back to
reading txt_match (or vice‑versa if you prefer txt priority), i.e., after
computing lrc_match and txt_match, attempt read_to_string(&lrc_match) -> trimmed
and only return it if non-empty, otherwise attempt read_to_string(&txt_match)
and return that if non-empty; update the code paths around chosen/raw/trimmed to
perform this fallback and add a regression test that creates an empty Song.lrc
and a valid Song.txt to assert the .txt is used.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 02b96f91-d8bd-4386-a144-0c381b748181

📥 Commits

Reviewing files that changed from the base of the PR and between d5d6e55 and 78d9af6.

📒 Files selected for processing (1)
  • src-tauri/src/commands/lyrics.rs

Real edge case from the review: a `Song.lrc` that exists but is
empty / whitespace-only would short-circuit the waterfall.
`lrc_match.or(txt_match)` always picked the lrc, the trimmed
read returned None, and a perfectly valid `Song.txt` sibling
was silently lost (we fell through to LRCLIB instead).

Try each candidate individually: read the lrc, if its trimmed
content is non-empty use it, otherwise read the txt. Extracted
the read+trim+is_empty dance into a `read_non_empty_file` helper
so both candidates share the same logic.

Added regression test `sidecar_empty_lrc_falls_back_to_txt` that
reproduces the original failure (empty `.lrc` + valid `.txt`)
and asserts the `.txt` content wins. All 19 `commands::lyrics`
tests pass.
@InstaZDLL
Copy link
Copy Markdown
Owner Author

Applied in 49f8d8a. Real bug — an empty Song.lrc (common in low-quality rips that ship stub files) would short-circuit the waterfall via the lrc_match.or(txt_match) shortcut, the trimmed read returned None, and a valid Song.txt next to it was silently lost (so we'd fall through to LRCLIB unnecessarily).

Fix: try each candidate individually, fall back to .txt only if the .lrc read is empty. Extracted the read+trim+is_empty dance into a read_non_empty_file helper so both candidates share the same logic and stay symmetrical.

New regression test sidecar_empty_lrc_falls_back_to_txt reproduces the failure and asserts the txt wins. All 19 commands::lyrics tests pass.

@InstaZDLL InstaZDLL merged commit 0274cf2 into main May 23, 2026
14 checks passed
@InstaZDLL InstaZDLL deleted the feat/lyrics-sidecar-autodetect branch May 23, 2026 17:14
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: docs Docs, README, assets size: l 200-500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Better support for local Lyrics files (.Lrc / .Txt ) and auto view them.

1 participant