feat(ui): load and cache enhanced lyrics

- Fetch structured lyric layers lazily with bounded, retryable request caching.
- Preserve language, track-update, abort, and Subsonic compatibility behavior.

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-17 09:20:45 +03:00
commit 917338dc56
No known key found for this signature in database
6 changed files with 1339 additions and 50 deletions

View file

@ -0,0 +1,500 @@
const normalizeLanguageTag = (language) =>
(language || '').toLowerCase().replace(/_/g, '-')
const LYRIC_KIND_MAIN = 'main'
const LYRIC_KIND_TRANSLATION = 'translation'
const LYRIC_KIND_PRONUNCIATION = 'pronunciation'
const toTime = (value) => {
if (value == null || value === '') {
return null
}
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
const applyTimeOffset = (value, offset = 0) => {
const time = toTime(value)
return time == null ? null : time + offset
}
const toByteOffset = (value) => {
if (value == null || value === '') {
return null
}
const numeric = Number(value)
if (!Number.isInteger(numeric) || numeric < 0) {
return null
}
return numeric
}
const compareNullableTime = (a, b) => {
if (a == null && b == null) return 0
if (a == null) return 1
if (b == null) return -1
return a - b
}
const sortTokensByStart = (tokens) =>
tokens
.map((token, order) => ({ ...token, order }))
.sort((a, b) => {
const byStart = compareNullableTime(a.start, b.start)
if (byStart !== 0) return byStart
const byEnd = compareNullableTime(a.end, b.end)
if (byEnd !== 0) return byEnd
return a.order - b.order
})
.map(({ order, ...token }) => token)
const languageMatch = (candidate, preferred) => {
if (!candidate || !preferred) return false
return (
candidate === preferred ||
candidate.startsWith(`${preferred}-`) ||
preferred.startsWith(`${candidate}-`)
)
}
const hasTimedCueLines = (lyric) =>
Array.isArray(lyric?.cueLine) &&
lyric.cueLine.some(
(cueLine) =>
toTime(cueLine?.start) != null ||
(Array.isArray(cueLine?.cue) &&
cueLine.cue.some((cue) => toTime(cue?.start) != null)),
)
const hasTimedLines = (lyric) =>
Boolean(
lyric &&
lyric.synced &&
((Array.isArray(lyric.line) &&
lyric.line.some((line) => toTime(line.start) != null)) ||
hasTimedCueLines(lyric)),
)
const preferTimedLyrics = (lyrics) => {
const timed = lyrics.filter(hasTimedLines)
return timed.length > 0 ? timed : lyrics
}
const normalizeToken = (token, offset = 0) => {
if (!token) return null
const value = typeof token.value === 'string' ? token.value : ''
if (value.length === 0) return null
const byteStart = toByteOffset(token.byteStart)
const byteEnd = toByteOffset(token.byteEnd)
return {
start: applyTimeOffset(token.start, offset),
end: applyTimeOffset(token.end, offset),
value,
...(byteStart != null ? { byteStart } : {}),
...(byteEnd != null ? { byteEnd } : {}),
}
}
const utf8BytesForCodePoint = (codePoint) => {
if (codePoint <= 0x7f) return 1
if (codePoint <= 0x7ff) return 2
if (codePoint <= 0xffff) return 3
return 4
}
export const utf8ByteOffsetToCodeUnitIndex = (text, targetByteOffset) => {
if (typeof text !== 'string' || text.length === 0) return 0
const target = toByteOffset(targetByteOffset)
if (target == null || target <= 0) return 0
let byteOffset = 0
let index = 0
while (index < text.length) {
if (byteOffset >= target) return index
const codePoint = text.codePointAt(index)
byteOffset += utf8BytesForCodePoint(codePoint)
index += codePoint > 0xffff ? 2 : 1
}
return text.length
}
export const utf8ByteRangeToCodeUnitRange = (text, byteStart, byteEnd) => {
if (typeof text !== 'string') return null
const start = toByteOffset(byteStart)
const end = toByteOffset(byteEnd)
if (start == null || end == null || end < start) return null
const startIndex = utf8ByteOffsetToCodeUnitIndex(text, start)
const endIndex = utf8ByteOffsetToCodeUnitIndex(text, end + 1)
if (
startIndex >= endIndex ||
startIndex > text.length ||
endIndex > text.length
) {
return null
}
return {
start: startIndex,
end: endIndex,
text: text.slice(startIndex, endIndex),
}
}
const buildAgentLookup = (structuredLyric) => {
const lookup = new Map()
const agents = Array.isArray(structuredLyric?.agents)
? structuredLyric.agents
: []
for (const agent of agents) {
const id = typeof agent?.id === 'string' ? agent.id : ''
if (!id || lookup.has(id)) continue
lookup.set(id, {
id,
role: typeof agent?.role === 'string' ? agent.role : '',
name: typeof agent?.name === 'string' ? agent.name : '',
})
}
return lookup
}
const deriveUiRole = (agent) => {
if (!agent?.role || agent.role === 'main') return ''
return agent.role
}
const normalizeCueLine = (cueLine, fallbackIndex, agentLookup, offset = 0) => {
const index = Number.isFinite(Number(cueLine?.index))
? Number(cueLine.index)
: fallbackIndex
const agentId = typeof cueLine?.agentId === 'string' ? cueLine.agentId : ''
const agent = agentId ? agentLookup.get(agentId) || null : null
const fallbackRole = typeof cueLine?.role === 'string' ? cueLine.role : ''
const tokens = sortTokensByStart(
Array.isArray(cueLine?.cue)
? cueLine.cue.map((cue) => normalizeToken(cue, offset)).filter(Boolean)
: [],
)
return {
index,
start: applyTimeOffset(cueLine?.start, offset),
end: applyTimeOffset(cueLine?.end, offset),
value: typeof cueLine?.value === 'string' ? cueLine.value : '',
role: agent ? deriveUiRole(agent) : fallbackRole,
agentId,
agentRole: agent?.role || fallbackRole,
agentName: agent?.name || '',
tokens,
}
}
const normalizeLyricKind = (kind) => {
const normalized = (kind || '').toLowerCase().trim()
switch (normalized) {
case LYRIC_KIND_TRANSLATION:
return LYRIC_KIND_TRANSLATION
case LYRIC_KIND_PRONUNCIATION:
return LYRIC_KIND_PRONUNCIATION
default:
return LYRIC_KIND_MAIN
}
}
const lyricsInPreferredLanguage = (lyrics, preferredLanguage) => {
if (!Array.isArray(lyrics) || lyrics.length === 0) return null
const preferred = normalizeLanguageTag(preferredLanguage)
const preferredBase = preferred.split('-')[0]
const preferredMatches = lyrics.filter((lyric) =>
languageMatch(normalizeLanguageTag(lyric.lang), preferred),
)
if (preferredMatches.length > 0) return preferredMatches
const baseMatches = lyrics.filter((lyric) =>
languageMatch(normalizeLanguageTag(lyric.lang), preferredBase),
)
if (baseMatches.length > 0) return baseMatches
const englishMatches = lyrics.filter((lyric) =>
languageMatch(normalizeLanguageTag(lyric.lang), 'en'),
)
return englishMatches.length > 0 ? englishMatches : lyrics
}
const pickPreferredLyric = (lyrics, preferredLanguage) => {
const preferred = lyricsInPreferredLanguage(lyrics, preferredLanguage)
return preferred ? preferTimedLyrics(preferred)[0] || null : null
}
export const hasCueTiming = (structuredLyric) =>
hasTimedCueLines(structuredLyric)
export const hasStructuredLyricContent = (structuredLyric) =>
Boolean(
structuredLyric &&
((Array.isArray(structuredLyric.line) &&
structuredLyric.line.some(
(line) => typeof line?.value === 'string' && line.value.trim() !== '',
)) ||
hasCueTiming(structuredLyric)),
)
export const getPreferredLyricLanguage = () => {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const stored = window.localStorage.getItem('locale')
if (stored) return stored
}
} catch {
// Fall back to the browser language when storage access is restricted.
}
if (typeof navigator !== 'undefined' && navigator.language) {
return navigator.language
}
return 'en'
}
export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
if (!Array.isArray(structuredLyrics)) {
return { main: null, translation: null, pronunciation: null }
}
const available = structuredLyrics.filter(hasStructuredLyricContent)
if (available.length === 0) {
return { main: null, translation: null, pronunciation: null }
}
const grouped = {
[LYRIC_KIND_MAIN]: [],
[LYRIC_KIND_TRANSLATION]: [],
[LYRIC_KIND_PRONUNCIATION]: [],
}
for (const lyric of available) {
grouped[normalizeLyricKind(lyric?.kind)].push(lyric)
}
const mainCandidates = grouped[LYRIC_KIND_MAIN].length
? grouped[LYRIC_KIND_MAIN]
: available
return {
main: pickPreferredLyric(mainCandidates, preferredLanguage),
translation: pickPreferredLyric(
grouped[LYRIC_KIND_TRANSLATION],
preferredLanguage,
),
pronunciation: pickPreferredLyric(
grouped[LYRIC_KIND_PRONUNCIATION],
preferredLanguage,
),
}
}
const buildBaseKaraokeLine = (line, index, offset = 0) => ({
index,
start: applyTimeOffset(line?.start, offset),
end: applyTimeOffset(line?.end, offset),
value: typeof line?.value === 'string' ? line.value : '',
tokens: [],
lanes: [],
})
const buildBaseKaraokeLines = (baseLines, offset = 0) =>
baseLines.map((line, index) => buildBaseKaraokeLine(line, index, offset))
const minNullableTime = (...values) => {
const times = values.filter((value) => value != null)
return times.length > 0 ? Math.min(...times) : null
}
const maxNullableTime = (...values) => {
const times = values.filter((value) => value != null)
return times.length > 0 ? Math.max(...times) : null
}
const laneRoleRank = (line) => {
const role = (line?.agentRole || line?.role || '').toLowerCase()
switch (role) {
case '':
case 'main':
return 0
case 'voice':
return 1
case 'group':
case 'chorus':
case 'choir':
return 2
case 'bg':
case 'background':
case 'background vocals':
case 'background-vocals':
case 'backing':
case 'backing vocals':
case 'backing-vocals':
return 3
default:
return 1
}
}
const sortCueLineLanes = (lanes) =>
[...lanes].sort((a, b) => {
const byRole = laneRoleRank(a) - laneRoleRank(b)
if (byRole !== 0) return byRole
return a.order - b.order
})
const buildLaneFromCueLine = (cueLine, laneIndex) => ({
key: `${cueLine.index}-${cueLine.agentId || 'default'}-${laneIndex}`,
index: cueLine.index,
start: cueLine.start,
end: cueLine.end,
value: cueLine.value,
role: cueLine.role,
agentId: cueLine.agentId,
agentName: cueLine.agentName,
agentRole: cueLine.agentRole,
tokens: cueLine.tokens,
})
const buildLineFromCueLineGroup = (index, group, baseLines, offset = 0) => {
const baseLine = buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
const orderedGroup = sortCueLineLanes(group)
const lanes = orderedGroup.map(buildLaneFromCueLine)
const first = lanes[0] || {}
const tokens = sortTokensByStart(lanes.flatMap((lane) => lane.tokens || []))
const fallbackStart =
tokens.find((token) => token.start != null)?.start ?? null
const fallbackEnd =
[...tokens].reverse().find((token) => token.end != null)?.end ?? null
const laneStart = minNullableTime(
...lanes.flatMap((lane) => [lane.start, lane.tokens?.[0]?.start]),
)
const laneEnd = maxNullableTime(
...lanes.flatMap((lane) => [
lane.end,
[...(lane.tokens || [])].reverse().find((token) => token.end != null)
?.end,
]),
)
const fallbackValue = tokens
.map((token) => token.value)
.filter(Boolean)
.join(' ')
.replace(/\s+/g, ' ')
.trim()
const value = baseLine.value || first.value || fallbackValue
return {
...baseLine,
index,
start: minNullableTime(
baseLine.start,
first.start,
laneStart,
fallbackStart,
),
end: maxNullableTime(baseLine.end, first.end, laneEnd, fallbackEnd),
value,
agentId: first.agentId,
agentName: first.agentName,
agentRole: first.agentRole,
tokens,
lanes,
}
}
export const buildKaraokeLinesFromCueLines = (
rawCueLines,
baseLines,
agentLookup,
offset = 0,
) => {
const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
const normalized = normalizeCueLine(
cueLine,
fallbackIndex,
agentLookup,
offset,
)
return {
...normalized,
order: fallbackIndex,
tokens: normalized.tokens.map((token) => ({
...token,
role: normalized.role,
agentId: normalized.agentId,
agentName: normalized.agentName,
agentRole: normalized.agentRole,
})),
}
})
const byIndex = new Map()
for (const cueLine of normalizedCueLines) {
if (!byIndex.has(cueLine.index)) byIndex.set(cueLine.index, [])
byIndex.get(cueLine.index).push(cueLine)
}
const indexes = new Set(baseLines.map((_line, index) => index))
byIndex.forEach((_group, index) => indexes.add(index))
return Array.from(indexes)
.sort((a, b) => a - b)
.map((index) => {
const group = byIndex.get(index) || []
if (group.length === 0) {
return buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
}
return buildLineFromCueLineGroup(index, group, baseLines, offset)
})
}
export const buildKaraokeLines = (structuredLyric) => {
if (!structuredLyric) return []
const offset = toTime(structuredLyric.offset) ?? 0
const agentLookup = buildAgentLookup(structuredLyric)
const baseLines = Array.isArray(structuredLyric.line)
? structuredLyric.line
: []
const rawCueLines = Array.isArray(structuredLyric.cueLine)
? structuredLyric.cueLine
: []
const lines =
rawCueLines.length > 0
? buildKaraokeLinesFromCueLines(
rawCueLines,
baseLines,
agentLookup,
offset,
)
: buildBaseKaraokeLines(baseLines, offset)
return lines
.map((line) => ({
...line,
renderable: Boolean(line.value?.trim() || line.tokens.length > 0),
}))
.sort((a, b) => a.index - b.index)
}
export const hasUsableKaraokeTiming = (lines) =>
Array.isArray(lines) &&
lines.some(
(line) =>
line?.renderable !== false &&
(toTime(line?.start) != null ||
(Array.isArray(line?.tokens) &&
line.tokens.some(
(token) =>
toTime(token?.start) != null || toTime(token?.end) != null,
))),
)

