Skip to content

feat(plugins): a plugin world that can carry word-level lyrics - #654

Merged
InstaZDLL merged 5 commits into
mainfrom
feat/lyrics-plugin-world
Sep 15, 2026
Merged

InstaZDLL merged 5 commits into
mainfrom
feat/lyrics-plugin-world

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented Sep 15, 2026

Copy link
Copy Markdown
Owner

waveflow:metadata/v1 could describe a line and an optional start time — which is what LRCLIB already gives away for free — so a provider holding per-word timing had to throw it away to hand lyrics over. The host never called the function either: runtime.rs said so in a comment and nothing contradicted it.

Closes #585.

What v2 carries

A bundle: the document a provider served, verbatim, with its translations and pronunciation alongside.

Documents rather than a lines/words record, because the host does not model lyrics as a structure at all. It stores content: String plus a format tag, and the line-and-word split lives in TypeScript, in the renderer. A Rust model here would duplicate a parser that already exists and would still drop what it cannot express: per-word timing, per-line singer, background vocals — all of which survive untouched inside TTML.

The host's whole trust in a plugin document is detect_format: re-sniff the content, refuse it unless it matches the declared format. It is the routine the embedded-tag and sidecar tiers already rely on, and it is deliberately conservative. No semantic parsing crosses into Rust.

A bundle is cached and replaced as a unit, so a Musixmatch original can never be served under a leftover Apple translation.

Why lyrics keeps its shape

lyrics and radio_lyrics mean "the lyrics currently chosen for this identity", and every existing read depends on that being structural rather than a query that has to ask which row is the primary one. Widening their key would have changed that property for the sake of a cardinality only one tier needs. Each gains a child table instead, with a real foreign key — one per parent rather than a shared polymorphic table, which could not have carried a foreign key to either.

The clearing lives in upsert_lyrics / upsert_radio_lyrics themselves, so every tier inherits it — embedded tag, sidecar, LRCLIB, the fallback chain, manual save, LRC import. Putting it in the callers would have meant finding all six and hoping the seventh remembers.

The child tables carry no CHECK on format, unlike their parents. SQLite cannot alter a CHECK in place, so adding ttml meant recreating lyrics wholesale in 20260516120000 — create, copy, drop, rename. With children cascading under foreign_keys = ON, repeating that pattern would now erase every translation in silence. The host validates the value before writing it, which removes the only reason anyone would need to.

Two things this had to fix to exist at all

Plugin enumeration matched worlds by prefix. "waveflow:metadata" would have matched a v2 plugin and instantiated it against v1 bindings — the one outcome a version label exists to prevent. Every call site now matches the exact label, and the UI constant's "and any future /v2" reasoning is corrected rather than carried forward.

The world catalog had already drifted from the published registry schema. waveflow:canvas/v1 is accepted by the host and absent from the enum, so a Canvas plugin would have been rejected at publication by a registry the host would have loaded happily. worlds::ALL is the single source now, worlds.json is checked against it by a test, and the registry is meant to generate its enum from that file rather than keep a copy.

Found by the local review, and worth naming

Three defects, none of them in the code being written — all in code that quietly read something whose nature had changed:

  • Motion artwork enumerated v1 only. v2 exports album-info exactly as v1 does, so the first plugin to migrate would have stopped serving animated covers, silently. That plugin is apple-artwork — the one this world exists for.
  • refetch_lyrics handed lyrics.provider to Provider::from_id. Re-fetching lyrics a plugin had supplied would have failed as "unknown lyrics provider", for a value this code wrote. Plugin provenance is namespaced plugin: now, and refetch re-runs that plugin, filtering the enumeration rather than trusting the stored id.
  • The source badge built lyrics.provider.${provider}. That key exists in none of the 17 locales, and i18next renders a missing key as the key itself, so the badge would have read literally lyrics.provider.plugin:apple-lyrics.

Deliberately not here

The renderer does not display the associated documents. The payload carries them and the TypeScript type exists, but nothing draws them — because nothing produces them yet. The only planned source is the Apple Music plugin of #584, which lives in a separate repository and was waiting for this world. Building the UI now would be drawing against data no one can make.

