mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): add lyrics source labels and download serializers
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
5bfc80a0db
commit
f3d0372261
175
ui/src/audioplayer/lyricsExport.js
Normal file
175
ui/src/audioplayer/lyricsExport.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
// 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)
|
||||
}
|
||||
136
ui/src/audioplayer/lyricsExport.test.js
Normal file
136
ui/src/audioplayer/lyricsExport.test.js
Normal 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 & 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')
|
||||
})
|
||||
})
|
||||
36
ui/src/audioplayer/lyricsSource.js
Normal file
36
ui/src/audioplayer/lyricsSource.js
Normal 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'
|
||||
}
|
||||
}
|
||||
27
ui/src/audioplayer/lyricsSource.test.js
Normal file
27
ui/src/audioplayer/lyricsSource.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user