Skip to content

Commit 2cb3c71

Browse files
committed
improve: implement TrackCacheManager and all source seekable
1 parent 795340b commit 2cb3c71

26 files changed

Lines changed: 291 additions & 67 deletions

src/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import WebSocketServer from '@performanc/pwsl-server'
88
import requestHandler from './api/index.js'
99
import connectionManager from './managers/connectionManager.js'
1010
import CredentialManager from './managers/credentialManager.js'
11+
import TrackCacheManager from './managers/trackCacheManager.js'
1112
import routePlannerManager from './managers/routePlannerManager.js'
1213
import sessionManager from './managers/sessionManager.js'
1314
import statsManager from './managers/statsManager.js'
@@ -167,6 +168,7 @@ class NodelinkServer extends EventEmitter {
167168

168169
this.routePlanner = new routePlannerManager(this)
169170
this.credentialManager = new CredentialManager(this)
171+
this.trackCacheManager = new TrackCacheManager(this)
170172
this.connectionManager = new connectionManager(this)
171173
this.statsManager = new statsManager(this)
172174
this.rateLimitManager = new RateLimitManager(this)
@@ -1520,6 +1522,7 @@ class NodelinkServer extends EventEmitter {
15201522
this._validateConfig()
15211523

15221524
await this.credentialManager.load()
1525+
await this.trackCacheManager.load()
15231526
await this.statsManager.initialize()
15241527

15251528
// Ensure sources are initialized before proceeding
@@ -1738,6 +1741,7 @@ if (clusterEnabled && cluster.isPrimary) {
17381741
nserver._stopHeartbeat()
17391742

17401743
await nserver.credentialManager.forceSave()
1744+
await nserver.trackCacheManager.forceSave()
17411745

17421746
workerManager.destroy()
17431747

@@ -1791,6 +1795,7 @@ if (clusterEnabled && cluster.isPrimary) {
17911795
nserver._stopHeartbeat()
17921796

17931797
await nserver.credentialManager.forceSave()
1798+
await nserver.trackCacheManager.forceSave()
17941799

17951800
await nserver._cleanupWebSocketServer()
17961801

src/managers/sourceManager.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,9 @@ export default class SourcesManager {
282282
await this.loadFolder()
283283
}
284284

285-
async getTrackUrl(track, itag) {
285+
async getTrackUrl(track, itag, ...args) {
286286
const instance = this.sourceMap.get(track.sourceName)
287-
return await instance.getTrackUrl(track, itag)
287+
return await instance.getTrackUrl(track, itag, ...args)
288288
}
289289

290290
async getTrackStream(track, url, protocol, additionalData) {

src/managers/trackCacheManager.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import crypto from 'node:crypto'
2+
import fs from 'node:fs/promises'
3+
import { logger } from '../utils.js'
4+
5+
export default class TrackCacheManager {
6+
constructor(nodelink) {
7+
this.nodelink = nodelink
8+
this.key = crypto.scryptSync(
9+
nodelink.options.server.password,
10+
'nodelink-track-salt',
11+
32
12+
)
13+
this.filePath = './.cache/tracks.bin'
14+
this.cache = new Map()
15+
this._saveTimeout = null
16+
}
17+
18+
async load() {
19+
try {
20+
const data = await fs.readFile(this.filePath)
21+
if (data.length < 32) return
22+
23+
const iv = data.subarray(0, 16)
24+
const tag = data.subarray(16, 32)
25+
const encrypted = data.subarray(32)
26+
27+
const decipher = crypto.createDecipheriv('aes-256-gcm', this.key, iv)
28+
decipher.setAuthTag(tag)
29+
30+
const decrypted =
31+
decipher.update(encrypted, 'binary', 'utf8') + decipher.final('utf8')
32+
const obj = JSON.parse(decrypted)
33+
34+
this.cache = new Map(Object.entries(obj))
35+
36+
const now = Date.now()
37+
let expiredCount = 0
38+
for (const [key, entry] of this.cache.entries()) {
39+
if (entry.expiresAt && now > entry.expiresAt) {
40+
this.cache.delete(key)
41+
expiredCount++
42+
}
43+
}
44+
if (expiredCount > 0) this.save()
45+
46+
logger('debug', 'TrackCache', `Loaded ${this.cache.size} cached tracks from disk.`)
47+
} catch (e) {
48+
if (e.code !== 'ENOENT') {
49+
logger('error', 'TrackCache', `Failed to load track cache: ${e.message}`)
50+
}
51+
this.cache = new Map()
52+
}
53+
}
54+
55+
async save() {
56+
if (this._saveTimeout) return
57+
58+
this._saveTimeout = setTimeout(async () => {
59+
this._saveTimeout = null
60+
await this.forceSave()
61+
}, 5000)
62+
}
63+
64+
async forceSave() {
65+
try {
66+
const plainText = JSON.stringify(Object.fromEntries(this.cache))
67+
const iv = crypto.randomBytes(16)
68+
const cipher = crypto.createCipheriv('aes-256-gcm', this.key, iv)
69+
70+
const encrypted = Buffer.concat([
71+
cipher.update(plainText, 'utf8'),
72+
cipher.final()
73+
])
74+
const tag = cipher.getAuthTag()
75+
76+
await fs.mkdir('./.cache', { recursive: true })
77+
await fs.writeFile(this.filePath, Buffer.concat([iv, tag, encrypted]))
78+
} catch (e) {
79+
logger('error', 'TrackCache', `Failed to save track cache: ${e.message}`)
80+
}
81+
}
82+
83+
get(source, identifier) {
84+
const key = `${source}:${identifier}`
85+
const entry = this.cache.get(key)
86+
if (!entry) return null
87+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
88+
this.cache.delete(key)
89+
this.save()
90+
return null
91+
}
92+
return entry.value
93+
}
94+
95+
set(source, identifier, value, ttlMs = 1000 * 60 * 60 * 6) {
96+
const key = `${source}:${identifier}`
97+
this.cache.set(key, {
98+
value,
99+
expiresAt: Date.now() + ttlMs
100+
})
101+
this.save()
102+
}
103+
}

src/playback/hls/HLSHandler.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default class HLSHandler extends PassThrough {
1313
this.localAddress = options.localAddress || null
1414
this.onResolveUrl = options.onResolveUrl || null
1515
this.strategy = options.strategy || (options.type?.includes('fmp4') ? 'segmented' : 'streaming')
16+
this.startTime = (options.startTime || 0) / 1000
1617

1718
this.fetcher = new SegmentFetcher({
1819
headers: this.headers,
@@ -122,6 +123,26 @@ export default class HLSHandler extends PassThrough {
122123
}
123124

124125
this.isLive = parsed.isLive
126+
logger('debug', 'HLSHandler', `Processing playlist. Live: ${this.isLive}, Segments: ${parsed.segments.length}, startTime: ${this.startTime}s`)
127+
128+
if (this.startTime > 0 && !this.isLive && this.processedSegments.size === 0) {
129+
let elapsed = 0
130+
let skippedCount = 0
131+
for (const seg of parsed.segments) {
132+
if (elapsed + seg.duration <= this.startTime) {
133+
elapsed += seg.duration
134+
const key = seg.sequence !== -1 ? seg.sequence : seg.url
135+
this.processedSegments.add(key)
136+
this.processedOrder.push(key)
137+
if (seg.sequence !== -1 && seg.sequence > this.highestSequence) this.highestSequence = seg.sequence
138+
skippedCount++
139+
} else {
140+
break
141+
}
142+
}
143+
logger('debug', 'HLSHandler', `Skipped ${skippedCount} segments. New elapsed: ${elapsed}s, Target: ${this.startTime}s`)
144+
this.startTime = 0
145+
}
125146

126147
if (this.lastMediaSequence !== -1 && (parsed.mediaSequence < this.lastMediaSequence || parsed.mediaSequence > this.lastMediaSequence + this.maxGap)) {
127148
if (this.isLive) {

src/playback/player.js

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -456,15 +456,15 @@ export class Player {
456456
async _fetchResource(info, urlData, startTime) {
457457
await getStreamProcessor()
458458

459-
if (startTime !== undefined)
460-
urlData.additionalData = { startTime, ...urlData.additionalData }
459+
const additionalData = { ...urlData.additionalData }
460+
if (startTime !== undefined) additionalData.startTime = startTime
461461

462462
const track = urlData?.newTrack ? urlData?.newTrack?.info : info
463463
const fetched = await this.nodelink.sources.getTrackStream(
464464
track,
465465
urlData.url,
466466
urlData.protocol,
467-
urlData.additionalData
467+
additionalData
468468
)
469469
if (fetched.exception) return fetched
470470
const resource = createAudioResource(
@@ -623,16 +623,12 @@ export class Player {
623623
...this.track.info,
624624
audioTrackId: this.track.audioTrackId
625625
}
626-
const urlData = await this.nodelink.sources.getTrackUrl(trackInfo)
626+
const urlData = await this.nodelink.sources.getTrackUrl(trackInfo, null, this._isRecovering)
627627
this.streamInfo = { ...urlData, trackInfo: this.track.info }
628628
logger('debug', 'Player', `Got track URL for guild ${this.guildId}`, {
629629
urlData
630630
})
631631

632-
if (['mp4', 'm4a', 'mov'].includes(this.streamInfo.format)) {
633-
this.track.info.isSeekable = false
634-
}
635-
636632
if (urlData.exception) {
637633
const err = new Error(urlData.exception.message)
638634
this._onError(err)
@@ -849,7 +845,7 @@ export class Player {
849845
const source = this.nodelink.sources.getSource(sourceName)
850846
const canNativeSeek = sourceName === 'deezer' || (source && typeof source.loadStream === 'function')
851847

852-
if (!unsupportedSources.includes(sourceName) && this.streamInfo?.url && sourceName !== 'deezer') {
848+
if (!unsupportedSources.includes(sourceName) && this.streamInfo?.url && sourceName !== 'deezer' && this.streamInfo.protocol !== 'hls') {
853849
seekPromise = this._seekeableSeek(
854850
seekPosition,
855851
endTime !== undefined ? endTime : this.track.endTime
@@ -1054,7 +1050,7 @@ export class Player {
10541050
...this.track.info,
10551051
audioTrackId: this.track.audioTrackId
10561052
}
1057-
const urlData = await this.nodelink.sources.getTrackUrl(trackInfo)
1053+
const urlData = await this.nodelink.sources.getTrackUrl(trackInfo, null, this._isRecovering)
10581054
this.streamInfo = { ...urlData, trackInfo: this.track.info }
10591055

10601056
if (urlData.exception) {

src/sourceWorker.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,24 +214,28 @@ if (isMainThread) {
214214
{ default: SourceManager },
215215
{ default: LyricsManager },
216216
{ default: CredentialManager },
217+
{ default: TrackCacheManager },
217218
{ default: RoutePlannerManager },
218219
{ default: StatsManager }
219220
] = await Promise.all([
220221
import('./playback/streamProcessor.js'),
221222
import('./managers/sourceManager.js'),
222223
import('./managers/lyricsManager.js'),
223224
import('./managers/credentialManager.js'),
225+
import('./managers/trackCacheManager.js'),
224226
import('./managers/routePlannerManager.js'),
225227
import('./managers/statsManager.js')
226228
])
227229

228230
nodelink.statsManager = new StatsManager(nodelink)
229231
nodelink.credentialManager = new CredentialManager(nodelink)
232+
nodelink.trackCacheManager = new TrackCacheManager(nodelink)
230233
nodelink.routePlanner = new RoutePlannerManager(nodelink)
231234
nodelink.sources = new SourceManager(nodelink)
232235
nodelink.lyrics = new LyricsManager(nodelink)
233236

234237
await nodelink.credentialManager.load()
238+
await nodelink.trackCacheManager.load()
235239
await nodelink.sources.loadFolder()
236240
await nodelink.lyrics.loadFolder()
237241

src/sources/amazonmusic.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,8 @@ export default class AmazonMusicSource {
524524
}
525525
}
526526

527-
async getTrackUrl(decodedTrack) {
528-
const query = `${decodedTrack.title} ${decodedTrack.author} official audio`
527+
async getTrackUrl(decodedTrack, itag, forceRefresh = false) {
528+
const query = `${decodedTrack.title} ${decodedTrack.author}`
529529

530530
try {
531531
let searchResult
@@ -570,7 +570,7 @@ export default class AmazonMusicSource {
570570
if (!bestMatch)
571571
throw new Error('No suitable alternative stream found after filtering.')
572572

573-
const streamInfo = await this.nodelink.sources.getTrackUrl(bestMatch.info)
573+
const streamInfo = await this.nodelink.sources.getTrackUrl(bestMatch.info, itag, forceRefresh)
574574
return { newTrack: bestMatch, ...streamInfo }
575575
} catch (e) {
576576
logger(

src/sources/applemusic.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ export default class AppleMusicSource {
493493
return results
494494
}
495495

496-
async getTrackUrl(decodedTrack) {
496+
async getTrackUrl(decodedTrack, itag, forceRefresh = false) {
497497
let isExplicit = false
498498
if (decodedTrack.uri) {
499499
try {
@@ -557,7 +557,7 @@ export default class AppleMusicSource {
557557
}
558558
}
559559

560-
const stream = await this.nodelink.sources.getTrackUrl(bestMatch.info)
560+
const stream = await this.nodelink.sources.getTrackUrl(bestMatch.info, itag, forceRefresh)
561561
return { newTrack: bestMatch, ...stream }
562562
} catch (error) {
563563
return { exception: { message: error.message, severity: 'fault' } }

src/sources/bilibili.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -776,11 +776,12 @@ export default class BilibiliSource {
776776
}
777777

778778
if (protocol === 'hls' || url.includes('.m3u8')) {
779-
const stream = new HLSHandler(url, {
780-
type: 'mpegts',
781-
headers,
782-
localAddress: this.nodelink.routePlanner?.getIP()
783-
})
779+
const stream = new HLSHandler(url, {
780+
headers: HEADERS,
781+
type: 'mpegts',
782+
localAddress: this.nodelink.routePlanner?.getIP(),
783+
startTime: additionalData?.startTime || 0
784+
})
784785
return { stream, type: 'mpegts' }
785786
}
786787

src/sources/deezer.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,12 @@ export default class DeezerSource {
349349
}
350350
}
351351

352-
async getTrackUrl(decodedTrack) {
352+
async getTrackUrl(decodedTrack, itag, forceRefresh = false) {
353+
if (!forceRefresh) {
354+
const cached = this.nodelink.trackCacheManager.get('deezer', decodedTrack.identifier)
355+
if (cached) return cached
356+
}
357+
353358
if (this.licenseToken) {
354359
try {
355360
const { body: trackData } = await makeRequest(
@@ -394,12 +399,14 @@ export default class DeezerSource {
394399

395400
if (streamData.data && streamData.data[0] && streamData.data[0].media && streamData.data[0].media.length > 0 && streamData.data[0].media[0].sources && streamData.data[0].media[0].sources.length > 0) {
396401
const streamInfo = streamData.data[0].media[0]
397-
return {
402+
const result = {
398403
url: streamInfo.sources[0].url,
399404
protocol: 'https',
400405
format: streamInfo.format.startsWith('MP3') ? 'mp3' : 'flac',
401406
additionalData: trackInfo
402407
}
408+
this.nodelink.trackCacheManager.set('deezer', decodedTrack.identifier, result, 1000 * 60 * 60 * 4)
409+
return result
403410
}
404411
}
405412
} catch (e) {

0 commit comments

Comments
 (0)