View file

@ -0,0 +1,347 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildKaraokeLines,
getPreferredLyricLanguage,
hasStructuredLyricContent,
selectLyricLayers,
utf8ByteRangeToCodeUnitRange,
} from './lyrics'
const timed = (value, start, end, extra = {}) => ({
...extra,
start,
...(end == null ? {} : { end }),
value,
})
const structured = (kind, lang, value) => ({
kind,
lang,
synced: true,
line: [timed(value, 0)],
})
const cueLine = (index, value, start, end, cue, extra = {}) => ({
index,
...timed(value, start, end, extra),
cue,
})
describe('lyrics helpers', () => {
it('selects main, pronunciation, and translation layers by kind and language', () => {
const layers = selectLyricLayers(
[
structured('translation', 'es', 'Hola'),
structured('main', 'en-US', 'Hello'),
structured('pronunciation', 'en', 'heh-low'),
],
'en',
)
expect(layers.main.value || layers.main.line[0].value).toBe('Hello')
expect(layers.pronunciation.line[0].value).toBe('heh-low')
expect(layers.translation.line[0].value).toBe('Hola')
})
it('prefers cue-timed lyrics when line starts are omitted', () => {
const layers = selectLyricLayers(
[
{
kind: 'main',
lang: 'en',
synced: false,
line: [{ value: 'Plain lyrics' }],
},
{
kind: 'main',
lang: 'en',
synced: true,
line: [{ value: 'Cue-timed lyrics' }],
cueLine: [
{
index: 0,
value: 'Cue-timed lyrics',
cue: [{ start: 1000, value: 'Cue-timed lyrics' }],
},
],
},
],
'en',
)
expect(layers.main.line[0].value).toBe('Cue-timed lyrics')
})
it('prefers the requested language before timed variants', () => {
const layers = selectLyricLayers(
[
{
kind: 'main',
lang: 'en',
synced: false,
line: [{ value: 'Preferred plain lyrics' }],
},
structured('main', 'ja', '別の言語'),
],
'en-US',
)
expect(layers.main.line[0].value).toBe('Preferred plain lyrics')
})
it('matches language tags with multiple underscore separators', () => {
const layers = selectLyricLayers(
[
structured('main', 'zh-Hans', '简体'),
structured('main', 'zh-Hant-TW', '繁體'),
],
'zh_Hant_TW',
)
expect(layers.main.line[0].value).toBe('繁體')
})
it('resolves UTF-8 byte ranges without confusing repeated words', () => {
const text = 'caf\u00e9 caf\u00e9'
expect(utf8ByteRangeToCodeUnitRange(text, 0, 4)).toMatchObject({
text: 'caf\u00e9',
})
expect(utf8ByteRangeToCodeUnitRange(text, 6, 10)).toMatchObject({
text: 'caf\u00e9',
start: 5,
})
})
it('applies structured lyric offsets to line and cue timing', () => {
const delayed = buildKaraokeLines({
synced: true,
offset: 500,
line: [timed('Delayed line', 1000, 3000)],
cueLine: [
cueLine(0, 'Delayed line', 1000, 3000, [
timed('Delayed ', 1000, 2000, { byteStart: 0, byteEnd: 7 }),
timed('line', 2000, 3000, { byteStart: 8, byteEnd: 11 }),
]),
],
})
const advanced = buildKaraokeLines({
synced: true,
offset: -250,
line: [timed('Advanced line', 1000)],
})
expect(delayed[0].start).toBe(1500)
expect(delayed[0].end).toBe(3500)
expect(delayed[0].tokens[0].start).toBe(1500)
expect(delayed[0].tokens[0].end).toBe(2500)
expect(advanced[0].start).toBe(750)
})
it('keeps base lyric lines that do not have word-level cue lines', () => {
const lines = buildKaraokeLines({
synced: true,
line: [
timed('Word timed', 1000),
timed('Plain timed line', 2000),
timed('More words', 3000),
],
cueLine: [
cueLine(0, 'Word timed', 1000, null, [
timed('Word ', 1000, null, { byteStart: 0, byteEnd: 4 }),
timed('timed', 1500, null, { byteStart: 5, byteEnd: 9 }),
]),
cueLine(2, 'More words', 3000, null, [
timed('More ', 3000, null, { byteStart: 0, byteEnd: 4 }),
timed('words', 3500, null, { byteStart: 5, byteEnd: 9 }),
]),
],
})
expect(lines.map((line) => line.value)).toEqual([
'Word timed',
'Plain timed line',
'More words',
])
expect(lines[1].tokens).toEqual([])
})
it('preserves repeated ELRC-style cue timing and trailing cue ends', () => {
const repeatedLine = (index, start) =>
cueLine(index, 'Hello world', start, start + 900, [
timed('Hello ', start + 100, start + 500, {
byteStart: 0,
byteEnd: 5,
}),
timed('world', start + 500, start + 900, {
byteStart: 6,
byteEnd: 10,
}),
])
const lines = buildKaraokeLines({
synced: true,
line: [
timed('Hello world', 10000, 10900),
timed('Hello world', 30000, 30900),
],
cueLine: [repeatedLine(0, 10000), repeatedLine(1, 30000)],
})
expect(lines).toHaveLength(2)
expect(lines[0].tokens[1].end).toBe(10900)
expect(lines[1].tokens[0].start).toBe(30100)
})
it('keeps multiline SRT, TTML, and plain text values intact', () => {
const lines = buildKaraokeLines({
synced: true,
line: [{ start: 1000, value: 'first line\nsecond line' }],
})
expect(lines[0].value).toBe('first line\nsecond line')
})
it('keeps adjacent TTML syllable cue tokens in order', () => {
const lines = buildKaraokeLines({
synced: true,
line: [timed('konni', 2000)],
cueLine: [
cueLine(0, 'konni', 2000, 2600, [
timed('ko', 2000, 2300, { byteStart: 0, byteEnd: 1 }),
timed('nni', 2300, 2600, { byteStart: 2, byteEnd: 4 }),
]),
],
})
expect(lines[0].tokens.map((token) => token.value)).toEqual(['ko', 'nni'])
})
it('keeps same-index agent cue lines as ordered voice lanes', () => {
const agentCue = (value, agentId, start, end) =>
cueLine(
0,
value,
start,
end,
[timed(value, start, end, { byteStart: 0, byteEnd: value.length - 1 })],
{ agentId },
)
const lines = buildKaraokeLines({
synced: true,
agents: [
{ id: 'lead', role: 'main' },
{ id: 'all', role: 'group' },
{ id: 'lead-bg', role: 'bg' },
],
line: [timed('Lead all echo', 1000, 4000)],
cueLine: [
agentCue('echo', 'lead-bg', 2000, 3000),
agentCue('all', 'all', 1500, 2500),
agentCue('Lead', 'lead', 1000, 2000),
],
})
expect(lines).toHaveLength(1)
expect(lines[0].lanes).toHaveLength(3)
expect(lines[0].lanes.map((lane) => lane.agentRole)).toEqual([
'main',
'group',
'bg',
])
expect(lines[0].tokens.map((token) => token.value)).toEqual([
'Lead',
'all',
'echo',
])
})
it('uses whitespace-aware text when cue-only fallback values are needed', () => {
const lines = buildKaraokeLines({
synced: true,
cueLine: [
cueLine(0, '', 1000, 3000, [
timed('Lead', 1000, 2000),
timed('all', 2000, 3000),
timed('echo', 3000, 3500),
]),
],
})
expect(lines[0].value).toBe('Lead all echo')
})
it('preserves untimed lyric rows between surrounding timed rows', () => {
const lines = buildKaraokeLines({
synced: true,
line: [
{ start: 1000, value: 'First verse' },
{ value: '[instrumental]' },
{ start: 2000, value: 'Second verse' },
],
})
expect(lines.map((line) => line.value)).toEqual([
'First verse',
'[instrumental]',
'Second verse',
])
})
it('preserves source display order independently from timestamps', () => {
const lines = buildKaraokeLines({
synced: true,
line: [
{ start: 3000, value: 'Displayed first' },
{ start: 1000, value: 'Displayed second' },
{ start: 2000, value: 'Displayed third' },
],
})
expect(lines.map((line) => line.value)).toEqual([
'Displayed first',
'Displayed second',
'Displayed third',
])
expect(lines.map((line) => line.start)).toEqual([3000, 1000, 2000])
})
it('preserves timed blank rows as non-renderable timing markers', () => {
const lines = buildKaraokeLines({
synced: true,
line: [
{ start: 1000, value: 'Before pause' },
{ start: 2000, value: '' },
{ start: 4000, value: 'After pause' },
],
})
expect(lines).toHaveLength(3)
expect(lines[1]).toMatchObject({
start: 2000,
value: '',
renderable: false,
})
})
it('falls back to the browser language when locale storage is unavailable', () => {
const storage = vi
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new DOMException('Access denied', 'SecurityError')
})
expect(getPreferredLyricLanguage()).toBe(navigator.language)
storage.mockRestore()
})
it('treats instrumental empty lyrics as no renderable content', () => {
expect(
hasStructuredLyricContent({
kind: 'main',
lang: 'en',
synced: false,
line: [],
}),
).toBe(false)
})
})

