Skip to content

Commit 5d0c8e3

Browse files
authored
Merge pull request #203 from wilywyrm/synced-lyrics-from-opensubsonic
Use the OpenSubsonic getLyricsBySongId to retrieve synced lyrics
2 parents 24e55ab + 046c0b5 commit 5d0c8e3

8 files changed

Lines changed: 132 additions & 8 deletions

File tree

.github/workflows/release.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
name: Release Desktop
22

33
on:
4+
workflow_dispatch:
45
push:
56
tags:
67
- 'v*'
@@ -103,4 +104,4 @@ jobs:
103104
if: env.SHOULD_RUN == 'true' && matrix.os == 'ubuntu-latest'
104105
env:
105106
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
106-
run: pnpm ci:build:linux
107+
run: pnpm ci:build:linux

src/api/queryServerInfo.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,27 @@ export async function queryServerInfo(url: string) {
1717
method: 'GET',
1818
})
1919
const data = await response.json()
20+
21+
const extensionsMap: {[key: string]: number[]} = {}
22+
if (data['subsonic-response']['openSubsonic']) {
23+
const response = await fetch(`${url}/rest/getOpenSubsonicExtensions.view?${queries}`, {
24+
method: 'GET',
25+
})
26+
27+
const eData = await response.json()
28+
29+
for (const extension of eData['subsonic-response']['openSubsonicExtensions']) {
30+
extensionsMap[extension['name']] = extension['versions']
31+
}
32+
}
2033

2134
return {
2235
protocolVersion: data['subsonic-response'].version,
2336
protocolVersionNumber: parseInt(
2437
data['subsonic-response'].version.replaceAll('.', ''),
2538
),
2639
serverType: data['subsonic-response'].type.toLowerCase() || 'subsonic',
40+
extensionsSupported: extensionsMap,
2741
}
2842
} catch (_) {
2943
return {
@@ -32,4 +46,4 @@ export async function queryServerInfo(url: string) {
3246
serverType: 'subsonic',
3347
}
3448
}
35-
}
49+
}

