Skip to content

Commit 52892db

Browse files
committed
add: implement lyrics subscription and unsubscription endpoints
1 parent ed70cb2 commit 52892db

4 files changed

Lines changed: 242 additions & 2 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import myzod from 'myzod'
2+
import { logger, sendErrorResponse } from '../utils.js'
3+
4+
const querySchema = myzod.object({
5+
skipTrackSource: myzod.string().optional()
6+
})
7+
8+
const pathSchema = myzod.object({
9+
sessionId: myzod.string(),
10+
guildId: myzod
11+
.string()
12+
.withPredicate(
13+
(val) => /^\d{17,20}$/.test(val),
14+
'guildId must be 17-20 digits'
15+
)
16+
})
17+
18+
async function handler(nodelink, req, res, _sendResponse, parsedUrl) {
19+
const method = req.method
20+
const pathParts = parsedUrl.pathname.split('/')
21+
const sessionId = pathParts[3]
22+
const guildId = pathParts[5]
23+
24+
try {
25+
pathSchema.parse({ sessionId, guildId })
26+
} catch (error) {
27+
if (error instanceof myzod.ValidationError) {
28+
return sendErrorResponse(req, res, 400, error.message)
29+
}
30+
return sendErrorResponse(req, res, 400, 'Invalid path parameters')
31+
}
32+
33+
const session = nodelink.sessions.get(sessionId)
34+
if (!session) {
35+
return sendErrorResponse(req, res, 404, 'Session not found')
36+
}
37+
38+
if (!session.players) {
39+
return sendErrorResponse(req, res, 500, 'Player manager not initialized')
40+
}
41+
42+
if (method === 'POST') {
43+
const result = querySchema.try({
44+
skipTrackSource: parsedUrl.searchParams.get('skipTrackSource')
45+
})
46+
47+
if (result instanceof myzod.ValidationError) {
48+
return sendErrorResponse(req, res, 400, result.message)
49+
}
50+
51+
const skipTrackSource = result.skipTrackSource === 'true'
52+
53+
try {
54+
await session.players.subscribeLyrics(guildId, skipTrackSource)
55+
res.writeHead(204)
56+
res.end()
57+
} catch (error) {
58+
logger('error', 'LyricsAPI', `Error subscribing to lyrics: ${error.message}`)
59+
return sendErrorResponse(req, res, 500, error.message)
60+
}
61+
return
62+
}
63+
64+
if (method === 'DELETE') {
65+
try {
66+
await session.players.unsubscribeLyrics(guildId)
67+
res.writeHead(204)
68+
res.end()
69+
} catch (error) {
70+
logger('error', 'LyricsAPI', `Error unsubscribing from lyrics: ${error.message}`)
71+
return sendErrorResponse(req, res, 500, error.message)
72+
}
73+
return
74+
}
75+
76+
return sendErrorResponse(req, res, 405, 'Method Not Allowed')
77+
}
78+
79+
export default {
80+
handler,
81+
methods: ['POST', 'DELETE']
82+
}

src/managers/lyricsManager.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export default class LyricsManager {
8484
}
8585
}
8686