View file

@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import subsonic from '../subsonic'
import { getPreferredLyricLanguage, selectLyricLayers } from './lyrics'
export const emptyLyricLayers = Object.freeze({
main: null,
translation: null,
pronunciation: null,
})
const LYRICS_CACHE_SCHEMA_VERSION = 3
const MAX_LYRIC_CACHE_ENTRIES = 75
const NEGATIVE_CACHE_TTL_MS = 30_000
const cache = new Map()
const inFlight = new Map()
const normalizeLyricLayers = (layers) => ({
main: layers?.main || null,
translation: layers?.translation || null,
pronunciation: layers?.pronunciation || null,
})
const readStructuredLyrics = (response) =>
response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || []
const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) =>
[
trackId || '',
updatedAt || '',
preferredLanguage || '',
LYRICS_CACHE_SCHEMA_VERSION,
].join('\u0000')
const rememberLyrics = (cacheKey, layers, expiresAt = null) => {
cache.delete(cacheKey)
cache.set(cacheKey, { layers, expiresAt })
while (cache.size > MAX_LYRIC_CACHE_ENTRIES) {
const oldestCacheKey = cache.keys().next().value
cache.delete(oldestCacheKey)
}
}
const readCachedLyrics = (cacheKey) => {
const cached = cache.get(cacheKey)
if (!cached) return null
if (cached.expiresAt != null && cached.expiresAt <= Date.now()) {
cache.delete(cacheKey)
return null
}
cache.delete(cacheKey)
cache.set(cacheKey, cached)
return cached.layers
}
const createLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
const controller = new AbortController()
const entry = {
controller,
consumers: 0,
settled: false,
promise: null,
}
entry.promise = subsonic
.getLyricsBySongId(trackId, { signal: controller.signal })
.then((response) => {
const selected = normalizeLyricLayers(
selectLyricLayers(readStructuredLyrics(response), preferredLanguage),
)
const hasAnyLayer = Boolean(
selected.main || selected.translation || selected.pronunciation,
)
rememberLyrics(
cacheKey,
selected,
hasAnyLayer ? null : Date.now() + NEGATIVE_CACHE_TTL_MS,
)
return selected
})
.finally(() => {
entry.settled = true
if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
})
inFlight.set(cacheKey, entry)
return entry
}
const acquireLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
const existing = inFlight.get(cacheKey)
const entry =
existing && !existing.controller.signal.aborted
? existing
: createLyricsRequest({ trackId, preferredLanguage, cacheKey })
entry.consumers += 1
let released = false
return {
promise: entry.promise,
release: () => {
if (released) return
released = true
entry.consumers = Math.max(0, entry.consumers - 1)
if (!entry.settled && entry.consumers === 0) {
if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
entry.controller.abort()
}
},
}
}
const useEnhancedLyrics = ({
trackId,
updatedAt,
disabled = false,
requested = true,
}) => {
const preferredLanguage = getPreferredLyricLanguage()
const cacheKey = useMemo(
() => buildCacheKey({ trackId, preferredLanguage, updatedAt }),
[preferredLanguage, trackId, updatedAt],
)
const [state, setState] = useState(() => ({
cacheKey,
layers: emptyLyricLayers,
loading: false,
error: null,
}))
const [requestGeneration, setRequestGeneration] = useState(0)
const retry = useCallback(() => {
cache.delete(cacheKey)
setRequestGeneration((current) => current + 1)
}, [cacheKey])
useEffect(() => {
if (!trackId || disabled || !requested) {
setState({
cacheKey,
layers: emptyLyricLayers,
loading: false,
error: null,
})
return undefined
}
const cached = readCachedLyrics(cacheKey)
if (cached) {
setState({ cacheKey, layers: cached, loading: false, error: null })
return undefined
}
let active = true
const request = acquireLyricsRequest({
trackId,
preferredLanguage,
cacheKey,
})
setState({
cacheKey,
layers: emptyLyricLayers,
loading: true,
error: null,
})
request.promise
.then((layers) => {
if (!active) return
setState({ cacheKey, layers, loading: false, error: null })
})
.catch((error) => {
if (!active || error?.name === 'AbortError') return
cache.delete(cacheKey)
setState({
cacheKey,
layers: emptyLyricLayers,
loading: false,
error,
})
})
return () => {
active = false
request.release()
}
}, [
cacheKey,
disabled,
preferredLanguage,
requestGeneration,
requested,
trackId,
])
if (state.cacheKey !== cacheKey) {
return {
layers: emptyLyricLayers,
loading: Boolean(trackId && !disabled && requested),
error: null,
retry,
}
}
return {
layers: state.layers,
loading: state.loading,
error: state.error,
retry,
}
}
export const clearEnhancedLyricsCache = () => {
cache.clear()
inFlight.forEach((entry) => entry.controller.abort())
inFlight.clear()
}
export default useEnhancedLyrics