The registry schema generation is in waveflow-plugins, not here. worlds.json is in place and proven consistent with the catalog; the other repository has to start generating from it, or canvas/v1 — and now metadata/v2 — stay unpublishable.

Validation

Four local review passes: 8 findings, all valid, all fixed; the fourth pass is clean.

Linux (the only job that runs the app-crate tests): fmt clean, clippy -D warnings at 0, 614 app + 319 core. Windows: fmt, clippy -D warnings across the workspace, typecheck, eslint, prettier.

Five tests added. The two that guard data were proven to fail without their fix, by breaking the code and watching them fall: removing the clearing from upsert_lyrics drops the bundle-invariant test, and neutralising the de-duplication drops the consistency test on 3 documents returned against 2 stored.

Summary by CodeRabbit

  • Nouvelles fonctionnalités

    • Les paroles peuvent désormais inclure des traductions et prononciations associées, avec prise en charge du cache et de la radio.
    • Les fournisseurs de paroles issus de plugins sont affichés plus clairement.
    • Les plugins de métadonnées de nouvelle génération peuvent fournir des informations d’artistes, d’albums, de paroles et de pochettes animées.
    • La récupération des pochettes animées prend en charge plusieurs générations de plugins.
  • Fiabilité

    • Les résultats invalides, erreurs et délais d’expiration des plugins sont gérés sans interrompre l’affichage des autres sources.
    • Les documents associés sont enregistrés et supprimés de manière cohérente avec les paroles principales.

`waveflow:metadata/v1` could describe a line and an optional start time,
which is what LRCLIB already gives away, so a provider holding per-word
timing had to discard it to hand lyrics over. The host never called the
function either — `runtime.rs` said so in a comment and nothing
contradicted it.

v2 carries a bundle instead: the document a provider served, verbatim,
with its translations and pronunciation alongside. Documents rather than
a lines/words record, because the host does not model lyrics as a
structure at all — it stores content plus a format tag, and the line and
word split lives in the renderer. A Rust model here would duplicate a
parser that already exists and would still drop what it cannot express:
per-word timing, singer attribution, background vocals, all of which
survive untouched inside TTML.

The host's whole trust in a plugin document is `detect_format`: re-sniff
the content, refuse it unless it matches the declared format. No semantic
parsing crosses into Rust.

A bundle is cached and replaced as a unit. `lyrics` and `radio_lyrics`
keep meaning "the lyrics currently chosen for this identity" and each
gains a child table, rather than growing a key that would make every
existing read ask which row is the primary one. The clearing lives in
`upsert_lyrics` / `upsert_radio_lyrics` so every tier inherits it —
embedded tag, sidecar, LRCLIB, the fallback chain, manual save, LRC
import — and a Musixmatch original can never be served under a leftover
Apple translation.

Two things found on the way, both required for v2 to exist at all:

Plugin enumeration matched worlds by PREFIX. `waveflow:metadata` would
have matched a v2 plugin and instantiated it against v1 bindings, which
is the one outcome a version label exists to prevent. Every call site now
matches the exact label, and the UI constant's "and any future /v2"
reasoning is corrected rather than carried forward.

The world catalog had already drifted from the published registry schema:
`waveflow:canvas/v1` is accepted by the host and absent from the enum, so
a Canvas plugin would have been rejected at publication by a registry the
host would have loaded happily. `worlds::ALL` is now the single source,
`worlds.json` is checked against it by a test, and the registry generates
its enum from that file instead of keeping a copy.

The child tables carry no CHECK on `format` on purpose. SQLite cannot
alter one in place, so adding `ttml` meant recreating `lyrics` wholesale
in 20260516120000 — and with children cascading under `foreign_keys = ON`
that same pattern would now erase every translation in silence. The host
validates the value before writing it, which removes the only reason
anyone would need to.

