# Lyrics **Goal** - tap the song title in the player and read the words, scrolling in time with the music when the library has timings for it. **Tier** - T2. One new method on `peartune/media/1` (`lyrics.get`) and one new `ping` capability (`caps.lyrics`). Both are additive: an old host never advertises the cap and the app never asks, an old phone never asks a new host. Nothing stored, nothing replicated, no grant semantics touched - lyrics are read-only library data, so a readonly grant may fetch them. ## What is wrong There are no lyrics anywhere in PearTune. Every source we support can supply them and we ask none of them. It is the most-expected music-player feature we do not have, and for a lot of libraries the words are already sitting in the files. ## Where the words come from One optional adapter method, `lyrics(trackId)`, resolving to `null` (nothing known) or `{ synced: boolean, lines: [{ t, text }] }` - `t` is milliseconds from the start of the track, and is `null` on every line when `synced` is false. | Adapter | Where it reads | | --- | --- | | Subsonic | `getLyricsBySongId` (the OpenSubsonic extension) for synced lines; falls back to classic `getLyrics` by artist+title, which is plain text only. Navidrome serves the first. | | Jellyfin / Emby | `GET /Audio/{id}/Lyrics`, whose `Lyrics[]` carries `Start` in ticks (10,000 per ms) and `Text`. | | Folder | A `.lrc` beside the file wins (it is what people curate); otherwise `common.lyrics` from the `music-metadata` scan, which is unsynced. | | Audiobookshelf | None. The method is absent, the cap is off for a books-only host. | | Combined | Delegates to whichever adapter owns the track, like `stream`. | An adapter without the method, or with it answering `null`, is not a failure: the app gets `{ lines: [] }` and says the library has no words for this one. ## The shape - **Host** - `lyrics.get { trackId }` -> `{ synced, lines }`, added to `METHODS` and NOT to `MUTATING`. `ping` gains `caps.lyrics: 1` when the live adapter implements the method. Answers are small (a few KB at most) and parsed on the host, so the phone never sees an `.lrc` file or a ticks-vs-ms difference. - **Phone** - a `lyrics` RPC in `src/bare.js`, gated on `capsFor(libraryId).lyrics` the way seek-offset already gates on `caps.timeOffset`. One fetch per track, cached in memory for the current track only. - **UI** - a Lyrics panel in the expanded player. With timings, the current line is highlighted and the panel follows playback (and a tap on a line seeks to it); without, it is plain scrolling text. No button at all when the cap is off, the same as playlists on a host too old for them. ## Backwards compatibility - An old host: no `caps.lyrics`, so the app never calls `lyrics.get` and shows no button. If it is called anyway the host answers `ENOMETHOD`, which the client already treats as "the host is too old" everywhere else (`worklet/outbox.js`, `client/index.js`). - An old phone against a new host: it never asks. The extra `caps` key is ignored - `caps` is a JSON object on the wire and has taken new keys before (`books`). - No migration: nothing is persisted. ## What could go wrong - **A huge or malicious lyrics payload.** The host caps the answer (256 KB read, 2,000 lines) and truncates rather than failing, so a bad `.lrc` cannot be used to push megabytes at a phone on cellular. - **Timings that lie.** Out-of-order or negative `t` values sort and clamp on the host. A line with no parseable time in a mostly-synced file drops to unsynced for the whole track rather than jumping the highlight around. - **A source that is slow to answer.** The fetch is off the playback path entirely: lyrics are asked for when the panel opens, never as part of starting a track. ## Verify `npm run verify` green, plus: unit tests for the `.lrc` and ticks parsers and for the cap gate; the Subsonic and Jellyfin adapters against their fixtures; on the emulator, a track with a `.lrc` scrolls in time and a track without one says so; and an OLD host (the Umbrel, until it is updated) shows no Lyrics button at all.