feat(ui): add lyrics source labels and download serializers

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:16:17 +03:00
parent 5bfc80a0db
commit f3d0372261
No known key found for this signature in database
4 changed files with 374 additions and 0 deletions

View File

@ -0,0 +1,175 @@
// Serializes a structured lyric (already parsed by Navidrome) back into the
// format its source reported, for download-to-device. Timestamps are absolute:
// the source's own offset is already folded into them by the response, and the
// listener's local nudge is deliberately not exported.
const pad = (value, length = 2) => String(value).padStart(length, '0')
export const lyricsFileExtension = (format) => {
const normalized = String(format || '')
.trim()
.toLowerCase()
if (normalized === 'ttml') return 'ttml'
if (normalized === 'lrc' || normalized === 'elrc' || normalized === 'srt') {
return 'lrc'
}
return 'txt'
}
const sanitizePart = (value, fallback) => {
const cleaned = String(value ?? '')
.replace(/[\\/:*?"<>|]+/g, '')
.replace(/\s+/g, ' ')
.trim()
.replace(/\.+$/, '')
return cleaned || fallback
}
export const buildLyricsFileName = ({ artist, title, label, format } = {}) =>
`${sanitizePart(artist, 'Unknown artist')} - ${sanitizePart(
title,
'Unknown title',
)} (${sanitizePart(label, 'Lyrics')}).${lyricsFileExtension(format)}`
const escapeXml = (value) =>
String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
// LRC stamps use centiseconds; timestamps clamp at zero because LRC has no
// negative time. Past 99 minutes the hours form keeps the field width valid.
const lrcStamp = (ms, open = '[', close = ']') => {
const total = Math.max(0, Math.round(Number(ms) || 0))
const centis = Math.floor(total / 10)
const seconds = Math.floor(centis / 100)
const minutes = Math.floor(seconds / 60)
if (minutes >= 100) {
const hours = Math.floor(minutes / 60)
return `${open}${pad(hours)}:${pad(minutes % 60)}:${pad(
seconds % 60,
)}.${pad(centis % 100)}${close}`
}
return `${open}${pad(minutes)}:${pad(seconds % 60)}.${pad(
centis % 100,
)}${close}`
}
const ttmlClock = (ms) => {
const total = Math.max(0, Math.round(Number(ms) || 0))
const hours = Math.floor(total / 3_600_000)
const minutes = Math.floor(total / 60_000) % 60
const seconds = Math.floor(total / 1_000) % 60
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(
total % 1_000,
3,
)}`
}
const cuesByLineIndex = (lyric) => {
const byIndex = new Map()
for (const cueLine of Array.isArray(lyric.cueLine) ? lyric.cueLine : []) {
const index = Number(cueLine?.index)
if (!Number.isInteger(index)) continue
if (!byIndex.has(index)) byIndex.set(index, [])
byIndex.get(index).push(cueLine)
}
return byIndex
}
const timedCues = (groups) =>
groups.flatMap((cueLine) =>
(Array.isArray(cueLine.cue) ? cueLine.cue : []).filter(
(cue) => cue?.start != null,
),
)
const serializeLrc = (lyric, lines) => {
const cuesByIndex = cuesByLineIndex(lyric)
return lines
.map((line, index) => {
const cues = timedCues(cuesByIndex.get(index) || [])
if (cues.length === 0) {
return `${lrcStamp(line?.start)}${line?.value ?? ''}`
}
const body = cues
.map((cue) => `${lrcStamp(cue.start, '<', '>')}${cue.value ?? ''}`)
.join('')
return `${lrcStamp(line?.start)}${body}`
})
.join('\n')
}
const serializeTtml = (lyric, lines) => {
const lang = typeof lyric.lang === 'string' ? lyric.lang.trim() : ''
const cuesByIndex = cuesByLineIndex(lyric)
const hasCues = [...cuesByIndex.values()].some(
(groups) => timedCues(groups).length > 0,
)
const attributes = [
'xmlns="http://www.w3.org/ns/ttml"',
'xmlns:itunes="http://music.apple.com/lyric-ttml-internal"',
...(hasCues ? ['itunes:timing="Word"'] : []),
...(lang && lang !== 'xxx' ? [`xml:lang="${escapeXml(lang)}"`] : []),
]
const paragraphs = lines.map((line, index) => {
const begin = line?.start == null ? '' : ` begin="${ttmlClock(line.start)}"`
const end = line?.end == null ? '' : ` end="${ttmlClock(line.end)}"`
const cues = timedCues(cuesByIndex.get(index) || [])
const content =
cues.length > 0
? cues
.map(
(cue) =>
`<span begin="${ttmlClock(cue.start)}"${
cue.end == null ? '' : ` end="${ttmlClock(cue.end)}"`
}>${escapeXml(cue.value)}</span>`,
)
.join('')
: escapeXml(line?.value)
return `<p${begin}${end}>${content}</p>`
})
return [
'<?xml version="1.0" encoding="UTF-8"?>',
`<tt ${attributes.join(' ')}>`,
'<body><div>',
...paragraphs,
'</div></body></tt>',
].join('\n')
}
export const serializeLyrics = (lyric, format) => {
if (!lyric) return ''
const lines = Array.isArray(lyric.line) ? lyric.line : []
switch (lyricsFileExtension(format)) {
case 'ttml':
return serializeTtml(lyric, lines)
case 'lrc':
return serializeLrc(lyric, lines)
default:
return lines.map((line) => line?.value ?? '').join('\n')
}
}
export const downloadLyrics = (
lyric,
{ format, artist, title, label } = {},
) => {
if (!lyric || typeof document === 'undefined') return
const text = serializeLyrics(lyric, format)
if (!text) return
const extension = lyricsFileExtension(format)
const type = extension === 'ttml' ? 'application/xml' : 'text/plain'
const blob = new Blob([text], { type: `${type};charset=utf-8` })
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = buildLyricsFileName({ artist, title, label, format })
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
}

View File

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import {
buildLyricsFileName,
lyricsFileExtension,
serializeLyrics,
} from './lyricsExport'
const line = (value, start, end) => ({
start,
...(end == null ? {} : { end }),
value,
})
const cueLine = (index, value, cues) => ({ index, value, cue: cues })
const wordLyric = () => ({
kind: 'main',
lang: 'eng',
synced: true,
line: [line('Hello world', 8000, 11000)],
cueLine: [
cueLine(0, 'Hello world', [
{ start: 8000, value: 'Hello ' },
{ start: 9500, value: 'world' },
]),
],
})
describe('lyrics export', () => {
it('maps source formats to download extensions', () => {
expect(lyricsFileExtension('ttml')).toBe('ttml')
expect(lyricsFileExtension('ELRC')).toBe('lrc')
expect(lyricsFileExtension('srt')).toBe('lrc')
expect(lyricsFileExtension('plain')).toBe('txt')
expect(lyricsFileExtension(undefined)).toBe('txt')
})
it('builds a sanitized Artist - Title (source).ext filename', () => {
expect(
buildLyricsFileName({
artist: 'AC/DC: Live',
title: 'Back? in Black',
label: 'LRCLib',
format: 'elrc',
}),
).toBe('ACDC Live - Back in Black (LRCLib).lrc')
expect(buildLyricsFileName({})).toBe(
'Unknown artist - Unknown title (Lyrics).txt',
)
})
it('serializes line and word timing to enhanced LRC', () => {
expect(serializeLyrics(wordLyric(), 'elrc')).toBe(
'[00:08.00]<00:08.00>Hello <00:09.50>world',
)
})
it('serializes line timing to a plain LRC stamp per line', () => {
const lyric = {
kind: 'main',
lang: 'eng',
synced: true,
line: [line('First line', 8000), line('Second line', 95010)],
}
expect(serializeLyrics(lyric, 'lrc')).toBe(
'[00:08.00]First line\n[01:35.01]Second line',
)
})
it('switches to hh:mm:ss stamps past 99 minutes and clamps negatives', () => {
const lyric = {
kind: 'main',
lang: 'eng',
synced: false,
line: [line('Long track', 6000500), line('Early line', -300)],
}
expect(serializeLyrics(lyric, 'lrc')).toBe(
'[01:40:00.50]Long track\n[00:00.00]Early line',
)
})
it('serializes a word-timed variant to TTML with escaped text', () => {
const lyric = {
kind: 'main',
lang: 'eng',
synced: true,
line: [line('A & B', 8000, 11000)],
cueLine: [
cueLine(0, 'A & B', [{ start: 8000, end: 9500, value: 'A & B' }]),
],
}
expect(serializeLyrics(lyric, 'ttml')).toBe(
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" itunes:timing="Word" xml:lang="eng">',
'<body><div>',
'<p begin="00:00:08.000" end="00:00:11.000"><span begin="00:00:08.000" end="00:00:09.500">A &amp; B</span></p>',
'</div></body></tt>',
].join('\n'),
)
})
it('serializes untimed lyrics to TTML without a timing attribute', () => {
const lyric = {
kind: 'main',
lang: 'xxx',
synced: false,
line: [line('Plain words')],
}
expect(serializeLyrics(lyric, 'ttml')).toBe(
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<tt xmlns="http://www.w3.org/ns/ttml" xmlns:itunes="http://music.apple.com/lyric-ttml-internal">',
'<body><div>',
'<p>Plain words</p>',
'</div></body></tt>',
].join('\n'),
)
})
it('serializes plain lyrics as text lines', () => {
const lyric = {
kind: 'main',
lang: 'eng',
synced: false,
line: [line('one'), line('two')],
}
expect(serializeLyrics(lyric, 'plain')).toBe('one\ntwo')
})
})

View File

@ -0,0 +1,36 @@
// Display labels for a lyrics source, shared by the controls and the
// download filename.
const UPPERCASE_SOURCE_VALUES = new Set([
'elrc',
'lrc',
'srt',
'ttml',
'txt',
'yaml',
])
export const formatSourceValue = (value) => {
const normalized = typeof value === 'string' ? value.trim() : ''
if (!normalized) return ''
if (UPPERCASE_SOURCE_VALUES.has(normalized.toLowerCase())) {
return normalized.toUpperCase()
}
if (normalized.toLowerCase() === 'plain') return 'Plain text'
return normalized
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (character) => character.toUpperCase())
}
export const getSourceName = (source, labels) => {
switch (source?.type) {
case 'embedded':
return labels.embeddedSource || 'Embedded lyrics'
case 'sidecar':
return labels.sidecarSource || 'Sidecar file'
case 'plugin':
return source.name || labels.pluginSource || 'Lyrics plugin'
default:
return source?.name || formatSourceValue(source?.type) || 'Lyrics'
}
}

View File

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { formatSourceValue, getSourceName } from './lyricsSource'
describe('lyrics source labels', () => {
it('formats known formats and slug-like values for display', () => {
expect(formatSourceValue('ttml')).toBe('TTML')
expect(formatSourceValue('elrc')).toBe('ELRC')
expect(formatSourceValue('plain')).toBe('Plain text')
expect(formatSourceValue('apple-lyrically')).toBe('Apple Lyrically')
expect(formatSourceValue(' ')).toBe('')
})
it('names built-in, plugin, and unknown sources', () => {
expect(getSourceName({ type: 'embedded' }, {})).toBe('Embedded lyrics')
expect(getSourceName({ type: 'sidecar', format: 'lrc' }, {})).toBe(
'Sidecar file',
)
expect(
getSourceName(
{ type: 'plugin', name: 'Better Lyrics', provider: 'NetEase' },
{},
),
).toBe('Better Lyrics')
expect(getSourceName({ type: 'plugin' }, {})).toBe('Lyrics plugin')
expect(getSourceName(null, {})).toBe('Lyrics')
})
})