Add support for OpenSubsonic enhanced lyrics (#2208)

This commit is contained in:
Jeff
2026-07-09 21:32:05 -07:00
committed by GitHub
parent 0c89fea1ea
commit 355b19c1cb
55 changed files with 4213 additions and 480 deletions
@@ -956,7 +956,10 @@ export const JellyfinController: InternalControllerEndpoint = {
return res.body.Lyrics.map((lyric) => lyric.Text).join('\n');
}
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
return res.body.Lyrics.map((lyric) => ({
startMs: lyric.Start! / 1e4,
text: lyric.Text,
}));
},
getMusicFolderList: async (args) => {
const { apiClientProps } = args;
@@ -8,6 +8,7 @@ import md5 from 'md5';
import { z } from 'zod';
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { mapStructuredLyric } from '/@/renderer/api/subsonic/subsonic-structured-lyrics';
import {
getDefaultTranscodingProfiles,
getDirectPlayProfiles,
@@ -21,7 +22,13 @@ import {
ssType,
SubsonicExtensions,
} from '/@/shared/api/subsonic/subsonic-types';
import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
import {
hasFeature,
hasFeatureWithVersion,
sortAlbumArtistList,
sortAlbumList,
sortSongList,
} from '/@/shared/api/utils';
import {
AlbumListSort,
GenreListSort,
@@ -1381,7 +1388,7 @@ export const SubsonicController: InternalControllerEndpoint = {
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = [1];
features.lyricsMultipleStructured = subsonicFeatures[SubsonicExtensions.SONG_LYRICS];
}
if (subsonicFeatures[SubsonicExtensions.FORM_POST]) {
@@ -1961,9 +1968,16 @@ export const SubsonicController: InternalControllerEndpoint = {
},
getStructuredLyrics: async (args) => {
const { apiClientProps, query } = args;
const server = apiClientProps.server;
const supportsEnhancedLyrics = hasFeatureWithVersion(
server,
ServerFeature.LYRICS_MULTIPLE_STRUCTURED,
2,
);
const res = await ssApiClient(apiClientProps).getStructuredLyrics({
query: {
enhanced: supportsEnhancedLyrics ? true : undefined,
id: query.songId,
},
});
@@ -1978,28 +1992,9 @@ export const SubsonicController: InternalControllerEndpoint = {
return [];
}
return lyrics.map((lyric) => {
const baseLyric = {
artist: lyric.displayArtist || '',
lang: lyric.lang,
name: lyric.displayTitle || '',
remote: false,
source: apiClientProps.server?.name || 'music server',
};
const source = apiClientProps.server?.name || 'music server';
if (lyric.synced) {
return {
...baseLyric,
lyrics: lyric.line.map((line) => [line.start!, line.value]),
synced: true,
};
}
return {
...baseLyric,
lyrics: lyric.line.map((line) => [line.value]).join('\n'),
synced: false,
};
});
return lyrics.map((lyric) => mapStructuredLyric(lyric, source));
},
getTopSongs: async (args) => {
const { apiClientProps, query } = args;
@@ -0,0 +1,118 @@
import { z } from 'zod';
import { sliceUtf8Bytes } from '/@/renderer/utils/utf8-byte-slice';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import {
LyricAgent,
LyricsKind,
StructuredLyric,
SyncedCueLine,
SyncedWordCue,
SynchronizedLyricLine,
} from '/@/shared/types/domain-types';
type ApiStructuredLyric = NonNullable<
z.infer<typeof ssType._response.structuredLyrics>['lyricsList']
>['structuredLyrics'] extends Array<infer T> | undefined
? T
: never;
const mapCueWords = (
apiCueLine: NonNullable<ApiStructuredLyric['cueLine']>[number],
): SyncedWordCue[] => {
if (!apiCueLine.cue?.length) {
return [];
}
return apiCueLine.cue.map((cue) => ({
endMs: cue.end,
startMs: cue.start,
text: cue.value || sliceUtf8Bytes(apiCueLine.value, cue.byteStart, cue.byteEnd),
}));
};
const mapCueLine = (
apiCueLine: NonNullable<ApiStructuredLyric['cueLine']>[number],
): SyncedCueLine => ({
agentId: apiCueLine.agentId,
endMs: apiCueLine.end,
index: apiCueLine.index,
startMs: apiCueLine.start,
value: apiCueLine.value,
words: mapCueWords(apiCueLine),
});
const attachCueLines = (
lines: SynchronizedLyricLine[],
apiCueLines: NonNullable<ApiStructuredLyric['cueLine']>,
): SynchronizedLyricLine[] => {
const cueLinesByIndex = new Map<number, SyncedCueLine[]>();
for (const apiCueLine of apiCueLines) {
const mappedCueLine = mapCueLine(apiCueLine);
const existing = cueLinesByIndex.get(apiCueLine.index) ?? [];
existing.push(mappedCueLine);
cueLinesByIndex.set(apiCueLine.index, existing);
}
return lines.map((line, index) => {
const cueLines = cueLinesByIndex.get(index);
if (!cueLines?.length) {
return line;
}
return {
...line,
cueLines,
};
});
};
const mapAgents = (agents: ApiStructuredLyric['agents']): LyricAgent[] | undefined => {
if (!agents?.length) {
return undefined;
}
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
role: agent.role,
}));
};
export const mapStructuredLyric = (lyric: ApiStructuredLyric, source: string): StructuredLyric => {
const baseLyric = {
artist: lyric.displayArtist || '',
lang: lyric.lang,
name: lyric.displayTitle || '',
offsetMs: lyric.offset ?? 0,
remote: false,
source,
};
if (lyric.synced) {
let lines: SynchronizedLyricLine[] = lyric.line.map((line) => ({
startMs: line.start ?? 0,
text: line.value,
}));
if (lyric.cueLine?.length) {
lines = attachCueLines(lines, lyric.cueLine);
}
return {
...baseLyric,
agents: mapAgents(lyric.agents),
kind: (lyric.kind ?? 'main') as LyricsKind,
lyrics: lines,
synced: true,
};
}
return {
...baseLyric,
lyrics: lyric.line.map((line) => line.value).join('\n'),
synced: false,
};
};