mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 21:37:09 +02:00
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:
parent
23548f40a0
commit
917338dc56
6 changed files with 1339 additions and 50 deletions
500
ui/src/audioplayer/lyrics.js
Normal file
500
ui/src/audioplayer/lyrics.js
Normal 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,
|
||||
))),
|
||||
)
|
||||
347
ui/src/audioplayer/lyrics.test.js
Normal file
347
ui/src/audioplayer/lyrics.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
215
ui/src/audioplayer/useEnhancedLyrics.js
Normal file
215
ui/src/audioplayer/useEnhancedLyrics.js
Normal 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
|
||||
205
ui/src/audioplayer/useEnhancedLyrics.test.js
Normal file
205
ui/src/audioplayer/useEnhancedLyrics.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue