mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): collect and select lyrics source variants
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
13e3d53b9a
commit
7d3c1bee0c
@ -252,6 +252,66 @@ export const hasStructuredLyricContent = (structuredLyric) =>
|
||||
hasCueTiming(structuredLyric)),
|
||||
)
|
||||
|
||||
// variantKey identifies a lyrics source for selection and persistence. The
|
||||
// plugin's provider label is the upstream identity; name and type cover
|
||||
// non-plugin sources.
|
||||
export const variantKey = (source) => {
|
||||
const label = [source?.provider, source?.name, source?.type].find(
|
||||
(value) => typeof value === 'string' && value.trim() !== '',
|
||||
)
|
||||
return (label || 'unknown').trim().toLowerCase()
|
||||
}
|
||||
|
||||
// variantSync grades a variant for its badge: word timing from cue lines,
|
||||
// line timing from synced line starts, plain otherwise.
|
||||
export const variantSync = (lyric) => {
|
||||
if (hasCueTiming(lyric)) return 'word'
|
||||
if (hasTimedLines(lyric)) return 'line'
|
||||
return 'plain'
|
||||
}
|
||||
|
||||
// mainKindEntries mirrors the contract's main rule: blank/unknown kinds count
|
||||
// as main, and a response without any main entry falls back to every entry.
|
||||
const mainKindEntries = (available) => {
|
||||
const mains = available.filter(
|
||||
(lyric) => normalizeLyricKind(lyric?.kind) === LYRIC_KIND_MAIN,
|
||||
)
|
||||
return mains.length > 0 ? mains : available
|
||||
}
|
||||
|
||||
// groupLyricVariants groups entries by source, keeping first-appearance
|
||||
// order. One source can emit several main entries (embedded multi-language
|
||||
// tags), and they stay one variant.
|
||||
const groupLyricVariants = (entries) => {
|
||||
const variants = []
|
||||
const byKey = new Map()
|
||||
for (const lyric of entries) {
|
||||
const key = variantKey(lyric?.source)
|
||||
let variant = byKey.get(key)
|
||||
if (!variant) {
|
||||
variant = {
|
||||
key,
|
||||
source: lyric?.source || null,
|
||||
sync: variantSync(lyric),
|
||||
lyric,
|
||||
entries: [],
|
||||
}
|
||||
byKey.set(key, variant)
|
||||
variants.push(variant)
|
||||
}
|
||||
variant.entries.push(lyric)
|
||||
}
|
||||
return variants
|
||||
}
|
||||
|
||||
export const collectLyricVariants = (structuredLyrics) => {
|
||||
if (!Array.isArray(structuredLyrics)) return []
|
||||
const available = structuredLyrics.filter(hasStructuredLyricContent)
|
||||
return groupLyricVariants(mainKindEntries(available)).map(
|
||||
({ entries, ...variant }) => variant,
|
||||
)
|
||||
}
|
||||
|
||||
export const getPreferredLyricLanguage = () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
@ -267,7 +327,11 @@ export const getPreferredLyricLanguage = () => {
|
||||
return 'en'
|
||||
}
|
||||
|
||||
export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
|
||||
export const selectLyricLayers = (
|
||||
structuredLyrics,
|
||||
preferredLanguage,
|
||||
selectedSourceKey = null,
|
||||
) => {
|
||||
if (!Array.isArray(structuredLyrics)) {
|
||||
return { main: null, translation: null, pronunciation: null }
|
||||
}
|
||||
@ -287,18 +351,32 @@ export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
|
||||
grouped[normalizeLyricKind(lyric?.kind)].push(lyric)
|
||||
}
|
||||
|
||||
const mainCandidates = grouped[LYRIC_KIND_MAIN].length
|
||||
? grouped[LYRIC_KIND_MAIN]
|
||||
: available
|
||||
const variants = groupLyricVariants(mainKindEntries(available))
|
||||
const selectedVariant =
|
||||
variants.find((variant) => variant.key === selectedSourceKey) ||
|
||||
variants[0] ||
|
||||
null
|
||||
const mainCandidates = selectedVariant ? selectedVariant.entries : []
|
||||
|
||||
// Translation/pronunciation entries travel with their own source when the
|
||||
// response tags them; without a tagged match the global pick stands.
|
||||
const scopedLayer = (kind) => {
|
||||
const entries = grouped[kind]
|
||||
if (!selectedVariant || entries.length === 0) return entries
|
||||
const scoped = entries.filter(
|
||||
(lyric) => variantKey(lyric?.source) === selectedVariant.key,
|
||||
)
|
||||
return scoped.length > 0 ? scoped : entries
|
||||
}
|
||||
|
||||
return {
|
||||
main: pickPreferredLyric(mainCandidates, preferredLanguage),
|
||||
translation: pickPreferredLyric(
|
||||
grouped[LYRIC_KIND_TRANSLATION],
|
||||
scopedLayer(LYRIC_KIND_TRANSLATION),
|
||||
preferredLanguage,
|
||||
),
|
||||
pronunciation: pickPreferredLyric(
|
||||
grouped[LYRIC_KIND_PRONUNCIATION],
|
||||
scopedLayer(LYRIC_KIND_PRONUNCIATION),
|
||||
preferredLanguage,
|
||||
),
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildKaraokeLines,
|
||||
collectLyricVariants,
|
||||
getPreferredLyricLanguage,
|
||||
hasStructuredLyricContent,
|
||||
selectLyricLayers,
|
||||
@ -360,3 +361,143 @@ describe('lyrics helpers', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lyrics variants', () => {
|
||||
it('groups main entries from the same source into one variant', () => {
|
||||
const lyrics = [
|
||||
{
|
||||
...structured('main', 'eng', 'Hello'),
|
||||
source: { type: 'embedded' },
|
||||
},
|
||||
{
|
||||
...structured('main', 'jpn', 'こんにちは'),
|
||||
source: { type: 'embedded' },
|
||||
},
|
||||
]
|
||||
|
||||
const variants = collectLyricVariants(lyrics)
|
||||
|
||||
expect(variants).toHaveLength(1)
|
||||
expect(variants[0].key).toBe('embedded')
|
||||
})
|
||||
|
||||
it('lists plugin variants in response order with their sync levels', () => {
|
||||
const wordEntry = {
|
||||
kind: 'main',
|
||||
lang: 'eng',
|
||||
synced: true,
|
||||
line: [timed('word line', 0)],
|
||||
cueLine: [
|
||||
cueLine(0, 'word line', 0, 1000, [{ start: 0, value: 'word line' }]),
|
||||
],
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'NetEase',
|
||||
format: 'elrc',
|
||||
},
|
||||
}
|
||||
const lineEntry = {
|
||||
...structured('main', 'eng', 'line line'),
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'LRCLib',
|
||||
format: 'lrc',
|
||||
},
|
||||
}
|
||||
const plainEntry = {
|
||||
kind: 'main',
|
||||
lang: 'eng',
|
||||
synced: false,
|
||||
line: [{ value: 'plain words' }],
|
||||
source: { type: 'plugin', provider: 'Genius', format: 'plain' },
|
||||
}
|
||||
|
||||
expect(
|
||||
collectLyricVariants([wordEntry, lineEntry, plainEntry]).map((v) => [
|
||||
v.key,
|
||||
v.sync,
|
||||
]),
|
||||
).toEqual([
|
||||
['netease', 'word'],
|
||||
['lrclib', 'line'],
|
||||
['genius', 'plain'],
|
||||
])
|
||||
})
|
||||
|
||||
it('selects the requested variant and falls back to the winner', () => {
|
||||
const netease = {
|
||||
...structured('main', 'eng', 'NetEase line'),
|
||||
source: { provider: 'NetEase', format: 'elrc' },
|
||||
}
|
||||
const lrclib = {
|
||||
...structured('main', 'eng', 'LRCLib line'),
|
||||
source: { provider: 'LRCLib', format: 'lrc' },
|
||||
}
|
||||
const lyrics = [netease, lrclib]
|
||||
|
||||
expect(selectLyricLayers(lyrics, 'en').main.line[0].value).toBe(
|
||||
'NetEase line',
|
||||
)
|
||||
expect(selectLyricLayers(lyrics, 'en', 'lrclib').main.line[0].value).toBe(
|
||||
'LRCLib line',
|
||||
)
|
||||
expect(selectLyricLayers(lyrics, 'en', 'gone').main.line[0].value).toBe(
|
||||
'NetEase line',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the language pick inside the selected variant', () => {
|
||||
const english = {
|
||||
...structured('main', 'eng', 'English words'),
|
||||
source: { provider: 'NetEase' },
|
||||
}
|
||||
const japanese = {
|
||||
...structured('main', 'jpn', '日本語の歌詞'),
|
||||
source: { provider: 'NetEase' },
|
||||
}
|
||||
|
||||
expect(
|
||||
selectLyricLayers([english, japanese], 'ja').main.line[0].value,
|
||||
).toBe('日本語の歌詞')
|
||||
expect(
|
||||
selectLyricLayers([english, japanese], 'en').main.line[0].value,
|
||||
).toBe('English words')
|
||||
})
|
||||
|
||||
it('scopes translation layers to the selected source with a global fallback', () => {
|
||||
const neteaseMain = {
|
||||
...structured('main', 'eng', 'NetEase main'),
|
||||
source: { provider: 'NetEase' },
|
||||
}
|
||||
const geniusMain = {
|
||||
...structured('main', 'eng', 'Genius main'),
|
||||
source: { provider: 'Genius' },
|
||||
}
|
||||
const neteaseTranslation = {
|
||||
...structured('translation', 'es', 'Traducción NetEase'),
|
||||
source: { provider: 'NetEase' },
|
||||
}
|
||||
const geniusTranslation = {
|
||||
...structured('translation', 'fr', 'Traduction Genius'),
|
||||
source: { provider: 'Genius' },
|
||||
}
|
||||
|
||||
const scoped = selectLyricLayers(
|
||||
[neteaseMain, geniusMain, neteaseTranslation, geniusTranslation],
|
||||
'en',
|
||||
'genius',
|
||||
)
|
||||
expect(scoped.main.line[0].value).toBe('Genius main')
|
||||
expect(scoped.translation.line[0].value).toBe('Traduction Genius')
|
||||
|
||||
const fallback = selectLyricLayers(
|
||||
[neteaseMain, geniusMain, geniusTranslation],
|
||||
'en',
|
||||
'netease',
|
||||
)
|
||||
expect(fallback.main.line[0].value).toBe('NetEase main')
|
||||
expect(fallback.translation.line[0].value).toBe('Traduction Genius')
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user