src/app/components/fullscreen/lyrics.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@ export function LyricsTab() {
2020
const { currentSong } = usePlayerSonglist()
2121
const { t } = useTranslation()
2222

23-
const { artist, title, duration } = currentSong
23+
const { id, artist, title, duration } = currentSong
2424

2525
const { data: lyrics, isLoading } = useQuery({
2626
queryKey: ['get-lyrics', artist, title, duration],
2727
queryFn: () =>
2828
subsonic.lyrics.getLyrics({
29+
id,
2930
artist,
3031
title,
3132
duration,

src/service/lyrics.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { get, set } from 'idb-keyval'
22
import { httpClient } from '@/api/httpClient'
33
import { usePlayerStore } from '@/store/player.store'
4-
import { LyricsResponse } from '@/types/responses/song'
4+
import {
5+
ILyric,
6+
IStructuredLyric,
7+
LyricsResponse,
8+
StructuredLyricsResponse,
9+
} from '@/types/responses/song'
510
import { lrclibClient } from '@/utils/appName'
6-
import { checkServerType } from '@/utils/servers'
11+
import { checkServerType, getServerExtensions } from '@/utils/servers'
712

813
interface GetLyricsData {
14+
id: string
915
artist: string
1016
title: string
1117
album?: string
@@ -22,6 +28,7 @@ interface LRCLibResponse {
2228

2329
async function getLyrics(getLyricsData: GetLyricsData) {
2430
const { preferSyncedLyrics } = usePlayerStore.getState().settings.lyrics
31+
const { songLyricsEnabled } = getServerExtensions()
2532

2633
const cacheKey = getLyricsCacheKey(getLyricsData, preferSyncedLyrics)
2734

@@ -31,9 +38,47 @@ async function getLyrics(getLyricsData: GetLyricsData) {
3138
return cachedLyrics
3239
}
3340

34-
// If the user prefers synced lyrics, attempt to fetch them from the LrcLib first.
35-
// If lyrics are found, return them immediately.
36-
// If not, proceed with the default flow.
41+
// First attempt to retrieve lyrics from the server.
42+
// If we know it supports the OpenSubsonic songLyrics extension with timing info, use that.
43+
// If the server does not support the extension or the lyrics returned from the server did
44+
// not include timing information, fetch them from the LrcLib
45+
46+
let osUnsyncedLyricsFound: ILyric | undefined
47+
if (songLyricsEnabled) {
48+
const response = await httpClient<StructuredLyricsResponse>(
49+
'/getLyricsBySongId',
50+
{
51+
method: 'GET',
52+
query: {
53+
id: getLyricsData.id,
54+
},
55+
},
56+
)
57+
58+
if (preferSyncedLyrics) {
59+
if (
60+
response?.data.lyricsList.structuredLyrics &&
61+
response.data.lyricsList.structuredLyrics.length > 0
62+
) {
63+
const syncedLyrics = response?.data.lyricsList.structuredLyrics.find(
64+
(lyrics) => lyrics.synced,
65+
)
66+
67+
if (syncedLyrics) {
68+
return osStructuredLyricsToILyric(syncedLyrics)
69+
}
70+
// save the plain lyrics from this call
71+
osUnsyncedLyricsFound = osStructuredLyricsToILyric(
72+
response.data.lyricsList.structuredLyrics[0],
73+
)
74+
}
75+
// save the plain lyrics retrieved from the server
76+
osUnsyncedLyricsFound = osStructuredLyricsToILyric(
77+
response.data.lyricsList.structuredLyrics[0],
78+
)
79+
}
80+
}
81+
3782
if (preferSyncedLyrics) {
3883
const lyrics = await getLyricsFromLRCLib(getLyricsData)
3984

@@ -44,6 +89,12 @@ async function getLyrics(getLyricsData: GetLyricsData) {
4489
}
4590
}
4691

92+
// if the server supported the songLyrics extension and lrc did not have lyrics, we don't need to query the server and lrc again.
93+
// so return the plain lyrics if we found them
94+
if (osUnsyncedLyricsFound) {
95+
return osUnsyncedLyricsFound
96+
}
97+
4798
const response = await httpClient<LyricsResponse>('/getLyrics', {
4899
method: 'GET',
49100
query: {
@@ -157,6 +208,31 @@ function getLyricsCacheKey(
157208
return `lyrics:${artist}:${title}:${type}`
158209
}
159210

211+
function osStructuredLyricsToILyric(lyrics: IStructuredLyric): ILyric {
212+
return {
213+
artist: lyrics.displayArtist,
214+
title: lyrics.displayTitle,
215+
value: formatLyrics(
216+
lyrics.line
217+
.map((l) => {
218+
if (l.start != undefined) {
219+
// l.start may have actual value 0 (falsy)
220+
return `[${osStartMsToSongTimestamp(l.start)}] ${l.value}`
221+
}
222+
return l.value
223+
})
224+
.join('\n'),
225+
),
226+
}
227+
}
228+
229+
function osStartMsToSongTimestamp(startTime: number): string {
230+
// Date() isoString is formatted as:
231+
// YYYY-MM-DDTHH:mm:ss.sssZ -> mm:ss.ss
232+
// 2011-10-05T14:48:00.000Z -> 48:00.00
233+
return new Date(startTime).toISOString().slice(14, -2)
234+
}
235+
160236
export const lyrics = {
161237
getLyrics,
162238
getLyricsFromLRCLib,

src/store/app.store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export const useAppStore = createWithEqualityFn<IAppContext>()(
205205
state.data.protocolVersion = serverInfo.protocolVersion
206206
state.data.serverType = serverInfo.serverType
207207
state.data.isServerConfigured = true
208+
state.data.extensionsSupported = serverInfo.extensionsSupported
208209
})
209210
return true
210211
}
@@ -225,6 +226,7 @@ export const useAppStore = createWithEqualityFn<IAppContext>()(
225226
state.data.protocolVersion = '1.16.0'
226227
state.data.serverType = 'subsonic'
227228
state.data.songCount = null
229+
state.data.extensionsSupported = {}
228230
state.pages.showInfoPanel = true
229231
state.pages.hideRadiosSection = HIDE_RADIOS_SECTION ?? false
230232
state.pages.artistsPageViewType = 'table'

src/types/responses/song.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,24 @@ export interface ILyric {
1818
value?: string
1919
}
2020

21+
export interface ILyricsList {
22+
structuredLyrics: IStructuredLyric[]
23+
}
24+
25+
export interface IStructuredLyric {
26+
displayArtist: string
27+
displayTitle: string
28+
lang?: string
29+
offset?: number
30+
synced: boolean
31+
line: IStructuredLine[]
32+
}
33+
34+
export interface IStructuredLine {
35+
start?: number
36+
value: string
37+
}
38+
2139
export interface IContributor {
2240
role: string
2341
artist: IFeaturedArtist
@@ -83,5 +101,6 @@ export interface FavoritesResponse
83101
extends SubsonicResponse<{ starred2: SongList }> {}
84102

85103
export interface LyricsResponse extends SubsonicResponse<{ lyrics: ILyric }> {}
104+
export interface StructuredLyricsResponse extends SubsonicResponse<{ lyricsList: ILyricsList }> {}
86105

87106
export interface GetSongResponse extends SubsonicResponse<{ song: ISong }> {}

src/types/serverConfig.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface IServerConfig {
1111
password: string
1212
protocolVersion?: string
1313
serverType?: string
14+
extensionsSupported?: {[key: string]: number[]}
1415
}
1516

1617
export type PageViewType = 'grid' | 'table'

src/utils/servers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,13 @@ export function checkServerType() {
1313
isLms,
1414
}
1515
}
16+
17+
export function getServerExtensions() {
18+
const { extensionsSupported } = useAppStore.getState().data
19+
20+
const songLyricsEnabled = extensionsSupported && extensionsSupported['songLyrics'] && extensionsSupported['songLyrics'].length > 0
21+
22+
return {
23+
songLyricsEnabled,
24+
}
25+
}

0 commit comments

Comments
 (0)