87-
async loadLyrics(decodedTrack, language) {
87+
async loadLyrics(decodedTrack, language, skipTrackSource = false) {
8888
if (
8989
!decodedTrack ||
9090
!decodedTrack.info?.sourceName ||
@@ -132,7 +132,7 @@ export default class LyricsManager {
132132
const sourceName = trackInfo?.sourceName
133133
const lyricsSource = this.lyricsSources.get(sourceName)
134134

135-
if (lyricsSource) {
135+
if (lyricsSource && !skipTrackSource) {
136136
const lyrics = await lyricsSource.getLyrics(trackInfo, language)
137137
if (lyrics && lyrics.loadType !== 'empty') {
138138
return lyrics

src/managers/playerManager.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,4 +515,60 @@ export default class PlayerManager {
515515
if (!player) throw new Error('Player not found locally.')
516516
return player.getMixes()
517517
}
518+
519+
async subscribeLyrics(guildId, skipTrackSource) {
520+
const session = this.nodelink.sessions.get(this.sessionId)
521+
const playerKey = `${this.sessionId}:${guildId}`
522+
523+
if (this.isCluster) {
524+
const worker = this.nodelink.workerManager.getWorkerForGuild(playerKey)
525+
if (!worker) throw new Error('Player not assigned to a worker.')
526+
const result = await this.nodelink.workerManager.execute(
527+
worker,
528+
'playerCommand',
529+
{
530+
sessionId: this.sessionId,
531+
guildId,
532+
userId: session.userId,
533+
command: 'subscribeLyrics',
534+
args: [skipTrackSource]
535+
}
536+
)
537+
if (result?.playerNotFound) {
538+
throw new Error('Player not found.')
539+
}
540+
return result
541+
}
542+
const player = this.players.get(playerKey)
543+
if (!player) throw new Error('Player not found locally.')
544+
return player.subscribeLyrics(skipTrackSource)
545+
}
546+
547+
async unsubscribeLyrics(guildId) {
548+
const session = this.nodelink.sessions.get(this.sessionId)
549+
const playerKey = `${this.sessionId}:${guildId}`
550+
551+
if (this.isCluster) {
552+
const worker = this.nodelink.workerManager.getWorkerForGuild(playerKey)
553+
if (!worker) throw new Error('Player not assigned to a worker.')
554+
const result = await this.nodelink.workerManager.execute(
555+
worker,
556+
'playerCommand',
557+
{
558+
sessionId: this.sessionId,
559+
guildId,
560+
userId: session.userId,
561+
command: 'unsubscribeLyrics',
562+
args: []
563+
}
564+
)
565+
if (result?.playerNotFound) {
566+
throw new Error('Player not found.')
567+
}
568+
return result
569+
}
570+
const player = this.players.get(playerKey)
571+
if (!player) throw new Error('Player not found locally.')
572+
return player.unsubscribeLyrics()
573+
}
518574
}

src/playback/player.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export class Player {
4848
this.audioMixer = null
4949
this._initAudioMixer()
5050

51+
this.isLyricsSubscribed = false
52+
this.currentLyrics = null
53+
this.lyricsLineIndex = -1
54+
this.skipTrackSource = false
55+
5156
logger(
5257
'debug',
5358
'Player',
@@ -182,6 +187,9 @@ export class Player {
182187
this.connection.on('audioStream', (audioStream) => {
183188
audioStream.on('data', () => {
184189
this._lastStreamDataTime = Date.now()
190+
if (this.isLyricsSubscribed && !this.isPaused && this.track) {
191+
this._syncLyrics()
192+
}
185193
})
186194
})
187195

@@ -381,6 +389,8 @@ export class Player {
381389
this.holoTrack = null
382390
this.isPaused = false
383391
this.position = 0
392+
this.currentLyrics = null
393+
this.lyricsLineIndex = -1
384394
}
385395

386396
async _emitTrackStart() {
@@ -391,6 +401,10 @@ export class Player {
391401
track: trackToEmit,
392402
playingQuality: this.streamInfo?.format?.itag || null
393403
})
404+
405+
if (this.isLyricsSubscribed) {
406+
this._loadLyrics()
407+
}
394408
}
395409

396410
_emitTrackEnd(reason) {
@@ -845,6 +859,7 @@ export class Player {
845859
const result = await seekPromise
846860
if (result) {
847861
this.emitEvent(GatewayEvents.SEEK, { position: this.position })
862+
if (this.isLyricsSubscribed) this._recalculateLyricsIndex()
848863
}
849864
return result
850865
} finally {
@@ -1316,6 +1331,93 @@ export class Player {
13161331
return this.audioMixer.getLayers()
13171332
}
13181333

1334+
async subscribeLyrics(skipTrackSource) {
1335+
if (this.isLyricsSubscribed) return
1336+
this.isLyricsSubscribed = true
1337+
this.skipTrackSource = skipTrackSource === 'true' || skipTrackSource === true
1338+
1339+
if (this.track && !this.isPaused) {
1340+
this._loadLyrics()
1341+
}
1342+
}
1343+
1344+
unsubscribeLyrics() {
1345+
this.isLyricsSubscribed = false
1346+
this.skipTrackSource = false
1347+
this.currentLyrics = null
1348+
this.lyricsLineIndex = -1
1349+
}
1350+
1351+
async _loadLyrics() {
1352+
if (!this.track) return
1353+
1354+
const lyricsData = await this.nodelink.lyrics.loadLyrics(
1355+
{ info: this.track.info },
1356+
undefined,
1357+
this.skipTrackSource
1358+
)
1359+
1360+
if (lyricsData && lyricsData.loadType === 'lyrics') {
1361+
this.currentLyrics = lyricsData.data
1362+
this.lyricsLineIndex = -1
1363+
this.emitEvent('LyricsFoundEvent', { lyrics: this.currentLyrics })
1364+
this._recalculateLyricsIndex()
1365+
} else {
1366+
this.currentLyrics = null
1367+
this.emitEvent('LyricsNotFoundEvent')
1368+
}
1369+
}
1370+
1371+
_syncLyrics() {
1372+
if (!this.isLyricsSubscribed || !this.currentLyrics || !this.currentLyrics.lines) return
1373+
1374+
const position = this._realPosition()
1375+
const lines = this.currentLyrics.lines
1376+
const nextIndex = this.lyricsLineIndex + 1
1377+
1378+
if (nextIndex >= lines.length) return
1379+
1380+
if (position >= lines[nextIndex].time) {
1381+
this._recalculateLyricsIndex()
1382+
}
1383+
}
1384+
1385+
_recalculateLyricsIndex() {
1386+
if (!this.currentLyrics || !this.currentLyrics.lines) return
1387+
1388+
const position = this._realPosition()
1389+
const lines = this.currentLyrics.lines
1390+
1391+
let foundIndex = -1
1392+
// Efficiently find the current line
1393+
for (let i = 0; i < lines.length; i++) {
1394+
if (lines[i].time <= position) {
1395+
foundIndex = i
1396+
} else {
1397+
break
1398+
}
1399+
}
1400+
1401+
if (foundIndex !== this.lyricsLineIndex) {
1402+
const skipped = foundIndex > this.lyricsLineIndex + 1
1403+
this.lyricsLineIndex = foundIndex
1404+
1405+
if (foundIndex !== -1) {
1406+
const line = lines[foundIndex]
1407+
this.emitEvent('LyricsLineEvent', {
1408+
lineIndex: foundIndex,
1409+
line: {
1410+
timestamp: line.time,
1411+
duration: line.duration,
1412+
line: line.text,
1413+
plugin: null
1414+
},
1415+
skipped
1416+
})
1417+
}
1418+
}
1419+
}
1420+
13191421
toJSON() {
13201422
return {
13211423
guildId: this.guildId,

0 commit comments

Comments
 (0)