Closes #585.
The harness ran ATTACH once through the pool, so it landed on whichever connection served that call and every later query answered 'no such table: app.lyrics'. ATTACH is per-connection; db::profile_db::open puts it in after_connect for that reason, and the test now does the same. Both databases are files for the same class of reason: a sqlite::memory: pool gives each connection its own empty database, so the profile schema would have gone missing one connection later.
The two that mattered were both the same shape — a second site that had
to move with the first.

Motion artwork enumerated `metadata/v1` only. v2 exports `album-info`
exactly as v1 does, so the first plugin to migrate would have stopped
serving animated covers, silently: a plugin that is never asked cannot
report that it was not asked. And the plugin the world exists for,
`apple-artwork`, is precisely the one due to migrate. Both worlds are
enumerated now, each id carried with the world it declared, because
instantiating a v2 component against v1 bindings is the one outcome a
version label exists to prevent.

`lyrics.provider` holds a plugin id in the plugin tier, and
`refetch_lyrics` hands that column to `Provider::from_id`, which knows
only the built-in network ids. Re-fetching lyrics a plugin had supplied
would have failed as "unknown lyrics provider" — for a value this code
wrote. Plugin provenance is namespaced with a `plugin:` prefix, and
refetch re-runs that plugin instead, filtering the enumeration rather
than trusting the stored id so an uninstalled one drops out on its own.

Also: one spelling of the primary-row write, shared by both callers and
taking the connection so each keeps its own transaction; the primary
document's language is stored instead of a hardcoded NULL; and the UI
world constant comes from the shared catalog rather than a local copy.
The insert is OR IGNORE, so a provider sending two documents for one (kind, language) slot has the second dropped by the database. The payload was built from the unfiltered list, so the panel would have shown both until the next reload and one after it, with nothing to explain the difference. The host now applies the same rule as the index -- first document wins, NULL and no-language are one slot -- and a test pins the payload to the row count.
A plugin id lands in LyricsPayload.provider, and the badge built lyrics.provider.${provider} from it. That key exists in none of the 17 locales and i18next renders a missing key as the key itself, so the badge would have read literally lyrics.provider.plugin:apple-lyrics. The prefix is recognised now and the plugin id shown bare -- it is what identifies the plugin in Settings, and a key per plugin is impossible for something installed at runtime.
@InstaZDLL InstaZDLL added this to the v1.8.0 milestone Sep 15, 2026
@InstaZDLL InstaZDLL added scope: frontend React/Vite frontend (src/) scope: backend Rust/Tauri backend (src-tauri/) scope: plugins Plugin runtime, SDK, store, and bundled plugins type: feat New feature size: xl > 500 lines labels Sep 15, 2026
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Review Change StackReview 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: Advanced

Run ID: 18b8d8bd-4fe2-427f-817d-53dff8cd1070

📥 Commits

Reviewing files that changed from the base of the PR and between 4475bde and 7a91b44.

📒 Files selected for processing (14)
  • src-tauri/crates/app/src/commands/canvas.rs
  • src-tauri/crates/app/src/commands/lyrics.rs
  • src-tauri/crates/app/src/commands/motion_artwork.rs
  • src-tauri/crates/app/src/commands/plugins.rs
  • src-tauri/crates/core/src/plugin/bindings.rs
  • src-tauri/crates/core/src/plugin/mod.rs
  • src-tauri/crates/core/src/plugin/runtime.rs
  • src-tauri/crates/plugin-sdk/src/lib.rs
  • src-tauri/crates/plugin-sdk/wit/metadata-v2/deps/host/host.wit
  • src-tauri/crates/plugin-sdk/wit/metadata-v2/plugin.wit
  • src-tauri/crates/plugin-sdk/worlds.json
  • src-tauri/migrations/app/20260915190000_lyrics_associated_documents.sql
  • src/components/layout/LyricsPanel.tsx
  • src/lib/tauri/lyrics.ts

Included review availability: 0 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.


📝 Walkthrough

Walkthrough

La PR ajoute le monde waveflow:metadata/v2 et son contrat hôte. Elle intègre les plugins v2 pour les albums et les paroles. Elle persiste les traductions et prononciations associées, puis les expose dans les payloads et l’interface.

