diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js new file mode 100644 index 000000000..ec93cc6f0 --- /dev/null +++ b/ui/src/audioplayer/lyrics.js @@ -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, + ))), + ) diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js new file mode 100644 index 000000000..c5ef6d736 --- /dev/null +++ b/ui/src/audioplayer/lyrics.test.js @@ -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) + }) +}) diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js new file mode 100644 index 000000000..69343c4ac --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.js @@ -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 diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js new file mode 100644 index 000000000..a1e26c43d --- /dev/null +++ b/ui/src/audioplayer/useEnhancedLyrics.test.js @@ -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() + }) +}) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..09f49464a 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -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, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..cb4e23838 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -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', () => {