View file

@ -0,0 +1,205 @@
import { act, waitFor } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import subsonic from '../subsonic'
import useEnhancedLyrics, {
clearEnhancedLyricsCache,
emptyLyricLayers,
} from './useEnhancedLyrics'
vi.mock('../subsonic', () => ({
default: {
getLyricsBySongId: vi.fn(),
},
}))
const responseFor = (value, lang = 'en') => ({
json: {
'subsonic-response': {
lyricsList: {
structuredLyrics: [
{
kind: 'main',
lang,
synced: true,
line: [{ start: 0, value }],
},
],
},
},
},
})
const createDeferred = () => {
let resolve
let reject
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
const renderLyrics = (initialProps) =>
renderHook((props) => useEnhancedLyrics(props), { initialProps })
const expectLyric = (result, value) =>
waitFor(() => expect(result.current.layers.main?.line[0].value).toBe(value))
describe('useEnhancedLyrics', () => {
beforeEach(() => {
localStorage.setItem('locale', 'en')
clearEnhancedLyricsCache()
subsonic.getLyricsBySongId.mockReset()
})
afterEach(() => {
localStorage.clear()
clearEnhancedLyricsCache()
})
it('fetches enhanced structured lyrics and caches them by track id', async () => {
subsonic.getLyricsBySongId
.mockResolvedValueOnce(responseFor('Track one'))
.mockResolvedValueOnce(responseFor('Track two'))
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
await expectLyric(result, 'Track one')
expect(subsonic.getLyricsBySongId).toHaveBeenCalledWith(
'song-1',
expect.objectContaining({ signal: expect.any(Object) }),
)
rerender({ trackId: 'song-2' })
await expectLyric(result, 'Track two')
rerender({ trackId: 'song-1' })
await expectLyric(result, 'Track one')
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
})
it('clears previous lyrics while loading an uncached track', async () => {
const nextRequest = createDeferred()
subsonic.getLyricsBySongId
.mockResolvedValueOnce(responseFor('Track one'))
.mockReturnValueOnce(nextRequest.promise)
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
await expectLyric(result, 'Track one')
rerender({ trackId: 'song-2' })
expect(result.current.loading).toBe(true)
expect(result.current.layers).toBe(emptyLyricLayers)
nextRequest.resolve(responseFor('Track two'))
await expectLyric(result, 'Track two')
})
it('stays empty when disabled or not requested', () => {
const { result } = renderLyrics({
trackId: 'song-1',
disabled: true,
requested: false,
})
expect(result.current.layers).toBe(emptyLyricLayers)
expect(result.current.loading).toBe(false)
expect(subsonic.getLyricsBySongId).not.toHaveBeenCalled()
})
it('resets layers and retries the same lyrics identity after an error', async () => {
const error = new Error('lyrics failed')
subsonic.getLyricsBySongId
.mockRejectedValueOnce(error)
.mockResolvedValueOnce(responseFor('Recovered lyrics'))
const { result } = renderLyrics({ trackId: 'song-error' })
await waitFor(() => expect(result.current.error).toBe(error))
expect(result.current.layers).toBe(emptyLyricLayers)
expect(result.current.loading).toBe(false)
act(() => {
result.current.retry()
})
await expectLyric(result, 'Recovered lyrics')
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
})
it('keeps cached lyrics separate by preferred language', async () => {
subsonic.getLyricsBySongId
.mockResolvedValueOnce(responseFor('English lyrics', 'en'))
.mockResolvedValueOnce(responseFor('Japanese lyrics', 'ja'))
const { result, rerender } = renderLyrics({ trackId: 'song-1' })
await expectLyric(result, 'English lyrics')
localStorage.setItem('locale', 'ja')
rerender({ trackId: 'song-1' })
await expectLyric(result, 'Japanese lyrics')
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
})
it('invalidates cache entries when the track update identity changes', async () => {
subsonic.getLyricsBySongId
.mockResolvedValueOnce(responseFor('Old lyrics'))
.mockResolvedValueOnce(responseFor('Updated lyrics'))
const { result, rerender } = renderLyrics({
trackId: 'song-1',
updatedAt: 'old',
})
await expectLyric(result, 'Old lyrics')
rerender({ trackId: 'song-1', updatedAt: 'new' })
await expectLyric(result, 'Updated lyrics')
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
})
it('deduplicates concurrent consumers and aborts only after the last one leaves', () => {
let signal
subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
signal = options.signal
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
})
})
const first = renderLyrics({ trackId: 'shared-song' })
const second = renderLyrics({ trackId: 'shared-song' })
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(1)
first.unmount()
expect(signal.aborted).toBe(false)
second.unmount()
expect(signal.aborted).toBe(true)
})
it('starts a fresh request after an abandoned request is aborted', () => {
const signals = []
subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
signals.push(options.signal)
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'))
})
})
})
const first = renderLyrics({ trackId: 'retry-song' })
first.unmount()
expect(signals[0].aborted).toBe(true)
const second = renderLyrics({ trackId: 'retry-song' })
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
second.unmount()
})
})

View file

@ -96,7 +96,6 @@ const getCoverArtUrl = (record, size, square) => {
// This is a playlist
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
} else if (record.streamUrl !== undefined) {
// This is a radio station
return baseUrl(url('getCoverArt', 'ra-' + record.id, options))
} else {
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
@ -129,6 +128,13 @@ const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
const getLyricsBySongId = (id, requestOptions) => {
const requestUrl = url('getLyricsBySongId', id, { enhanced: true })
return requestOptions
? httpClient(requestUrl, requestOptions)
: httpClient(requestUrl)
}
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
@ -158,4 +164,5 @@ export default {
getArtistInfo,
getTopSongs,
getSimilarSongs2,
getLyricsBySongId,
}

View file

@ -1,27 +1,56 @@
import { vi } from 'vitest'
import { afterEach, vi } from 'vitest'
import config from '../config'
import { httpClient } from '../dataProvider'
import subsonic from './index'
vi.mock('../dataProvider', () => ({
httpClient: vi.fn((target) => Promise.resolve({ target })),
clientUniqueId: 'test-client-id',
clientUniqueIdHeader: 'x-test-client-id',
}))
const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
window,
'localStorage',
)
const mockSubsonicLocalStorage = () => {
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
setItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: localStorageMock,
})
}
afterEach(() => {
if (originalLocalStorageDescriptor) {
Object.defineProperty(
window,
'localStorage',
originalLocalStorageDescriptor,
)
}
})
describe('getCoverArtUrl', () => {
beforeEach(() => {
// Mock window.location
delete window.location
window.location = { href: 'http://localhost:3000/app' }
// Mock localStorage values required by subsonic
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
setItem: vi.fn(),
clear: vi.fn(),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
mockSubsonicLocalStorage()
})
it('should return playlist cover art URL for records with sync property', () => {
@ -122,19 +151,26 @@ describe('getCoverArtUrl', () => {
})
})
describe('getLyricsBySongId', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSubsonicLocalStorage()
})
it('requests enhanced structured lyrics for the song id', async () => {
await subsonic.getLyricsBySongId('song-123')
expect(httpClient).toHaveBeenCalledTimes(1)
const requestedUrl = httpClient.mock.calls[0][0]
expect(requestedUrl).toContain('/rest/getLyricsBySongId?')
expect(requestedUrl).toContain('id=song-123')
expect(requestedUrl).toContain('enhanced=true')
})
})
describe('getDiscCoverArtUrl', () => {
beforeEach(() => {
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
mockSubsonicLocalStorage()
})
it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => {
@ -174,18 +210,7 @@ describe('getDiscCoverArtUrl', () => {
describe('getAvatarUrl', () => {
beforeEach(() => {
// Mock localStorage values required by subsonic
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
mockSubsonicLocalStorage()
})
it('should include username parameter', () => {
@ -197,17 +222,7 @@ describe('getAvatarUrl', () => {
describe('reportPlayback', () => {
beforeEach(() => {
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
mockSubsonicLocalStorage()
})
it('should construct reportPlayback URL with correct parameters', () => {