Changes

Plugins metadata v2 et catalogue des mondes

Layer / File(s) Summary
Contrats WIT et sélection exacte des mondes
src-tauri/crates/plugin-sdk/wit/metadata-v2/*, src-tauri/crates/plugin-sdk/src/lib.rs, src-tauri/crates/plugin-sdk/worlds.json, src-tauri/crates/core/src/plugin/mod.rs, src-tauri/crates/app/src/commands/plugins.rs, src-tauri/crates/app/src/commands/canvas.rs
Le monde metadata v2 et les interfaces hôte HTTP, journalisation, stockage et configuration sont ajoutés. Le catalogue public des mondes devient la source vérifiée. Les commandes sélectionnent les mondes par identifiant complet.
Bindings et adaptateurs runtime
src-tauri/crates/core/src/plugin/bindings.rs, src-tauri/crates/core/src/plugin/runtime.rs, src-tauri/crates/app/src/commands/motion_artwork.rs
Les bindings et DTOs metadata v2 convertissent les réponses album-info et lyrics. Les plugins metadata v1 et v2 utilisent leurs appels runtime respectifs.
Résolution et persistance des paroles
src-tauri/crates/app/src/commands/lyrics.rs, src-tauri/migrations/app/20260915190000_lyrics_associated_documents.sql
Les plugins sont interrogés en parallèle avec un délai de 20 secondes. Les bundles valides sont validés, dédupliqués et persistés avec leurs documents associés. Le cache, les remplacements et les suppressions traitent aussi ces documents.
Contrat TypeScript et affichage
src/lib/tauri/lyrics.ts, src/components/layout/LyricsPanel.tsx
Les payloads acceptent les fournisseurs plugin: et les documents associés. Le panneau affiche l’identifiant des fournisseurs plugins après retrait du préfixe.

Priority: ➖ Normal

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

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant LyricsCommand
  participant PluginRuntime
  participant MetadataV2Plugin
  participant LyricsDatabase
  LyricsCommand->>PluginRuntime: metadata_v2_lyrics(artist, title)
  PluginRuntime->>MetadataV2Plugin: lyrics
  MetadataV2Plugin-->>PluginRuntime: lyrics-bundle ou erreur
  PluginRuntime-->>LyricsCommand: LyricsBundle ou None
  LyricsCommand->>LyricsDatabase: Remplacer le document principal et les documents associés
  LyricsDatabase-->>LyricsCommand: Payload mis en cache
Loading

Merge Risk: ⚪ Minimal · up to 7a91b

The metadata v2 lyrics path is compatible with existing TTML rendering, with no identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Le titre décrit clairement la modification principale : l’ajout d’un monde de plugin capable de transporter des paroles avec un minutage au niveau des mots. Il suit aussi le format Conventional Commit…
Description check ✅ Passed La description présente le contexte, les objectifs, les changements techniques, les corrections associées, les tests exécutés et l’issue liée Closes #585``. Elle ne reprend pas la checklist sous forme…
Linked Issues check ✅ Passed L’interface versionnée waveflow:metadata/v2 ajoute lyrics-bundle avec documents primaires et associés (translation et pronunciation). Les documents restent verbatim et acceptent les formats `e…
Out of Scope Changes check ✅ Passed Les changements restent liés à #585. Le support de l’artwork motion, la sélection exacte des mondes, le refetch des plugins, les badges de source et la persistance des documents associés complètent l’…
Docstring Coverage ✅ Passed Docstring coverage is 80.95% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 42 functions across 10 files. (4 skipped: 4…
✨ 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-plugin-world

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

@InstaZDLL InstaZDLL self-assigned this Sep 15, 2026
@InstaZDLL
InstaZDLL merged commit fd3abb6 into main Sep 15, 2026
16 checks passed
@InstaZDLL
InstaZDLL deleted the feat/lyrics-plugin-world branch September 15, 2026 19:56
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: plugins Plugin runtime, SDK, store, and bundled plugins size: xl > 500 lines type: feat New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: a plugin world that can carry word-level lyrics

1 participant