flameface commited on
Commit
b58c6cb
·
verified ·
1 Parent(s): de5f9e6

Upload 25 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20
2
+
3
+ WORKDIR /
4
+
5
+ EXPOSE 7680
6
+
7
+ COPY package*.json ./
8
+
9
+ RUN npm install
10
+
11
+ COPY . .
12
+
13
+ CMD ["npm", "start"]
LICENSE ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2024, The PerformanC Organization
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
config.js ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ version: {
3
+ major: '2',
4
+ minor: '0',
5
+ patch: '1',
6
+ preRelease: null
7
+ },
8
+ server: {
9
+ port: 7680,
10
+ password: 'youshallnotpass'
11
+ },
12
+ options: {
13
+ threshold: false,
14
+ playerUpdateInterval: false,
15
+ statsInterval: false,
16
+ maxResultsLength: 200,
17
+ maxAlbumPlaylistLength: 200,
18
+ maxCaptionsLength: 3,
19
+ bypassAgeRestriction: false // Bypasses age-restricted videos. Enable at your own risk.
20
+ },
21
+ debug: {
22
+ youtube: {
23
+ success: true,
24
+ error: true
25
+ },
26
+ pandora: {
27
+ success: true,
28
+ error: true
29
+ },
30
+ deezer: {
31
+ success: true,
32
+ error: true
33
+ },
34
+ spotify: {
35
+ success: true,
36
+ error: true
37
+ },
38
+ soundcloud: {
39
+ success: true,
40
+ error: true
41
+ },
42
+ musixmatch: true,
43
+ websocket: {
44
+ connect: true,
45
+ disconnect: true,
46
+ resume: true,
47
+ failedResume: true,
48
+ resumeTimeout: true,
49
+ error: true,
50
+ connectCD: true,
51
+ disconnectCD: true,
52
+ sentDataCD: true
53
+ },
54
+ request: {
55
+ all: false, // Only enable for debugging purposes.
56
+ enabled: true,
57
+ error: true,
58
+ showBody: true,
59
+ showHeaders: true,
60
+ showParams: true
61
+ },
62
+ track: {
63
+ start: true,
64
+ end: true,
65
+ exception: true,
66
+ stuck: true
67
+ },
68
+ sources: {
69
+ retrieveStream: true,
70
+ loadtrack: {
71
+ request: true,
72
+ results: true,
73
+ exception: true
74
+ },
75
+ search: {
76
+ request: true,
77
+ results: true,
78
+ exception: true
79
+ },
80
+ loadlyrics: {
81
+ request: true,
82
+ results: true,
83
+ exception: true
84
+ }
85
+ }
86
+ },
87
+ search: {
88
+ defaultSearchSource: 'youtube',
89
+ fallbackSearchSource: 'bandcamp',
90
+ lyricsFallbackSource: 'genius',
91
+ sources: {
92
+ youtube: {
93
+ enabled: true,
94
+ authentication: {
95
+ enabled: false, // Authentication using accounts outside EU helps bypass 403 errors. Enable at your own risk.
96
+ cookies: { // Available in YouTube website cookies.
97
+ SID: 'DISABLED',
98
+ LOGIN_INFO: 'DISABLED'
99
+ },
100
+ authorization: 'DISABLED' // Available in YouTube website in Authorization header.
101
+ }
102
+ },
103
+ bandcamp: true,
104
+ http: true,
105
+ local: true,
106
+ pandora: false,
107
+ spotify: {
108
+ enabled: true,
109
+ market: 'BR',
110
+ sp_dc: 'DISABLED' // Necessary for direct Spotify loadLyrics. Available in Spotify website cookies in sp_dc parameter.
111
+ },
112
+ deezer: {
113
+ enabled: false,
114
+ decryptionKey: 'DISABLED', // For legal reasons, this key is not provided.
115
+ arl: 'DISABLED' // Necessary for direct Deezer Lyrics. Available in Deezer website cookies in arl parameter.
116
+ },
117
+ soundcloud: {
118
+ enabled: true,
119
+ clientId: 'AUTOMATIC', // Available in SoundCloud website API requests in client_id parameter.
120
+ fallbackIfSnipped: true
121
+ },
122
+ musixmatch: {
123
+ enabled: false,
124
+ signatureSecret: 'DISABLED' // For legal reasons, this key is not provided.
125
+ },
126
+ genius: {
127
+ enabled: true
128
+ }
129
+ }
130
+ },
131
+ filters: {
132
+ enabled: true,
133
+ threads: 4,
134
+ list: {
135
+ volume: true,
136
+ equalizer: true,
137
+ karaoke: true,
138
+ timescale: true,
139
+ tremolo: true,
140
+ vibrato: true,
141
+ rotation: true,
142
+ distortion: true,
143
+ channelMix: true,
144
+ lowPass: true
145
+ }
146
+ },
147
+ audio: {
148
+ quality: 'high',
149
+ encryption: 'xsalsa20_poly1305_lite'
150
+ },
151
+ voiceReceive: {
152
+ type: 'pcm', // pcm, opus
153
+ timeout: 1000 // 1s of silence to consider as it stopped speaking.
154
+ }
155
+ }
constants.js ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default {
2
+ YouTube: {
3
+ video: 1,
4
+ playlist: 2,
5
+ shorts: 3,
6
+ },
7
+ VoiceWSCloseCodes: {
8
+ 4001: 'Unknown opcode',
9
+ 4002: 'Failed to decode payload',
10
+ 4003: 'Not authenticated',
11
+ 4004: 'Authentication failed',
12
+ 4005: 'Already authenticated',
13
+ 4006: 'Session no longer valid',
14
+ 4009: 'Session timeout',
15
+ 4011: 'Server not found',
16
+ 4012: 'Unknown protocol',
17
+ 4014: 'Disconnected',
18
+ 4015: 'Voice server crashed',
19
+ 4016: 'Unknown encryption mode'
20
+ },
21
+ opus: {
22
+ samplingRate: 48000,
23
+ frameSize: 960,
24
+ channels: 2
25
+ },
26
+ sampleRate: {
27
+ coefficients: [{
28
+ beta: 0.99847546664,
29
+ alpha: 76226668143e-14,
30
+ gamma: 1.9984647656
31
+ }, {
32
+ beta: 0.99756184654,
33
+ alpha: 0.0012190767289,
34
+ gamma: 1.9975344645
35
+ }, {
36
+ beta: 0.99616261379,
37
+ alpha: 0.0019186931041,
38
+ gamma: 1.9960947369
39
+ }, {
40
+ beta: 0.99391578543,
41
+ alpha: 0.0030421072865,
42
+ gamma: 1.9937449618
43
+ }, {
44
+ beta: 0.99028307215,
45
+ alpha: 0.0048584639242,
46
+ gamma: 1.9898465702
47
+ }, {
48
+ beta: 0.98485897264,
49
+ alpha: 0.0075705136795,
50
+ gamma: 1.9837962543
51
+ }, {
52
+ beta: 0.97588512657,
53
+ alpha: 0.012057436715,
54
+ gamma: 1.9731772447
55
+ }, {
56
+ beta: 0.96228521814,
57
+ alpha: 0.018857390928,
58
+ gamma: 1.9556164694
59
+ }, {
60
+ beta: 0.94080933132,
61
+ alpha: 0.029595334338,
62
+ gamma: 1.9242054384
63
+ }, {
64
+ beta: 0.90702059196,
65
+ alpha: 0.046489704022,
66
+ gamma: 1.8653476166
67
+ }, {
68
+ beta: 0.85868004289,
69
+ alpha: 0.070659978553,
70
+ gamma: 1.7600401337
71
+ }, {
72
+ beta: 0.78409610788,
73
+ alpha: 0.10795194606,
74
+ gamma: 1.5450725522
75
+ }, {
76
+ beta: 0.68332861002,
77
+ alpha: 0.15833569499,
78
+ gamma: 1.1426447155
79
+ }, {
80
+ beta: 0.55267518228,
81
+ alpha: 0.22366240886,
82
+ gamma: 0.40186190803
83
+ }, {
84
+ beta: 0.41811888447,
85
+ alpha: 0.29094055777,
86
+ gamma: -0.70905944223
87
+ }]
88
+ },
89
+ pcm: {
90
+ maximumRate: 32767,
91
+ minimumRate: -32768,
92
+ bits: 16,
93
+ bytes: 2,
94
+ channels: 2
95
+ },
96
+ filtering: {
97
+ equalizerBands: 15,
98
+ types: {
99
+ equalizer: 1,
100
+ tremolo: 2,
101
+ rotationHz: 3,
102
+ }
103
+ },
104
+ circunferece: {
105
+ diameter: 2 * Math.PI
106
+ }
107
+ }
package.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "type": "module",
3
+ "scripts": {
4
+ "start": "node --dns-result-order=ipv4first --openssl-legacy-provider src/connection/index.js"
5
+ },
6
+ "dependencies": {
7
+ "@performanc/voice": "^1.0.5",
8
+ "opusscript": "^0.0.8",
9
+ "prism-media": "^1.3.5",
10
+ "sodium-native": "^4.0.4"
11
+ }
12
+ }
src/connection/handler.js ADDED
@@ -0,0 +1,829 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os from 'node:os'
2
+ import { URL } from 'node:url'
3
+
4
+ import { randomLetters, debugLog, sendResponse, sendResponseNonNull, verifyMethod, encodeTrack, decodeTrack, tryParseBody } from '../utils.js'
5
+ import config from '../../config.js'
6
+ import sources from '../sources.js'
7
+ import VoiceConnection from './voiceHandler.js'
8
+
9
+ const clients = {}
10
+ let statsInterval = null
11
+ let playerUpdateInterval = null
12
+
13
+ function startStats() {
14
+ statsInterval = setInterval(() => {
15
+ let memoryUsage = process.memoryUsage()
16
+
17
+ const statistics = {
18
+ sent: 0,
19
+ nulled: 0,
20
+ expected: 0,
21
+ deficit: 0
22
+ }
23
+
24
+ Object.keys(clients).forEach((key) => {
25
+ const client = clients[key]
26
+
27
+ client.players.forEach((player) => {
28
+ if (!player.connection) return;
29
+
30
+ statistics.sent += player.connection.statistics.packetsSent
31
+ statistics.nulled += player.connection.statistics.packetsLost
32
+ statistics.expected += player.connection.statistics.packetsExpected
33
+ })
34
+ })
35
+
36
+ statistics.deficit = statistics.sent - statistics.expected
37
+
38
+ const statisticsResponse = JSON.stringify({
39
+ op: 'stats',
40
+ players: nodelinkPlayingPlayersCount,
41
+ playingPlayers: nodelinkPlayingPlayersCount,
42
+ uptime: Math.floor(process.uptime() * 1000),
43
+ memory: {
44
+ free: memoryUsage.heapTotal - memoryUsage.heapUsed,
45
+ used: memoryUsage.heapUsed,
46
+ allocated: 0,
47
+ reservable: memoryUsage.rss
48
+ },
49
+ cpu: {
50
+ cores: os.cpus().length,
51
+ systemLoad: os.loadavg()[0],
52
+ lavalinkLoad: 0
53
+ },
54
+ frameStats: statistics
55
+ })
56
+
57
+ Object.keys(clients).forEach((key) => clients[key].ws.send(statisticsResponse, 200))
58
+ }, config.options.statsInterval)
59
+ }
60
+
61
+ function startPlayerUpdate() {
62
+ playerUpdateInterval = setInterval(() => {
63
+ if (Object.keys(clients).length === 0) return;
64
+
65
+ Object.keys(clients).forEach((key) => {
66
+ const client = clients[key]
67
+
68
+ client.players.forEach((player) => {
69
+ if (!player.connection) return;
70
+
71
+ player.config.state = {
72
+ time: Date.now(),
73
+ position: player.connection.playerState.status === 'playing' ? player._getRealTime() : 0,
74
+ connected: player.connection.state.status === 'connected',
75
+ ping: player.connection.ping || -1
76
+ }
77
+
78
+ client.ws.send(JSON.stringify({
79
+ op: 'playerUpdate',
80
+ guildId: player.guildId,
81
+ state: player.config.state
82
+ }))
83
+ })
84
+ })
85
+ }, config.options.playerUpdateInterval)
86
+ }
87
+
88
+ async function configureConnection(ws, req, parsedClientName) {
89
+ let sessionId = null
90
+ let client = null
91
+
92
+ ws.on('close', (code, reason) => {
93
+ debugLog('disconnect', 3, { ...parsedClientName, code, reason })
94
+
95
+ if (!client) return;
96
+
97
+ if (clients.length === 1) {
98
+ clearInterval(statsInterval)
99
+ statsInterval = null
100
+
101
+ clearInterval(playerUpdateInterval)
102
+ playerUpdateInterval = null
103
+
104
+ if (config.search.sources.youtube && config.options.bypassAgeRestriction)
105
+ sources.youtube.free()
106
+ }
107
+
108
+ client.players.forEach((player) => player.destroy())
109
+ delete clients[sessionId]
110
+ })
111
+
112
+ sessionId = randomLetters(16)
113
+ client = {
114
+ userId: req.headers['user-id'],
115
+ ws,
116
+ players: new Map()
117
+ }
118
+
119
+ clients[sessionId] = client
120
+
121
+ await startSourceAPIs()
122
+
123
+ ws.send(
124
+ JSON.stringify({
125
+ op: 'ready',
126
+ resumed: false,
127
+ sessionId
128
+ })
129
+ )
130
+ }
131
+
132
+ async function requestHandler(req, res) {
133
+ const parsedUrl = new URL(req.url, `http://${req.headers.host}`)
134
+
135
+ if (config.debug.request.all) {
136
+ const body = []
137
+ req.on('data', (chunk) => body.push(chunk))
138
+ req.on('end', () => {
139
+ debugLog('all', 6, { method: req.method, path: parsedUrl.pathname, headers: req.headers, body: Buffer.concat(body).toString() })
140
+
141
+ req.removeAllListeners()
142
+
143
+ req.push(Buffer.concat(body))
144
+ })
145
+ }
146
+
147
+ if (!req.headers || req.headers.authorization !== config.server.password) {
148
+ res.writeHead(401, { 'Content-Type': 'text/plain' })
149
+
150
+ res.end('Unauthorized')
151
+ }
152
+
153
+ else if (parsedUrl.pathname === '/version') {
154
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
155
+
156
+ debugLog('version', 1, { headers: req.headers })
157
+
158
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
159
+ res.end(`${config.version.major}.${config.version.minor}.${config.version.patch}${config.version.preRelease ? `-${config.version.preRelease}` : ''}`)
160
+ }
161
+
162
+ else if (parsedUrl.pathname === '/v4/info') {
163
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
164
+
165
+ debugLog('info', 1, { headers: req.headers })
166
+
167
+ sendResponse(req, res, {
168
+ version: {
169
+ semver: `${config.version.major}.${config.version.minor}.${config.version.patch}${config.version.preRelease ? `-${config.version.preRelease}` : ''}`,
170
+ ...config.version
171
+ },
172
+ buildTime: -1,
173
+ git: {
174
+ branch: 'main',
175
+ commit: 'unknown',
176
+ commitTime: -1
177
+ },
178
+ nodejs: process.version,
179
+ isNodeLink: true,
180
+ jvm: '0.0.0',
181
+ lavaplayer: '0.0.0',
182
+ sourceManagers: Object.keys(config.search.sources).filter((source) => {
183
+ if (typeof config.search.sources[source] === 'boolean') return source
184
+ return config.search.sources[source].enabled
185
+ }),
186
+ filters: Object.keys(config.filters.list).filter((filter) => config.filters.list[filter]),
187
+ plugins: []
188
+ }, 200)
189
+ }
190
+
191
+ else if (parsedUrl.pathname === '/v4/decodetrack') {
192
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
193
+
194
+ let encodedTrack = parsedUrl.searchParams.get('encodedTrack')
195
+
196
+ if (!encodedTrack) {
197
+ debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
198
+
199
+ return sendResponse(req, res, {
200
+ timestamp: Date.now(),
201
+ status: 400,
202
+ error: 'Bad Request',
203
+ trace: new Error().stack,
204
+ message: 'The provided track is invalid.',
205
+ path: parsedUrl.pathname
206
+ }, 400)
207
+ }
208
+
209
+ encodedTrack = encodedTrack.replace(/ /, '+')
210
+
211
+ let decodedTrack = null
212
+
213
+ if (!encodedTrack || !(decodedTrack = decodeTrack(encodedTrack))) {
214
+ debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
215
+
216
+ return sendResponse(req, res, {
217
+ timestamp: Date.now(),
218
+ status: 400,
219
+ error: 'Bad Request',
220
+ trace: new Error().stack,
221
+ message: 'The provided track is invalid.',
222
+ path: parsedUrl.pathname
223
+ }, 400)
224
+ }
225
+
226
+ debugLog('decodetrack', 1, { params: parsedUrl.pathname, headers: req.headers })
227
+
228
+ sendResponse(req, res, { encoded: encodedTrack, info: decodedTrack }, 200)
229
+ }
230
+
231
+ else if (parsedUrl.pathname === '/v4/decodetracks') {
232
+ if (verifyMethod(parsedUrl, req, res, 'POST')) return;
233
+
234
+ let buffer = ''
235
+ if (!(buffer = await tryParseBody(req, res))) return;
236
+
237
+ if (typeof buffer !== 'object' || !Array.isArray(buffer)) {
238
+ debugLog('decodetracks', 1, { headers: req.headers, body: buffer, error: 'The provided body is invalid.' })
239
+
240
+ return sendResponse(req, res, {
241
+ timestamp: Date.now(),
242
+ status: 400,
243
+ error: 'Bad request',
244
+ trace: new Error().stack,
245
+ message: 'The provided body is invalid.',
246
+ path: parsedUrl.pathname
247
+ }, 400)
248
+ }
249
+
250
+ const tracks = []
251
+ let failed = false
252
+
253
+ buffer.nForEach((encodedTrack) => {
254
+ const decodedTrack = decodeTrack(encodedTrack)
255
+
256
+ if (!decodedTrack) {
257
+ failed = true
258
+
259
+ debugLog('decodetracks', 1, { headers: req.headers, body: encodedTrack, error: 'The provided track is invalid.' })
260
+
261
+ sendResponse(req, res, {
262
+ timestamp: Date.now(),
263
+ status: 400,
264
+ error: 'Bad request',
265
+ trace: new Error().stack,
266
+ message: 'The provided track is invalid.',
267
+ path: parsedUrl.pathname
268
+ }, 400)
269
+
270
+ return true
271
+ }
272
+
273
+ tracks.push({ encoded: encodedTrack, info: decodedTrack })
274
+ })
275
+
276
+ if (failed) return;
277
+
278
+ debugLog('decodetracks', 1, { headers: req.headers, body: buffer })
279
+
280
+ sendResponse(req, res, tracks, 200)
281
+ }
282
+
283
+ else if (parsedUrl.pathname === '/v4/encodetrack') {
284
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
285
+
286
+ let buffer = ''
287
+ if (!(buffer = await tryParseBody(req, res))) return;
288
+
289
+ let encodedTrack = null
290
+
291
+ if (!(encodedTrack = encodeTrack(buffer))) {
292
+ debugLog('encodetrack', 1, { headers: req.headers, body: buffer, error: 'Invalid track object' })
293
+
294
+ return sendResponse(req, res, {
295
+ timestamp: Date.now(),
296
+ status: 400,
297
+ error: 'Bad Request',
298
+ trace: new Error().stack,
299
+ message: 'Invalid track object',
300
+ path: '/v4/encodetrack'
301
+ }, 400)
302
+ }
303
+
304
+ debugLog('encodetrack', 1, { headers: req.headers, body: buffer })
305
+
306
+ sendResponse(req, res, encodedTrack, 200)
307
+ }
308
+
309
+ else if (parsedUrl.pathname === '/v4/encodetracks') {
310
+ if (verifyMethod(parsedUrl, req, res, 'POST')) return;
311
+
312
+ let buffer = ''
313
+ if (!(buffer = await tryParseBody(req, res))) return;
314
+
315
+ if (typeof buffer !== 'object' || !Array.isArray(buffer)) {
316
+ debugLog('decodetracks', 1, { headers: req.headers, body: buffer, error: 'The provided body is invalid.' })
317
+
318
+ return sendResponse(req, res, {
319
+ timestamp: Date.now(),
320
+ status: 400,
321
+ error: 'Bad request',
322
+ trace: new Error().stack,
323
+ message: 'The provided body is invalid.',
324
+ path: parsedUrl.pathname
325
+ }, 400)
326
+ }
327
+
328
+ const tracks = []
329
+
330
+ buffer.forEach((track) => {
331
+ let encodedTrack = null
332
+
333
+ if (!(encodedTrack = encodeTrack(track))) {
334
+ debugLog('encodetracks', 1, { headers: req.headers, body: buffer, error: 'Invalid track object' })
335
+
336
+ return sendResponse(req, res, {
337
+ timestamp: Date.now(),
338
+ status: 400,
339
+ error: 'Bad Request',
340
+ trace: new Error().stack,
341
+ message: 'Invalid track object',
342
+ path: '/v4/encodetracks'
343
+ }, 400)
344
+ }
345
+
346
+ tracks.push(encodedTrack)
347
+ })
348
+
349
+ debugLog('encodetracks', 1, { headers: req.headers, body: buffer })
350
+
351
+ sendResponse(req, res, tracks, 200)
352
+ }
353
+
354
+ else if (parsedUrl.pathname === '/v4/stats') {
355
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
356
+
357
+ debugLog('stats', 1, { headers: req.headers })
358
+
359
+ const statistics = {
360
+ sent: 0,
361
+ nulled: 0,
362
+ expected: 0,
363
+ deficit: 0
364
+ }
365
+
366
+ Object.keys(clients).forEach((key) => {
367
+ const client = clients[key]
368
+
369
+ client.players.forEach((player) => {
370
+ if (!player.connection) return;
371
+
372
+ statistics.sent += player.connection.statistics.packetsSent
373
+ statistics.nulled += player.connection.statistics.packetsLost
374
+ statistics.expected += player.connection.statistics.packetsExpected
375
+ })
376
+ })
377
+
378
+ statistics.deficit = statistics.sent - statistics.expected
379
+
380
+ sendResponse(req, res, {
381
+ players: nodelinkPlayersCount,
382
+ playingPlayers: nodelinkPlayingPlayersCount,
383
+ uptime: Math.floor(process.uptime() * 1000),
384
+ memory: {
385
+ free: process.memoryUsage().heapTotal - process.memoryUsage().heapUsed,
386
+ used: process.memoryUsage().heapUsed,
387
+ allocated: 0,
388
+ reservable: process.memoryUsage().rss
389
+ },
390
+ cpu: {
391
+ cores: os.cpus().length,
392
+ systemLoad: os.loadavg()[0],
393
+ lavalinkLoad: 0
394
+ },
395
+ frameStats: statistics
396
+ }, 200)
397
+ }
398
+
399
+ else if (parsedUrl.pathname === '/v4/loadtracks') {
400
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
401
+
402
+ debugLog('loadtracks', 1, { params: parsedUrl.pathname, headers: req.headers })
403
+
404
+ const search = await sources.loadTracks(parsedUrl.searchParams.get('identifier'))
405
+
406
+ sendResponse(req, res, search, 200)
407
+
408
+ return;
409
+ }
410
+
411
+ else if (parsedUrl.pathname === '/v4/loadlyrics') {
412
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
413
+
414
+ const encodedTrack = parsedUrl.searchParams.get('encodedTrack')
415
+ let decodedTrack = null
416
+
417
+ if (!encodedTrack || !(decodedTrack = decodeTrack(encodedTrack))) {
418
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided track is invalid.' })
419
+
420
+ return sendResponse(req, res, {
421
+ timestamp: Date.now(),
422
+ status: 400,
423
+ error: 'Bad Request',
424
+ trace: new Error().stack,
425
+ message: 'The provided track is invalid.',
426
+ path: '/v4/loadlyrics'
427
+ }, 400)
428
+ }
429
+
430
+ const language = parsedUrl.searchParams.get('language')
431
+ const captions = await sources.loadLyrics(parsedUrl, req, decodedTrack, language)
432
+
433
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers })
434
+
435
+ sendResponse(req, res, captions, 200)
436
+ }
437
+
438
+ else if (/^\/v4\/sessions\/[A-Za-z0-9]+\/players$(?!\/)/.test(parsedUrl.pathname)) {
439
+ if (verifyMethod(parsedUrl, req, res, 'GET')) return;
440
+
441
+ const client = clients[/^\/v4\/sessions\/([A-Za-z0-9]+)\/players$/.exec(parsedUrl.pathname)[1]]
442
+
443
+ if (!client) {
444
+ debugLog('getPlayers', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided session Id doesn\'t exist.' })
445
+
446
+ return sendResponse(req, res, {
447
+ timestamp: Date.now(),
448
+ status: 404,
449
+ trace: new Error().stack,
450
+ message: 'The provided session Id doesn\'t exist.',
451
+ path: parsedUrl.pathname
452
+ }, 404)
453
+ }
454
+
455
+ const players = []
456
+
457
+ client.players.forEach((player) => {
458
+ player.config.state = {
459
+ time: Date.now(),
460
+ position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
461
+ connected: player.connection ? player.connection.state.status === 'ready' : false,
462
+ ping: player.connection?.ping || -1
463
+ }
464
+
465
+ players.push(player.config)
466
+ })
467
+
468
+ debugLog('getPlayers', 1, { headers: req.headers })
469
+
470
+ sendResponse(req, res, players, 200)
471
+ }
472
+
473
+ else if (/^\/v4\/sessions\/\w+\/players\/\w+./.test(parsedUrl.pathname)) {
474
+ if (![ 'DELETE', 'PATCH', 'GET' ].includes(req.method)) {
475
+ sendResponse(req, res, {
476
+ timestamp: Date.now(),
477
+ status: 405,
478
+ error: 'Method Not Allowed',
479
+ message: `Request method must be DELETE, PATCH or GET`,
480
+ path: parsedUrl.pathname
481
+ }, 405)
482
+
483
+ return;
484
+ }
485
+
486
+ const client = clients[/^\/v4\/sessions\/([A-Za-z0-9]+)\/players\/\d+$/.exec(parsedUrl.pathname)[1]]
487
+
488
+ if (!client) {
489
+ debugLog('updatePlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided session Id doesn\'t exist.' })
490
+
491
+ return sendResponse(req, res, {
492
+ timestamp: Date.now(),
493
+ status: 404,
494
+ trace: new Error().stack,
495
+ message: 'The provided session Id doesn\'t exist.',
496
+ path: parsedUrl.pathname
497
+ }, 404)
498
+ }
499
+
500
+ const guildId = /\/players\/(\d+)$/.exec(parsedUrl.pathname)[1]
501
+ let player = client.players.get(guildId)
502
+
503
+ if (req.method === 'DELETE') {
504
+ if (!player) {
505
+ debugLog('deletePlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'The provided guildId doesn\'t exist.' })
506
+
507
+ return sendResponse(req, res, {
508
+ timestamp: Date.now(),
509
+ status: 404,
510
+ trace: new Error().stack,
511
+ message: 'The provided guildId doesn\'t exist.',
512
+ path: parsedUrl.pathname
513
+ }, 404)
514
+ }
515
+
516
+ player.destroy()
517
+ client.players.delete(guildId)
518
+
519
+ debugLog('deletePlayer', 1, { params: parsedUrl.pathname, headers: req.headers })
520
+
521
+ return sendResponse(req, res, null, 204)
522
+ }
523
+
524
+ if (req.method === 'GET') {
525
+ if (!guildId) {
526
+ debugLog('getPlayer', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'Missing guildId parameter.' })
527
+
528
+ return sendResponse(req, res, {
529
+ timestamp: Date.now(),
530
+ status: 400,
531
+ trace: new Error().stack,
532
+ message: 'Missing guildId parameter.',
533
+ path: parsedUrl.pathname
534
+ }, 400)
535
+ }
536
+
537
+ let player = client.players.get(guildId)
538
+
539
+ if (!player) {
540
+ player = new VoiceConnection(guildId, client)
541
+
542
+ client.players.set(guildId, player)
543
+ }
544
+
545
+ player.config.state = {
546
+ time: Date.now(),
547
+ position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
548
+ connected: player.connection ? player.connection.state.status === 'ready' : false,
549
+ ping: player.connection?.ping || -1
550
+ }
551
+
552
+ debugLog('getPlayer', 1, { params: parsedUrl.pathname, headers: req.headers })
553
+
554
+ return sendResponse(req, res, player.config, 200)
555
+ }
556
+
557
+ let buffer = ''
558
+ if (!(buffer = await tryParseBody(req, res))) return;
559
+
560
+ if (req.method === 'PATCH') {
561
+ if (!player) player = new VoiceConnection(guildId, client)
562
+
563
+ if (buffer.voice !== undefined) {
564
+ if (!buffer.voice.endpoint || !buffer.voice.token || !buffer.voice.sessionId) {
565
+ debugLog('voice', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: `Invalid voice object.` })
566
+
567
+ return sendResponse(req, res, {
568
+ timestamp: Date.now(),
569
+ status: 400,
570
+ trace: new Error().stack,
571
+ message: 'Invalid voice object.',
572
+ path: parsedUrl.pathname
573
+ }, 400)
574
+ }
575
+
576
+ player.updateVoice(buffer.voice)
577
+
578
+ if (player.cache.track) {
579
+ player.play(player.cache.track, decodeTrack(player.cache.track), false)
580
+
581
+ player.cache.track = null
582
+ }
583
+
584
+ client.players.set(guildId, player)
585
+
586
+ debugLog('voice', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
587
+ }
588
+
589
+ /* Deprecated */
590
+ const encodedTrack = buffer.track?.encoded === undefined ? buffer.encodedTrack : buffer.track?.encoded
591
+
592
+ if (encodedTrack !== undefined) {
593
+ if (buffer.encodedTrack !== undefined) /* Deprecated */
594
+ debugLog('encodedTrack', 2, { params: parsedUrl.pathname, headers: req.headers, body: buffer, warning: 'The client is using a deprecated method of play (encodedTrack), deprecated by LavaLink. Report to the client GitHub.' })
595
+
596
+ if (encodedTrack === null) {
597
+ if (!player.config.track) {
598
+ debugLog('stop', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The player is not playing.' })
599
+
600
+ return sendResponse(req, res, {
601
+ timestamp: Date.now(),
602
+ status: 400,
603
+ trace: new Error().stack,
604
+ message: 'The player is not playing.',
605
+ path: parsedUrl.pathname
606
+ }, 400)
607
+ }
608
+
609
+ player.stop()
610
+
611
+ debugLog('stop', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
612
+ } else {
613
+ const noReplace = parsedUrl.searchParams.get('noReplace')
614
+ const decodedTrack = decodeTrack(encodedTrack)
615
+
616
+ if (!decodedTrack) {
617
+ debugLog('play', 1, { track: encodedTrack, exception: { message: 'The provided track is invalid.', severity: 'common', cause: 'Invalid track' } })
618
+
619
+ return sendResponse(req, res, {
620
+ timestamp: Date.now(),
621
+ status: 400,
622
+ trace: new Error().stack,
623
+ message: 'The provided track is invalid.',
624
+ path: parsedUrl.pathname
625
+ }, 400)
626
+ }
627
+
628
+ if (!player.connection.voiceServer) player.cache.track = encodedTrack
629
+ else player.play(encodedTrack, decodedTrack, noReplace === true)
630
+
631
+ debugLog('play', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
632
+ }
633
+
634
+ client.players.set(guildId, player)
635
+ }
636
+
637
+ if (buffer.track?.userData !== undefined) {
638
+ player.config.track = {
639
+ ...(player.config.track ? player.config.track : {}),
640
+ userData: buffer.userData
641
+ }
642
+
643
+ debugLog('userData', 1, { params: parsedUrl.pathname, params: parsedUrl.pathname, body: buffer })
644
+ }
645
+
646
+ if (buffer.volume !== undefined) {
647
+ if (buffer.volume < 0 || buffer.volume > 1000) {
648
+ debugLog('volume', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The volume must be between 0 and 1000.' })
649
+
650
+ return sendResponse(req, res, {
651
+ timestamp: Date.now(),
652
+ status: 400,
653
+ trace: new Error().stack,
654
+ message: 'The volume must be between 0 and 1000.',
655
+ path: parsedUrl.pathname
656
+ }, 400)
657
+ }
658
+
659
+ player.volume(buffer.volume)
660
+
661
+ client.players.set(guildId, player)
662
+
663
+ debugLog('volume', 1, { params: parsedUrl.pathname, params: parsedUrl.pathname, body: buffer })
664
+ }
665
+
666
+ if (buffer.paused !== undefined) {
667
+ if (typeof buffer.paused !== 'boolean') {
668
+ debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The paused value must be a boolean.' })
669
+
670
+ return sendResponse(req, res, {
671
+ timestamp: Date.now(),
672
+ status: 400,
673
+ trace: new Error().stack,
674
+ message: 'The paused value must be a boolean.',
675
+ path: parsedUrl.pathname
676
+ }, 400)
677
+ }
678
+
679
+ if (!player.connection?.ws) {
680
+ debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The player is not connected to a voice server.' })
681
+
682
+ return sendResponse(req, res, {
683
+ timestamp: Date.now(),
684
+ status: 400,
685
+ trace: new Error().stack,
686
+ message: 'The player is not connected to a voice server.',
687
+ path: parsedUrl.pathname
688
+ }, 400)
689
+ }
690
+
691
+ player.pause(buffer.paused)
692
+
693
+ client.players.set(guildId, player)
694
+
695
+ debugLog('pause', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
696
+ }
697
+
698
+ let filters = {}
699
+
700
+ if (buffer.filters !== undefined) {
701
+ if (typeof buffer.filters !== 'object') {
702
+ debugLog('filters', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The filters value must be an object.' })
703
+
704
+ return sendResponse(req, res, {
705
+ timestamp: Date.now(),
706
+ status: 400,
707
+ trace: new Error().stack,
708
+ message: 'The filters value must be an object.',
709
+ path: parsedUrl.pathname
710
+ }, 400)
711
+ }
712
+
713
+ filters = buffer.filters
714
+
715
+ debugLog('filters', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
716
+ }
717
+
718
+ if (buffer.position !== undefined) {
719
+ if (typeof buffer.position !== 'number' && buffer.endTime !== null) {
720
+ debugLog('seek', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The position value must be a number.' })
721
+
722
+ return sendResponse(req, res, {
723
+ timestamp: Date.now(),
724
+ status: 400,
725
+ trace: new Error().stack,
726
+ message: 'The position value must be a number.',
727
+ path: parsedUrl.pathname
728
+ }, 400)
729
+ }
730
+
731
+ filters.seek = buffer.position
732
+
733
+ debugLog('seek', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
734
+ }
735
+
736
+ if (buffer.endTime !== undefined) {
737
+ if (typeof buffer.endTime !== 'number' && buffer.endTime !== null) {
738
+ debugLog('endTime', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer, error: 'The endTime value must be a number.' })
739
+
740
+ return sendResponse(req, res, {
741
+ timestamp: Date.now(),
742
+ status: 400,
743
+ trace: new Error().stack,
744
+ message: 'The endTime value must be a number.',
745
+ path: parsedUrl.pathname
746
+ }, 400)
747
+ }
748
+
749
+ filters.endTime = buffer.endTime
750
+
751
+ debugLog('endTime', 1, { params: parsedUrl.pathname, headers: req.headers, body: buffer })
752
+ }
753
+
754
+ if (Object.keys(filters).length != 0 || JSON.stringify(buffer.filters) === '{}') {
755
+ player.filters(filters)
756
+
757
+ client.players.set(guildId, player)
758
+ }
759
+
760
+ /* Updating player state to ensure it's sending up-to-date data */
761
+ player.config.state = {
762
+ time: Date.now(),
763
+ position: player.connection ? player.connection.playerState.status === 'playing' ? player._getRealTime() : 0 : 0,
764
+ connected: player.connection ? player.connection.state.status === 'ready' : false,
765
+ ping: player.connection?.ping || -1
766
+ }
767
+
768
+ sendResponse(req, res, player.config, 200)
769
+ }
770
+ }
771
+
772
+ else {
773
+ sendResponse(req, res, {
774
+ timestamp: Date.now(),
775
+ status: 404,
776
+ error: 'Not Found',
777
+ trace: new Error().stack,
778
+ message: 'The requested route was not found.',
779
+ path: parsedUrl.pathname
780
+ }, 404)
781
+ }
782
+ }
783
+
784
+ function startSourceAPIs() {
785
+ if (Object.keys(clients).length !== 1) return;
786
+
787
+ return new Promise((resolve) => {
788
+ const sourcesToInitialize = []
789
+
790
+ if (config.search.sources.youtube && config.options.bypassAgeRestriction)
791
+ sourcesToInitialize.push(sources.youtube)
792
+
793
+ if (config.search.sources.spotify.enabled)
794
+ sourcesToInitialize.push(sources.spotify)
795
+
796
+ if (config.search.sources.pandora)
797
+ sourcesToInitialize.push(sources.pandora)
798
+
799
+ if (config.search.sources.deezer.enabled)
800
+ sourcesToInitialize.push(sources.deezer)
801
+
802
+ if (config.search.sources.soundcloud.enabled)
803
+ sourcesToInitialize.push(sources.soundcloud)
804
+
805
+ if (config.options.statsInterval)
806
+ startStats()
807
+
808
+ if (config.options.playerUpdateInterval)
809
+ startPlayerUpdate()
810
+
811
+ if (config.search.sources.musixmatch.enabled)
812
+ sources.musixmatch.init()
813
+
814
+ if (sourcesToInitialize.length === 0) resolve()
815
+
816
+ let i = 0
817
+ sourcesToInitialize.forEach(async (source) => {
818
+ await source.init()
819
+
820
+ if (++i === sourcesToInitialize.length) resolve()
821
+ })
822
+ })
823
+ }
824
+
825
+ export default {
826
+ configureConnection,
827
+ requestHandler,
828
+ startSourceAPIs
829
+ }
src/connection/index.js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from 'node:http'
2
+ import { URL } from 'node:url'
3
+
4
+ import connectionHandler from './handler.js'
5
+ import inputHandler from './inputHandler.js'
6
+ import config from '../../config.js'
7
+ import { debugLog, parseClientName } from '../utils.js'
8
+ import { WebSocketServer } from '../ws.js'
9
+
10
+ if (typeof config.server.port !== 'number')
11
+ throw new Error('Port must be a number.')
12
+
13
+ if (typeof config.server.password !== 'string')
14
+ throw new Error('Password must be a string.')
15
+
16
+ if (typeof config.options.threshold !== 'boolean' && typeof config.options.threshold !== 'number')
17
+ throw new Error('Threshold must be a boolean or a number.')
18
+
19
+ if (typeof config.options.playerUpdateInterval !== 'boolean' && typeof config.options.playerUpdateInterval !== 'number')
20
+ throw new Error('Player update interval must be a boolean or a number.')
21
+
22
+ if (typeof config.options.statsInterval !== 'boolean' && typeof config.options.statsInterval !== 'number')
23
+ throw new Error('Stats interval must be a boolean or a number.')
24
+
25
+ if (typeof config.options.maxResultsLength !== 'number')
26
+ throw new Error('Max results length must be a number.')
27
+
28
+ if (typeof config.options.maxAlbumPlaylistLength !== 'number')
29
+ throw new Error('Max album playlist length must be a number.')
30
+
31
+ if (typeof config.options.maxCaptionsLength !== 'number')
32
+ throw new Error('Max captions length must be a number.')
33
+
34
+ if (typeof config.options.bypassAgeRestriction !== 'boolean')
35
+ throw new Error('Bypass age restriction must be a boolean.')
36
+
37
+ if (!['bandcamp', 'deezer', 'soundcloud', 'youtube', 'ytmusic'].includes(config.search.defaultSearchSource))
38
+ throw new Error('Default search source must be either "bandcamp", "deezer", "soundcloud", "youtube" or "ytmusic".')
39
+
40
+ if (config.search.fallbackSearchSource === 'soundcloud')
41
+ throw new Error('SoundCloud is not supported as a fallback source.')
42
+
43
+ if (config.search.sources.spotify.enabled && !config.search.sources.spotify.market)
44
+ throw new Error('Spotify is enabled but no market was provided.')
45
+
46
+ if (config.search.sources.deezer.enabled && config.search.sources.deezer.decryptionKey === 'DISABLED')
47
+ throw new Error('Deezer is enabled but no decryption key or API key was provided.')
48
+
49
+ if (config.search.sources.soundcloud.enabled && config.search.sources.soundcloud.clientId === 'DISABLED')
50
+ throw new Error('SoundCloud is enabled but no client ID was provided.')
51
+
52
+ if (![ 'high', 'medium', 'low', 'lowest' ].includes(config.audio.quality))
53
+ throw new Error('Audio quality must be either "high", "medium", "low" or "lowest".')
54
+
55
+ if (![ 'xsalsa20_poly1305', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305_lite' ].includes(config.audio.encryption))
56
+ throw new Error('Encryption must be either "xsalsa20_poly1305", "xsalsa20_poly1305_suffix" or "xsalsa20_poly1305_lite".')
57
+
58
+ if (typeof config.voiceReceive.timeout !== 'number')
59
+ throw new Error('Voice receive timeout must be a number.')
60
+
61
+ if (![ 'opus', 'pcm' ].includes(config.voiceReceive.type))
62
+ throw new Error('Voice receive type must be either "opus" or "pcm".')
63
+
64
+ const server = http.createServer(connectionHandler.requestHandler)
65
+ const v4 = new WebSocketServer()
66
+
67
+ v4.on('/v4/websocket', connectionHandler.configureConnection)
68
+
69
+ v4.on('/connection/data', inputHandler.setupConnection)
70
+
71
+ server.on('upgrade', (req, socket, head) => {
72
+ const { pathname } = new URL(req.url, `http://${req.headers.host}`)
73
+
74
+ if (req.headers.authorization !== config.server.password) {
75
+ debugLog('disconnect', 3, { name: 'Unknown', version: '0.0.0', code: 401, reason: 'Invalid password' })
76
+
77
+ req.socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
78
+
79
+ return req.socket.destroy()
80
+ }
81
+
82
+ const parsedClientName = parseClientName(req.headers['client-name'])
83
+
84
+ if (!parsedClientName) {
85
+ debugLog('connect', 1, { name: req.headers['client-name'], error: 'Client-name doesn\'t conform to NAME/VERSION format.' })
86
+
87
+ req.socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
88
+
89
+ return req.socket.destroy()
90
+ }
91
+
92
+ req.clientInfo = parsedClientName
93
+
94
+ if (pathname === '/v4/websocket') {
95
+ debugLog('connect', 3, parsedClientName)
96
+
97
+ v4.handleUpgrade(req, socket, head, { 'isNodeLink': true }, (ws) => v4.emit('/v4/websocket', ws, req, parsedClientName))
98
+ }
99
+
100
+ if (pathname === '/connection/data') {
101
+ if (!req.headers['guild-id'] || !req.headers['user-id']) {
102
+ debugLog('connectCD', 1, { ...parsedClientName, error: `"${!req.headers['guild-id'] ? 'guild-id' : 'user-id'}" header not provided.` })
103
+
104
+ req.socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
105
+
106
+ return req.socket.destroy()
107
+ }
108
+
109
+ debugLog('connectCD', 3, { ...parsedClientName, guildId: req.headers['guild-id'] })
110
+
111
+ v4.handleUpgrade(req, socket, head, {}, (ws) => v4.emit('/connection/data', ws, req, parsedClientName))
112
+ }
113
+ })
114
+
115
+ v4.on('error', (err) => {
116
+ debugLog('error', 3, { error: err.message })
117
+ })
118
+
119
+ server.on('error', (err) => {
120
+ debugLog('http', 1, { error: err.message })
121
+ })
122
+
123
+ server.listen(config.server.port || 2333, () => {
124
+ console.log(`[\u001b[32mwebsocket\u001b[37m]: Listening on port \u001b[94m${config.server.port || 2333}\u001b[37m.`)
125
+ })
src/connection/inputHandler.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { debugLog } from '../utils.js'
2
+ import config from '../../config.js'
3
+
4
+ import voiceUtils from '../voice/utils.js'
5
+ import discordVoice from '@performanc/voice'
6
+ import prism from 'prism-media'
7
+
8
+ const Connections = {}
9
+
10
+ function setupConnection(ws, req, parsedClientName) {
11
+ const userId = req.headers['user-id']
12
+ const guildId = req.headers['guild-id']
13
+
14
+ ws.on('close', (code, reason) => {
15
+ debugLog('disconnectCD', 3, { ...parsedClientName, code, reason, guildId })
16
+
17
+ delete Connections[userId]
18
+ })
19
+
20
+ ws.on('error', (err) => {
21
+ debugLog('disconnectCD', 3, { ...parsedClientName, error: `Error: ${err.message}`, guildId })
22
+
23
+ delete Connections[userId]
24
+ })
25
+
26
+ Connections[userId] = {
27
+ ws,
28
+ guildId
29
+ }
30
+ }
31
+
32
+ function handleStartSpeaking(ssrc, userId, guildId) {
33
+ const opusStream = discordVoice.getSpeakStream(ssrc)
34
+ const stream = new voiceUtils.NodeLinkStream(opusStream, config.voiceReceive.type === 'pcm' ? [ new prism.opus.Decoder({ rate: 48000, channels: 2, frameSize: 960 }) ] : [])
35
+ let timeout = null
36
+
37
+ const startSpeakingResponse = JSON.stringify({
38
+ op: 'speak',
39
+ type: 'startSpeakingEvent',
40
+ data: {
41
+ userId,
42
+ guildId
43
+ }
44
+ })
45
+
46
+ Object.keys(Connections).forEach((botId) => {
47
+ if (Connections[botId].guildId !== guildId) return;
48
+
49
+ Connections[botId].ws.send(startSpeakingResponse)
50
+ })
51
+
52
+ let buffer = []
53
+ stream.on('data', (chunk) => {
54
+ if (timeout) {
55
+ clearTimeout(timeout)
56
+ timeout = null
57
+ }
58
+
59
+ if (Object.keys(Connections).length === 0) {
60
+ stream.destroy()
61
+ buffer = null
62
+
63
+ return;
64
+ }
65
+
66
+ buffer.push(chunk)
67
+ })
68
+
69
+ stream.on('end', () => {
70
+ let i = 0
71
+
72
+ const connectionsArray = Object.keys(Connections)
73
+
74
+ if (connectionsArray.length === 0) {
75
+ buffer = []
76
+
77
+ return;
78
+ }
79
+
80
+ timeout = setTimeout(() => {
81
+ const endSpeakingResponse = JSON.stringify({
82
+ op: 'speak',
83
+ type: 'endSpeakingEvent',
84
+ data: {
85
+ userId,
86
+ guildId,
87
+ data: Buffer.concat(buffer).toString('base64'),
88
+ type: config.voiceReceive.type
89
+ }
90
+ })
91
+
92
+ connectionsArray.forEach((botId) => {
93
+ if (Connections[botId].guildId !== guildId) return;
94
+
95
+ Connections[botId].ws.send(endSpeakingResponse)
96
+
97
+ i++
98
+ })
99
+
100
+ buffer = []
101
+
102
+ debugLog('sentDataCD', 3, { clientsAmount: i, guildId })
103
+ }, config.voiceReceive.timeout)
104
+ })
105
+ }
106
+
107
+ export default {
108
+ setupConnection,
109
+ handleStartSpeaking
110
+ }
src/connection/voiceHandler.js ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { debugLog, waitForEvent } from '../utils.js'
2
+ import config from '../../config.js'
3
+ import constants from '../../constants.js'
4
+ import sources from '../sources.js'
5
+ import Filters from '../filters.js'
6
+
7
+ import inputHandler from './inputHandler.js'
8
+
9
+ import voiceUtils from '../voice/utils.js'
10
+
11
+ import discordVoice from '@performanc/voice'
12
+
13
+ globalThis.nodelinkPlayersCount = 0
14
+ globalThis.nodelinkPlayingPlayersCount = 0
15
+
16
+ class VoiceConnection {
17
+ constructor(guildId, client) {
18
+ nodelinkPlayersCount++
19
+
20
+ this.client = {
21
+ userId: client.userId,
22
+ ws: client.ws
23
+ }
24
+
25
+ this.cache = {
26
+ url: null,
27
+ protocol: null,
28
+ track: null
29
+ }
30
+
31
+ this.config = {
32
+ guildId,
33
+ track: null,
34
+ volume: 100,
35
+ paused: false,
36
+ filters: {},
37
+ voice: {
38
+ token: null,
39
+ endpoint: null,
40
+ sessionId: null
41
+ }
42
+ }
43
+
44
+ this._setupVoice()
45
+ }
46
+
47
+ _setupVoice() {
48
+ this.connection = discordVoice.joinVoiceChannel({ guildId: this.config.guildId, userId: this.client.userId, encryption: config.audio.encryption })
49
+
50
+ this.connection.on('speakStart', (userId, ssrc) => inputHandler.handleStartSpeaking(ssrc, userId, this.config.guildId))
51
+
52
+ this.connection.on('stateChange', async (oldState, newState) => {
53
+ switch (newState.status) {
54
+ case 'disconnected': {
55
+ debugLog('websocketClosed', 2, { track: this.config.track?.info, exception: constants.VoiceWSCloseCodes[newState.closeCode] })
56
+
57
+ this.connection.destroy()
58
+ this.connection = null
59
+ this._stopTrack()
60
+
61
+ this.client.ws.send(JSON.stringify({
62
+ op: 'event',
63
+ type: 'WebSocketClosedEvent',
64
+ guildId: this.config.guildId,
65
+ code: newState.code,
66
+ reason: constants.VoiceWSCloseCodes[newState.code],
67
+ byRemote: true
68
+ }))
69
+
70
+ break
71
+ }
72
+ }
73
+ })
74
+
75
+ this.connection.on('playerStateChange', (_oldState, newState) => {
76
+ if (newState.status === 'idle' && [ 'stopped', 'finished' ].includes(newState.reason)) {
77
+ nodelinkPlayingPlayersCount--
78
+
79
+ debugLog('trackEnd', 2, { track: this.config.track.info, reason: newState.reason })
80
+
81
+ this.client.ws.send(JSON.stringify({
82
+ op: 'event',
83
+ type: 'TrackEndEvent',
84
+ guildId: this.config.guildId,
85
+ track: this.config.track,
86
+ reason: newState.reason
87
+ }))
88
+
89
+ this._stopTrack()
90
+ }
91
+
92
+ if (newState.status === 'playing' && newState.reason === 'requested') {
93
+ nodelinkPlayingPlayersCount++
94
+
95
+ debugLog('trackStart', 2, { track: this.config.track.info })
96
+
97
+ this.client.ws.send(JSON.stringify({
98
+ op: 'event',
99
+ type: 'TrackStartEvent',
100
+ guildId: this.config.guildId,
101
+ track: this.config.track
102
+ }))
103
+ }
104
+ })
105
+
106
+ this.connection.on('error', (error) => {
107
+ debugLog('trackException', 2, { track: this.config.track?.info, exception: error.message })
108
+
109
+ this.client.ws.send(JSON.stringify({
110
+ op: 'event',
111
+ type: 'TrackExceptionEvent',
112
+ guildId: this.config.guildId,
113
+ track: this.config.track,
114
+ exception: {
115
+ message: error.message,
116
+ severity: 'fault',
117
+ cause: `${error.name}: ${error.message}`
118
+ }
119
+ }))
120
+
121
+ this.client.ws.send(JSON.stringify({
122
+ op: 'event',
123
+ type: 'TrackEndEvent',
124
+ guildId: this.config.guildId,
125
+ track: this.config.track,
126
+ reason: 'loadFailed'
127
+ }))
128
+
129
+ this._stopTrack()
130
+ })
131
+ }
132
+
133
+ _stopTrack() {
134
+ this.cache = {
135
+ url: null,
136
+ protocol: null,
137
+ track: null
138
+ }
139
+
140
+ this.config = {
141
+ ...this.config,
142
+ track: null,
143
+ paused: false
144
+ }
145
+ }
146
+
147
+ _getRealTime() {
148
+ return this.connection.statistics.packetsExpected * 20
149
+ }
150
+
151
+ updateVoice(buffer) {
152
+ this.config.voice = buffer
153
+
154
+ if (!this.connection) this._setupVoice()
155
+
156
+ this.connection.voiceStateUpdate({
157
+ session_id: buffer.sessionId
158
+ })
159
+ this.connection.voiceServerUpdate({
160
+ token: buffer.token,
161
+ endpoint: buffer.endpoint
162
+ })
163
+
164
+ if (!this.connection.ws) this.connection.connect()
165
+ }
166
+
167
+ destroy() {
168
+ if (this.connection) {
169
+ this.connection.destroy()
170
+ this.connection = null
171
+ }
172
+
173
+ this._stopTrack()
174
+ }
175
+
176
+ async getResource(decodedTrack, urlInfo) {
177
+ const streamInfo = await sources.getTrackStream(decodedTrack, urlInfo.url, urlInfo.protocol, urlInfo.additionalData)
178
+
179
+ if (streamInfo.exception) return streamInfo
180
+
181
+ return { stream: voiceUtils.createAudioResource(streamInfo.stream, urlInfo.format) }
182
+ }
183
+
184
+ async play(track, decodedTrack, noReplace) {
185
+ if (noReplace && this.config.track) return this.config
186
+
187
+ const urlInfo = await sources.getTrackURL(decodedTrack)
188
+
189
+ if (urlInfo.exception) {
190
+ this._stopTrack()
191
+
192
+ this.client.ws.send(JSON.stringify({
193
+ op: 'event',
194
+ type: 'TrackExceptionEvent',
195
+ guildId: this.config.guildId,
196
+ track: {
197
+ encoded: track,
198
+ info: decodedTrack
199
+ },
200
+ exception: urlInfo.exception
201
+ }))
202
+
203
+ this.client.ws.send(JSON.stringify({
204
+ op: 'event',
205
+ type: 'TrackEndEvent',
206
+ guildId: this.config.guildId,
207
+ track: {
208
+ encoded: track,
209
+ info: decodedTrack,
210
+ userData: this.config.track?.userData
211
+ },
212
+ reason: 'loadFailed'
213
+ }))
214
+
215
+ return this.config
216
+ }
217
+
218
+ if (this.config.track?.encoded) {
219
+ debugLog('trackEnd', 2, { track: this.config.track.info, reason: 'replaced' })
220
+
221
+ this.client.ws.send(JSON.stringify({
222
+ op: 'event',
223
+ type: 'TrackEndEvent',
224
+ guildId: this.config.guildId,
225
+ track: this.config.track,
226
+ reason: 'replaced'
227
+ }))
228
+
229
+ debugLog('trackStart', 2, { track: decodedTrack })
230
+
231
+ this.client.ws.send(JSON.stringify({
232
+ op: 'event',
233
+ type: 'TrackStartEvent',
234
+ guildId: this.config.guildId,
235
+ track: {
236
+ encoded: track,
237
+ info: decodedTrack,
238
+ userData: this.config.track?.userData
239
+ }
240
+ }))
241
+ }
242
+
243
+ let resource = null
244
+ if (Object.keys(this.config.filters).length > 0) {
245
+ const filter = new Filters()
246
+ this.config.filters = filter.configure(this.config.filters)
247
+
248
+ resource = await filter.getResource(decodedTrack, urlInfo.protocol, urlInfo.url, null, null, this.cache.ffmpeg, urlInfo.additionalData)
249
+ } else {
250
+ resource = await this.getResource(decodedTrack, urlInfo)
251
+ }
252
+
253
+ if (resource.exception) {
254
+ this._stopTrack()
255
+
256
+ debugLog('trackException', 2, { track: decodedTrack, exception: resource.exception.message })
257
+
258
+ this.client.ws.send(JSON.stringify({
259
+ op: 'event',
260
+ type: 'TrackExceptionEvent',
261
+ guildId: this.config.guildId,
262
+ track: {
263
+ encoded: track,
264
+ info: decodedTrack,
265
+ userData: this.config.track?.userData
266
+ },
267
+ exception: resource.exception
268
+ }))
269
+
270
+ this.client.ws.send(JSON.stringify({
271
+ op: 'event',
272
+ type: 'TrackEndEvent',
273
+ guildId: this.config.guildId,
274
+ track: {
275
+ encoded: track,
276
+ info: decodedTrack,
277
+ userData: this.config.track?.userData
278
+ },
279
+ reason: 'loadFailed'
280
+ }))
281
+
282
+ return this.config
283
+ }
284
+
285
+ this.cache.url = urlInfo.url
286
+ this.cache.protocol = urlInfo.protocol
287
+ this.config.track = { encoded: track, info: decodedTrack }
288
+ this.config.paused = false
289
+
290
+ if (this.config.volume !== 100)
291
+ resource.stream.setVolume(this.config.volume / 100)
292
+
293
+ if (!this.connection)
294
+ return this.config
295
+
296
+ if (!this.connection.udpInfo?.secretKey)
297
+ await waitForEvent(this.connection, 'stateChange', (_oldState, newState) => newState.status === 'connected', config.options.threshold || undefined)
298
+
299
+ const oldResource = this.connection.audioStream
300
+
301
+ this.connection.play(resource.stream)
302
+
303
+ if (oldResource)
304
+ resource.stream.once('readable', () => oldResource.destroy())
305
+
306
+ return this.config
307
+ }
308
+
309
+ stop() {
310
+ if (!this.config.track) return this.config
311
+
312
+ if (this.connection.audioStream) this.connection.stop()
313
+ else this._stopTrack()
314
+ }
315
+
316
+ volume(volume) {
317
+ if (this.connection.audioStream)
318
+ this.connection.audioStream.setVolume(volume / 100)
319
+
320
+ this.config.volume = volume
321
+
322
+ return this.config
323
+ }
324
+
325
+ pause(pause) {
326
+ if (this.connection.audioStream) {
327
+ if (pause) this.connection.pause()
328
+ else this.connection.unpause()
329
+ }
330
+
331
+ this.config.paused = pause
332
+
333
+ return this.config
334
+ }
335
+
336
+ async filters(filters) {
337
+ if (!this.config.track?.encoded || !config.filters.enabled) return this.config
338
+
339
+ const filter = new Filters()
340
+ this.config.filters = filter.configure(filters, this.config.track.info)
341
+
342
+ if (!this.config.track) return this.config
343
+
344
+ const resource = await filter.getResource(this.config.track.info, this.cache.protocol, this.cache.url, this._getRealTime(), filters.endTime, null, null)
345
+
346
+ if (resource.exception) {
347
+ this._stopTrack()
348
+
349
+ this.client.ws.send(JSON.stringify({
350
+ op: 'event',
351
+ type: 'TrackExceptionEvent',
352
+ guildId: this.config.guildId,
353
+ track: this.config.track,
354
+ exception: resource.exception
355
+ }))
356
+
357
+ this.client.ws.send(JSON.stringify({
358
+ op: 'event',
359
+ type: 'TrackEndEvent',
360
+ guildId: this.config.guildId,
361
+ track: this.config.track,
362
+ reason: 'loadFailed'
363
+ }))
364
+
365
+ return this.config
366
+ }
367
+
368
+ resource.stream.setVolume(filters.volume || (this.config.volume / 100))
369
+ this.config.volume = (filters.volume * 100) || this.config.volume
370
+
371
+ if (!this.connection)
372
+ return this.config
373
+
374
+ if (!this.connection.udpInfo?.secretKey)
375
+ await waitForEvent(this.connection, 'stateChange', (_oldState, newState) => newState.status === 'connected', config.options.threshold || undefined)
376
+
377
+ this.connection.play(resource.stream)
378
+
379
+ return this.config
380
+ }
381
+ }
382
+
383
+ export default VoiceConnection
src/filters.js ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PassThrough, Transform } from 'node:stream'
2
+
3
+ import config from '../config.js'
4
+ import { debugLog, clamp16Bit, isEmpty } from './utils.js'
5
+ import soundcloud from './sources/soundcloud.js'
6
+ import voiceUtils from './voice/utils.js'
7
+ import constants from '../constants.js'
8
+
9
+ import prism from 'prism-media'
10
+
11
+ class ChannelProcessor {
12
+ constructor(data, type) {
13
+ this.type = type
14
+
15
+ switch (type) {
16
+ case constants.filtering.types.equalizer: {
17
+ this.history = new Array(constants.filtering.equalizerBands * 6).fill(0)
18
+ this.bandMultipliers = data
19
+ this.current = 0
20
+ this.minus1 = 2
21
+ this.minus2 = 1
22
+
23
+ break
24
+ }
25
+ case constants.filtering.types.tremolo: {
26
+ this.frequency = data.frequency
27
+ this.depth = data.depth
28
+ this.phase = 0
29
+ this.offset = 1 - this.depth / 2
30
+
31
+ break
32
+ }
33
+ case constants.filtering.types.rotationHz: {
34
+ this.phase = 0
35
+ this.rotationStep = (constants.circunferece.diameter * data.rotationHz) / constants.opus.samplingRate
36
+
37
+ break
38
+ }
39
+ }
40
+ }
41
+
42
+ processEqualizer(band) {
43
+ let processedBand = band * 0.25
44
+
45
+ for (let bandIndex = 0; bandIndex < constants.filtering.equalizerBands; bandIndex++) {
46
+ const coefficient = constants.sampleRate.coefficients[bandIndex]
47
+
48
+ const x = bandIndex * 6
49
+ const y = x + 3
50
+
51
+ const bandResult = coefficient.alpha * (band - this.history[x + this.minus2]) + coefficient.gamma * this.history[y + this.minus1] - coefficient.beta * this.history[y + this.minus2]
52
+
53
+ this.history[x + this.current] = band
54
+ this.history[y + this.current] = bandResult
55
+
56
+ processedBand += bandResult * this.bandMultipliers[bandIndex]
57
+ }
58
+
59
+ return processedBand * 4
60
+ }
61
+
62
+ getTremoloMultiplier() {
63
+ let env = this.frequency * this.phase / constants.opus.samplingRate
64
+ env = Math.sin(2 * Math.PI * ((env + 0.25) % 1.0))
65
+
66
+ this.phase++
67
+
68
+ return env * (1 - Math.abs(this.offset)) + this.offset
69
+ }
70
+
71
+ processRotationHz(leftSample, rightSample) {
72
+ const panning = Math.sin(this.phase)
73
+
74
+ const leftMultiplier = panning <= 0 ? 1 : 1 - panning
75
+ const rightMultiplier = panning >= 0 ? 1 : 1 + panning
76
+
77
+ this.phase += this.rotationStep
78
+ if (this.phase > constants.circunferece.diameter)
79
+ this.phase -= constants.circunferece.diameter
80
+
81
+ return {
82
+ left: leftSample * leftMultiplier,
83
+ right: rightSample * rightMultiplier
84
+ }
85
+ }
86
+
87
+ process(samples) {
88
+ let bytes = constants.pcm.bytes
89
+ if ([ constants.filtering.types.rotationHz, constants.filtering.types.tremolo ].includes(this.type)) bytes *= 2
90
+
91
+ for (let i = 0; i < samples.length - constants.pcm.bytes; i += bytes) {
92
+ const sample = samples.readInt16LE(i)
93
+ let result = null
94
+
95
+ switch (this.type) {
96
+ case constants.filtering.types.equalizer: {
97
+ result = this.processEqualizer(sample)
98
+
99
+ if (++this.current === 3) this.current = 0
100
+ if (++this.minus1 === 3) this.minus1 = 0
101
+ if (++this.minus2 === 3) this.minus2 = 0
102
+
103
+ samples.writeInt16LE(clamp16Bit(result), i)
104
+
105
+ break
106
+ }
107
+ case constants.filtering.types.tremolo: {
108
+ const multiplier = this.getTremoloMultiplier()
109
+
110
+ const rightSample = samples.readInt16LE(i + 2)
111
+
112
+ samples.writeInt16LE(clamp16Bit(sample * multiplier), i)
113
+ samples.writeInt16LE(clamp16Bit(rightSample * multiplier), i + 2)
114
+
115
+ break
116
+ }
117
+ case constants.filtering.types.rotationHz: {
118
+ const { left, right } = this.processRotationHz(sample, samples.readInt16LE(i + 2))
119
+
120
+ samples.writeInt16LE(clamp16Bit(left), i)
121
+ samples.writeInt16LE(clamp16Bit(right), i + 2)
122
+
123
+ break
124
+ }
125
+ }
126
+ }
127
+
128
+ return samples
129
+ }
130
+ }
131
+
132
+ class Filtering extends Transform {
133
+ constructor(data, type) {
134
+ super()
135
+
136
+ this.type = type
137
+ this.channel = new ChannelProcessor(data, type)
138
+ }
139
+
140
+ process(input) {
141
+ this.channel.process(input)
142
+ }
143
+
144
+ _transform(data, _encoding, callback) {
145
+ this.process(data)
146
+
147
+ return callback(null, data)
148
+ }
149
+ }
150
+
151
+ class Filters {
152
+ constructor() {
153
+ this.command = []
154
+ this.equalizer = Array(constants.filtering.equalizerBands).fill(0).map((_, i) => ({ band: i, gain: 0 }))
155
+ this.result = {}
156
+ }
157
+
158
+ configure(filters, decodedTrack) {
159
+ const result = {}
160
+
161
+ if (filters.equalizer && Array.isArray(filters.equalizer) && filters.equalizer.length && config.filters.list.equalizer) {
162
+ filters.equalizer.forEach((equalizedBand) => {
163
+ const band = this.equalizer.find((i) => i.band === equalizedBand.band)
164
+ if (band) band.gain = Math.min(Math.max(equalizedBand.gain, -0.25), 1.0)
165
+ })
166
+
167
+ result.equalizer = this.equalizer
168
+ }
169
+
170
+ if (!isEmpty(filters.karaoke) && config.filters.list.karaoke) {
171
+ result.karaoke = {
172
+ level: Math.min(Math.max(filters.karaoke.level, 0.0), 1.0),
173
+ monoLevel: Math.min(Math.max(filters.karaoke.monoLevel, 0.0), 1.0),
174
+ filterBand: filters.karaoke.filterBand,
175
+ filterWidth: filters.karaoke.filterWidth
176
+ }
177
+
178
+ this.command.push(`stereotools=mlev=${result.karaoke.monoLevel}:mwid=${result.karaoke.filterWidth}:k=${result.karaoke.level}:kc=${result.karaoke.filterBand}`)
179
+ }
180
+
181
+ if (!isEmpty(filters.timescale) && config.filters.list.timescale) {
182
+ result.timescale = {
183
+ speed: Math.max(filters.timescale.speed, 0.0),
184
+ pitch: Math.max(filters.timescale.pitch, 0.0),
185
+ rate: Math.max(filters.timescale.rate, 0.0)
186
+ }
187
+
188
+ const finalspeed = result.timescale.speed + (1.0 - result.timescale.pitch)
189
+ const ratedif = 1.0 - result.timescale.rate
190
+
191
+ this.command.push(`asetrate=${constants.opus.samplingRate}*${result.timescale.pitch + ratedif},atempo=${finalspeed},aresample=${constants.opus.samplingRate}`)
192
+ }
193
+
194
+ if (!isEmpty(filters.tremolo) && config.filters.list.tremolo) {
195
+ result.tremolo = {
196
+ frequency: Math.min(Math.max(filters.tremolo.frequency, 0.0), 14.0),
197
+ depth: Math.min(Math.max(filters.tremolo.depth, 0.0), 1.0)
198
+ }
199
+ }
200
+
201
+ if (!isEmpty(filters.vibrato) && config.filters.list.vibrato) {
202
+ result.vibrato = {
203
+ frequency: Math.min(Math.max(filters.vibrato.frequency, 0.0), 14.0),
204
+ depth: Math.min(Math.max(filters.vibrato.depth, 0.0), 1.0)
205
+ }
206
+
207
+ this.command.push(`vibrato=f=${result.vibrato.frequency}:d=${result.vibrato.depth}`)
208
+ }
209
+
210
+ if (!isEmpty(filters.rotation?.rotationHz) && config.filters.list.rotation) {
211
+ result.rotation = {
212
+ rotationHz: filters.rotation.rotationHz
213
+ }
214
+ }
215
+
216
+ if (!isEmpty(filters.distortion) && config.filters.list.distortion) {
217
+ result.distortion = {
218
+ sinOffset: filters.distortion.sinOffset,
219
+ sinScale: filters.distortion.sinScale,
220
+ cosOffset: filters.distortion.cosOffset,
221
+ cosScale: filters.distortion.cosScale,
222
+ tanOffset: filters.distortion.tanOffset,
223
+ tanScale: filters.distortion.tanScale,
224
+ offset: filters.distortion.offset,
225
+ scale: filters.distortion.scale
226
+ }
227
+
228
+ this.command.push(`afftfilt=real='hypot(re,im)*sin(0.1*${filters.distortion.sinOffset}*PI*t)*${filters.distortion.sinScale}+hypot(re,im)*cos(0.1*${filters.distortion.cosOffset}*PI*t)*${filters.distortion.cosScale}+hypot(re,im)*tan(0.1*${filters.distortion.tanOffset}*PI*t)*${filters.distortion.tanScale}+${filters.distortion.offset}':imag='hypot(re,im)*sin(0.1*${filters.distortion.sinOffset}*PI*t)*${filters.distortion.sinScale}+hypot(re,im)*cos(0.1*${filters.distortion.cosOffset}*PI*t)*${filters.distortion.cosScale}+hypot(re,im)*tan(0.1*${filters.distortion.tanOffset}*PI*t)*${filters.distortion.tanScale}+${filters.distortion.offset}':win_size=512:overlap=0.75:scale=${filters.distortion.scale}`)
229
+ }
230
+
231
+ if (filters.channelMix && filters.channelMix.leftToLeft !== undefined && filters.channelMix.leftToRight !== undefined && filters.channelMix.rightToLeft !== undefined && filters.channelMix.rightToRight !== undefined && config.filters.list.channelMix) {
232
+ result.channelMix = {
233
+ leftToLeft: Math.min(Math.max(filters.channelMix.leftToLeft, 0.0), 1.0),
234
+ leftToRight: Math.min(Math.max(filters.channelMix.leftToRight, 0.0), 1.0),
235
+ rightToLeft: Math.min(Math.max(filters.channelMix.rightToLeft, 0.0), 1.0),
236
+ rightToRight: Math.min(Math.max(filters.channelMix.rightToRight, 0.0), 1.0)
237
+ }
238
+
239
+ this.command.push(`pan=stereo|c0<c0*${result.channelMix.leftToLeft}+c1*${result.channelMix.rightToLeft}|c1<c0*${result.channelMix.leftToRight}+c1*${result.channelMix.rightToRight}`)
240
+ }
241
+
242
+ if (filters.lowPass?.smoothing !== undefined && config.filters.list.lowPass) {
243
+ result.lowPass = {
244
+ smoothing: Math.max(filters.lowPass.smoothing, 1.0)
245
+ }
246
+
247
+ this.command.push(`lowpass=f=${filters.lowPass.smoothing / 500}`)
248
+ }
249
+
250
+ if (filters.seek !== undefined) {
251
+ result.startTime = Math.min(filters.seek, decodedTrack.length)
252
+ }
253
+
254
+ this.result = result
255
+
256
+ return result
257
+ }
258
+
259
+ getResource(decodedTrack, protocol, url, startTime, endTime, oldFFmpeg, additionalData) {
260
+ return new Promise(async (resolve) => {
261
+ if (decodedTrack.sourceName === 'deezer') {
262
+ debugLog('retrieveStream', 4, { type: 2, sourceName: decodedTrack.sourceName, query: decodedTrack.title, message: 'Filtering does not support Deezer platform.' })
263
+
264
+ return resolve({ status: 1, exception: { message: 'Filtering does not support Deezer platform', severity: 'fault', cause: 'Unimplemented feature.' } })
265
+ }
266
+
267
+ if (decodedTrack.sourceName === 'soundcloud')
268
+ url = await soundcloud.loadFilters(url, protocol)
269
+
270
+ const ffmpeg = new prism.FFmpeg({
271
+ args: [
272
+ '-loglevel', '0',
273
+ '-analyzeduration', '0',
274
+ '-hwaccel', 'auto',
275
+ '-threads', config.filters.threads,
276
+ '-filter_threads', config.filters.threads,
277
+ '-filter_complex_threads', config.filters.threads,
278
+ ...(this.result.startTime !== undefined || startTime ? ['-ss', `${this.result.startTime !== undefined ? this.result.startTime : startTime}ms`] : []),
279
+ '-i', url,
280
+ ...(this.command.length !== 0 ? [ '-af', this.command.join(',') ] : [] ),
281
+ ...(endTime ? ['-t', `${endTime}ms`] : []),
282
+ '-f', 's16le',
283
+ '-ar', constants.opus.samplingRate,
284
+ '-ac', '2',
285
+ '-crf', '0'
286
+ ]
287
+ })
288
+
289
+ const stream = PassThrough()
290
+
291
+ ffmpeg.process.stdout.on('data', (data) => stream.write(data))
292
+ ffmpeg.process.stdout.on('end', () => stream.end())
293
+ ffmpeg.on('error', (err) => {
294
+ debugLog('retrieveStream', 4, { type: 2, sourceName: decodedTrack.sourceName, query: decodedTrack.title, message: err.message })
295
+
296
+ resolve({ status: 1, exception: { message: err.message, severity: 'fault', cause: 'Unknown' } })
297
+ })
298
+
299
+ ffmpeg.process.stdout.once('readable', () => {
300
+ const pipelines = [
301
+ new prism.VolumeTransformer({ type: 's16le' })
302
+ ]
303
+
304
+ if (this.equalizer.some((band) => band.gain !== 0)) {
305
+ pipelines.push(
306
+ new Filtering(
307
+ this.equalizer.map((band) => band.gain),
308
+ constants.filtering.types.equalizer
309
+ )
310
+ )
311
+ }
312
+
313
+ if (this.result.tremolo) {
314
+ pipelines.push(
315
+ new Filtering({
316
+ frequency: this.result.tremolo.frequency,
317
+ depth: this.result.tremolo.depth
318
+ },
319
+ constants.filtering.types.tremolo)
320
+ )
321
+ }
322
+
323
+ if (this.result.rotation) {
324
+ pipelines.push(
325
+ new Filtering({
326
+ rotationHz: this.result.rotation.rotationHz / 2
327
+ }, constants.filtering.types.rotationHz)
328
+ )
329
+ }
330
+
331
+ pipelines.push(
332
+ new prism.opus.Encoder({
333
+ rate: constants.opus.samplingRate,
334
+ channels: constants.opus.channels,
335
+ frameSize: constants.opus.frameSize
336
+ })
337
+ )
338
+
339
+ resolve({ stream: new voiceUtils.NodeLinkStream(stream, pipelines) })
340
+ })
341
+ })
342
+ }
343
+ }
344
+
345
+ export default Filters
src/sources.js ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+ import { PassThrough } from 'node:stream'
3
+
4
+ import config from '../config.js'
5
+ import bandcamp from './sources/bandcamp.js'
6
+ import deezer from './sources/deezer.js'
7
+ import httpSource from './sources/http.js'
8
+ import local from './sources/local.js'
9
+ import pandora from './sources/pandora.js'
10
+ import soundcloud from './sources/soundcloud.js'
11
+ import spotify from './sources/spotify.js'
12
+ import youtube from './sources/youtube.js'
13
+ import genius from './sources/genius.js'
14
+ import musixmatch from './sources/musixmatch.js'
15
+ import searchWithDefault from './sources/default.js'
16
+
17
+ import { debugLog, http1makeRequest, makeRequest } from './utils.js'
18
+
19
+ async function getTrackURL(track, toDefault) {
20
+ switch (track.sourceName === 'pandora' || toDefault ? config.search.defaultSearchSource : track.sourceName) {
21
+ case 'spotify': {
22
+ const result = await searchWithDefault(`${track.title} - ${track.author}`, false)
23
+
24
+ if (result.loadType === 'error') {
25
+ return {
26
+ exception: result.data
27
+ }
28
+ }
29
+
30
+ if (result.loadType === 'empty') {
31
+ return {
32
+ exception: {
33
+ message: 'Failed to retrieve stream from source. (Spotify track not found)',
34
+ severity: 'common',
35
+ cause: 'Spotify track not found'
36
+ }
37
+ }
38
+ }
39
+
40
+ const trackInfo = result.data[0].info
41
+
42
+ return getTrackURL(trackInfo, true)
43
+ }
44
+ case 'ytmusic':
45
+ case 'youtube': {
46
+ return youtube.retrieveStream(track.identifier, track.sourceName, track.title)
47
+ }
48
+ case 'local': {
49
+ return { url: track.uri, protocol: 'file', format: 'arbitrary' }
50
+ }
51
+
52
+ case 'http':
53
+ case 'https': {
54
+ return { url: track.uri, protocol: track.sourceName, format: 'arbitrary' }
55
+ }
56
+ case 'soundcloud': {
57
+ return soundcloud.retrieveStream(track.identifier, track.title)
58
+ }
59
+ case 'bandcamp': {
60
+ return bandcamp.retrieveStream(track.uri, track.title)
61
+ }
62
+ case 'deezer': {
63
+ return deezer.retrieveStream(track.identifier, track.title)
64
+ }
65
+ default: {
66
+ return {
67
+ exception: {
68
+ message: 'Unknown source',
69
+ severity: 'common',
70
+ cause: 'Not supported source.'
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ function getTrackStream(decodedTrack, url, protocol, additionalData) {
78
+ return new Promise(async (resolve) => {
79
+ if (protocol === 'file') {
80
+ const file = fs.createReadStream(url)
81
+
82
+ file.on('error', () => {
83
+ debugLog('retrieveStream', 4, { type: 2, sourceName: decodedTrack.sourceName, query: decodedTrack.title, message: 'Failed to retrieve stream from source. (File not found or not accessible)' })
84
+
85
+ return resolve({
86
+ status: 1,
87
+ exception: {
88
+ message: 'Failed to retrieve stream from source. (File not found or not accessible)',
89
+ severity: 'common',
90
+ cause: 'No permission to access file or doesn\'t exist'
91
+ }
92
+ })
93
+ })
94
+
95
+ file.on('open', () => {
96
+ resolve({
97
+ stream: file,
98
+ type: 'arbitrary'
99
+ })
100
+ })
101
+ } else {
102
+ let trueSource = [ 'pandora', 'spotify' ].includes(decodedTrack.sourceName) ? config.search.defaultSearchSource : decodedTrack.sourceName
103
+
104
+ if (trueSource === 'youtube' && protocol === 'hls') {
105
+ return resolve({
106
+ stream: await youtube.loadStream(url)
107
+ })
108
+ }
109
+
110
+ if (trueSource === 'deezer') {
111
+ return resolve({
112
+ stream: await deezer.loadTrack(decodedTrack.title, url, additionalData)
113
+ })
114
+ }
115
+
116
+ if (trueSource === 'soundcloud') {
117
+ if (additionalData === true) {
118
+ trueSource = config.search.fallbackSearchSource
119
+ } else if (protocol === 'hls') {
120
+ const stream = await soundcloud.loadHLSStream(url)
121
+
122
+ return resolve({
123
+ stream
124
+ })
125
+ }
126
+ }
127
+
128
+ const res = await ((trueSource === 'youtube' || trueSource === 'ytmusic') ? http1makeRequest : makeRequest)(url, {
129
+ headers: {
130
+ 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
131
+ },
132
+ method: 'GET',
133
+ streamOnly: true
134
+ })
135
+
136
+ if (res.statusCode !== 200) {
137
+ res.stream.emit('end') /* (http1)makeRequest will handle this automatically */
138
+
139
+ debugLog('retrieveStream', 4, { type: 2, sourceName: decodedTrack.sourceName, query: decodedTrack.title, message: `Expected 200, received ${res.statusCode}.` })
140
+
141
+ return resolve({
142
+ status: 1,
143
+ exception: {
144
+ message: `Failed to retrieve stream from source. Expected 200, received ${res.statusCode}.`,
145
+ severity: 'suspicious',
146
+ cause: 'Wrong status code'
147
+ }
148
+ })
149
+ }
150
+
151
+ const stream = new PassThrough()
152
+
153
+ res.stream.on('data', (chunk) => stream.write(chunk))
154
+ res.stream.on('end', () => stream.end())
155
+ res.stream.on('error', (error) => {
156
+ debugLog('retrieveStream', 4, { type: 2, sourceName: decodedTrack.sourceName, query: decodedTrack.title, message: error.message })
157
+
158
+ resolve({
159
+ status: 1,
160
+ exception: {
161
+ message: error.message,
162
+ severity: 'fault',
163
+ cause: 'Unknown'
164
+ }
165
+ })
166
+ })
167
+
168
+ resolve({
169
+ stream
170
+ })
171
+ }
172
+ })
173
+ }
174
+
175
+ async function loadTracks(identifier) {
176
+ const ytSearch = config.search.sources.youtube ? identifier.startsWith('ytsearch:') : null
177
+ const ytRegex = config.search.sources.youtube && !ytSearch ? /^(?:(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:shorts\/(?:\?v=)?[a-zA-Z0-9_-]{11}|playlist\?list=[a-zA-Z0-9_-]+|watch\?(?=.*v=[a-zA-Z0-9_-]{11})[^\s]+))|(?:https?:\/\/)?(?:www\.)?youtu\.be\/[a-zA-Z0-9_-]{11})/.test(identifier) : null
178
+ if (config.search.sources.youtube && (ytSearch || ytRegex))
179
+ return ytSearch ? youtube.search(identifier.replace('ytsearch:', ''), 'youtube', true) : youtube.loadFrom(identifier, 'youtube')
180
+
181
+ const ytMusicSearch = config.search.sources.youtube ? identifier.startsWith('ytmsearch:') : null
182
+ const ytMusicRegex = config.search.sources.youtube && !ytMusicSearch ? /^(https?:\/\/)?(music\.)?youtube\.com\/(?:shorts\/(?:\?v=)?[a-zA-Z0-9_-]{11}|playlist\?list=[a-zA-Z0-9_-]+|watch\?(?=.*v=[a-zA-Z0-9_-]{11})[^\s]+)$/.test(identifier) : null
183
+ if (config.search.sources.youtube && (ytMusicSearch || ytMusicRegex))
184
+ return ytMusicSearch ? youtube.search(identifier.replace('ytmsearch:', ''), 'ytmusic', true) : youtube.loadFrom(identifier, 'ytmusic')
185
+
186
+ const spSearch = config.search.sources.spotify.enabled ? identifier.startsWith('spsearch:') : null
187
+ const spRegex = config.search.sources.spotify.enabled && !spSearch ? /^https?:\/\/(?:open\.spotify\.com\/|spotify:)(?:[^?]+)?(track|playlist|artist|episode|show|album)[/:]([A-Za-z0-9]+)/.exec(identifier) : null
188
+ if (config.search.sources[config.search.defaultSearchSource] && (spSearch || spRegex))
189
+ return spSearch ? spotify.search(identifier.replace('spsearch:', '')) : spotify.loadFrom(identifier, spRegex)
190
+
191
+ const dzSearch = config.search.sources.deezer.enabled ? identifier.startsWith('dzsearch:') : null
192
+ const dzRegex = config.search.sources.deezer.enabled && !dzSearch ? /^https?:\/\/(?:www\.)?deezer\.com\/(?:[a-z]{2}\/)?(track|album|playlist)\/(\d+)/.exec(identifier) : null
193
+ if (config.search.sources.deezer.enabled && (dzSearch || dzRegex))
194
+ return dzSearch ? deezer.search(identifier.replace('dzsearch:', ''), true) : deezer.loadFrom(identifier, dzRegex)
195
+
196
+ const scSearch = config.search.sources.soundcloud.enabled ? identifier.startsWith('scsearch:') : null
197
+ const scRegex = config.search.sources.soundcloud.enabled && !scSearch ? /^(https?:\/\/)?(www.)?(m\.)?soundcloud\.com\/[\w\-\.]+(\/)+[\w\-\.]+?$/.test(identifier) : null
198
+ if (config.search.sources.soundcloud.enabled && (scSearch || scRegex))
199
+ return scSearch ? soundcloud.search(identifier.replace('scsearch:', ''), true) : soundcloud.loadFrom(identifier)
200
+
201
+ const bcSearch = config.search.sources.bandcamp ? identifier.startsWith('bcsearch:') : null
202
+ const bcRegex = config.search.sources.bandcamp && !bcSearch ? /^https?:\/\/[\w-]+\.bandcamp\.com(\/(track|album)\/[\w-]+)?/.test(identifier) : null
203
+ if (config.search.sources.bandcamp && (bcSearch || bcRegex))
204
+ return bcSearch ? bandcamp.search(identifier.replace('bcsearch:', ''), true) : bandcamp.loadFrom(identifier)
205
+
206
+ const pdSearch = config.search.sources.pandora ? identifier.startsWith('pdsearch:') : null
207
+ const pdRegex = config.search.sources.pandora && !pdSearch ? /^https:\/\/www\.pandora\.com\/(?:playlist|station|podcast|artist)\/.+/.exec(identifier) : null
208
+ if (config.search.sources.pandora && (pdSearch || pdRegex))
209
+ return pdSearch ? pandora.search(identifier.replace('pdsearch:', '')) : pandora.loadFrom(identifier)
210
+
211
+ if (config.search.sources.http && (identifier.startsWith('http://') || identifier.startsWith('https://')))
212
+ return httpSource.loadFrom(identifier)
213
+
214
+ if (config.search.sources.local && identifier.startsWith('local:'))
215
+ return local.loadFrom(identifier.replace('local:', ''))
216
+
217
+ debugLog('loadTracks', 1, { params: identifier, error: 'No possible search source found.' })
218
+
219
+ return { loadType: 'empty', data: {} }
220
+ }
221
+
222
+ function loadLyrics(parsedUrl, req, decodedTrack, language, fallback) {
223
+ return new Promise(async (resolve) => {
224
+ let captions = { loadType: 'empty', data: {} }
225
+
226
+ switch (fallback ? config.search.lyricsFallbackSource : decodedTrack.sourceName) {
227
+ case 'ytmusic':
228
+ case 'youtube': {
229
+ if (!config.search.sources.youtube) {
230
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'No possible search source found.' })
231
+
232
+ break
233
+ }
234
+
235
+ captions = await youtube.loadLyrics(decodedTrack, language) || captions
236
+
237
+ if (captions.loadType === 'error')
238
+ captions = await loadLyrics(parsedUrl, req, decodedTrack, language, true)
239
+
240
+ break
241
+ }
242
+ case 'spotify': {
243
+ if (!config.search.sources[config.search.defaultSearchSource] || !config.search.sources.spotify.enabled) {
244
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'No possible search source found.' })
245
+
246
+ break
247
+ }
248
+
249
+ if (config.search.sources.spotify.sp_dc === 'DISABLED')
250
+ return resolve(loadLyrics(parsedUrl, decodedTrack, language, true))
251
+
252
+ captions = await spotify.loadLyrics(decodedTrack, language) || captions
253
+
254
+ if (captions.loadType === 'error')
255
+ captions = await loadLyrics(parsedUrl, req, decodedTrack, language, true)
256
+
257
+ break
258
+ }
259
+ case 'deezer': {
260
+ if (!config.search.sources.deezer.enabled) {
261
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'No possible search source found.' })
262
+
263
+ break
264
+ }
265
+
266
+ if (config.search.sources.deezer.arl === 'DISABLED')
267
+ return resolve(loadLyrics(parsedUrl, decodedTrack, language, true))
268
+
269
+ captions = await deezer.loadLyrics(decodedTrack, language) || captions
270
+
271
+ if (captions.loadType === 'error')
272
+ captions = await loadLyrics(parsedUrl, req, decodedTrack, language, true)
273
+
274
+ break
275
+ }
276
+ case 'genius': {
277
+ if (!config.search.sources.genius.enabled) {
278
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'No possible search source found.' })
279
+
280
+ break
281
+ }
282
+
283
+ captions = await genius.loadLyrics(decodedTrack, language) || captions
284
+
285
+ break
286
+ }
287
+ case 'musixmatch': {
288
+ if (!config.search.sources.musixmatch.enabled) {
289
+ debugLog('loadlyrics', 1, { params: parsedUrl.pathname, headers: req.headers, error: 'No possible search source found.' })
290
+
291
+ break
292
+ }
293
+
294
+ captions = await musixmatch.loadLyrics(decodedTrack, language) || captions
295
+
296
+ break
297
+ }
298
+ default: {
299
+ captions = await loadLyrics(parsedUrl, req, decodedTrack, language, true)
300
+ }
301
+ }
302
+
303
+ resolve(captions)
304
+ })
305
+ }
306
+
307
+ export default {
308
+ getTrackURL,
309
+ getTrackStream,
310
+ loadTracks,
311
+ loadLyrics,
312
+ bandcamp,
313
+ deezer,
314
+ http: httpSource,
315
+ local,
316
+ pandora,
317
+ soundcloud,
318
+ spotify,
319
+ youtube,
320
+ genius,
321
+ musixmatch
322
+ }
src/sources/bandcamp.js ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from '../../config.js'
2
+ import { debugLog, makeRequest, encodeTrack } from '../utils.js'
3
+
4
+ async function loadFrom(url) {
5
+ const { body: data } = await makeRequest(url, { method: 'GET' })
6
+ const matches = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(data)
7
+
8
+ if (!matches.length) {
9
+ debugLog('loadtracks', 4, { type: 2, loadType: 'empty', sourceName: 'BandCamp', query: url, message: 'No matches found.' })
10
+
11
+ return {
12
+ loadType: 'empty',
13
+ data: {}
14
+ }
15
+ }
16
+
17
+ const trackInfo = JSON.parse(matches[1])
18
+
19
+ debugLog('loadtracks', 4, { type: 1, loadType: trackInfo['@type'] === 'MusicRecording' ? 'track' : 'album', sourceName: 'BandCamp', query: url })
20
+
21
+ switch (trackInfo['@type']) {
22
+ case 'MusicRecording': {
23
+ const identifier = trackInfo['@id'].match(/^https?:\/\/([^/]+)\/track\/([^/?]+)/)
24
+
25
+ const track = {
26
+ identifier: `${identifier[1]}:${identifier[2]}`,
27
+ isSeekable: true,
28
+ author: trackInfo.byArtist.name,
29
+ length: (trackInfo.duration.split('P')[1].split('H')[0] * 3600000) + (trackInfo.duration.split('H')[1].split('M')[0] * 60000) + (trackInfo.duration.split('M')[1].split('S')[0] * 1000),
30
+ isStream: false,
31
+ position: 0,
32
+ title: trackInfo.name,
33
+ uri: trackInfo['@id'],
34
+ artworkUrl: trackInfo.image,
35
+ isrc: null,
36
+ sourceName: 'bandcamp'
37
+ }
38
+
39
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'BandCamp', track, query: url })
40
+
41
+ return {
42
+ loadType: 'track',
43
+ data: {
44
+ encoded: encodeTrack(track),
45
+ info: track,
46
+ pluginInfo: {}
47
+ }
48
+ }
49
+ }
50
+ case 'MusicAlbum': {
51
+ const tracks = []
52
+
53
+ trackInfo.track.itemListElement.forEach((item) => {
54
+ const identifier = item.item['@id'].match(/^https?:\/\/([^/]+)\/track\/([^/?]+)/)
55
+
56
+ const track = {
57
+ identifier: `${identifier[1]}:${identifier[2]}`,
58
+ isSeekable: true,
59
+ author: trackInfo.byArtist.name,
60
+ length: (item.item.duration.split('P')[1].split('H')[0] * 3600000) + (item.item.duration.split('H')[1].split('M')[0] * 60000) + (item.item.duration.split('M')[1].split('S')[0] * 1000),
61
+ isStream: false,
62
+ position: 0,
63
+ title: item.item.name,
64
+ uri: item.item['@id'],
65
+ artworkUrl: trackInfo.image,
66
+ isrc: null,
67
+ sourceName: 'bandcamp'
68
+ }
69
+
70
+ tracks.push({
71
+ encoded: encodeTrack(track),
72
+ info: track,
73
+ pluginInfo: {}
74
+ })
75
+ })
76
+
77
+ debugLog('loadtracks', 4, { type: 2, loadType: 'album', sourceName: 'BandCamp', playlistName: trackInfo.name })
78
+
79
+ return {
80
+ loadType: 'album',
81
+ data: {
82
+ info: {
83
+ name: trackInfo.name,
84
+ selectedTrack: 0
85
+ },
86
+ tracks
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ async function search(query, shouldLog) {
94
+ if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'BandCamp', query })
95
+
96
+ const { body: data } = await makeRequest(`https://bandcamp.com/search?q=${encodeURI(query)}&item_type=t&from=results`, { method: 'GET' })
97
+
98
+ const names = data.match(/<div class="heading">\s+<a.*?>(.*?)<\/a>/gs)
99
+
100
+ if (!names) {
101
+ if (shouldLog) debugLog('search', 4, { type: 2, sourceName: 'BandCamp', query, message: 'No matches found.' })
102
+
103
+ return {
104
+ loadType: 'empty',
105
+ data: {}
106
+ }
107
+ }
108
+
109
+ const tracks = []
110
+
111
+ if (names.length > config.options.maxResultsLength)
112
+ names = names.slice(0, config.options.maxResultsLength)
113
+
114
+ names.forEach((name) => {
115
+ tracks.push({
116
+ encoded: null,
117
+ info: {
118
+ identifier: null,
119
+ isSeekable: true,
120
+ author: null,
121
+ length: -1,
122
+ isStream: false,
123
+ position: 0,
124
+ title: name[1].trim(),
125
+ uri: null,
126
+ artworkUrl: null,
127
+ isrc: null,
128
+ sourceName: 'bandcamp'
129
+ },
130
+ pluginInfo: {}
131
+ })
132
+ })
133
+
134
+ if (!tracks.length) {
135
+ if (shouldLog) debugLog('search', 4, { type: 2, sourceName: 'BandCamp', query, message: 'No matches found.' })
136
+
137
+ return {
138
+ loadType: 'empty',
139
+ data: {}
140
+ }
141
+ }
142
+
143
+ const authors = data.match(/<div class="subhead">\s+(?:from\s+)?[\s\S]*?by (.*?)\s+<\/div>/gs)
144
+
145
+ authors.forEach((author, i) => {
146
+ tracks[i].info.author = author.split('by')[1].split('</div>')[0].trim()
147
+ })
148
+
149
+ const artworkUrls = data.match(/<div class="art">\s*<img src="(.+?)"/gs)
150
+
151
+ artworkUrls.forEach((artworkUrl, i) => {
152
+ tracks[i].info.artworkUrl = artworkUrl.split('"')[3].split('"')[0]
153
+ })
154
+
155
+ const urls = data.match(/<div class="itemurl">\s+<a.*?>(.*?)<\/a>/gs)
156
+
157
+ urls.forEach((url, i) => {
158
+ tracks[i].info.uri = url.split('">')[2].split('</a>')[0]
159
+
160
+ const identifier = tracks[i].info.uri.match(/^https?:\/\/([^/]+)\/track\/([^/?]+)/)
161
+ tracks[i].info.identifier = `${identifier[1]}:${identifier[2]}`
162
+
163
+ tracks[i].encoded = encodeTrack(tracks[i].info)
164
+ tracks[i].pluginInfo = {}
165
+ })
166
+
167
+ if (shouldLog) debugLog('search', 4, { type: 2, sourceName: 'BandCamp', tracksLen: tracks.length, query })
168
+
169
+ return {
170
+ loadType: 'search',
171
+ data: tracks
172
+ }
173
+ }
174
+
175
+ async function retrieveStream(uri, title) {
176
+ const { body: data } = await makeRequest(uri, { method: 'GET' })
177
+
178
+ const streamURL = data.match(/https?:\/\/t4\.bcbits\.com\/stream\/[a-zA-Z0-9]+\/mp3-128\/\d+\?p=\d+&amp;ts=\d+&amp;t=[a-zA-Z0-9]+&amp;token=\d+_[a-zA-Z0-9]+/)
179
+
180
+ if (!streamURL) {
181
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'BandCamp', query: title, message: 'No stream URL was found.' })
182
+
183
+ return {
184
+ exception: {
185
+ message: 'Failed to get the stream from source.',
186
+ severity: 'fault',
187
+ cause: 'Unknown'
188
+ }
189
+ }
190
+ }
191
+
192
+ return {
193
+ url: streamURL[0],
194
+ protocol: 'https',
195
+ format: 'arbitrary'
196
+ }
197
+ }
198
+
199
+ export default {
200
+ loadFrom,
201
+ search,
202
+ retrieveStream
203
+ }
src/sources/deezer.js ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+ import { PassThrough } from 'node:stream'
4
+
5
+ import config from '../../config.js'
6
+ import { debugLog, makeRequest, encodeTrack } from '../utils.js'
7
+
8
+ let sourceInfo = {
9
+ licenseToken: null,
10
+ csrfToken: null,
11
+ mediaUrl: null,
12
+ Cookie: null,
13
+ jwtToken: null
14
+ }
15
+
16
+ const bufferSize = 2048
17
+ const IV = Buffer.from(Array.from({ length: 8 }, (_i, x) => x))
18
+
19
+ async function init() {
20
+ if (sourceInfo.licenseToken) return;
21
+ // TODO: Need to reset when timestamp is expired
22
+
23
+ debugLog('deezer', 5, { type: 1, message: 'Fetching user data...' })
24
+
25
+ const res = await makeRequest(`https://www.deezer.com/ajax/gw-light.php?method=deezer.getUserData&input=3&api_version=1.0&api_token=${config.search.sources.deezer.apiToken}`, {
26
+ method: 'GET',
27
+ getCookies: true
28
+ })
29
+
30
+ sourceInfo.Cookie = res.headers['set-cookie'].join('; ')
31
+
32
+ if (config.search.sources.deezer.arl !== 'DISABLED') {
33
+ sourceInfo.Cookie += `; arl=${config.search.sources.deezer.arl}`
34
+
35
+ const { body: jwtInfo } = await makeRequest('https://auth.deezer.com/login/arl?jo=p&rto=c&i=c', {
36
+ headers: {
37
+ Cookie: sourceInfo.Cookie
38
+ },
39
+ method: 'POST'
40
+ })
41
+
42
+ sourceInfo.jwtToken = JSON.parse(jwtInfo).jwt
43
+ }
44
+
45
+ sourceInfo.licenseToken = res.body.results.USER.OPTIONS.license_token
46
+ sourceInfo.csrfToken = res.body.results.checkForm
47
+ sourceInfo.mediaUrl = res.body.results.URL_MEDIA
48
+
49
+ debugLog('deezer', 5, { type: 1, message: 'Successfully fetched user data.' })
50
+ }
51
+
52
+ async function loadFrom(query, type) {
53
+ let endpoint
54
+
55
+ switch (type[1]) {
56
+ case 'track':
57
+ endpoint = `track/${type[2]}`
58
+ break
59
+ case 'playlist':
60
+ endpoint = `playlist/${type[2]}`
61
+ break
62
+ case 'album':
63
+ endpoint = `album/${type[2]}`
64
+ break
65
+ default: {
66
+ return {
67
+ loadType: 'empty',
68
+ data: {}
69
+ }
70
+ }
71
+ }
72
+
73
+ debugLog('loadtracks', 4, { type: 1, loadType: type[1], sourceName: 'Deezer', query })
74
+
75
+ const { body: data } = await makeRequest(`https://api.deezer.com/2.0/${endpoint}`, { method: 'GET' })
76
+
77
+ if (data.error) {
78
+ if (data.error.code === 800) {
79
+ return {
80
+ loadType: 'empty',
81
+ data: {}
82
+ }
83
+ }
84
+
85
+ return {
86
+ loadType: 'error',
87
+ data: {
88
+ message: data.error.message,
89
+ severity: 'fault',
90
+ cause: 'Unknown'
91
+ }
92
+ }
93
+ }
94
+
95
+ switch (type[1]) {
96
+ case 'track': {
97
+ const track = {
98
+ identifier: data.id.toString(),
99
+ isSeekable: true,
100
+ author: data.artist.name,
101
+ length: data.duration * 1000,
102
+ isStream: false,
103
+ position: 0,
104
+ title: data.title,
105
+ uri: data.link,
106
+ artworkUrl: data.album.cover_xl,
107
+ isrc: data.isrc,
108
+ sourceName: 'deezer'
109
+ }
110
+
111
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Deezer', track, query })
112
+
113
+ return {
114
+ loadType: 'track',
115
+ data: {
116
+ encoded: encodeTrack(track),
117
+ info: track,
118
+ pluginInfo: {}
119
+ }
120
+ }
121
+ }
122
+ case 'album':
123
+ case 'playlist': {
124
+ const tracks = []
125
+
126
+ if (data.tracks.data.length > config.options.maxAlbumPlaylistLength)
127
+ data.tracks.data = data.tracks.data.slice(0, config.options.maxAlbumPlaylistLength)
128
+
129
+ data.tracks.data.forEach(async (item, i) => {
130
+ const track = {
131
+ identifier: item.id.toString(),
132
+ isSeekable: true,
133
+ author: item.artist.name,
134
+ length: item.duration * 1000,
135
+ isStream: false,
136
+ position: 0,
137
+ title: item.title,
138
+ uri: item.link,
139
+ artworkUrl: type[1] === 'album' ? data.cover_xl : data.picture_xl,
140
+ isrc: null,
141
+ sourceName: 'deezer'
142
+ }
143
+
144
+ tracks.push({
145
+ encoded: encodeTrack(track),
146
+ info: track,
147
+ pluginInfo: {}
148
+ })
149
+ })
150
+
151
+ debugLog('loadtracks', 4, { type: 2, loadType: type[1], sourceName: 'Deezer', playlistName: data.title })
152
+
153
+ return {
154
+ loadType: type[1],
155
+ data: {
156
+ info: {
157
+ name: data.title,
158
+ selectedTrack: 0
159
+ },
160
+ pluginInfo: {},
161
+ tracks
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ async function search(query, shouldLog) {
169
+ if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'Deezer', query })
170
+
171
+ const { body: data } = await makeRequest(`https://api.deezer.com/2.0/search?q=${encodeURI(query)}`, { method: 'GET' })
172
+
173
+ // This API doesn't give ISRC, must change to internal API
174
+
175
+ if (data.error) {
176
+ return {
177
+ loadType: 'error',
178
+ data: {
179
+ message: data.error.message,
180
+ severity: 'fault',
181
+ cause: 'Unknown'
182
+ }
183
+ }
184
+ }
185
+
186
+ if (data.total === 0) {
187
+ if (shouldLog) debugLog('search', 4, { type: 3, sourceName: 'Deezer', query, message: 'No matches found.' })
188
+
189
+ return {
190
+ loadType: 'empty',
191
+ data: {}
192
+ }
193
+ }
194
+
195
+ const tracks = []
196
+
197
+ if (data.data.length > config.options.maxResultsLength)
198
+ data.data = data.data.filter((item, i) => i < config.options.maxResultsLength || item.type === 'track')
199
+
200
+ data.data.forEach(async (item) => {
201
+ const track = {
202
+ identifier: item.id.toString(),
203
+ isSeekable: true,
204
+ author: item.artist.name,
205
+ length: item.duration * 1000,
206
+ isStream: false,
207
+ position: 0,
208
+ title: item.title,
209
+ uri: item.link,
210
+ artworkUrl: item.album.cover_xl,
211
+ isrc: item.isrc,
212
+ sourceName: 'deezer'
213
+ }
214
+
215
+ tracks.push({
216
+ encoded: encodeTrack(track),
217
+ info: track,
218
+ pluginInfo: {}
219
+ })
220
+ })
221
+
222
+ if (shouldLog)
223
+ debugLog('search', 4, { type: 2, sourceName: 'Deezer', tracksLen: tracks.length, query })
224
+
225
+ return {
226
+ loadType: 'search',
227
+ data: tracks
228
+ }
229
+ }
230
+
231
+ async function retrieveStream(identifier, title) {
232
+ const { body: data } = await makeRequest(`https://www.deezer.com/ajax/gw-light.php?method=song.getListData&input=3&api_version=1.0&api_token=${sourceInfo.csrfToken}`, {
233
+ body: {
234
+ sng_ids: [ identifier ]
235
+ },
236
+ headers: {
237
+ Cookie: sourceInfo.Cookie
238
+ },
239
+ method: 'POST',
240
+ disableBodyCompression: true
241
+ })
242
+
243
+ if (data.error.length !== 0) {
244
+ const errorMessage = Object.keys(data.error).map((err) => data.error[err]).join('; ')
245
+
246
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'Deezer', query: title, message: errorMessage })
247
+
248
+ return {
249
+ exception: {
250
+ message: errorMessage,
251
+ severity: 'fault',
252
+ cause: 'Unknown'
253
+ }
254
+ }
255
+ }
256
+
257
+ const trackInfo = data.results.data[0]
258
+
259
+ const { body: streamData } = await makeRequest('https://media.deezer.com/v1/get_url', {
260
+ body: {
261
+ license_token: sourceInfo.licenseToken,
262
+ media: [{
263
+ type: 'FULL',
264
+ formats: [{
265
+ cipher: 'BF_CBC_STRIPE',
266
+ format: 'FLAC'
267
+ }, {
268
+ cipher: 'BF_CBC_STRIPE',
269
+ format: 'MP3_256'
270
+ }, {
271
+ cipher: 'BF_CBC_STRIPE',
272
+ format: 'MP3_128'
273
+ }, {
274
+ cipher: 'BF_CBC_STRIPE',
275
+ format: 'MP3_MISC'
276
+ }]
277
+ }],
278
+ track_tokens: [ trackInfo.TRACK_TOKEN ]
279
+ },
280
+ method: 'POST',
281
+ disableBodyCompression: true
282
+ })
283
+
284
+ return {
285
+ url: streamData.data[0].media[0].sources[0].url,
286
+ protocol: 'https',
287
+ format: 'arbitrary',
288
+ additionalData: trackInfo
289
+ }
290
+ }
291
+
292
+ async function loadLyrics(decodedTrack, _language) {
293
+ const { body: video } = await makeRequest('https://pipe.deezer.com/api', {
294
+ headers: {
295
+ Cookie: sourceInfo.Cookie,
296
+ Authorization: `Bearer ${sourceInfo.jwtToken}`
297
+ },
298
+ body: {
299
+ operationName: 'SynchronizedTrackLyrics',
300
+ query: 'query SynchronizedTrackLyrics($trackId: String!) {\n track(trackId: $trackId) {\n ...SynchronizedTrackLyrics\n __typename\n }\n}\n\nfragment SynchronizedTrackLyrics on Track {\n id\n lyrics {\n ...Lyrics\n __typename\n }\n album {\n cover {\n small: urls(pictureRequest: {width: 100, height: 100})\n medium: urls(pictureRequest: {width: 264, height: 264})\n large: urls(pictureRequest: {width: 800, height: 800})\n explicitStatus\n __typename\n }\n __typename\n }\n __typename\n}\n\nfragment Lyrics on Lyrics {\n id\n copyright\n text\n writers\n synchronizedLines {\n ...LyricsSynchronizedLines\n __typename\n }\n __typename\n}\n\nfragment LyricsSynchronizedLines on LyricsSynchronizedLine {\n lrcTimestamp\n line\n lineTranslated\n milliseconds\n duration\n __typename\n}',
301
+ variables: {
302
+ trackId: decodedTrack.identifier
303
+ }
304
+ },
305
+ method: 'POST',
306
+ disableBodyCompression: true
307
+ })
308
+
309
+ if (video.errors) {
310
+ const errorMessage = video.errors.map((err) => `${err.message} (${err.type})`).join('; ')
311
+
312
+ debugLog('loadlyrics', 4, { type: 3, track: decodedTrack, sourceName: 'Deezer', message: errorMessage })
313
+
314
+ return {
315
+ loadType: 'error',
316
+ data: {
317
+ message: errorMessage,
318
+ severity: 'common',
319
+ cause: 'Unknown'
320
+ }
321
+ }
322
+ }
323
+
324
+ const lyricsEvents = video.data.track.lyrics.synchronizedLines.map((event) => {
325
+ return {
326
+ startTime: event.milliseconds,
327
+ endTime: event.milliseconds + event.duration,
328
+ text: event.line
329
+ }
330
+ })
331
+
332
+ return {
333
+ loadType: 'lyricsSingle',
334
+ data: {
335
+ name: 'original',
336
+ synced: true,
337
+ data: lyricsEvents,
338
+ rtl: false
339
+ }
340
+ }
341
+ }
342
+
343
+ function _calculateKey(songId) {
344
+ const key = config.search.sources.deezer.decryptionKey
345
+ const songIdHash = crypto.createHash('md5').update(songId, 'ascii').digest('hex')
346
+ const trackKey = Buffer.alloc(16)
347
+
348
+ for (let i = 0; i < 16; i++) {
349
+ trackKey.writeInt8(songIdHash[i].charCodeAt(0) ^ songIdHash[i + 16].charCodeAt(0) ^ key[i].charCodeAt(0), i)
350
+ }
351
+
352
+ return trackKey
353
+ }
354
+
355
+ function loadTrack(title, url, trackInfos) {
356
+ return new Promise(async (resolve) => {
357
+ const stream = new PassThrough()
358
+
359
+ const trackKey = _calculateKey(trackInfos.SNG_ID)
360
+ let buf = Buffer.alloc(0)
361
+ let i = 0
362
+
363
+ const res = await makeRequest(url, {
364
+ method: 'GET',
365
+ streamOnly: true
366
+ })
367
+
368
+ res.stream.on('end', () => stream.end())
369
+ res.stream.on('error', (error) => {
370
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'Deezer', query: title, message: error.message })
371
+
372
+ resolve({
373
+ status: 1,
374
+ exception: {
375
+ message: error.message,
376
+ severity: 'fault',
377
+ cause: 'Unknown'
378
+ }
379
+ })
380
+ })
381
+
382
+ res.stream.on('readable', () => {
383
+ let chunk = null
384
+ while (1) {
385
+ chunk = res.stream.read(bufferSize)
386
+
387
+ if (!chunk) {
388
+ if (res.stream.readableLength) {
389
+ chunk = res.stream.read(res.stream.readableLength)
390
+ buf = Buffer.concat([ buf, chunk ])
391
+ }
392
+
393
+ break
394
+ } else {
395
+ buf = Buffer.concat([ buf, chunk ])
396
+ }
397
+
398
+ while (buf.length >= bufferSize) {
399
+ const bufferSized = buf.subarray(0, bufferSize)
400
+
401
+ if (i % 3 === 0) {
402
+ const decipher = crypto.createDecipheriv('bf-cbc', trackKey, IV).setAutoPadding(false)
403
+
404
+ stream.push(decipher.update(bufferSized))
405
+ stream.push(decipher.final())
406
+ } else {
407
+ stream.push(bufferSized)
408
+ }
409
+
410
+ i++
411
+
412
+ buf = buf.subarray(bufferSize)
413
+ }
414
+ }
415
+
416
+ resolve(stream)
417
+ })
418
+ })
419
+ }
420
+
421
+ export default {
422
+ init,
423
+ loadFrom,
424
+ search,
425
+ retrieveStream,
426
+ loadLyrics,
427
+ loadTrack
428
+ }
src/sources/default.js ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from '../../config.js'
2
+ import youtube from './youtube.js'
3
+ import soundcloud from './soundcloud.js'
4
+ import bandcamp from './bandcamp.js'
5
+ import deezer from './deezer.js'
6
+
7
+ async function searchWithDefault(query, fallback) {
8
+ const searchSource = fallback ? config.search.fallbackSearchSource : config.search.defaultSearchSource
9
+
10
+ switch (searchSource) {
11
+ case 'ytmusic':
12
+ case 'youtube': {
13
+ return youtube.search(query, searchSource, false)
14
+ }
15
+ case 'soundcloud': {
16
+ return soundcloud.search(query, false)
17
+ }
18
+ case 'bandcamp': {
19
+ return bandcamp.search(query, false)
20
+ }
21
+ case 'deezer': {
22
+ return deezer.search(query, false)
23
+ }
24
+ default: {
25
+ console.warn(`[\u001b[33msources\u001b[37m]: Default search source: unknown, falling back to: ${config.search.fallbackSearchSource}`)
26
+
27
+ return searchWithDefault(query, true)
28
+ }
29
+ }
30
+ }
31
+
32
+ export default searchWithDefault
src/sources/genius.js ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { makeRequest } from '../utils.js'
2
+
3
+ async function search(query) {
4
+ const { body: data } = await makeRequest(`https://genius.com/api/search/multi?q=${encodeURIComponent(query)}`, {
5
+ method: 'GET'
6
+ })
7
+
8
+ if (data.response.sections[1].hits.length === 0) return null
9
+
10
+ return data.response.sections[1].hits[0].result.path
11
+ }
12
+
13
+ async function loadLyrics(decodedTrack, language) {
14
+ const searchResult = await search(`${decodedTrack.title} ${decodedTrack.author}`)
15
+
16
+ if (!searchResult) return null
17
+
18
+ const { body: data } = await makeRequest(`https://genius.com${searchResult}`, {
19
+ method: 'GET'
20
+ })
21
+
22
+ const trackInfo = JSON.parse(data.match(/JSON.parse\('(.*)'\);/)[1].replace(/\\(.)/g, '$1'))
23
+
24
+ const lyricsEvents = []
25
+ trackInfo.songPage.lyricsData.body.children[0].children.forEach((text) => {
26
+ if (typeof text === 'object') {
27
+ if (!text.children) return;
28
+
29
+ text.children.forEach((child) => {
30
+ if (typeof child !== 'string') return;
31
+
32
+ lyricsEvents.push({
33
+ text: child
34
+ })
35
+ })
36
+
37
+ return;
38
+ }
39
+
40
+ lyricsEvents.push({
41
+ text
42
+ })
43
+ })
44
+
45
+ return {
46
+ loadType: 'lyricsSingle',
47
+ data: {
48
+ name: 'original',
49
+ synced: false,
50
+ data: lyricsEvents,
51
+ rtl: false
52
+ }
53
+ }
54
+ }
55
+
56
+ export default {
57
+ loadLyrics
58
+ }
src/sources/http.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { debugLog, makeRequest, encodeTrack } from '../utils.js'
2
+
3
+ async function loadFrom(uri) {
4
+ const type = uri.startsWith('http://') ? 'http' : 'https'
5
+ debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: type, query: uri })
6
+
7
+ const data = await makeRequest(uri, { method: 'HEAD' })
8
+
9
+ if (data.error) {
10
+ debugLog('loadtracks', 4, { type: 3, loadType: 'track', sourceName: type, query: uri, message: 'Not possible to connect to the URL.', })
11
+
12
+ return {
13
+ message: 'Not possible to connect to the URL.',
14
+ severity: 'fault',
15
+ cause: 'Unknown'
16
+ }
17
+ }
18
+
19
+
20
+ if (!data.headers || !data.headers['content-type']?.startsWith('audio/')) {
21
+ debugLog('loadtracks', 4, { type: 2, loadType: 'error', sourceName: type, query: uri, message: 'Url is not a playable stream.' })
22
+
23
+ return {
24
+ loadType: 'error',
25
+ data: {
26
+ message: 'URL is not a playable stream.',
27
+ severity: 'common',
28
+ cause: 'Invalid URL'
29
+ }
30
+ }
31
+ }
32
+
33
+ const track = {
34
+ identifier: 'unknown',
35
+ isSeekable: false,
36
+ author: 'unknown',
37
+ length: -1,
38
+ isStream: false,
39
+ position: 0,
40
+ title: 'unknown',
41
+ uri,
42
+ artworkUrl: null,
43
+ isrc: null,
44
+ sourceName: type
45
+ }
46
+
47
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: type, track, query: uri })
48
+
49
+ return {
50
+ loadType: 'track',
51
+ data: {
52
+ encoded: encodeTrack(track),
53
+ info: track,
54
+ pluginInfo: {}
55
+ }
56
+ }
57
+ }
58
+
59
+ export default {
60
+ loadFrom
61
+ }
src/sources/local.js ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+
3
+ import { debugLog, encodeTrack } from '../utils.js'
4
+
5
+ function loadFrom(path) {
6
+ return new Promise((resolve) => {
7
+ debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: 'local', query: path })
8
+
9
+ fs.open(path, (err) => {
10
+ if (err) {
11
+ debugLog('loadtracks', 4, { type: 2, loadType: 'error', sourceName: 'local', query: path, message: 'Failed to retrieve stream from source. (File not found or not accessible)' })
12
+
13
+ return resolve({
14
+ loadType: 'error',
15
+ data: {
16
+ message: 'Failed to retrieve stream from source. (File not found or not accessible)',
17
+ severity: 'common',
18
+ cause: 'No permission to access file or doesn\'t exist'
19
+ }
20
+ })
21
+ }
22
+
23
+ const track = {
24
+ identifier: 'unknown',
25
+ isSeekable: false,
26
+ author: 'unknown',
27
+ length: -1,
28
+ isStream: false,
29
+ position: 0,
30
+ title: 'unknown',
31
+ uri: path,
32
+ artworkUrl: null,
33
+ isrc: null,
34
+ sourceName: 'local'
35
+ }
36
+
37
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'local', track, query: path })
38
+
39
+ resolve({
40
+ loadType: 'track',
41
+ data: {
42
+ encoded: encodeTrack(track),
43
+ info: track,
44
+ pluginInfo: {}
45
+ }
46
+ })
47
+ })
48
+ })
49
+ }
50
+
51
+ export default {
52
+ loadFrom
53
+ }
src/sources/musixmatch.js ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'node:crypto'
2
+
3
+ import config from '../../config.js'
4
+ import { debugLog, makeRequest } from '../utils.js'
5
+
6
+ function _getGuid() {
7
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
8
+ const random = 16 * Math.random() | 0
9
+ const value = character === 'x' ? random : 3 & random | 8
10
+
11
+ return value.toString(16)
12
+ })
13
+ }
14
+
15
+ let sourceInfo = {
16
+ guid: null
17
+ }
18
+
19
+ function init() {
20
+ sourceInfo.guid = _getGuid()
21
+
22
+ debugLog('musixmatch', 5, { message: `New guid: ${sourceInfo.guid}` })
23
+ }
24
+
25
+ function _signUrl(url) {
26
+ const time = new Date
27
+ const year = time.getUTCFullYear()
28
+ let month = time.getUTCMonth() + 1
29
+ month < 10 && (month = '0' + month)
30
+ let day = time.getUTCDate()
31
+ day < 10 && (day = '0' + day)
32
+
33
+ let superKey = crypto.createHmac('sha1', config.search.sources.musixmatch.signatureSecret)
34
+ superKey = superKey.update(url + year + month + day)
35
+ superKey = superKey.digest('base64')
36
+
37
+ return url + `&signature=${encodeURIComponent(superKey)}&signature_protocol=sha1`
38
+ }
39
+
40
+ function _normalizeLanguage(language) {
41
+ switch (language) {
42
+ case 'ara': return 'ar'
43
+ case 'afr': return 'af'
44
+ case 'ind': return 'id'
45
+ case 'kan': return 'kn'
46
+ case 'kor': return 'ko'
47
+ case 'rkr': return 'rk'
48
+ case 'zho': return 'zh'
49
+ case 'rz0': return 'rz'
50
+ case 'zht': return 'z1'
51
+ case 'ces': return 'cs'
52
+ case 'deu': return 'de'
53
+ case 'nld': return 'nl'
54
+ case 'spa': return 'es'
55
+ case 'dan': return 'da'
56
+ case 'ell': return 'el'
57
+ case 'eng': return 'en'
58
+ case 'fas': return 'fa'
59
+ case 'fin': return 'fi'
60
+ case 'fra': return 'fr'
61
+ case 'heb': return 'he'
62
+ case 'hin': return 'hi'
63
+ case 'ita': return 'it'
64
+ case 'jpn': return 'ja'
65
+ case 'hun': return 'hu'
66
+ case 'rja': return 'rj'
67
+ case 'nor': return 'no'
68
+ case 'pol': return 'pl'
69
+ case 'por': return 'pt'
70
+ case 'pt-br': return 'pt-br'
71
+ case 'rus': return 'ru'
72
+ case 'ron': return 'ro'
73
+ case 'tur': return 'tr'
74
+ case 'lit': return 'lt'
75
+ case 'mas': return 'ms'
76
+ case 'mkd': return 'mk'
77
+ case 'sqi': return 'sq'
78
+ case 'hye': return 'hy'
79
+ case 'aze': return 'az'
80
+ case 'ben': return 'bn'
81
+ case 'bos': return 'bs'
82
+ case 'bul': return 'bg'
83
+ case 'hrv': return 'hr'
84
+ case 'est': return 'et'
85
+ case 'fil': return 'f1'
86
+ case 'kat': return 'ka'
87
+ case 'hat': return 'ht'
88
+ case 'isl': return 'is'
89
+ case 'kaz': return 'kk'
90
+ case 'kir': return 'ky'
91
+ case 'lao': return 'lo'
92
+ case 'lav': return 'lv'
93
+ case 'mon': return 'mn'
94
+ case 'msa': return 'ms'
95
+ case 'nep': return 'ne'
96
+ case 'pan': return 'pa'
97
+ case 'srp': return 'sr'
98
+ case 'slk': return 'sk'
99
+ case 'slv': return 'sl'
100
+ case 'swe': return 'se'
101
+ case 'tha': return 'th'
102
+ case 'ukr': return 'uk'
103
+ case 'uzb': return 'uz'
104
+ case 'vie': return 'vi'
105
+ }
106
+ }
107
+
108
+ async function search(query) {
109
+ init()
110
+ const { body: data } = await makeRequest(_signUrl(`https://www.musixmatch.com/ws/1.1/macro.search?app_id=community-app-v1.0&part=track_artist,track_lyrics_translation_status&guid=${sourceInfo.guid}&format=json&q=${encodeURIComponent(query)}&page_size=1`), {
111
+ method: 'GET'
112
+ })
113
+
114
+ return data.message.body.macro_result_list.track_list[0].track
115
+ }
116
+
117
+ async function loadLyrics(decodedTrack, language) {
118
+ const searchResults = await search(`${decodedTrack.title} ${decodedTrack.artist}`)
119
+
120
+ if (!searchResults) return null
121
+
122
+ const { body: data } = await makeRequest(_signUrl(`https://www.musixmatch.com/ws/1.1/track.lyrics.get?page_size=100&page=1&commontrack_id=${searchResults.commontrack_id}&format=json&app_id=community-app-v1.0&guid=${sourceInfo.guid}`), {
123
+ method: 'GET'
124
+ })
125
+
126
+ const lyricsEvents = data.message.body.lyrics.lyrics_body.split('\n').map((text) => {
127
+ return {
128
+ text
129
+ }
130
+ })
131
+
132
+ if (language && language !== searchResults.lyrics_language && searchResults.track_lyrics_translation_status.find((status) => _normalizeLanguage(status.to) === language)) {
133
+ const { body: data } = await makeRequest(_signUrl(`https://www.musixmatch.com/ws/1.1/crowd.track.translations.get?page_size=1&selected_language=${language}&track_id=${searchResults.track_id}&format=json&app_id=community-app-v1.0&guid=${sourceInfo.guid}`), {
134
+ method: 'GET'
135
+ })
136
+
137
+ lyricsEvents.forEach((text, i) => {
138
+ if (text.text === '') return;
139
+
140
+ data.message.body.translations_list.forEach((translation) => {
141
+ if (text.text && text.text.replace(/′/, '\'') === translation.translation.matched_line) {
142
+ lyricsEvents[i].text = translation.translation.description
143
+ }
144
+ })
145
+ })
146
+ } else {
147
+ language = searchResults.lyrics_language
148
+ }
149
+
150
+ return {
151
+ loadType: 'lyricsSingle',
152
+ data: {
153
+ name: language,
154
+ synced: false,
155
+ data: lyricsEvents,
156
+ rtl: false
157
+ }
158
+ }
159
+ }
160
+
161
+ export default {
162
+ init,
163
+ loadLyrics
164
+ }
src/sources/pandora.js ADDED
@@ -0,0 +1,919 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from '../../config.js'
2
+ import { debugLog, makeRequest, encodeTrack, http1makeRequest } from '../utils.js'
3
+ import searchWithDefault from './default.js'
4
+
5
+ let csrfToken = null
6
+ let authToken = null
7
+
8
+ async function init() {
9
+ debugLog('pandora', 5, { type: 1, message: 'Setting Pandora auth and CSRF token.' })
10
+
11
+ const { headers: headers } = await makeRequest('https://www.pandora.com', { method: 'HEAD' })
12
+ const csfr = headers['set-cookie']
13
+
14
+ if (!csfr[1]) return debugLog('pandora', 5, { type: 2, message: 'Failed to set CSRF token from Pandora.' })
15
+
16
+ csrfToken = { raw: csfr[1], parsed: /csrftoken=([a-f0-9]{16});/.exec(csfr[1])[1] }
17
+
18
+ const { body: token } = await makeRequest('https://www.pandora.com/api/v1/auth/anonymousLogin', {
19
+ headers: {
20
+ 'Cookie': csrfToken.raw,
21
+ 'Content-Type': 'application/json',
22
+ 'Accept': '*/*',
23
+ 'X-CsrfToken': csrfToken.parsed
24
+ },
25
+ method: 'POST'
26
+ })
27
+
28
+ if (token.errorCode === 0) return debugLog('pandora', 5, { type: 2, message: 'Failed to set auth token from Pandora.' })
29
+
30
+ authToken = token.authToken
31
+
32
+ debugLog('pandora', 5, { type: 1, message: 'Successfully set Pandora auth and CSRF token.' })
33
+ }
34
+
35
+ async function search(query) {
36
+ return new Promise(async (resolve) => {
37
+ debugLog('search', 4, { type: 1, sourceName: 'Pandora', query })
38
+
39
+ const body = {
40
+ query,
41
+ types: ['TR'],
42
+ listener: null,
43
+ start: 0,
44
+ count: config.options.maxResultsLength,
45
+ annotate: true,
46
+ searchTime: 0,
47
+ annotationRecipe: 'CLASS_OF_2019'
48
+ }
49
+
50
+ const { body: data } = await makeRequest('https://www.pandora.com/api/v3/sod/search', {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Content-Type': 'application/json',
54
+ 'Accept': '*/*',
55
+ 'Content-Length': JSON.stringify(body).length
56
+ },
57
+ body,
58
+ disableBodyCompression: true
59
+ })
60
+
61
+ if (data.results.length === 0) {
62
+ return {
63
+ loadType: 'empty',
64
+ data: {}
65
+ }
66
+ }
67
+
68
+ const tracks = []
69
+ let index = 0
70
+
71
+ let annotationKeys = Object.keys(data.annotations)
72
+
73
+ if (annotationKeys.length > config.options.maxResultsLength)
74
+ annotationKeys = annotationKeys.slice(0, config.options.maxResultsLength)
75
+
76
+ annotationKeys.forEach(async (key) => {
77
+ if (data.annotations[key].type === 'TR') {
78
+ const search = await searchWithDefault(`${data.annotations[key].name} ${data.annotations[key].artistName}`)
79
+
80
+ if (search.loadType === 'search') {
81
+ const track = {
82
+ identifier: search.data[0].info.identifier,
83
+ isSeekable: true,
84
+ author: data.annotations[key].artistName,
85
+ length: search.data[0].info.length,
86
+ isStream: false,
87
+ position: 0,
88
+ title: data.annotations[key].name,
89
+ uri: `https://www.pandora.com${data.annotations[key].shareableUrlPath}`,
90
+ artworkUrl: `https://content-images.p-cdn.com/${data.annotations[key].icon.artUrl}`,
91
+ isrc: data.annotations[key].isrc,
92
+ sourceName: 'pandora'
93
+ }
94
+
95
+ tracks.push({
96
+ encoded: encodeTrack(track),
97
+ info: track,
98
+ playlistInfo: {}
99
+ })
100
+ }
101
+ }
102
+
103
+ if (index !== data.results.length - 1) return index++
104
+
105
+ const new_tracks = []
106
+ annotationKeys.nForEach(async (key2) => {
107
+ await tracks.nForEach((track) => {
108
+ if (track.info.title !== data.annotations[key2].name || track.info.author !== data.annotations[key2].artistName) return false
109
+
110
+ new_tracks.push(track)
111
+
112
+ return true
113
+ })
114
+
115
+ if (new_tracks.length !== tracks.length) return false
116
+
117
+ debugLog('search', 4, { type: 2, sourceName: 'Pandora', tracksLen: new_tracks.length, query })
118
+
119
+ resolve({
120
+ loadType: 'search',
121
+ data: new_tracks
122
+ })
123
+
124
+ return true
125
+ })
126
+ })
127
+ })
128
+ }
129
+
130
+ async function loadFrom(query) {
131
+ return new Promise(async (resolve) => {
132
+ const type = /^(https:\/\/www\.pandora\.com\/)((playlist)|(station)|(podcast)|(artist))\/.+/.exec(query)
133
+
134
+ debugLog('loadtracks', 4, { type: 1, loadType: type[2], sourceName: 'Pandora', query })
135
+
136
+ if (!type) {
137
+ return resolve({
138
+ loadType: 'empty',
139
+ data: {}
140
+ })
141
+ }
142
+
143
+ if (!csrfToken) {
144
+ return resolve({
145
+ loadType: 'error',
146
+ data: {
147
+ message: 'Pandora not available in current country.',
148
+ severity: 'common',
149
+ cause: 'Pandora availability'
150
+ }
151
+ })
152
+ }
153
+
154
+ let lastPart = query.split('/')
155
+ lastPart = lastPart[lastPart.length - 1]
156
+
157
+ switch (type[2]) {
158
+ case 'artist': {
159
+ const { body: trackData } = await http1makeRequest('https://www.pandora.com/api/v4/catalog/annotateObjectsSimple', {
160
+ body: {
161
+ pandoraIds: [ lastPart ],
162
+ },
163
+ headers: {
164
+ 'Cookie': csrfToken.raw,
165
+ 'X-CsrfToken': csrfToken.parsed,
166
+ 'X-AuthToken': authToken,
167
+ 'Content-Type': 'application/json',
168
+ },
169
+ method: 'POST',
170
+ disableBodyCompression: true
171
+ })
172
+
173
+ const keysTrackData = Object.keys(trackData)
174
+
175
+ let trackType = null
176
+ switch (trackData[keysTrackData[0]] ? trackData[keysTrackData[0]].type : 'unknown') {
177
+ case 'TR': trackType = 'track'; break
178
+ case 'AL': trackType = 'album'; break
179
+ case 'AR': trackType = 'artist'; break
180
+ default: trackType = 'unknown'; break
181
+ }
182
+
183
+ if (keysTrackData.length === 0) {
184
+ debugLog('loadtracks', 4, { type: 3, loadType: trackType, sourceName: 'Pandora', query, message: 'No matches found.' })
185
+
186
+ return resolve({
187
+ loadType: 'empty',
188
+ data: {}
189
+ })
190
+ }
191
+
192
+ if (trackData.message) {
193
+ debugLog('loadtracks', 4, { type: 3, loadType: trackType, sourceName: 'Pandora', query, message: trackData.message })
194
+
195
+ return resolve({
196
+ loadType: 'error',
197
+ data: {
198
+ message: trackData.message,
199
+ severity: 'common',
200
+ cause: 'Unknown'
201
+ }
202
+ })
203
+ }
204
+
205
+ const trackId = trackData[keysTrackData[0]].pandoraId
206
+
207
+ switch (trackData[keysTrackData].type) {
208
+ case 'TR': {
209
+ const item = trackData[keysTrackData]
210
+
211
+ const search = await searchWithDefault(`${item.name} ${item.artistName}`)
212
+
213
+ if (search.loadType !== 'search')
214
+ return resolve(search)
215
+
216
+ const track = {
217
+ identifier: search.data[0].info.identifier,
218
+ isSeekable: true,
219
+ author: item.artistName,
220
+ length: search.data[0].info.length,
221
+ isStream: false,
222
+ position: 0,
223
+ title: item.name,
224
+ uri: `https://www.pandora.com${item.shareableUrlPath}`,
225
+ artworkUrl: `https://content-images.p-cdn.com/${item.icon.artUrl}`,
226
+ isrc: item.isrc,
227
+ sourceName: 'pandora'
228
+ }
229
+
230
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Pandora', track, query })
231
+
232
+ resolve({
233
+ loadType: 'track',
234
+ data: {
235
+ encoded: encodeTrack(track),
236
+ info: track,
237
+ playlistInfo: {}
238
+ }
239
+ })
240
+
241
+ break
242
+ }
243
+ case 'AL': {
244
+ const { body: data } = await http1makeRequest('https://www.pandora.com/api/v4/catalog/getDetails', {
245
+ body: {
246
+ pandoraId: trackId
247
+ },
248
+ headers: {
249
+ 'Cookie': csrfToken.raw,
250
+ 'X-CsrfToken': csrfToken.parsed,
251
+ 'X-AuthToken': authToken
252
+ },
253
+ method: 'POST',
254
+ disableBodyCompression: true
255
+ })
256
+
257
+ if (data.errors || typeof data !== 'object') {
258
+ const errorMessage = typeof data !== 'object' ? 'Unknown error' : data.errors.map((err) => `${err.message} (${err.extensions.code})`).join('; ')
259
+
260
+ debugLog('loadtracks', 4, { type: 3, loadType: 'album', sourceName: 'Pandora', query, message: errorMessage })
261
+
262
+ return resolve({
263
+ loadType: 'error',
264
+ data: {
265
+ message: errorMessage,
266
+ severity: 'common',
267
+ cause: 'Unknown'
268
+ }
269
+ })
270
+ }
271
+
272
+ const tracks = []
273
+ let index = 0
274
+
275
+ let trackKeys = Object.keys(data.annotations)
276
+
277
+ if (trackKeys.length > config.options.maxAlbumPlaylistLength)
278
+ trackKeys = trackKeys.slice(0, config.options.maxAlbumPlaylistLength)
279
+
280
+ trackKeys.forEach(async (key) => {
281
+ const search = await searchWithDefault(`${data.annotations[key].name} ${data.annotations[key].artistName}`)
282
+
283
+ if (search.loadType === 'search') {
284
+ const track = {
285
+ identifier: search.data[0].info.identifier,
286
+ isSeekable: true,
287
+ author: data.annotations[key].artistName,
288
+ length: search.data[0].info.length,
289
+ isStream: false,
290
+ position: 0,
291
+ title: data.annotations[key].name,
292
+ uri: `https://www.pandora.com${data.annotations[key].shareableUrlPath}`,
293
+ artworkUrl: `https://content-images.p-cdn.com/${data.annotations[key].icon.artUrl}`,
294
+ isrc: data.annotations[key].isrc,
295
+ sourceName: 'pandora'
296
+ }
297
+
298
+ tracks.push({
299
+ encoded: encodeTrack(track),
300
+ info: track,
301
+ playlistInfo: {}
302
+ })
303
+ }
304
+
305
+ if (index !== trackKeys.length - 1) return index++
306
+
307
+ if (tracks.length === 0) {
308
+ debugLog('loadtracks', 4, { type: 3, loadType: 'album', sourceName: 'Pandora', query, message: 'No matches found.' })
309
+
310
+ return resolve({
311
+ loadType: 'empty',
312
+ data: {}
313
+ })
314
+ }
315
+
316
+ const new_tracks = []
317
+ trackKeys.nForEach(async (key2) => {
318
+ await tracks.nForEach((track) => {
319
+ if (track.info.title !== data.annotations[key2].name || track.info.author !== data.annotations[key2].artistName) return false
320
+
321
+ new_tracks.push(track)
322
+
323
+ return true
324
+ })
325
+
326
+ if (new_tracks.length !== tracks.length) return false
327
+
328
+ debugLog('loadtracks', 4, { type: 2, loadType: 'album', sourceName: 'Pandora', playlistName: trackData[trackId].name })
329
+
330
+ resolve({
331
+ loadType: 'album',
332
+ data: {
333
+ info: {
334
+ name: trackData[trackId].name,
335
+ selectedTrack: 0,
336
+ },
337
+ pluginInfo: {},
338
+ tracks: new_tracks,
339
+ }
340
+ })
341
+
342
+ return true
343
+ })
344
+ })
345
+
346
+ break
347
+ }
348
+ case 'AR': {
349
+ const { body: data } = await http1makeRequest('https://www.pandora.com/api/v1/graphql/graphql', {
350
+ body: {
351
+ operationName: 'GetArtistDetailsWithCuratorsWeb',
352
+ query: 'query GetArtistDetailsWithCuratorsWeb($pandoraId: String!) {\n entity(id: $pandoraId) {\n ... on Artist {\n id\n type\n urlPath\n name\n trackCount\n albumCount\n bio\n canSeedStation\n stationListenerCount\n albumCount\n artistTracksId\n art {\n ...ArtFragment\n __typename\n }\n isMegastar\n headerArt {\n ...ArtFragment\n __typename\n }\n topTracksWithCollaborations {\n ...TrackFragment\n __typename\n }\n artistPlay {\n id\n __typename\n }\n events {\n externalId\n __typename\n }\n latestReleaseWithCollaborations {\n ...AlbumFragment\n __typename\n }\n topAlbumsWithCollaborations {\n ...AlbumFragment\n __typename\n }\n similarArtists {\n id\n name\n art {\n ...ArtFragment\n __typename\n }\n urlPath\n __typename\n }\n twitterHandle\n twitterUrl\n allArtistAlbums {\n totalItems\n __typename\n }\n curator {\n ...CurationFragment\n __typename\n }\n featured(types: [PL, AR, AL, TR, SF, PC, PE]) {\n ... on Playlist {\n ...PlaylistFragment\n __typename\n }\n ... on StationFactory {\n ...StationFactoryFragment\n __typename\n }\n ... on Artist {\n ...ArtistFragment\n __typename\n }\n ... on Album {\n ...AlbumFragment\n __typename\n }\n ... on Track {\n ...TrackFragment\n __typename\n }\n ... on Podcast {\n ...PodcastFragment\n __typename\n }\n ... on PodcastEpisode {\n ...PodcastEpisodeFragment\n __typename\n }\n __typename\n }\n __typename\n }\n __typename\n }\n}\n\nfragment ArtFragment on Art {\n artId\n dominantColor\n artUrl: url(size: WIDTH_500)\n}\n\nfragment TrackFragment on Track {\n pandoraId: id\n type\n name\n sortableName\n duration\n trackNumber\n explicitness\n hasRadio: canSeedStation\n shareableUrlPath: urlPath\n modificationtime: dateModified\n slugPlusPandoraId: slugPlusId\n artistId: artist {\n pandoraId: id\n __typename\n }\n artistName: artist {\n name\n __typename\n }\n albumId: album {\n pandoraId: id\n __typename\n }\n albumName: album {\n name\n __typename\n }\n album {\n urlPath\n __typename\n }\n icon: art {\n ...ArtFragment\n __typename\n }\n rightsInfo: rights {\n ...RightsFragment\n __typename\n }\n}\n\nfragment AlbumFragment on Album {\n pandoraId: id\n type\n name\n sortableName\n duration\n trackCount\n releaseDate\n explicitness\n isCompilation\n shareableUrlPath: urlPath\n modificationTime: dateModified\n slugPlusPandoraId: slugPlusId\n artistId: artist {\n pandoraId: id\n __typename\n }\n artistName: artist {\n name\n __typename\n }\n icon: art {\n ...ArtFragment\n __typename\n }\n artist {\n url\n __typename\n }\n rightsInfo: rights {\n ...RightsFragment\n __typename\n }\n}\n\nfragment CurationFragment on Curator {\n curatedStations {\n items {\n ...StationFactoryFragment\n __typename\n }\n __typename\n }\n playlists {\n items {\n ...PlaylistFragment\n __typename\n }\n __typename\n }\n}\n\nfragment PlaylistFragment on Playlist {\n pandoraId: id\n type\n name\n sortableName\n description\n duration\n totalTracks\n version\n isEditable\n linkedId\n linkedType: origin\n shareableUrlPath: urlPath\n modificationTime: dateModified\n unlocked: isUnlocked\n autogenForListener: isOfAnyOrigin(origins: [PERSONALIZED, SHARED])\n hasVoiceTrack: includesAny(types: [AM])\n listenerIdInfo: owner {\n listenerPandoraId: id\n displayName\n isMe\n __typename\n }\n icon: art {\n ...ArtFragment\n __typename\n }\n}\n\nfragment StationFactoryFragment on StationFactory {\n pandoraId: id\n type\n name\n sortableName\n hasTakeoverModes\n isHosted\n shareableUrlPath: urlPath\n modificationTime: dateModified\n seedId: seed {\n pandoraId: id\n __typename\n }\n seedType: seed {\n type\n __typename\n }\n icon: art {\n ...ArtFragment\n __typename\n }\n listenerCount\n}\n\nfragment ArtistFragment on Artist {\n pandoraId: id\n type\n name\n sortableName\n trackCount\n collaboration: isCollaboration\n megastar: isMegastar\n shareableUrlPath: urlPath\n modificationTime: dateModified\n hasRadio: canSeedStation\n icon: art {\n ...ArtFragment\n __typename\n }\n}\n\nfragment PodcastFragment on Podcast {\n pandoraId: id\n type\n name\n sortableName\n publisherName\n ordering: releaseType\n episodeCount: totalEpisodeCount\n shareableUrlPath: urlPath\n modificationTime: dateModified\n icon: art {\n ...ArtFragment\n __typename\n }\n rightsInfo: rights {\n ...RightsFragment\n __typename\n }\n}\n\nfragment PodcastEpisodeFragment on PodcastEpisode {\n pandoraId: id\n type\n name\n sortableName\n duration\n releaseDate\n explicitness\n shareableUrlPath: urlPath\n modificationTime: dateModified\n podcastId: podcast {\n pandoraId: id\n __typename\n }\n programName: podcast {\n name\n __typename\n }\n elapsedTime: playbackProgress {\n elapsedTime\n __typename\n }\n icon: art {\n ...ArtFragment\n __typename\n }\n rightsInfo: rights {\n ...RightsFragment\n __typename\n }\n}\n\nfragment RightsFragment on Rights {\n expirationTime\n hasInteractive\n hasRadioRights\n hasOffline\n}\n',
353
+ variables: {
354
+ pandoraId: trackId
355
+ }
356
+ },
357
+ headers: {
358
+ 'Cookie': csrfToken.raw,
359
+ 'X-CsrfToken': csrfToken.parsed,
360
+ 'X-AuthToken': authToken
361
+ },
362
+ method: 'POST',
363
+ disableBodyCompression: true
364
+ })
365
+
366
+ if (data.errors || typeof data !== 'object') {
367
+ const errorMessage = typeof data !== 'object' ? 'Unknown error' : data.errors.map((err) => `${err.message} (${err.extensions.code})`).join('; ')
368
+
369
+ debugLog('loadtracks', 4, { type: 3, loadType: 'artist', sourceName: 'Pandora', query, message: errorMessage })
370
+
371
+ return resolve({
372
+ loadType: 'error',
373
+ data: {
374
+ message: errorMessage,
375
+ severity: 'common',
376
+ cause: 'Unknown'
377
+ }
378
+ })
379
+ }
380
+
381
+ const tracks = []
382
+ let index = 0
383
+
384
+ let topTracks = data.data.entity.topTracksWithCollaborations
385
+
386
+ if (topTracks.length > config.options.maxAlbumPlaylistLength)
387
+ topTracks = topTracks.slice(0, config.options.maxAlbumPlaylistLength)
388
+
389
+ topTracks.forEach(async (pTrack) => {
390
+ const search = await searchWithDefault(`${pTrack.name} ${pTrack.artistName.name}`)
391
+
392
+ if (search.loadType === 'search') {
393
+ const track = {
394
+ identifier: search.data[0].info.identifier,
395
+ isSeekable: true,
396
+ author: pTrack.artistName.name,
397
+ length: search.data[0].info.length,
398
+ isStream: false,
399
+ position: 0,
400
+ title: pTrack.name,
401
+ uri: `https://www.pandora.com${pTrack.shareableUrlPath}`,
402
+ artworkUrl: pTrack.icon.artUrl,
403
+ isrc: null,
404
+ sourceName: 'pandora'
405
+ }
406
+
407
+ tracks.push({
408
+ encoded: encodeTrack(track),
409
+ info: track,
410
+ playlistInfo: {}
411
+ })
412
+ }
413
+
414
+ if (index !== topTracks.length - 1) return index++
415
+
416
+ if (tracks.length === 0) {
417
+ debugLog('loadtracks', 4, { type: 3, loadType: 'artist', sourceName: 'Pandora', query, message: 'No matches found.' })
418
+
419
+ return resolve({
420
+ loadType: 'empty',
421
+ data: {}
422
+ })
423
+ }
424
+
425
+ const new_tracks = []
426
+ topTracks.nForEach(async (pTrack2) => {
427
+ await tracks.nForEach((track) => {
428
+ if (track.info.title !== pTrack2.name || track.info.author !== pTrack2.artistName.name) return false
429
+
430
+ track.push(track)
431
+
432
+ return true
433
+ })
434
+
435
+ if (new_tracks.length !== tracks.length) return false
436
+
437
+ debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'Pandora', playlistName: data.data.entity.name })
438
+
439
+ resolve({
440
+ loadType: 'artist',
441
+ data: {
442
+ info: {
443
+ name: trackData[trackId].name,
444
+ artworkUrl: `https://content-images.p-cdn.com/${trackData[trackId].icon.artUrl}`,
445
+ },
446
+ pluginInfo: {},
447
+ tracks: new_tracks,
448
+ }
449
+ })
450
+
451
+ return true
452
+ })
453
+ })
454
+
455
+ break
456
+ }
457
+ }
458
+
459
+ break
460
+ }
461
+ case 'playlist': {
462
+ const body = {
463
+ request: {
464
+ pandoraId: lastPart,
465
+ playlistVersion: 0,
466
+ offset: 0,
467
+ limit: config.options.maxAlbumPlaylistLength,
468
+ annotationLimit: config.options.maxAlbumPlaylistLength,
469
+ allowedTypes: ['TR', 'AM'],
470
+ bypassPrivacyRules: true
471
+ }
472
+ }
473
+
474
+ const { body: data } = await makeRequest('https://www.pandora.com/api/v7/playlists/getTracks', {
475
+ method: 'POST',
476
+ headers: {
477
+ 'Cookie': csrfToken.raw,
478
+ 'Content-Type': 'application/json',
479
+ 'Accept': '*/*',
480
+ 'Content-Length': JSON.stringify(body).length,
481
+ 'X-CsrfToken': csrfToken.parsed,
482
+ 'X-AuthToken': authToken
483
+ },
484
+ body,
485
+ disableBodyCompression: true
486
+ })
487
+
488
+ const tracks = []
489
+ let index = 0
490
+
491
+ let keys = Object.keys(data.annotations).filter((key) => key.indexOf('TR:') !== -1)
492
+
493
+ if (keys.length > config.options.maxAlbumPlaylistLength)
494
+ keys = keys.slice(0, config.options.maxAlbumPlaylistLength)
495
+
496
+ keys.forEach(async (key) => {
497
+ const search = await searchWithDefault(`${data.annotations[key].name} ${data.annotations[key].artistName}`)
498
+
499
+ if (search.loadType === 'search') {
500
+ const track = {
501
+ identifier: search.data[0].info.identifier,
502
+ isSeekable: data.annotations[key].visible,
503
+ author: data.annotations[key].artistName,
504
+ length: search.data[0].info.length,
505
+ isStream: false,
506
+ position: 0,
507
+ title: data.annotations[key].name,
508
+ uri: search.data[0].info.uri,
509
+ artworkUrl: `https://content-images.p-cdn.com/${data.annotations[key].icon.artUrl}`,
510
+ isrc: data.annotations[key].isrc,
511
+ sourceName: 'pandora'
512
+ }
513
+
514
+ tracks.push({
515
+ encoded: encodeTrack(track),
516
+ info: track,
517
+ playlistInfo: {}
518
+ })
519
+ }
520
+
521
+ if (index !== keys.length - 1) return index++
522
+
523
+ if (tracks.length === 0) {
524
+ debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: 'Pandora', query, message: 'No matches found.' })
525
+
526
+ return resolve({
527
+ loadType: 'empty',
528
+ data: {}
529
+ })
530
+ }
531
+
532
+ const new_tracks = []
533
+ keys.nForEach(async (key2) => {
534
+ await tracks.nForEach((track) => {
535
+ if (track.info.title !== data.annotations[key2].name || track.info.author !== data.annotations[key2].artistName) return false
536
+
537
+ new_tracks.push(track)
538
+
539
+ return true
540
+ })
541
+
542
+ if (new_tracks.length !== tracks.length) return false
543
+
544
+ debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'Pandora', playlistName: data.name })
545
+
546
+ resolve({
547
+ loadType: 'playlist',
548
+ data: {
549
+ info: {
550
+ name: data.name,
551
+ selectedTrack: 0,
552
+ },
553
+ pluginInfo: {},
554
+ tracks: new_tracks,
555
+ }
556
+ })
557
+
558
+ return true
559
+ })
560
+ })
561
+
562
+ break
563
+ }
564
+ case 'station': {
565
+ const { body: stationData } = await http1makeRequest('https://www.pandora.com/api/v1/station/getStationDetails', {
566
+ body: {
567
+ stationId: lastPart
568
+ },
569
+ headers: {
570
+ 'Cookie': csrfToken.raw,
571
+ 'X-CsrfToken': csrfToken.parsed,
572
+ 'X-AuthToken': authToken
573
+ },
574
+ method: 'POST',
575
+ disableBodyCompression: true
576
+ })
577
+
578
+ if (stationData.length === 0) {
579
+ debugLog('loadtracks', 4, { type: 3, loadType: 'station', sourceName: 'Pandora', query, message: 'No matches found.' })
580
+
581
+ return resolve({
582
+ loadType: 'empty',
583
+ data: {}
584
+ })
585
+ }
586
+
587
+ if (stationData.message) {
588
+ debugLog('loadtracks', 4, { type: 3, loadType: 'station', sourceName: 'Pandora', query, message: stationData.message })
589
+
590
+ return resolve({
591
+ loadType: 'error',
592
+ data: {
593
+ message: stationData.message,
594
+ severity: 'common',
595
+ cause: 'Unknown'
596
+ }
597
+ })
598
+ }
599
+
600
+ const tracks = []
601
+ let index = 0
602
+
603
+ let seeds = stationData.seeds
604
+
605
+ if (seeds.length > config.options.maxAlbumPlaylistLength)
606
+ seeds = seeds.slice(0, config.options.maxAlbumPlaylistLength)
607
+
608
+ seeds.forEach(async (seed) => {
609
+ const search = await searchWithDefault(`${seed.song.songTitle} ${seed.song.artistSummary}`)
610
+
611
+ if (search.loadType === 'search') {
612
+ const track = {
613
+ identifier: search.data[0].info.identifier,
614
+ isSeekable: true,
615
+ author: seed.song.artistSummary,
616
+ length: search.data[0].info.length,
617
+ isStream: false,
618
+ position: 0,
619
+ title: seed.song.songTitle,
620
+ uri: seed.song.songDetailUrl,
621
+ artworkUrl: seed.art[seed.art.length - 1].url,
622
+ isrc: null,
623
+ sourceName: 'pandora'
624
+ }
625
+
626
+ tracks.push({
627
+ encoded: encodeTrack(track),
628
+ info: track,
629
+ playlistInfo: {}
630
+ })
631
+ }
632
+
633
+ if (index !== seeds.length - 1) return index++
634
+
635
+ if (tracks.length === 0) {
636
+ debugLog('loadtracks', 4, { type: 3, loadType: 'station', sourceName: 'Pandora', query, message: 'No matches found.' })
637
+
638
+ return resolve({
639
+ loadType: 'empty',
640
+ data: {}
641
+ })
642
+ }
643
+
644
+ const new_tracks = []
645
+ seeds.nForEach(async (seed2) => {
646
+ await tracks.nForEach((track) => {
647
+ if (track.info.title !== seed2.song.songTitle || track.info.author !== seed2.song.artistSummary) return false
648
+
649
+ new_tracks.push(track)
650
+
651
+ return true
652
+ })
653
+
654
+ if (new_tracks.length !== tracks.length) return false
655
+
656
+ debugLog('loadtracks', 4, { type: 2, loadType: 'station', sourceName: 'Pandora', playlistName: stationData.name })
657
+
658
+ resolve({
659
+ loadType: 'station',
660
+ data: {
661
+ info: {
662
+ name: stationData.name,
663
+ selectedTrack: 0,
664
+ },
665
+ pluginInfo: {},
666
+ tracks: new_tracks,
667
+ }
668
+ })
669
+
670
+ return true
671
+ })
672
+ })
673
+
674
+ break
675
+ }
676
+ case 'podcast': {
677
+ const { body: podcastData } = await http1makeRequest('https://www.pandora.com/api/v1/aesop/getDetails', {
678
+ body: {
679
+ catalogVersion: 4,
680
+ pandoraId: lastPart
681
+ },
682
+ headers: {
683
+ 'Cookie': csrfToken.raw,
684
+ 'X-CsrfToken': csrfToken.parsed,
685
+ 'X-AuthToken': authToken
686
+ },
687
+ method: 'POST',
688
+ disableBodyCompression: true
689
+ })
690
+
691
+ if (podcastData.length === 0) {
692
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: 'No matches found.' })
693
+
694
+ return resolve({
695
+ loadType: 'empty',
696
+ data: {}
697
+ })
698
+ }
699
+
700
+ if (podcastData.message) {
701
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: podcastData.message })
702
+
703
+ return resolve({
704
+ loadType: 'error',
705
+ data: {
706
+ message: podcastData.message,
707
+ severity: 'common',
708
+ cause: 'Unknown'
709
+ }
710
+ })
711
+ }
712
+
713
+ const tracks = []
714
+ let index = 0
715
+
716
+ switch (podcastData.details.podcastProgramDetails ? podcastData.details.podcastProgramDetails.type : podcastData.details.podcastEpisodeDetails.type) {
717
+ case 'PE': {
718
+ const podcastEpisode = podcastData.details.annotations[Object.keys(podcastData.details.annotations).find((key) => key === podcastData.details.podcastEpisodeDetails.pandoraId)]
719
+
720
+ const search = await searchWithDefault(`${podcastEpisode.name} ${podcastEpisode.programName}`)
721
+
722
+ if (search.loadType !== 'search')
723
+ return resolve(search)
724
+
725
+ const track = {
726
+ identifier: search.data[0].info.identifier,
727
+ isSeekable: true,
728
+ author: podcastEpisode.programName,
729
+ length: search.data[0].info.length,
730
+ isStream: false,
731
+ position: 0,
732
+ title: podcastEpisode.name,
733
+ uri: `https://www.pandora.com${podcastEpisode.shareableUrlPath}`,
734
+ artworkUrl: `https://content-images.p-cdn.com/${podcastEpisode.icon.artUrl}`,
735
+ isrc: null,
736
+ sourceName: 'pandora'
737
+ }
738
+
739
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Pandora', track, query })
740
+
741
+ resolve({
742
+ loadType: 'track',
743
+ data: {
744
+ encoded: encodeTrack(track),
745
+ info: track,
746
+ playlistInfo: {}
747
+ }
748
+ })
749
+
750
+ break
751
+ }
752
+ case 'PC': {
753
+ const { body: allEpisodesIdsData } = await http1makeRequest('https://www.pandora.com/api/v1/aesop/getAllEpisodesByPodcastProgram', {
754
+ body: {
755
+ catalogVersion: 4,
756
+ pandoraId: lastPart
757
+ },
758
+ headers: {
759
+ 'Cookie': csrfToken.raw,
760
+ 'X-CsrfToken': csrfToken.parsed,
761
+ 'X-AuthToken': authToken
762
+ },
763
+ method: 'POST',
764
+ disableBodyCompression: true
765
+ })
766
+
767
+ if (allEpisodesIdsData.length === 0) {
768
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: 'No matches found.' })
769
+
770
+ return resolve({
771
+ loadType: 'empty',
772
+ data: {}
773
+ })
774
+ }
775
+
776
+ if (allEpisodesIdsData.message) {
777
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: allEpisodesIdsData.message })
778
+
779
+ return resolve({
780
+ loadType: 'error',
781
+ data: {
782
+ message: allEpisodesIdsData.message,
783
+ severity: 'common',
784
+ cause: 'Unknown'
785
+ }
786
+ })
787
+ }
788
+
789
+ let allEpisodesIds = []
790
+ allEpisodesIdsData.episodes.episodesWithLabel.forEach((yearInfo) => {
791
+ allEpisodesIds.push(...yearInfo.episodes)
792
+ })
793
+
794
+ if (allEpisodesIds.length > config.options.maxAlbumPlaylistLength)
795
+ allEpisodesIds = allEpisodesIds.slice(0, config.options.maxAlbumPlaylistLength)
796
+
797
+ const { body: allEpisodesData } = await http1makeRequest('https://www.pandora.com/api/v1/aesop/annotateObjects', {
798
+ body: {
799
+ catalogVersion: 4,
800
+ pandoraIds: allEpisodesIds
801
+ },
802
+ headers: {
803
+ 'Cookie': csrfToken.raw,
804
+ 'X-CsrfToken': csrfToken.parsed,
805
+ 'X-AuthToken': authToken
806
+ },
807
+ method: 'POST',
808
+ disableBodyCompression: true
809
+ })
810
+
811
+ if (allEpisodesData.length === 0) {
812
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: 'No matches found.' })
813
+
814
+ return resolve({
815
+ loadType: 'empty',
816
+ data: {}
817
+ })
818
+ }
819
+
820
+ if (allEpisodesData.message) {
821
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: allEpisodesData.message })
822
+
823
+ return resolve({
824
+ loadType: 'error',
825
+ data: {
826
+ message: allEpisodesData.message,
827
+ severity: 'common',
828
+ cause: 'Unknown'
829
+ }
830
+ })
831
+ }
832
+
833
+ let episodes = Object.keys(allEpisodesData.annotations)
834
+
835
+ episodes.forEach(async (episode) => {
836
+ episode = allEpisodesData.annotations[episode]
837
+
838
+ const search = await searchWithDefault(`${episode.name} ${episode.programName}`)
839
+
840
+ if (search.loadType === 'search') {
841
+ const track = {
842
+ identifier: search.data[0].info.identifier,
843
+ isSeekable: true,
844
+ author: episode.programName,
845
+ length: search.data[0].info.length,
846
+ isStream: false,
847
+ position: 0,
848
+ title: episode.name,
849
+ uri: `https://www.pandora.com${episode.shareableUrlPath}`,
850
+ artworkUrl: `https://content-images.p-cdn.com/${episode.icon.artUrl}`,
851
+ isrc: null,
852
+ sourceName: 'pandora'
853
+ }
854
+
855
+ tracks.push({
856
+ encoded: encodeTrack(track),
857
+ info: track,
858
+ playlistInfo: {}
859
+ })
860
+ }
861
+
862
+ if (index !== episodes.length - 1) return index++
863
+
864
+ if (tracks.length === 0) {
865
+ debugLog('loadtracks', 4, { type: 3, loadType: 'podcast', sourceName: 'Pandora', query, message: 'No matches found.' })
866
+
867
+ return resolve({
868
+ loadType: 'empty',
869
+ data: {}
870
+ })
871
+ }
872
+
873
+ const new_tracks = []
874
+ episodes.nForEach(async (episode2) => {
875
+ if (typeof episode2 !== 'object') episode2 = allEpisodesData.annotations[episode2]
876
+
877
+ await tracks.nForEach((track) => {
878
+ if (track.info.title !== episode2.name || track.info.author !== episode2.programName) return false
879
+
880
+ new_tracks.push(track)
881
+
882
+ return true
883
+ })
884
+
885
+ if (new_tracks.length !== tracks.length) return false
886
+
887
+ const podcastName = podcastData.details.annotations[Object.keys(podcastData.details.annotations).find((key) => key === podcastData.details.podcastProgramDetails.pandoraId)].name
888
+
889
+ debugLog('loadtracks', 4, { type: 2, loadType: 'podcast', sourceName: 'Pandora', playlistName: podcastName })
890
+
891
+ resolve({
892
+ loadType: 'podcast',
893
+ data: {
894
+ info: {
895
+ name: podcastName,
896
+ selectedTrack: 0,
897
+ },
898
+ pluginInfo: {},
899
+ tracks: new_tracks,
900
+ }
901
+ })
902
+
903
+ return true
904
+ })
905
+ })
906
+
907
+ break
908
+ }
909
+ }
910
+ }
911
+ }
912
+ })
913
+ }
914
+
915
+ export default {
916
+ init,
917
+ search,
918
+ loadFrom
919
+ }
src/sources/soundcloud.js ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PassThrough } from 'node:stream'
2
+
3
+ import config from '../../config.js'
4
+ import { debugLog, encodeTrack, http1makeRequest, loadHLS } from '../utils.js'
5
+ import searchWithDefault from './default.js'
6
+ import sources from '../sources.js'
7
+
8
+ const sourceInfo = {
9
+ clientId: null
10
+ }
11
+
12
+ async function init() {
13
+ if (config.search.sources.soundcloud.clientId !== 'AUTOMATIC') {
14
+ sourceInfo.clientId = config.search.sources.soundcloud.clientId
15
+
16
+ return;
17
+ }
18
+
19
+ debugLog('soundcloud', 5, { type: 1, message: 'clientId not provided. Fetching clientId...' })
20
+
21
+ const { body: mainpage } = await http1makeRequest('https://soundcloud.com', {
22
+ method: 'GET'
23
+ }).catch(() => {
24
+ debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
25
+ })
26
+
27
+ const assetId = mainpage.match(/https:\/\/a-v2.sndcdn.com\/assets\/([a-zA-Z0-9-]+).js/gs)[5]
28
+
29
+ const { body: data } = await http1makeRequest(assetId, {
30
+ method: 'GET'
31
+ }).catch(() => {
32
+ debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
33
+ })
34
+
35
+ const clientId = data.match(/client_id=([a-zA-Z0-9]{32})/)[1]
36
+
37
+ if (!clientId) {
38
+ debugLog('soundcloud', 5, { type: 2, message: 'Failed to fetch clientId.' })
39
+
40
+ return;
41
+ }
42
+
43
+ sourceInfo.clientId = clientId
44
+
45
+ debugLog('soundcloud', 5, { type: 1, message: 'Successfully fetched clientId.' })
46
+ }
47
+
48
+ async function loadFrom(url) {
49
+ let req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=${encodeURI(url)}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
50
+
51
+ if (req.error || req.statusCode !== 200) {
52
+ const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
53
+
54
+ debugLog('loadtracks', 4, { type: 2, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: errorMessage })
55
+
56
+ return {
57
+ loadType: 'error',
58
+ data: {
59
+ message: errorMessage,
60
+ severity: 'fault',
61
+ cause: 'Unknown'
62
+ }
63
+ }
64
+ }
65
+
66
+ const body = req.body
67
+
68
+ if (typeof body !== 'object') {
69
+ debugLog('loadtracks', 4, { type: 3, loadType: 'unknown', sourceName: 'Soundcloud', query: url, message: 'Invalid response from SoundCloud.' })
70
+
71
+ return {
72
+ loadType: 'error',
73
+ data: {
74
+ message: 'Invalid response from SoundCloud.',
75
+ severity: 'common',
76
+ cause: 'Unknown'
77
+ }
78
+ }
79
+ }
80
+
81
+ debugLog('loadtracks', 4, { type: 1, loadType: body.kind || 'unknown', sourceName: 'SoundCloud', query: url })
82
+
83
+ if (Object.keys(body).length === 0) {
84
+ debugLog('loadtracks', 4, { type: 3, loadType: body.kind || 'unknown', sourceName: 'Soundcloud', query: url, message: 'No matches found.' })
85
+
86
+ return {
87
+ loadType: 'empty',
88
+ data: {}
89
+ }
90
+ }
91
+
92
+ switch (body.kind) {
93
+ case 'track': {
94
+ const track = {
95
+ identifier: body.id.toString(),
96
+ isSeekable: true,
97
+ author: body.user.username,
98
+ length: body.duration,
99
+ isStream: false,
100
+ position: 0,
101
+ title: body.title,
102
+ uri: body.permalink_url,
103
+ artworkUrl: body.artwork_url,
104
+ isrc: body.publisher_metadata ? body.publisher_metadata.isrc : null,
105
+ sourceName: 'soundcloud'
106
+ }
107
+
108
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'SoundCloud', track, query: url })
109
+
110
+ return {
111
+ loadType: 'track',
112
+ data: {
113
+ encoded: encodeTrack(track),
114
+ info: track,
115
+ playlistInfo: {}
116
+ }
117
+ }
118
+ }
119
+ case 'playlist': {
120
+ const tracks = []
121
+ const notLoaded = []
122
+
123
+ if (body.tracks.length > config.options.maxAlbumPlaylistLength)
124
+ data.tracks = body.tracks.slice(0, config.options.maxAlbumPlaylistLength)
125
+
126
+ body.tracks.forEach((item) => {
127
+ if (!item.title) {
128
+ notLoaded.push(item.id.toString())
129
+
130
+ return;
131
+ }
132
+
133
+ const track = {
134
+ identifier: item.id.toString(),
135
+ isSeekable: true,
136
+ author: item.user.username,
137
+ length: item.duration,
138
+ isStream: false,
139
+ position: 0,
140
+ title: item.title,
141
+ uri: item.permalink_url,
142
+ artworkUrl: item.artwork_url,
143
+ isrc: item.publisher_metadata?.isrc,
144
+ sourceName: 'soundcloud'
145
+ }
146
+
147
+ tracks.push({
148
+ encoded: encodeTrack(track),
149
+ info: track,
150
+ playlistInfo: {}
151
+ })
152
+ })
153
+
154
+ if (notLoaded.length) {
155
+ let stop = false
156
+
157
+ while ((notLoaded.length && !stop) && (tracks.length > config.options.maxAlbumPlaylistLength)) {
158
+ const notLoadedLimited = notLoaded.slice(0, 50)
159
+ data = await http1makeRequest(`https://api-v2.soundcloud.com/tracks?ids=${notLoadedLimited.join('%2C')}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
160
+ data = data.body
161
+
162
+ data.forEach((item) => {
163
+ const track = {
164
+ identifier: item.id.toString(),
165
+ isSeekable: true,
166
+ author: item.user.username,
167
+ length: item.duration,
168
+ isStream: false,
169
+ position: 0,
170
+ title: item.title,
171
+ uri: item.permalink_url,
172
+ artworkUrl: item.artwork_url,
173
+ isrc: item.publisher_metadata ? item.publisher_metadata.isrc : null,
174
+ sourceName: 'soundcloud'
175
+ }
176
+
177
+ tracks.push({
178
+ encoded: encodeTrack(track),
179
+ info: track,
180
+ playlistInfo: {}
181
+ })
182
+ })
183
+
184
+ notLoaded.splice(0, 50)
185
+
186
+ if (notLoaded.length === 0)
187
+ stop = true
188
+ }
189
+ }
190
+
191
+ debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'SoundCloud', playlistName: data.title })
192
+
193
+ return {
194
+ loadType: 'playlist',
195
+ data: {
196
+ info: {
197
+ name: data.title,
198
+ selectedTrack: 0,
199
+ },
200
+ pluginInfo: {},
201
+ tracks,
202
+ }
203
+ }
204
+ }
205
+ case 'user': {
206
+ debugLog('loadtracks', 4, { type: 2, loadType: 'artist', sourceName: 'SoundCloud', playlistName: data.full_name })
207
+
208
+ return {
209
+ loadType: 'empty',
210
+ data: {}
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ async function search(query, shouldLog) {
217
+ if (shouldLog) debugLog('search', 4, { type: 1, sourceName: 'SoundCloud', query })
218
+
219
+ const req = await http1makeRequest(`https://api-v2.soundcloud.com/search?q=${encodeURI(query)}&variant_ids=&facet=model&user_id=992000-167630-994991-450103&client_id=${sourceInfo.clientId}&limit=${config.options.maxResultsLength}&offset=0&linked_partitioning=1&app_version=1679652891&app_locale=en`, { method: 'GET' })
220
+ const body = req.body
221
+
222
+ if (req.error || req.statusCode !== 200) {
223
+ const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
224
+
225
+ debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: errorMessage })
226
+
227
+ return {
228
+ exception: {
229
+ message: errorMessage,
230
+ severity: 'fault',
231
+ cause: 'Unknown'
232
+ }
233
+ }
234
+ }
235
+
236
+ if (body.total_results === 0) {
237
+ debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', query, message: 'No matches found.' })
238
+
239
+ return {
240
+ loadType: 'empty',
241
+ data: {}
242
+ }
243
+ }
244
+
245
+ const tracks = []
246
+
247
+ if (body.collection.length > config.options.maxSearchResults)
248
+ body.collection = body.collection.filter((item, i) => i < config.options.maxSearchResults || item.kind === 'track')
249
+
250
+ body.collection.forEach((item) => {
251
+ if (item.kind !== 'track') return;
252
+
253
+ const track = {
254
+ identifier: item.id.toString(),
255
+ isSeekable: true,
256
+ author: item.user.username,
257
+ length: item.duration,
258
+ isStream: false,
259
+ position: 0,
260
+ title: item.title,
261
+ uri: item.uri,
262
+ artworkUrl: item.artwork_url,
263
+ isrc: null,
264
+ sourceName: 'soundcloud'
265
+ }
266
+
267
+ tracks.push({
268
+ encoded: encodeTrack(track),
269
+ info: track,
270
+ pluginInfo: {}
271
+ })
272
+ })
273
+
274
+ if (shouldLog)
275
+ debugLog('search', 4, { type: 2, sourceName: 'SoundCloud', tracksLen: tracks.length, query })
276
+
277
+ return {
278
+ loadType: 'search',
279
+ data: tracks
280
+ }
281
+ }
282
+
283
+ async function retrieveStream(identifier, title) {
284
+ const req = await http1makeRequest(`https://api-v2.soundcloud.com/resolve?url=https://api.soundcloud.com/tracks/${identifier}&client_id=${sourceInfo.clientId}`, { method: 'GET' })
285
+ const body = req.body
286
+
287
+ if (req.error || req.statusCode !== 200) {
288
+ const errorMessage = req.error ? req.error.message : `SoundCloud returned invalid status code: ${req.statusCode}`
289
+
290
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: errorMessage })
291
+
292
+ return {
293
+ exception: {
294
+ message: errorMessage,
295
+ severity: 'fault',
296
+ cause: 'Unknown'
297
+ }
298
+ }
299
+ }
300
+
301
+ if (body.errors) {
302
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: body.errors[0].error_message })
303
+
304
+ return {
305
+ exception: {
306
+ message: body.errors[0].error_message,
307
+ severity: 'fault',
308
+ cause: 'Unknown'
309
+ }
310
+ }
311
+ }
312
+
313
+ const oggOpus = body.media.transcodings.find((transcoding) => transcoding.format.mime_type === 'audio/ogg; codecs="opus"')
314
+ const transcoding = oggOpus || body.media.transcodings[0]
315
+
316
+ if (transcoding.snipped && config.search.sources.soundcloud.fallbackIfSnipped) {
317
+ debugLog('retrieveStream', 4, { type: 2, sourceName: 'SoundCloud', query: title, message: `Track is snipped, falling back to: ${config.search.fallbackSearchSource}.` })
318
+
319
+ const search = await searchWithDefault(title, true)
320
+
321
+ if (search.loadType === 'search') {
322
+ const urlInfo = await sources.getTrackURL(search.data[0].info)
323
+
324
+ return {
325
+ url: urlInfo.url,
326
+ protocol: urlInfo.protocol,
327
+ format: urlInfo.format,
328
+ additionalData: true
329
+ }
330
+ }
331
+ }
332
+
333
+ return {
334
+ url: `${transcoding.url}?client_id=${sourceInfo.clientId}`,
335
+ protocol: transcoding.format.protocol,
336
+ format: oggOpus ? 'ogg/opus' : 'arbitrary'
337
+ }
338
+ }
339
+
340
+ async function loadHLSStream(url) {
341
+ const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })
342
+
343
+ const stream = new PassThrough()
344
+ await loadHLS(streamHlsRedirect.body.url, stream)
345
+
346
+ return stream
347
+ }
348
+
349
+ async function loadFilters(url, protocol) {
350
+ if (protocol === 'hls') {
351
+ const streamHlsRedirect = await http1makeRequest(url, { method: 'GET' })
352
+
353
+ return streamHlsRedirect.body.url
354
+ } else {
355
+ return url
356
+ }
357
+ }
358
+
359
+ export default {
360
+ init,
361
+ loadFrom,
362
+ search,
363
+ retrieveStream,
364
+ loadHLSStream,
365
+ loadFilters
366
+ }
src/sources/spotify.js ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import crypto from 'node:crypto'
2
+
3
+ import config from '../../config.js'
4
+ import { debugLog, makeRequest, encodeTrack, http1makeRequest } from '../utils.js'
5
+ import searchWithDefault from './default.js'
6
+
7
+ let playerInfo = {}
8
+
9
+ async function init() {
10
+ debugLog('spotify', 5, { type: 1, message: 'Fetching token...' })
11
+
12
+ const { body: token } = await makeRequest('https://open.spotify.com/get_access_token', {
13
+ headers: {
14
+ ...(config.search.sources.spotify.sp_dc !== 'DISABLED' ? { Cookie: `sp_dc=${config.search.sources.spotify.sp_dc}` } : {})
15
+ },
16
+ method: 'GET'
17
+ })
18
+
19
+ if (typeof token !== 'object') {
20
+ debugLog('spotify', 5, { type: 2, message: 'Failed to fetch Spotify token.' })
21
+
22
+ return;
23
+ }
24
+
25
+ const { body: data } = await http1makeRequest(`https://clienttoken.spotify.com/v1/clienttoken`, {
26
+ body: {
27
+ client_data: {
28
+ client_version: '1.2.9.2269.g2fe25d39',
29
+ client_id: token.clientId,
30
+ js_sdk_data: {
31
+ device_brand: 'unknown',
32
+ device_model: 'unknown',
33
+ os: 'linux',
34
+ os_version: 'unknown',
35
+ device_id: crypto.randomUUID(),
36
+ device_type: 'computer'
37
+ }
38
+ }
39
+ },
40
+ headers: {
41
+ 'Accept': 'application/json'
42
+ },
43
+ method: 'POST',
44
+ disableBodyCompression: true
45
+ })
46
+
47
+ if (typeof data !== 'object') {
48
+ debugLog('spotify', 5, { type: 2, message: 'Failed to fetch client token.' })
49
+
50
+ return;
51
+ }
52
+
53
+ if (data.response_type !== 'RESPONSE_GRANTED_TOKEN_RESPONSE') {
54
+ debugLog('spotify', 5, { type: 2, message: 'Failed to fetch client token.' })
55
+
56
+ return;
57
+ }
58
+
59
+ playerInfo = {
60
+ accessToken: token.accessToken,
61
+ clientToken: data.granted_token.token
62
+ }
63
+
64
+ debugLog('spotify', 5, { type: 1, message: 'Successfully fetched token.' })
65
+ }
66
+
67
+ async function search(query) {
68
+ return new Promise(async (resolve) => {
69
+ debugLog('search', 4, { type: 1, sourceName: 'Spotify', query })
70
+
71
+ const limit = config.options.maxResultsLength >= 50 ? 50 : config.options.maxResultsLength
72
+
73
+ const { body: data } = await makeRequest(`https://api.spotify.com/v1/search?q=${encodeURI(query)}&type=track&limit=${limit}&market=${config.search.sources.spotify.market}`, {
74
+ method: 'GET',
75
+ headers: {
76
+ Authorization: `Bearer ${playerInfo.accessToken}`,
77
+ 'client-token': playerInfo.clientToken,
78
+ 'accept': 'application/json'
79
+ }
80
+ })
81
+
82
+ if (data.tracks.total === 0) {
83
+ debugLog('search', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' })
84
+
85
+ return resolve({
86
+ loadType: 'empty',
87
+ data: {}
88
+ })
89
+ }
90
+
91
+ const tracks = []
92
+
93
+ data.tracks.items.forEach(async (items) => {
94
+ const track = {
95
+ identifier: items.id,
96
+ isSeekable: true,
97
+ author: items.artists.map((artist) => artist.name).join(', '),
98
+ length: items.duration_ms,
99
+ isStream: false,
100
+ position: 0,
101
+ title: items.name,
102
+ uri: items.href,
103
+ artworkUrl: items.album.images[0].url,
104
+ isrc: items.external_ids.isrc,
105
+ sourceName: 'spotify'
106
+ }
107
+
108
+ tracks.push({
109
+ encoded: encodeTrack(track),
110
+ info: track,
111
+ pluginInfo: {}
112
+ })
113
+ })
114
+
115
+ if (tracks.length === 0) {
116
+ debugLog('search', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' })
117
+
118
+ return resolve({
119
+ loadType: 'empty',
120
+ data: {}
121
+ })
122
+ }
123
+
124
+ debugLog('search', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', tracksLen: tracks.length, query })
125
+
126
+ return resolve({
127
+ loadType: 'search',
128
+ data: tracks
129
+ })
130
+ })
131
+ }
132
+
133
+ async function loadFrom(query, type) {
134
+ return new Promise(async (resolve) => {
135
+ let endpoint
136
+
137
+ switch (type[1]) {
138
+ case 'track': {
139
+ endpoint = `/tracks/${type[2]}?limit=${config.options.maxResultsLength}`
140
+
141
+ break
142
+ }
143
+ case 'playlist': {
144
+ endpoint = `/playlists/${type[2]}`
145
+
146
+ break
147
+ }
148
+ case 'album': {
149
+ endpoint = `/albums/${type[2]}?limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}`
150
+
151
+ break
152
+ }
153
+ case 'episode': {
154
+ endpoint = `/episodes/${type[2]}?market=${config.search.sources.spotify.market}&limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}`
155
+
156
+ break
157
+ }
158
+ case 'show': {
159
+ endpoint = `/shows/${type[2]}?market=${config.search.sources.spotify.market}&limit=${config.options.maxAlbumPlaylistLength < 100 ? config.options.maxAlbumPlaylistLength : 100}`
160
+
161
+ break
162
+ }
163
+ default: {
164
+ debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' })
165
+
166
+ return resolve({
167
+ loadType: 'empty',
168
+ data: {}
169
+ })
170
+ }
171
+ }
172
+
173
+ debugLog('loadtracks', 4, { type: 1, loadType: type[1], sourceName: 'Spotify', query })
174
+
175
+ let { body: data } = await makeRequest(`https://api.spotify.com/v1${endpoint}`, {
176
+ method: 'GET',
177
+ headers: {
178
+ Authorization: `Bearer ${playerInfo.accessToken}`
179
+ }
180
+ })
181
+
182
+ if (data.error) {
183
+ if (data.error.status === 401) {
184
+ await init()
185
+
186
+ data = await makeRequest(`https://api.spotify.com/v1${endpoint}`, {
187
+ method: 'GET',
188
+ headers: {
189
+ Authorization: `Bearer ${playerInfo.accessToken}`
190
+ }
191
+ })
192
+ data = data.body
193
+ }
194
+
195
+ if (data.error?.status === 400) {
196
+ debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' })
197
+
198
+ return resolve({
199
+ loadType: 'empty',
200
+ data: {}
201
+ })
202
+ }
203
+
204
+ if (data.error?.message === 'Invalid playlist Id') {
205
+ debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: 'No matches found.' })
206
+
207
+ return resolve({
208
+ loadType: 'empty',
209
+ data: {}
210
+ })
211
+ }
212
+
213
+ if (data.error) {
214
+ debugLog('loadtracks', 4, { type: 3, loadType: type[1], sourceName: 'Spotify', query, message: data.error.message })
215
+
216
+ return resolve({
217
+ loadType: 'error',
218
+ data: {
219
+ message: data.error.message,
220
+ severity: 'fault',
221
+ cause: 'Unknown'
222
+ }
223
+ })
224
+ }
225
+ }
226
+
227
+ switch (type[1]) {
228
+ case 'track': {
229
+ const track = {
230
+ identifier: data.id,
231
+ isSeekable: true,
232
+ author: data.artists[0].name,
233
+ length: data.duration_ms,
234
+ isStream: false,
235
+ position: 0,
236
+ title: data.name,
237
+ uri: data.external_urls.spotify,
238
+ artworkUrl: data.album.images[0].url,
239
+ isrc: data.external_ids?.isrc || null,
240
+ sourceName: 'spotify'
241
+ }
242
+
243
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', track, query })
244
+
245
+ return resolve({
246
+ loadType: 'track',
247
+ data: {
248
+ encoded: encodeTrack(track),
249
+ info: track,
250
+ pluginInfo: {}
251
+ }
252
+ })
253
+ }
254
+ case 'episode': {
255
+ const track = {
256
+ identifier: data.id,
257
+ isSeekable: true,
258
+ author: data.show.publisher,
259
+ length: data.duration_ms,
260
+ isStream: false,
261
+ position: 0,
262
+ title: data.name,
263
+ uri: data.external_urls.spotify,
264
+ artworkUrl: data.images[0].url,
265
+ isrc: data.external_ids?.isrc || null,
266
+ sourceName: 'spotify'
267
+ }
268
+
269
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'Spotify', track, query })
270
+
271
+ return resolve({
272
+ loadType: 'track',
273
+ data: {
274
+ encoded: encodeTrack(track),
275
+ info: track,
276
+ pluginInfo: {}
277
+ }
278
+ })
279
+ }
280
+ case 'playlist':
281
+ case 'album': {
282
+ const tracks = []
283
+ let index = 0
284
+
285
+ if (data.tracks.total > config.options.maxAlbumPlaylistLength)
286
+ data.tracks.total = config.options.maxAlbumPlaylistLength
287
+
288
+ const fragments = []
289
+ const fragmentLengths = []
290
+
291
+ for (let i = data.tracks.items.length; i != data.tracks.total;) {
292
+ const requestLimit = data.tracks.total - i > 100 ? 100 : data.tracks.total - i
293
+
294
+ fragmentLengths.push(requestLimit)
295
+ i += requestLimit
296
+ }
297
+
298
+ fragmentLengths.forEach(async (limit, i) => {
299
+ if (fragmentLengths.length !== 0) {
300
+ let url = `https://api.spotify.com/v1${endpoint}/tracks?offset=${(i + 1) * 100}&limit=${limit}`
301
+
302
+ const { body: data2 } = await makeRequest(url, {
303
+ method: 'GET',
304
+ headers: {
305
+ Authorization: `Bearer ${playerInfo.accessToken}`
306
+ }
307
+ })
308
+
309
+ fragments[i] = data2.items
310
+
311
+ if (index === fragmentLengths.length - 1)
312
+ data.tracks.items = data.tracks.items.concat(...fragments)
313
+ }
314
+
315
+ if (index === fragmentLengths.length - 1) {
316
+ data.tracks.items.forEach(async (item) => {
317
+ item = type[1] === 'playlist' ? item.track : item
318
+
319
+ const track = {
320
+ identifier: item.id || 'unknown',
321
+ isSeekable: true,
322
+ author: item.artists[0].name,
323
+ length: item.duration_ms,
324
+ isStream: false,
325
+ position: 0,
326
+ title: item.name,
327
+ uri: item.external_urls.spotify,
328
+ artworkUrl: item.album ? item.album.images[0]?.url : null,
329
+ isrc: item.external_ids?.isrc || null,
330
+ sourceName: 'spotify'
331
+ }
332
+
333
+ tracks.push({
334
+ encoded: encodeTrack(track),
335
+ info: track,
336
+ pluginInfo: {}
337
+ })
338
+ })
339
+
340
+ if (tracks.length === 0) {
341
+ debugLog('loadtracks', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' })
342
+
343
+ return resolve({
344
+ loadType: 'empty',
345
+ data: {}
346
+ })
347
+ }
348
+
349
+ debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: 'Spotify', playlistName: data.name })
350
+
351
+ return resolve({
352
+ loadType: type[1],
353
+ data: {
354
+ info: {
355
+ name: data.name,
356
+ selectedTrack: 0
357
+ },
358
+ pluginInfo: {},
359
+ tracks
360
+ }
361
+ })
362
+ }
363
+
364
+ index++
365
+ })
366
+
367
+ break
368
+ }
369
+ case 'show': {
370
+ const tracks = []
371
+
372
+ data.episodes.items.forEach(async (episode) => {
373
+ const track = {
374
+ identifier: episode.id,
375
+ isSeekable: true,
376
+ author: data.publisher,
377
+ length: episode.duration_ms,
378
+ isStream: false,
379
+ position: 0,
380
+ title: episode.name,
381
+ uri: episode.external_urls.spotify,
382
+ artworkUrl: episode.images[0].url,
383
+ isrc: episode.external_ids?.isrc || null,
384
+ sourceName: 'spotify'
385
+ }
386
+
387
+ tracks.push({
388
+ encoded: encodeTrack(track),
389
+ info: track,
390
+ pluginInfo: {}
391
+ })
392
+ })
393
+
394
+ if (tracks.length === 0) {
395
+ debugLog('loadtracks', 4, { type: 3, sourceName: 'Spotify', query, message: 'No matches found.' })
396
+
397
+ return resolve({
398
+ loadType: 'empty',
399
+ data: {}
400
+ })
401
+ }
402
+
403
+ debugLog('loadtracks', 4, { type: 2, loadType: 'show', sourceName: 'Spotify', playlistName: data.name })
404
+
405
+ return resolve({
406
+ loadType: 'show',
407
+ data: {
408
+ info: {
409
+ name: data.name,
410
+ selectedTrack: 0
411
+ },
412
+ pluginInfo: {},
413
+ tracks
414
+ }
415
+ })
416
+ }
417
+ }
418
+ })
419
+ }
420
+
421
+ async function loadLyrics(decodedTrack, _language) {
422
+ const identifier = /^https?:\/\/(?:open\.spotify\.com\/|spotify:)(?:[^?]+)?(track|playlist|artist|episode|show|album)[/:]([A-Za-z0-9]+)/.exec(decodedTrack.uri)
423
+
424
+ if (config.search.sources.spotify.sp_dc === 'DISABLED') {
425
+ debugLog('loadlyrics', 4, { type: 3, sourceName: 'Spotify', message: 'Spotify lyrics are disabled.' })
426
+
427
+ return null
428
+ }
429
+
430
+ const { body: data, statusCode } = await makeRequest(`https://spclient.wg.spotify.com/color-lyrics/v2/track/${identifier[2]}?format=json&vocalRemoval=false&market=from_token`, {
431
+ headers: {
432
+ 'authorization': `Bearer ${playerInfo.accessToken}`,
433
+ 'client-token': playerInfo.clientToken,
434
+ 'app-platform': 'WebPlayer'
435
+ },
436
+ method: 'GET'
437
+ })
438
+
439
+ if (statusCode === 404) {
440
+ debugLog('loadlyrics', 4, { type: 3, sourceName: 'Spotify', message: 'No lyrics found.' })
441
+
442
+ return null
443
+ }
444
+
445
+ const lyricsEvents = []
446
+ data.lyrics.lines.forEach((event, index) => {
447
+ if (index === data.lyrics.lines.length - 1) return;
448
+
449
+ lyricsEvents.push({
450
+ startTime: Number(event.startTimeMs),
451
+ endTime: Number(data.lyrics.lines[index + 1] ? data.lyrics.lines[index + 1].startTimeMs : data.lyrics.durationMs),
452
+ text: event.words
453
+ })
454
+ })
455
+
456
+ return {
457
+ loadType: 'lyricsSingle',
458
+ data: {
459
+ name: data.lyrics.language,
460
+ synced: data.lyrics.syncType === 'LINE_SYNCED',
461
+ data: lyricsEvents,
462
+ rtl: false
463
+ }
464
+ }
465
+ }
466
+
467
+ export default {
468
+ init,
469
+ search,
470
+ loadFrom,
471
+ loadLyrics
472
+ }
src/sources/youtube.js ADDED
@@ -0,0 +1,715 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { PassThrough } from 'node:stream'
2
+
3
+ import config from '../../config.js'
4
+ import constants from '../../constants.js'
5
+ import { debugLog, makeRequest, encodeTrack, randomLetters, loadHLSPlaylist } from '../utils.js'
6
+
7
+ const ytContext = {
8
+ ...(config.options.bypassAgeRestriction ? {
9
+ thirdParty: {
10
+ embedUrl: 'https://www.youtube.com'
11
+ },
12
+ } : {}),
13
+ client: {
14
+ ...(!config.options.bypassAgeRestriction ? {
15
+ userAgent: 'com.google.android.youtube/19.13.34 (Linux; U; Android 14 gzip)',
16
+ clientName: 'ANDROID',
17
+ clientVersion: '19.13.34',
18
+ } : {
19
+ clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
20
+ clientVersion: '2.0',
21
+ userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0'
22
+ }),
23
+ screenDensityFloat: 1,
24
+ screenHeightPoints: 1080,
25
+ screenPixelDensity: 1,
26
+ screenWidthPoints: 1920,
27
+ }
28
+ }
29
+
30
+ const sourceInfo = {
31
+ innertubeInterval: null,
32
+ signatureTimestamp: null,
33
+ functions: []
34
+ }
35
+
36
+ function _getBaseHostRequest(type) {
37
+ if (ytContext.client.clientName.startsWith('ANDROID'))
38
+ return 'youtubei.googleapis.com'
39
+
40
+ return `${type === 'ytmusic' ? 'music' : 'www'}.youtube.com`
41
+ }
42
+
43
+ function _getBaseHost(type) {
44
+ return `${type === 'ytmusic' ? 'music' : 'www'}.youtube.com`
45
+ }
46
+
47
+ function _switchClient(newClient) {
48
+ if (newClient === 'ANDROID') {
49
+ ytContext.client.clientName = 'ANDROID'
50
+ ytContext.client.clientVersion = '19.04.33'
51
+ ytContext.client.userAgent = 'com.google.android.youtube/19.04.33 (Linux; U; Android 14 gzip)'
52
+ } else if (newClient === 'ANDROID_MUSIC') {
53
+ ytContext.client.clientName = 'ANDROID_MUSIC'
54
+ ytContext.client.clientVersion = '6.37.50'
55
+ ytContext.client.userAgent = 'com.google.android.apps.youtube.music/6.37.50 (Linux; U; Android 14 gzip)'
56
+ }
57
+ }
58
+
59
+ function _getSourceName(type) {
60
+ return type === 'ytmusic' ? 'YouTube Music' : 'YouTube'
61
+ }
62
+
63
+ async function _init() {
64
+ debugLog('youtube', 5, { type: 1, message: 'Fetching deciphering functions...' })
65
+
66
+ const { body: data } = await makeRequest('https://www.youtube.com/embed', { method: 'GET' }).catch((err) => {
67
+ debugLog('youtube', 5, { type: 2, message: `Failed to access YouTube website: ${err.message}` })
68
+ })
69
+
70
+ const { body: player } = await makeRequest(`https://www.youtube.com${/(?<=jsUrl":")[^"]+/.exec(data)[0]}`, { method: 'GET' }).catch((err) => {
71
+ debugLog('youtube', 5, { type: 2, message: `Failed to fetch player.js: ${err.message}` })
72
+ })
73
+
74
+ sourceInfo.signatureTimestamp = /(?<=signatureTimestamp:)[0-9]+/.exec(player)[0]
75
+
76
+ let functionName = player.match(/a.set\("alr","yes"\);c&&\(c=(.*?)\(/)[1]
77
+ const decipherFunctionName = functionName
78
+
79
+ const sigFunction = player.match(new RegExp(`${functionName.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')}=function\\(a\\){(.*)\\)};`, 'g'))[0]
80
+
81
+ functionName = player.match(/a=a\.split\(""\);(.*?)\./)[1]
82
+ const sigWrapper = player.match(new RegExp(`var ${functionName}={(.*?)};`, 's'))[1]
83
+
84
+ sourceInfo.functions[0] = `const ${functionName}={${sigWrapper}};const ${sigFunction}${decipherFunctionName}(sig);`
85
+
86
+ functionName = player.match(/&&\(b=a\.get\("n"\)\)&&\(b=(.*?)\(/)[1]
87
+
88
+ if (functionName && functionName.includes('['))
89
+ functionName = player.match(new RegExp(`${functionName.match(/([^[]*)\[/)[1]}=\\[(.*?)]`))[1]
90
+
91
+ const ncodeFunction = player.match(new RegExp(`${functionName}=function(.*?)};`, 's'))[1]
92
+ sourceInfo.functions[1] = `const ${functionName} = function${ncodeFunction}};${functionName}(ncode)`
93
+
94
+ debugLog('youtube', 5, { type: 1, message: 'Successfully fetched deciphering functions.' })
95
+ }
96
+
97
+ async function init() {
98
+ debugLog('youtube', 5, { type: 1, message: 'Unrecommended option "bypass age-restricted" is enabled.' })
99
+
100
+ await _init()
101
+
102
+ sourceInfo.innertubeInterval = setInterval(async () => _init(), 3600000)
103
+ }
104
+
105
+ function free() {
106
+ clearInterval(sourceInfo.innertubeInterval)
107
+ sourceInfo.innertubeInterval = null
108
+
109
+ sourceInfo.signatureTimestamp = null
110
+ sourceInfo.functions = []
111
+ }
112
+
113
+ function checkURLType(url, type) {
114
+ if (type === 'ytmusic') {
115
+ const videoRegex = /^https?:\/\/music\.youtube\.com\/watch\?v=[\w-]+/
116
+ const playlistRegex = /^https?:\/\/music\.youtube\.com\/playlist\?list=[\w-]+/
117
+ const selectedVideoRegex = /^https?:\/\/music\.youtube\.com\/watch\?v=[\w-]+&list=[\w-]+/
118
+
119
+ if (selectedVideoRegex.test(url) || playlistRegex.test(url)) return constants.YouTube.playlist
120
+ else if (videoRegex.test(url)) return constants.YouTube.video
121
+ else return -1
122
+ } else {
123
+ const videoRegex = /^https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=[\w-]+)|youtu\.be\/[\w-]+)/
124
+ const playlistRegex = /^https?:\/\/(?:www\.)?youtube\.com\/playlist\?list=[\w-]+/
125
+ const selectedVideoRegex = /^https?:\/\/(?:www\.)?youtube\.com\/watch\?v=[\w-]+&list=[\w-]+/
126
+ const shortsRegex = /^https?:\/\/(?:www\.)?youtube\.com\/shorts\/[\w-]+/
127
+
128
+ if (selectedVideoRegex.test(url) || playlistRegex.test(url)) return constants.YouTube.playlist
129
+ else if (shortsRegex.test(url)) return constants.YouTube.shorts
130
+ else if (videoRegex.test(url)) return constants.YouTube.video
131
+ else return -1
132
+ }
133
+ }
134
+
135
+ async function search(query, type, shouldLog) {
136
+ if (shouldLog) debugLog('search', 4, { type: 1, sourceName: _getSourceName(type), query })
137
+
138
+ if (!config.options.bypassAgeRestriction)
139
+ _switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
140
+
141
+ const { body: search } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/search`, {
142
+ headers: {
143
+ 'User-Agent': ytContext.client.userAgent,
144
+ ...(config.search.sources.youtube.authentication.enabled ? {
145
+ Authorization: config.search.sources.youtube.authentication.authorization,
146
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
147
+ } : {})
148
+ },
149
+ body: {
150
+ context: ytContext,
151
+ query,
152
+ params: type === 'ytmusic' ? 'EgWKAQIIAWoQEAMQBBAJEAoQBRAREBAQFQ%3D%3D' : 'EgIQAQ%3D%3D'
153
+ },
154
+ method: 'POST',
155
+ disableBodyCompression: true
156
+ })
157
+
158
+ if (typeof search !== 'object') {
159
+ debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: 'Failed to load results.' })
160
+
161
+ return {
162
+ loadType: 'error',
163
+ data: {
164
+ message: 'Failed to load results.',
165
+ severity: 'common',
166
+ cause: 'Unknown'
167
+ }
168
+ }
169
+ }
170
+
171
+ if (search.error) {
172
+ debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: search.error.message })
173
+
174
+ return {
175
+ loadType: 'error',
176
+ data: {
177
+ message: search.error.message,
178
+ severity: 'fault',
179
+ cause: 'Unknown'
180
+ }
181
+ }
182
+ }
183
+
184
+ const tracks = []
185
+
186
+ let videos = null
187
+ if (config.options.bypassAgeRestriction) videos = type == 'ytmusic' ? search.contents.sectionListRenderer.contents[0].itemSectionRenderer.contents : search.contents.sectionListRenderer.contents[search.contents.sectionListRenderer.contents.length - 1].itemSectionRenderer.contents
188
+ else videos = type == 'ytmusic' ? search.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.musicSplitViewRenderer.mainContent.sectionListRenderer.contents[0].musicShelfRenderer.contents : search.contents.sectionListRenderer.contents[search.contents.sectionListRenderer.contents.length - 1].itemSectionRenderer.contents
189
+
190
+ if (videos.length > config.options.maxSearchResults)
191
+ videos = videos.slice(0, config.options.maxSearchResults)
192
+
193
+ videos.forEach((video) => {
194
+ video = video.compactVideoRenderer || video.musicTwoColumnItemRenderer
195
+
196
+ if (video) {
197
+ const identifier = type === 'ytmusic' ? video.navigationEndpoint.watchEndpoint.videoId : video.videoId
198
+ const length = type === 'ytmusic' && !config.options.bypassAgeRestriction ? video.subtitle.runs[2].text : video.lengthText?.runs[0]?.text
199
+ const thumbnails = type === 'ytmusic' && !config.options.bypassAgeRestriction ? video.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails : video.thumbnail.thumbnails
200
+
201
+ const track = {
202
+ identifier,
203
+ isSeekable: true,
204
+ author: video.longBylineText ? video.longBylineText.runs[0].text : video.subtitle.runs[0].text,
205
+ length: length ? (parseInt(length.split(':')[0]) * 60 + parseInt(length.split(':')[1])) * 1000 : 0,
206
+ isStream: !length,
207
+ position: 0,
208
+ title: video.title.runs[0].text,
209
+ uri: `https://${_getBaseHost(type)}/watch?v=${identifier}`,
210
+ artworkUrl: thumbnails[thumbnails.length - 1].url.split('?')[0],
211
+ isrc: null,
212
+ sourceName: type
213
+ }
214
+
215
+ tracks.push({
216
+ encoded: encodeTrack(track),
217
+ info: track,
218
+ pluginInfo: {}
219
+ })
220
+ }
221
+ })
222
+
223
+ if (tracks.length === 0) {
224
+ debugLog('search', 4, { type: 3, sourceName: _getSourceName(type), query, message: 'No matches found.' })
225
+
226
+ return {
227
+ loadType: 'empty',
228
+ data: {}
229
+ }
230
+ }
231
+
232
+ if (shouldLog)
233
+ debugLog('search', 4, { type: 2, sourceName: _getSourceName(type), tracksLen: tracks.length, query })
234
+
235
+ return {
236
+ loadType: 'search',
237
+ data: tracks
238
+ }
239
+ }
240
+
241
+ async function loadFrom(query, type) {
242
+ if (!config.options.bypassAgeRestriction)
243
+ _switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
244
+
245
+ switch (checkURLType(query, type)) {
246
+ case constants.YouTube.video: {
247
+ debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: _getSourceName(type), query })
248
+
249
+ const identifier = (/v=([^&]+)/.exec(query) || /youtu\.be\/([^?]+)/.exec(query))[1]
250
+
251
+ const { body: video } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
252
+ headers: {
253
+ 'User-Agent': ytContext.client.userAgent,
254
+ ...(config.search.sources.youtube.authentication.enabled ? {
255
+ Authorization: config.search.sources.youtube.authentication.authorization,
256
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
257
+ } : {})
258
+ },
259
+ body: {
260
+ context: ytContext,
261
+ videoId: identifier,
262
+ contentCheckOk: true,
263
+ racyCheckOk: true,
264
+ params: 'CgIQBg'
265
+ },
266
+ method: 'POST'
267
+ })
268
+
269
+ if (video.playabilityStatus.status !== 'OK') {
270
+ const errorMessage = video.playabilityStatus.reason || video.playabilityStatus.messages[0]
271
+
272
+ debugLog('loadtracks', 4, { type: 3, loadType: 'track', sourceName: _getSourceName(type), query, message: errorMessage })
273
+
274
+ return {
275
+ loadType: 'error',
276
+ data: {
277
+ message: errorMessage,
278
+ severity: 'common',
279
+ cause: 'Unknown'
280
+ }
281
+ }
282
+ }
283
+
284
+ const track = {
285
+ identifier: video.videoDetails.videoId,
286
+ isSeekable: true,
287
+ author: video.videoDetails.author,
288
+ length: parseInt(video.videoDetails.lengthSeconds) * 1000,
289
+ isStream: video.videoDetails.isLiveContent,
290
+ position: 0,
291
+ title: video.videoDetails.title,
292
+ uri: `https://${_getBaseHost(type)}/watch?v=${video.videoDetails.videoId}`,
293
+ artworkUrl: video.videoDetails.thumbnail.thumbnails[video.videoDetails.thumbnail.thumbnails.length - 1].url,
294
+ isrc: null,
295
+ sourceName: type
296
+ }
297
+
298
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: _getSourceName(type), track, query })
299
+
300
+ return {
301
+ loadType: 'track',
302
+ data: {
303
+ encoded: encodeTrack(track),
304
+ info: track,
305
+ pluginInfo: {}
306
+ }
307
+ }
308
+ }
309
+ case constants.YouTube.playlist: {
310
+ debugLog('loadtracks', 4, { type: 1, loadType: 'playlist', sourceName: _getSourceName(type), query })
311
+
312
+ let identifier = /v=([^&]+)/.exec(query)
313
+ if (identifier) identifier = identifier[1]
314
+
315
+ const { body: playlist } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/next?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
316
+ headers: {
317
+ 'User-Agent': ytContext.client.userAgent,
318
+ ...(config.search.sources.youtube.authentication.enabled ? {
319
+ Authorization: config.search.sources.youtube.authentication.authorization,
320
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
321
+ } : {})
322
+ },
323
+ body: {
324
+ context: ytContext,
325
+ playlistId: /(?<=list=)[\w-]+/.exec(query)[0],
326
+ contentCheckOk: true,
327
+ racyCheckOk: true,
328
+ params: 'CgIQBg'
329
+ },
330
+ method: 'POST'
331
+ })
332
+
333
+ let contentsRoot = null
334
+
335
+ if (config.options.bypassAgeRestriction) contentsRoot = playlist.contents.singleColumnWatchNextResults.playlist
336
+ else contentsRoot = type === 'ytmusic' ? playlist.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content.musicQueueRenderer : playlist.contents.singleColumnWatchNextResults
337
+
338
+ if (!(type === 'ytmusic' && !config.options.bypassAgeRestriction ? contentsRoot.content : contentsRoot)) {
339
+ debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
340
+
341
+ return {
342
+ loadType: 'empty',
343
+ data: {}
344
+ }
345
+ }
346
+
347
+ const tracks = []
348
+ let selectedTrack = 0
349
+
350
+ let playlistContent = null
351
+
352
+ if (config.options.bypassAgeRestriction) playlistContent = contentsRoot.playlist.contents
353
+ else playlistContent = type === 'ytmusic' ? contentsRoot.content.playlistPanelRenderer.contents : contentsRoot.playlist?.playlist?.contents
354
+
355
+ if (!playlistContent) {
356
+ debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
357
+
358
+ return {
359
+ loadType: 'empty',
360
+ data: {}
361
+ }
362
+ }
363
+
364
+ if (playlistContent.length > config.options.maxAlbumPlaylistLength)
365
+ playlistContent = playlistContent.slice(0, config.options.maxAlbumPlaylistLength)
366
+
367
+ playlistContent.forEach((video, i) => {
368
+ video = video.playlistPanelVideoRenderer || video.gridVideoRenderer
369
+
370
+ if (video) {
371
+ const track = {
372
+ identifier: video.videoId,
373
+ isSeekable: true,
374
+ author: video.shortBylineText.runs ? video.shortBylineText.runs[0].text : 'Unknown author',
375
+ length: video.lengthText ? (parseInt(video.lengthText.runs[0].text.split(':')[0]) * 60 + parseInt(video.lengthText.runs[0].text.split(':')[1])) * 1000 : 0,
376
+ isStream: false,
377
+ position: 0,
378
+ title: video.title.runs[0].text,
379
+ uri: `https://${_getBaseHost(type)}/watch?v=${video.videoId}`,
380
+ artworkUrl: video.thumbnail.thumbnails[video.thumbnail.thumbnails.length - 1].url,
381
+ isrc: null,
382
+ sourceName: 'youtube'
383
+ }
384
+
385
+ tracks.push({
386
+ encoded: encodeTrack(track),
387
+ info: track,
388
+ pluginInfo: {}
389
+ })
390
+
391
+ if (identifier && track.identifier === identifier)
392
+ selectedTrack = i
393
+ }
394
+ })
395
+
396
+ if (tracks.length === 0) {
397
+ debugLog('loadtracks', 4, { type: 3, loadType: 'playlist', sourceName: _getSourceName(type), query, message: 'No matches found.' })
398
+
399
+ return {
400
+ loadType: 'empty',
401
+ data: {}
402
+ }
403
+ }
404
+
405
+ let playlistName = null
406
+
407
+ if (config.options.bypassAgeRestriction) playlistName = contentsRoot.playlist.title
408
+ else playlistName = type === 'ytmusic' ? contentsRoot.header.musicQueueHeaderRenderer.subtitle.runs[0].text : contentsRoot.playlist.playlist.title
409
+
410
+ debugLog('loadtracks', 4, { type: 2, loadType: 'playlist', sourceName: _getSourceName(type), playlistName: playlistName })
411
+
412
+ return {
413
+ loadType: 'playlist',
414
+ data: {
415
+ info: {
416
+ name: playlistName,
417
+ selectedTrack: selectedTrack
418
+ },
419
+ pluginInfo: {},
420
+ tracks
421
+ }
422
+ }
423
+ }
424
+ case constants.YouTube.shorts: {
425
+ debugLog('loadtracks', 4, { type: 1, loadType: 'track', sourceName: 'YouTube Shorts', query })
426
+
427
+ const { body: short } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
428
+ headers: {
429
+ 'User-Agent': ytContext.client.userAgent,
430
+ ...(config.search.sources.youtube.authentication.enabled ? {
431
+ Authorization: config.search.sources.youtube.authentication.authorization,
432
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
433
+ } : {})
434
+ },
435
+ body: {
436
+ context: ytContext,
437
+ videoId: /shorts\/([a-zA-Z0-9_-]+)/.exec(query)[1],
438
+ contentCheckOk: true,
439
+ racyCheckOk: true,
440
+ params: 'CgIQBg'
441
+ },
442
+ method: 'POST'
443
+ })
444
+
445
+ if (short.playabilityStatus.status !== 'OK') {
446
+ const errorMessage = short.playabilityStatus.reason || short.playabilityStatus.messages[0]
447
+
448
+ debugLog('loadtracks', 4, { type: 3, loadType: 'track', sourceName: 'YouTube Shorts', query, message: errorMessage })
449
+
450
+ return {
451
+ loadType: 'error',
452
+ data: { message: errorMessage,
453
+ severity: 'common',
454
+ cause: 'Unknown'
455
+ }
456
+ }
457
+ }
458
+
459
+ const track = {
460
+ identifier: short.videoDetails.videoId,
461
+ isSeekable: true,
462
+ author: short.videoDetails.author,
463
+ length: parseInt(short.videoDetails.lengthSeconds) * 1000,
464
+ isStream: false,
465
+ position: 0,
466
+ title: short.videoDetails.title,
467
+ uri: `https://${_getBaseHost(type)}/watch?v=${short.videoDetails.videoId}`,
468
+ artworkUrl: short.videoDetails.thumbnail.thumbnails[short.videoDetails.thumbnail.thumbnails.length - 1].url,
469
+ isrc: null,
470
+ sourceName: 'youtube'
471
+ }
472
+
473
+ debugLog('loadtracks', 4, { type: 2, loadType: 'track', sourceName: 'YouTube Shorts', track, query })
474
+
475
+ return {
476
+ loadType: 'short',
477
+ data: {
478
+ encoded: encodeTrack(track),
479
+ info: track,
480
+ pluginInfo: {}
481
+ }
482
+ }
483
+ }
484
+ default: {
485
+ debugLog('loadtracks', 4, { type: 3, loadType: 'unknown', sourceName: _getSourceName(type), query, message: 'No matches found.' })
486
+
487
+ return {
488
+ loadType: 'empty',
489
+ data: {}
490
+ }
491
+ }
492
+ }
493
+ }
494
+
495
+ async function retrieveStream(identifier, type, title) {
496
+ if (!config.options.bypassAgeRestriction)
497
+ _switchClient(type === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
498
+
499
+ const { body: videos } = await makeRequest(`https://${_getBaseHostRequest(type)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false&t=${randomLetters(12)}&id=${identifier}`, {
500
+ headers: {
501
+ 'User-Agent': ytContext.client.userAgent,
502
+ ...(config.search.sources.youtube.authentication.enabled ? {
503
+ Authorization: config.search.sources.youtube.authentication.authorization,
504
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
505
+ } : {})
506
+ },
507
+ body: {
508
+ context: ytContext,
509
+ cpn: randomLetters(16),
510
+ ...(config.options.bypassAgeRestriction ? {
511
+ playbackContext: {
512
+ contentPlaybackContext: {
513
+ signatureTimestamp: sourceInfo.signatureTimestamp
514
+ }
515
+ }
516
+ } : {}),
517
+ videoId: identifier,
518
+ contentCheckOk: true,
519
+ racyCheckOk: true,
520
+ params: 'CgIQBg'
521
+ },
522
+ method: 'POST',
523
+ disableBodyCompression: true
524
+ })
525
+
526
+ if (videos.playabilityStatus.status !== 'OK') {
527
+ debugLog('retrieveStream', 4, { type: 2, sourceName: _getSourceName(type), query: title, message: videos.playabilityStatus.reason })
528
+
529
+ return {
530
+ exception: {
531
+ message: videos.playabilityStatus.reason,
532
+ severity: 'common',
533
+ cause: 'Unknown'
534
+ }
535
+ }
536
+ }
537
+
538
+ let itag = null
539
+ switch (config.audio.quality) {
540
+ case 'high': itag = 251; break
541
+ case 'medium': itag = 250; break
542
+ case 'low': itag = 249; break
543
+ case 'lowest': itag = 599; break
544
+ default: itag = 251; break
545
+ }
546
+
547
+ const audio = videos.streamingData.adaptiveFormats.find((format) => format.itag === itag) || videos.streamingData.adaptiveFormats.find((format) => format.mimeType.startsWith('audio/'))
548
+ let url = audio.url || audio.signatureCipher || audio.cipher
549
+
550
+ if ((audio.signatureCipher || audio.cipher) && config.options.bypassAgeRestriction) {
551
+ const args = new URLSearchParams(url)
552
+ url = decodeURIComponent(args.get('url'))
553
+
554
+ if (audio.signatureCipher || audio.cipher)
555
+ url += `&${args.get('sp')}=${eval(`const sig = "${args.get('s')}";` + sourceInfo.functions[0])}`
556
+ } else {
557
+ url = decodeURIComponent(url)
558
+ }
559
+
560
+ url += `&rn=1&cpn=${randomLetters(16)}&ratebypass=yes&range=0-` /* range query is necessary to bypass throttling */
561
+
562
+ return {
563
+ url: videos.streamingData.hlsManifestUrl ? videos.streamingData.hlsManifestUrl : url,
564
+ protocol: videos.streamingData.hlsManifestUrl ? 'hls' : 'http',
565
+ format: audio.mimeType === 'audio/webm; codecs="opus"' ? 'webm/opus' : 'arbitrary'
566
+ }
567
+ }
568
+
569
+ function loadLyrics(decodedTrack, language) {
570
+ return new Promise(async (resolve) => {
571
+ if (!config.options.bypassAgeRestriction)
572
+ _switchClient(decodedTrack.sourceName === 'ytmusic' ? 'ANDROID_MUSIC' : 'ANDROID')
573
+
574
+ const { body: video } = await makeRequest(`https://${_getBaseHostRequest(decodedTrack.sourceName)}/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false`, {
575
+ headers: {
576
+ 'User-Agent': ytContext.client.userAgent,
577
+ ...(config.search.sources.youtube.authentication.enabled ? {
578
+ Authorization: config.search.sources.youtube.authentication.authorization,
579
+ Cookie: `SID=${config.search.sources.youtube.authentication.cookies.SID}; LOGIN_INFO=${config.search.sources.youtube.authentication.cookies.LOGIN_INFO}`
580
+ } : {})
581
+ },
582
+ body: {
583
+ context: ytContext,
584
+ videoId: decodedTrack.identifier,
585
+ contentCheckOk: true,
586
+ racyCheckOk: true,
587
+ params: 'CgIQBg'
588
+ },
589
+ method: 'POST'
590
+ })
591
+
592
+ if (video.playabilityStatus.status !== 'OK') {
593
+ debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: video.playabilityStatus.reason })
594
+
595
+ return resolve({
596
+ loadType: 'error',
597
+ data: {
598
+ message: video.playabilityStatus.reason,
599
+ severity: 'common',
600
+ cause: 'Unknown'
601
+ }
602
+ })
603
+ }
604
+
605
+ if (!video.captions)
606
+ return resolve(null)
607
+
608
+ const selectedCaption = video.captions.playerCaptionsTracklistRenderer.captionTracks.find((caption) => {
609
+ return caption.languageCode === language
610
+ })
611
+
612
+ if (selectedCaption) {
613
+ const { body: captionData } = await makeRequest(selectedCaption.baseUrl.replace('&fmt=srv3', '&fmt=json3'), { method: 'GET' }).catch((err) => {
614
+ debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: err.message })
615
+
616
+ return resolve({
617
+ loadType: 'error',
618
+ data: {
619
+ message: err.message,
620
+ severity: 'common',
621
+ cause: 'Unknown'
622
+ }
623
+ })
624
+ })
625
+
626
+ const captionEvents = []
627
+ captionData.events.forEach((event) => {
628
+ if (!event.segs) return null
629
+
630
+ captionEvents.push({
631
+ startTime: event.tStartMs,
632
+ endTime: event.tStartMs + (event.dDurationMs || 0),
633
+ text: event.segs ? event.segs.map((seg) => seg.utf8).join('') : null
634
+ })
635
+ })
636
+
637
+ return resolve({
638
+ loadType: 'lyricsSingle',
639
+ data: {
640
+ name: selectedCaption.languageCode,
641
+ synced: true,
642
+ data: captionEvents,
643
+ rtl: !!selectedCaption.rtl
644
+ }
645
+ })
646
+ } else {
647
+ const captions = []
648
+ let i = 0
649
+
650
+ video.captions.playerCaptionsTracklistRenderer.captionTracks.forEach(async (caption) => {
651
+ const { body: captionData } = await makeRequest(caption.baseUrl.replace('&fmt=srv3', '&fmt=json3'), { method: 'GET' }).catch((err) => {
652
+ debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: err.message })
653
+
654
+ return resolve({
655
+ loadType: 'error',
656
+ data: {
657
+ message: err.message,
658
+ severity: 'common',
659
+ cause: 'Unknown'
660
+ }
661
+ })
662
+ })
663
+
664
+ const captionEvents = captionData.events.map((event) => {
665
+ return {
666
+ startTime: event.tStartMs,
667
+ endTime: event.tStartMs + event.dDurationMs,
668
+ text: event.segs ? event.segs.map((seg) => seg.utf8).join('') : null
669
+ }
670
+ })
671
+
672
+ captions.push({
673
+ name: caption.languageCode,
674
+ synced: true,
675
+ data: captionEvents,
676
+ rtl: !!caption.rtl
677
+ })
678
+
679
+ if (++i === video.captions.playerCaptionsTracklistRenderer.captionTracks.length) {
680
+ if (captions.length === 0) {
681
+ debugLog('loadlyrics', 4, { type: 3, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author }, message: 'No captions found.' })
682
+
683
+ return resolve(null)
684
+ }
685
+
686
+ debugLog('loadlyrics', 4, { type: 2, sourceName: _getSourceName(decodedTrack.sourceName), track: { title: decodedTrack.title, author: decodedTrack.author } })
687
+
688
+ return resolve({
689
+ loadType: 'lyricsMultiple',
690
+ data: captions
691
+ })
692
+ }
693
+ })
694
+ }
695
+ })
696
+ }
697
+
698
+ async function loadStream(url) {
699
+ return new Promise(async (resolve) => {
700
+ const stream = new PassThrough()
701
+ await loadHLSPlaylist(url, stream)
702
+
703
+ resolve(stream)
704
+ })
705
+ }
706
+
707
+ export default {
708
+ init,
709
+ free,
710
+ search,
711
+ loadFrom,
712
+ retrieveStream,
713
+ loadLyrics,
714
+ loadStream
715
+ }
src/utils.js ADDED
@@ -0,0 +1,943 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import http from 'node:http'
2
+ import https from 'node:https'
3
+ import http2 from 'node:http2'
4
+ import zlib from 'node:zlib'
5
+ import process from 'node:process'
6
+ import { Buffer } from 'node:buffer'
7
+ import { URL } from 'node:url'
8
+ import { PassThrough } from 'node:stream'
9
+
10
+ import config from '../config.js'
11
+ import constants from '../constants.js'
12
+
13
+ export function randomLetters(size) {
14
+ let result = ''
15
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
16
+
17
+ let counter = 0
18
+ while (counter < size) {
19
+ result += characters.charAt(Math.floor(Math.random() * characters.length))
20
+ counter++
21
+ }
22
+
23
+ return result
24
+ }
25
+
26
+ function _http1Events(request, headers, statusCode) {
27
+ return new Promise((resolve) => {
28
+ let data = ''
29
+
30
+ request.setEncoding('utf8')
31
+ request.on('data', (chunk) => data += chunk)
32
+ request.on('end', () => {
33
+ resolve({
34
+ statusCode: statusCode,
35
+ headers: headers,
36
+ body: (headers && headers['content-type'] && headers['content-type'].startsWith('application/json')) ? JSON.parse(data) : data
37
+ })
38
+ })
39
+ })
40
+ }
41
+
42
+ export function http1makeRequest(url, options) {
43
+ return new Promise(async (resolve, reject) => {
44
+ let compression = null
45
+
46
+ let req = (url.startsWith('https') ? https : http).request(url, {
47
+ method: options.method,
48
+ headers: {
49
+ 'Accept-Encoding': 'br, gzip, deflate',
50
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0',
51
+ 'DNT': '1',
52
+ ...(options.headers || {}),
53
+ ...(options.body ? { 'Content-Type': 'application/json' } : {})
54
+ }
55
+ }, async (res) => {
56
+ const statusCode = res.statusCode
57
+ const headers = res.headers
58
+
59
+ if (headers.location) {
60
+ resolve(http1makeRequest(headers.location, options))
61
+
62
+ return res.destroy()
63
+ }
64
+
65
+ switch (res.headers['content-encoding']) {
66
+ case 'deflate': {
67
+ compression = zlib.createInflate()
68
+ break
69
+ }
70
+ case 'br': {
71
+ compression = zlib.createBrotliDecompress()
72
+ break
73
+ }
74
+ case 'gzip': {
75
+ compression = zlib.createGunzip()
76
+ break
77
+ }
78
+ }
79
+
80
+ if (compression) {
81
+ res.pipe(compression)
82
+
83
+ if (options.streamOnly) {
84
+ return resolve({
85
+ statusCode,
86
+ headers,
87
+ stream: compression
88
+ })
89
+ }
90
+
91
+ resolve(await _http1Events(compression, headers, statusCode))
92
+ } else {
93
+ if (options.streamOnly) {
94
+ return resolve({
95
+ statusCode,
96
+ headers,
97
+ stream: res
98
+ })
99
+ }
100
+
101
+ resolve(await _http1Events(res, headers, statusCode))
102
+ }
103
+ })
104
+
105
+ if (options.body) {
106
+ if (options.disableBodyCompression || process.versions.deno)
107
+ req.end(JSON.stringify(options.body))
108
+ else zlib.gzip(JSON.stringify(options.body), (error, data) => {
109
+ if (error) throw new Error(`\u001b[31mhttp1makeRequest\u001b[37m]: Failed gziping body: ${error}`)
110
+ req.end(data)
111
+ })
112
+ } else req.end()
113
+
114
+ req.on('error', (error) => {
115
+ console.error(`[\u001b[31mhttp1makeRequest\u001b[37m]: Failed sending HTTP request to ${url}: \u001b[31m${error}\u001b[37m`)
116
+
117
+ reject(error)
118
+ })
119
+ })
120
+ }
121
+
122
+ function _http2Events(request, headers) {
123
+ return new Promise((resolve) => {
124
+ let data = ''
125
+
126
+ request.setEncoding('utf8')
127
+ request.on('data', (chunk) => data += chunk)
128
+ request.on('end', () => {
129
+ resolve({
130
+ statusCode: headers[':status'],
131
+ headers: headers,
132
+ body: (headers && headers['content-type'] && headers['content-type'].startsWith('application/json')) ? JSON.parse(data) : data
133
+ })
134
+ })
135
+ })
136
+ }
137
+
138
+ export function makeRequest(url, options) {
139
+ if (process.versions.deno) return http1makeRequest(url, options)
140
+
141
+ return new Promise(async (resolve) => {
142
+ const parsedUrl = new URL(url)
143
+ let compression = null
144
+
145
+ const client = http2.connect(parsedUrl.origin)
146
+
147
+ let reqOptions = {
148
+ ':method': options.method,
149
+ ':path': parsedUrl.pathname + parsedUrl.search,
150
+ 'Accept-Encoding': 'br, gzip, deflate',
151
+ 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/111.0',
152
+ 'DNT': '1',
153
+ ...(options.headers || {})
154
+ }
155
+
156
+ if (options.body) {
157
+ if (!options.disableBodyCompression) reqOptions['Content-Encoding'] = 'gzip'
158
+
159
+ reqOptions['Content-Type'] = 'application/json'
160
+ }
161
+
162
+ let req = client.request(reqOptions)
163
+
164
+ client.on('error', () => { /* Add listener or else will crash */ })
165
+
166
+ req.on('error', (error) => {
167
+ console.error(`[\u001b[31mmakeRequest\u001b[37m]: Failed sending HTTP request to ${url}: \u001b[31m${error}\u001b[37m`)
168
+
169
+ resolve({ error })
170
+ })
171
+
172
+ req.on('response', async (headers) => {
173
+ if (headers.location) {
174
+ client.close()
175
+ req.destroy()
176
+
177
+ return resolve(makeRequest(headers.location, options))
178
+ }
179
+
180
+ switch (headers['content-encoding']) {
181
+ case 'deflate': {
182
+ compression = zlib.createInflate()
183
+ break
184
+ }
185
+ case 'br': {
186
+ compression = zlib.createBrotliDecompress()
187
+ break
188
+ }
189
+ case 'gzip': {
190
+ compression = zlib.createGunzip()
191
+ break
192
+ }
193
+ }
194
+
195
+ if (compression) {
196
+ req.pipe(compression)
197
+
198
+ if (options.streamOnly) {
199
+ req.on('end', () => client.close())
200
+
201
+ return resolve({
202
+ statusCode: headers[':status'],
203
+ headers: headers,
204
+ stream: compression
205
+ })
206
+ }
207
+
208
+ compression.on('error', (error) => {
209
+ console.error(`[\u001b[31mmakeRequest\u001b[37m]: Failed decompressing HTTP response: \u001b[31m${error}\u001b[37m`)
210
+
211
+ resolve({ error })
212
+ })
213
+
214
+ resolve(await _http2Events(compression, headers))
215
+
216
+ client.close()
217
+ } else {
218
+ if (options.streamOnly) {
219
+ req.on('end', () => client.close())
220
+
221
+ return resolve({
222
+ statusCode: headers[':status'],
223
+ headers: headers,
224
+ stream: req
225
+ })
226
+ }
227
+
228
+ resolve(await _http2Events(req, headers))
229
+
230
+ client.close()
231
+ }
232
+ })
233
+
234
+ if (options.body) {
235
+ if (options.disableBodyCompression)
236
+ req.end(JSON.stringify(options.body))
237
+ else zlib.gzip(JSON.stringify(options.body), (error, data) => {
238
+ if (error) throw new Error(`\u001b[31mmakeRequest\u001b[37m]: Failed gziping body: ${error}`)
239
+ req.end(data)
240
+ })
241
+ } else req.end()
242
+ })
243
+ }
244
+
245
+ class EncodeClass {
246
+ constructor() {
247
+ this.position = 0
248
+ this.buffer = Buffer.alloc(512)
249
+ }
250
+
251
+ changeBytes(bytes) {
252
+ if (this.position + bytes > this.buffer.length) {
253
+ const newBuffer = Buffer.alloc(Math.max(this.buffer.length * 2, this.position + bytes))
254
+ this.buffer.copy(newBuffer)
255
+ this.buffer = newBuffer
256
+ }
257
+ this.position += bytes
258
+ return this.position - bytes
259
+ }
260
+
261
+ write(type, value) {
262
+ switch (type) {
263
+ case 'byte': {
264
+ this.buffer[this.changeBytes(1)] = value
265
+ break
266
+ }
267
+ case 'unsignedShort': {
268
+ this.buffer.writeUInt16BE(value, this.changeBytes(2))
269
+ break
270
+ }
271
+ case 'int': {
272
+ this.buffer.writeInt32BE(value, this.changeBytes(4))
273
+ break
274
+ }
275
+ case 'long': {
276
+ const msb = value / BigInt(2 ** 32)
277
+ const lsb = value % BigInt(2 ** 32)
278
+
279
+ this.write('int', Number(msb))
280
+ this.write('int', Number(lsb))
281
+ break
282
+ }
283
+ case 'utf': {
284
+ const len = Buffer.byteLength(value, 'utf8')
285
+ this.write('unsignedShort', len)
286
+ const start = this.changeBytes(len)
287
+ this.buffer.write(value, start, len, 'utf8')
288
+ break
289
+ }
290
+ }
291
+ }
292
+
293
+ result() {
294
+ return this.buffer.subarray(0, this.position)
295
+ }
296
+ }
297
+
298
+ export function encodeTrack(obj) {
299
+ try {
300
+ const buf = new EncodeClass()
301
+
302
+ buf.write('byte', 3)
303
+ buf.write('utf', obj.title)
304
+ buf.write('utf', obj.author)
305
+ buf.write('long', BigInt(obj.length))
306
+ buf.write('utf', obj.identifier)
307
+ buf.write('byte', obj.isStream ? 1 : 0)
308
+ buf.write('byte', obj.uri ? 1 : 0)
309
+ if (obj.uri) buf.write('utf', obj.uri)
310
+ buf.write('byte', obj.artworkUrl ? 1 : 0)
311
+ if (obj.artworkUrl) buf.write('utf', obj.artworkUrl)
312
+ buf.write('byte', obj.isrc ? 1 : 0)
313
+ if (obj.isrc) buf.write('utf', obj.isrc)
314
+ buf.write('utf', obj.sourceName)
315
+ buf.write('long', BigInt(obj.position))
316
+
317
+ const buffer = buf.result()
318
+ const result = Buffer.alloc(buffer.length + 4)
319
+
320
+ result.writeInt32BE(buffer.length | (1 << 30))
321
+ buffer.copy(result, 4)
322
+
323
+ return result.toString('base64')
324
+ } catch {
325
+ return null
326
+ }
327
+ }
328
+
329
+ class DecodeClass {
330
+ constructor(buffer) {
331
+ this.position = 0
332
+ this.buffer = buffer
333
+ }
334
+
335
+ changeBytes(bytes) {
336
+ this.position += bytes
337
+ return this.position - bytes
338
+ }
339
+
340
+ read(type) {
341
+ switch (type) {
342
+ case 'byte': {
343
+ return this.buffer[this.changeBytes(1)]
344
+ }
345
+ case 'unsignedShort': {
346
+ const result = this.buffer.readUInt16BE(this.changeBytes(2))
347
+ return result
348
+ }
349
+ case 'int': {
350
+ const result = this.buffer.readInt32BE(this.changeBytes(4))
351
+ return result
352
+ }
353
+ case 'long': {
354
+ const msb = BigInt(this.read('int'))
355
+ const lsb = BigInt(this.read('int'))
356
+
357
+ return msb * BigInt(2 ** 32) + lsb
358
+ }
359
+ case 'utf': {
360
+ const len = this.read('unsignedShort')
361
+ const start = this.changeBytes(len)
362
+ const result = this.buffer.toString('utf8', start, start + len)
363
+ return result
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ export function decodeTrack(track) {
370
+ try {
371
+ const buf = new DecodeClass(Buffer.from(track, 'base64'))
372
+
373
+ const version = ((buf.read('int') & 0xC0000000) >> 30 & 1) !== 0 ? buf.read('byte') : 1
374
+
375
+ switch (version) {
376
+ case 1: {
377
+ return {
378
+ title: buf.read('utf'),
379
+ author: buf.read('utf'),
380
+ length: Number(buf.read('long')),
381
+ identifier: buf.read('utf'),
382
+ isStream: buf.read('byte') === 1,
383
+ uri: null,
384
+ source: buf.read('utf'),
385
+ position: Number(buf.read('long'))
386
+ }
387
+ }
388
+ case 2: {
389
+ return {
390
+ title: buf.read('utf'),
391
+ author: buf.read('utf'),
392
+ length: Number(buf.read('long')),
393
+ identifier: buf.read('utf'),
394
+ isStream: buf.read('byte') === 1,
395
+ uri: buf.read('byte') === 1 ? buf.read('utf') : null,
396
+ source: buf.read('utf'),
397
+ position: Number(buf.read('long'))
398
+ }
399
+ }
400
+ case 3: {
401
+ return {
402
+ title: buf.read('utf'),
403
+ author: buf.read('utf'),
404
+ length: Number(buf.read('long')),
405
+ identifier: buf.read('utf'),
406
+ isSeekable: true,
407
+ isStream: buf.read('byte') === 1,
408
+ uri: buf.read('byte') === 1 ? buf.read('utf') : null,
409
+ artworkUrl: buf.read('byte') === 1 ? buf.read('utf') : null,
410
+ isrc: buf.read('byte') === 1 ? buf.read('utf') : null,
411
+ sourceName: buf.read('utf'),
412
+ position: Number(buf.read('long'))
413
+ }
414
+ }
415
+ }
416
+ } catch {
417
+ return null
418
+ }
419
+ }
420
+
421
+ export function debugLog(name, type, options) {
422
+ switch (type) {
423
+ case 1: {
424
+ if (!config.debug.request.enabled) return;
425
+
426
+ if (options.headers) {
427
+ options.headers.authorization = 'REDACTED'
428
+ options.headers.host = 'REDACTED'
429
+ }
430
+
431
+ if (options.error)
432
+ console.error(`[\u001b[32m${name}\u001b[37m]: Detected an error in a request: \u001b[31m${options.error}\u001b[37m${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`)
433
+ else
434
+ console.log(`[\u001b[32m${name}\u001b[37m]: Received a request from client.${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`)
435
+
436
+ break
437
+ }
438
+ case 2: {
439
+ switch (name) {
440
+ case 'trackStart': {
441
+ if (!config.debug.track.start) return;
442
+
443
+ console.log(`[\u001b[32mtrackStart\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m.`)
444
+
445
+ break
446
+ }
447
+ case 'trackEnd': {
448
+ if (!config.debug.track.end) return;
449
+
450
+ console.log(`[\u001b[32mtrackEnd\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m because was \u001b[94m${options.reason}\u001b[37m.`)
451
+
452
+ break
453
+ }
454
+ case 'trackException': {
455
+ if (!config.debug.track.exception) return;
456
+
457
+ console.error(`[\u001b[31mtrackException\u001b[37m]: \u001b[94m${options.track?.title || 'None'}\u001b[37m by \u001b[94m${options.track?.author || 'none'}\u001b[37m: \u001b[31m${options.exception}\u001b[37m`)
458
+
459
+ break
460
+ }
461
+ case 'trackStuck': {
462
+ if (!config.debug.track.stuck) return;
463
+
464
+ console.warn(`[\u001b[33mtrackStuck\u001b[37m]: \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m: \u001b[33m${config.options.threshold}ms have passed.\u001b[37m`)
465
+
466
+ break
467
+ }
468
+ }
469
+
470
+ break
471
+ }
472
+ case 3: {
473
+ switch (name) {
474
+ case 'connect': {
475
+ if (!config.debug.websocket.connect) return;
476
+
477
+ if (options.error)
478
+ return console.error(`[\u001b[31mwebsocket\u001b[37m]: \u001b[31m${options.error}\u001b[37m\n Name: \u001b[94m${options.name}\u001b[37m`)
479
+
480
+ console.log(`[\u001b[32mwebsocket\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m client connected to NodeLink.`)
481
+
482
+ break
483
+ }
484
+ case 'disconnect': {
485
+ if (!config.debug.websocket.disconnect) return;
486
+
487
+ console.error(`[\u001b[33mwebsocket\u001b[37m]: A connection was closed with a client.\n Code: \u001b[33m${options.code}\u001b[37m\n Reason: \u001b[33m${options.reason === '' ? 'No reason provided' : options.reason}\u001b[37m`)
488
+
489
+ break
490
+ }
491
+ case 'error': {
492
+ if (!config.debug.websocket.error) return;
493
+
494
+ console.error(`[\u001b[31mwebsocketError\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m ran into an error: \u001b[31m${options.error}\u001b[37m`)
495
+
496
+ break
497
+ }
498
+ case 'connectCD': {
499
+ if (!config.debug.websocket.connectCD) return;
500
+
501
+ console.log(`[\u001b[32mwebsocketCD\u001b[37m]: \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m client connected to NodeLink.\n Guild: \u001b[94m${options.guildId}\u001b[37m`)
502
+
503
+ break
504
+ }
505
+ case 'disconnectCD': {
506
+ if (!config.debug.websocket.disconnectCD) return;
507
+
508
+ console.error(`[\u001b[32mwebsocketCD\u001b[37m]: Connection with \u001b[94m${options.name}\u001b[37m@\u001b[94m${options.version}\u001b[37m was closed.\n Guild: \u001b[94m${options.guildId}\u001b[37m\n Code: \u001b[33m${options.code}\u001b[37m\n Reason: \u001b[33m${options.reason === '' ? 'No reason provided' : options.reason}\u001b[37m`)
509
+
510
+ break
511
+ }
512
+ case 'sentDataCD': {
513
+ if (!config.debug.websocket.sentDataCD) return;
514
+
515
+ console.log(`[\u001b[32msentData\u001b[37m]: Sent data to \u001b[94m${options.clientsAmount}\u001b[37m clients.\n Guild: \u001b[94m${options.guildId}\u001b[37m`)
516
+
517
+ break
518
+ }
519
+ default: {
520
+ if (!config.debug.request.error) return;
521
+
522
+ console.error(`[\u001b[31m${name}\u001b[37m]: \u001b[31m${options.error}\u001b[37m${config.debug.request.showParams && options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${config.debug.request.showHeaders && options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${config.debug.request.showBody && options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`)
523
+
524
+ break
525
+ }
526
+ }
527
+
528
+ break
529
+ }
530
+ case 4: {
531
+ switch (name) {
532
+ case 'loadtracks': {
533
+ if (options.type === 1 && config.debug.sources.loadtrack.request)
534
+ console.log(`[\u001b[32mloadTracks\u001b[37m]: Loading \u001b[94m${options.loadType}\u001b[37m from ${options.sourceName}: ${options.query}`)
535
+
536
+ if (options.type === 2 && config.debug.sources.loadtrack.results) {
537
+ if (options.loadType !== 'search' && options.loadType !== 'track')
538
+ console.log(`[\u001b[32mloadTracks\u001b[37m]: Loaded \u001b[94m${options.playlistName}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m.`)
539
+ else
540
+ console.log(`[\u001b[32mloadTracks\u001b[37m]: Loaded \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: ${options.query}`)
541
+ }
542
+
543
+ if (options.type === 3 && config.debug.sources.loadtrack.exception)
544
+ console.error(`[\u001b[31mloadTracks\u001b[37m]: Exception loading \u001b[94m${options.loadType}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: \u001b[31m${options.message}\u001b[37m`)
545
+
546
+ break
547
+ }
548
+ case 'search': {
549
+ if (options.type === 1 && config.debug.sources.search.request)
550
+ console.log(`[\u001b[32msearch\u001b[37m]: Searching for \u001b[94m${options.query}\u001b[37m on \u001b[94m${options.sourceName}\u001b[37m`)
551
+
552
+ if (options.type === 2 && config.debug.sources.search.results)
553
+ console.log(`[\u001b[32msearch\u001b[37m]: Found \u001b[94m${options.tracksLen}\u001b[37m tracks on \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m`)
554
+
555
+ if (options.type === 3 && config.debug.sources.search.exception)
556
+ console.error(`[\u001b[31msearch\u001b[37m]: Exception from ${options.sourceName} for query \u001b[94m${options.query}\u001b[37m: \u001b[31m${options.message}\u001b[37m`)
557
+
558
+ break
559
+ }
560
+ case 'retrieveStream': {
561
+ if (!config.debug.sources.retrieveStream) return;
562
+
563
+ if (options.type === 1)
564
+ console.log(`[\u001b[32mretrieveStream\u001b[37m]: Retrieved from \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m`)
565
+
566
+ if (options.type === 2)
567
+ console.error(`[\u001b[31mretrieveStream\u001b[37m]: Exception from \u001b[94m${options.sourceName}\u001b[37m for query \u001b[94m${options.query}\u001b[37m: \u001b[31m${options.message}\u001b[37m`)
568
+
569
+ break
570
+ }
571
+ case 'loadlyrics': {
572
+ if (options.type === 1 && config.debug.sources.loadlyrics.request)
573
+ console.log(`[\u001b[32mloadCaptions\u001b[37m]: Loading captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m`)
574
+
575
+ if (options.type === 2 && config.debug.sources.loadlyrics.results)
576
+ console.log(`[\u001b[32mloadCaptions\u001b[37m]: Loaded captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m`)
577
+
578
+ if (options.type === 3 && config.debug.sources.loadlyrics.exception)
579
+ console.error(`[\u001b[31mloadCaptions\u001b[37m]: Exception loading captions for \u001b[94m${options.track.title}\u001b[37m by \u001b[94m${options.track.author}\u001b[37m from \u001b[94m${options.sourceName}\u001b[37m: \u001b[31m${options.message}\u001b[37m`)
580
+
581
+ break
582
+ }
583
+ }
584
+
585
+ break
586
+ }
587
+ case 5: {
588
+ switch (name) {
589
+ case 'youtube': {
590
+ if (options.type === 1 && config.debug.youtube.success)
591
+ console.log(`[\u001b[32myoutube\u001b[37m]: ${options.message}`)
592
+
593
+ if (options.type === 2 && config.debug.youtube.error)
594
+ console.error(`[\u001b[31myoutube\u001b[37m]: ${options.message}`)
595
+
596
+ break
597
+ }
598
+
599
+ case 'pandora': {
600
+ if (options.type === 1 && config.debug.pandora.success)
601
+ console.log(`[\u001b[32mpandora\u001b[37m]: ${options.message}`)
602
+
603
+ if (options.type === 2 && config.debug.pandora.error)
604
+ console.error(`[\u001b[31mpandora\u001b[37m]: ${options.message}`)
605
+
606
+ break
607
+ }
608
+ case 'deezer': {
609
+ if (options.type === 1 && config.debug.deezer.success)
610
+ console.log(`[\u001b[32mdeezer\u001b[37m]: ${options.message}`)
611
+
612
+ if (options.type === 2 && config.debug.deezer.error)
613
+ console.error(`[\u001b[31mdeezer\u001b[37m]: ${options.message}`)
614
+
615
+ break
616
+ }
617
+ case 'spotify': {
618
+ if (options.type === 1 && config.debug.spotify.success)
619
+ console.log(`[\u001b[32mspotify\u001b[37m]: ${options.message}`)
620
+
621
+ if (options.type === 2 && config.debug.spotify.error)
622
+ console.error(`[\u001b[31mspotify\u001b[37m]: ${options.message}`)
623
+
624
+ break
625
+ }
626
+ case 'soundcloud': {
627
+ if (options.type === 1 && config.debug.soundcloud.success)
628
+ console.log(`[\u001b[32msoundcloud\u001b[37m]: ${options.message}`)
629
+
630
+ if (options.type === 2 && config.debug.soundcloud.error)
631
+ console.error(`[\u001b[31msoundcloud\u001b[37m]: ${options.message}`)
632
+
633
+ break
634
+ }
635
+ case 'musixmatch': {
636
+ console.log(`[\u001b[32mmusixmatch\u001b[37m]: ${options.message}`)
637
+
638
+ break
639
+ }
640
+ }
641
+
642
+ break
643
+ }
644
+ case 6: {
645
+ if (!config.debug.request.all) return;
646
+
647
+ if (options.headers) {
648
+ options.headers.authorization = 'REDACTED'
649
+ options.headers.host = 'REDACTED'
650
+ }
651
+
652
+ console.log(`[\u001b[32mALL\u001b[37m]: Received a request from client.\n Path: ${options.path}${options.params ? `\n Params: ${JSON.stringify(options.params)}` : ''}${options.headers ? `\n Headers: ${JSON.stringify(options.headers)}` : ''}${options.body ? `\n Body: ${JSON.stringify(options.body)}` : ''}`)
653
+
654
+ break
655
+ }
656
+ }
657
+ }
658
+
659
+ export function sendResponse(req, res, data, status) {
660
+ if (!data) {
661
+ res.writeHead(status)
662
+ res.end()
663
+
664
+ return true
665
+ }
666
+
667
+ if (!req.headers || !req.headers['accept-encoding']) {
668
+ res.setHeader('Connection', 'close')
669
+ res.writeHead(status, { 'Content-Type': 'application/json' })
670
+
671
+ res.end(JSON.stringify(data))
672
+ }
673
+
674
+ if (req.headers && req.headers['accept-encoding']) {
675
+ if (req.headers['accept-encoding'].includes('br')) {
676
+ res.setHeader('Content-Encoding', 'br')
677
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'br' })
678
+
679
+ zlib.brotliCompress(JSON.stringify(data), (err, result) => {
680
+ if (err) {
681
+ res.writeHead(500)
682
+ res.end()
683
+
684
+ return;
685
+ }
686
+
687
+ res.end(result)
688
+ })
689
+ }
690
+
691
+ else if (req.headers['accept-encoding'].includes('gzip')) {
692
+ res.setHeader('Content-Encoding', 'gzip')
693
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' })
694
+
695
+ zlib.gzip(JSON.stringify(data), (err, result) => {
696
+ if (err) {
697
+ res.writeHead(500)
698
+ res.end()
699
+
700
+ return;
701
+ }
702
+
703
+ res.end(result)
704
+ })
705
+ }
706
+
707
+ else if (req.headers['accept-encoding'].includes('deflate')) {
708
+ res.setHeader('Content-Encoding', 'deflate')
709
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Encoding': 'deflate' })
710
+
711
+ zlib.deflate(JSON.stringify(data), (err, result) => {
712
+ if (err) {
713
+ res.writeHead(500)
714
+ res.end()
715
+
716
+ return;
717
+ }
718
+
719
+ res.end(result)
720
+ })
721
+ }
722
+ }
723
+
724
+ return true
725
+ }
726
+
727
+ export function tryParseBody(req, res) {
728
+ return new Promise((resolve) => {
729
+ let buffer = ''
730
+
731
+ req.on('data', (chunk) => buffer += chunk)
732
+ req.on('end', () => {
733
+ try {
734
+ resolve(JSON.parse(buffer))
735
+ } catch {
736
+ sendResponse(req, res, {
737
+ timestamp: Date.now(),
738
+ status: 400,
739
+ trace: new Error().stack,
740
+ error: 'Bad Request',
741
+ message: 'Invalid JSON body',
742
+ path: req.url
743
+ }, 400)
744
+
745
+ resolve(null)
746
+ }
747
+ })
748
+ })
749
+ }
750
+
751
+ export function sendResponseNonNull(req, res, data) {
752
+ if (data === null) return;
753
+
754
+ sendResponse(req, res, data, 200)
755
+
756
+ return true
757
+ }
758
+
759
+ export function verifyMethod(parsedUrl, req, res, expected) {
760
+ if (req.method !== expected) {
761
+ sendResponse(req, res, {
762
+ timestamp: Date.now(),
763
+ status: 405,
764
+ error: 'Method Not Allowed',
765
+ message: `Request method must be ${expected}`,
766
+ path: parsedUrl.pathname
767
+ }, 405)
768
+
769
+ return 1
770
+ }
771
+
772
+ return 0
773
+ }
774
+
775
+ Array.prototype.nForEach = async function(callback) {
776
+ return new Promise(async (resolve) => {
777
+ for (let i = 0; i < this.length - 1; i++) {
778
+ const res = await callback(this[i], i)
779
+
780
+ if (res) return resolve()
781
+ }
782
+
783
+ resolve()
784
+ })
785
+ }
786
+
787
+ export function waitForEvent(emitter, eventName, func, timeoutMs) {
788
+ return new Promise((resolve) => {
789
+ const timeout = timeoutMs ? setTimeout(() => {
790
+ throw new Error(`Event ${eventName} timed out after ${timeoutMs}ms`)
791
+ }, timeoutMs) : null
792
+
793
+ const listener = (param, param2) => {
794
+ if (func(param, param2) === true) {
795
+ emitter.removeListener(eventName, listener)
796
+ timeoutMs ? clearTimeout(timeout) : null
797
+ resolve()
798
+ }
799
+ }
800
+ emitter.on(eventName, listener)
801
+ })
802
+ }
803
+
804
+ export function clamp16Bit(sample) {
805
+ return Math.max(constants.pcm.minimumRate, Math.min(sample, constants.pcm.maximumRate))
806
+ }
807
+
808
+ export function parseClientName(clientName) {
809
+ if (!clientName)
810
+ return null
811
+
812
+ let clientInfo = clientName.split('(')
813
+ if (clientInfo.length > 1) clientInfo = clientInfo[0].slice(0, clientInfo[0].length - 1)
814
+ else clientInfo = clientInfo[0]
815
+
816
+ const split = clientInfo.split('/')
817
+ const name = split[0]
818
+ const version = split[1]
819
+
820
+ if (!name || !version || split.length != 2) return null
821
+
822
+ return { name, version }
823
+ }
824
+
825
+ export function isEmpty(value) {
826
+ return value === undefined || value === null || false
827
+ }
828
+
829
+ export function loadHLS(url, stream, onceEnded) {
830
+ return new Promise(async (resolve) => {
831
+ const response = await http1makeRequest(url, { method: 'GET' })
832
+ const body = response.body.split('\n')
833
+
834
+ let segmentMetadata = {
835
+ duration: 0
836
+ }
837
+
838
+ body.nForEach(async (line, i) => {
839
+ return new Promise(async (resolveSegment) => {
840
+ if (stream.ended) {
841
+ resolveSegment(true)
842
+
843
+ return resolve(false)
844
+ }
845
+
846
+ if (line.startsWith('#')) {
847
+ const tag = line.split(':')[0]
848
+ let value = line.split(':')[1]
849
+ if (value) value = value.split(',')[0]
850
+
851
+ if (tag === '#EXTINF') {
852
+ segmentMetadata.duration = parseFloat(value) * 1000
853
+ } else if (tag === '#EXT-X-ENDLIST') {
854
+ stream.end()
855
+
856
+ return resolveSegment(true)
857
+ }
858
+
859
+ return resolveSegment(false)
860
+ }
861
+
862
+ const now = Date.now()
863
+
864
+ const segment = await http1makeRequest(line, { method: 'GET', streamOnly: true })
865
+
866
+ segment.stream.on('data', (chunk) => stream.write(chunk))
867
+ segment.stream.once('readable', () => {
868
+ if (segmentMetadata.duration) {
869
+ setTimeout(() => {
870
+ resolveSegment(false)
871
+ }, segmentMetadata.duration - (Date.now() - now) * 2)
872
+
873
+ segmentMetadata.duration = 0
874
+ } else {
875
+ segment.stream.on('end', () => {
876
+ resolveSegment(false)
877
+
878
+ segment.stream.destroy()
879
+ })
880
+ }
881
+ })
882
+
883
+ if (onceEnded && i === body.length - 2) {
884
+ segment.stream.on('end', () => {
885
+ resolve(true)
886
+
887
+ segment.stream.destroy()
888
+ })
889
+ }
890
+ })
891
+ })
892
+
893
+ if (!onceEnded) resolve(true)
894
+ })
895
+ }
896
+
897
+ export function loadHLSPlaylist(url, stream) {
898
+ return new Promise(async (resolve) => {
899
+ const response = await http1makeRequest(url, { method: 'GET' })
900
+ const body = response.body.split('\n')
901
+
902
+ body.nForEach(async (line, i) => {
903
+ return new Promise(async (resolvePlaylist) => {
904
+ if (line.startsWith('#')) {
905
+ const tag = line.split(':')[0]
906
+ let value = line.split(':')[1]
907
+ if (value) value = value.split(',')[0]
908
+
909
+ if (tag === '#EXT-X-ENDLIST') {
910
+ stream.end()
911
+
912
+ resolvePlaylist(true)
913
+
914
+ return resolve(stream)
915
+ }
916
+
917
+ resolvePlaylist(false)
918
+
919
+ if (i === body.length - 1) {
920
+ loadHLSPlaylist(value, stream)
921
+
922
+ resolve(stream)
923
+ }
924
+
925
+ return;
926
+ }
927
+
928
+ if (await loadHLS(line, stream, true) === false)
929
+ return resolve(stream)
930
+
931
+ resolvePlaylist(false)
932
+
933
+ if (i === body.length - 2) {
934
+ loadHLSPlaylist(url, stream)
935
+
936
+ return resolve(stream)
937
+ }
938
+ })
939
+ })
940
+
941
+ resolve(stream)
942
+ })
943
+ }
src/voice/utils.js ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import config from '../../config.js'
2
+ import constants from '../../constants.js'
3
+
4
+ import prism from 'prism-media'
5
+
6
+ class NodeLinkStream {
7
+ constructor(stream, pipes) {
8
+ pipes.unshift(stream)
9
+
10
+ for (let i = 0; i < pipes.length - 1; i++) {
11
+ const pipe = pipes[i]
12
+
13
+ pipe.pipe(pipes[i + 1])
14
+ }
15
+
16
+ this.stream = pipes[pipes.length - 1]
17
+
18
+ this.listeners = []
19
+ this.pipes = pipes
20
+ }
21
+
22
+ _end() {
23
+ this.listeners.forEach(({ event, listener }) => this.stream.removeListener(event, listener))
24
+ this.listeners = []
25
+
26
+ if (this.stream) {
27
+ this.stream.destroy()
28
+ this.stream = null
29
+ }
30
+
31
+ this.pipes.forEach((_, i) => {
32
+ if (this.pipes[i].destroy) this.pipes[i].destroy()
33
+ delete this.pipes[i]
34
+ })
35
+ }
36
+
37
+ on(event, listener) {
38
+ this.listeners.push({ event, listener })
39
+
40
+ this.stream.on(event, listener)
41
+ }
42
+
43
+ once(event, listener) {
44
+ this.listeners.push({ event, listener })
45
+
46
+ this.stream.once(event, listener)
47
+ }
48
+
49
+ emit(event, ...args) {
50
+ this.stream.emit(event, ...args)
51
+ }
52
+
53
+ read() {
54
+ return this.stream?.read()
55
+ }
56
+
57
+ resume() {
58
+ this.stream?.resume()
59
+ }
60
+
61
+ destroy() {
62
+ this._end()
63
+ }
64
+
65
+ setVolume(volume) {
66
+ this.pipes.find((pipe) => pipe instanceof prism.VolumeTransformer)?.setVolume(volume)
67
+ }
68
+ }
69
+
70
+ function createAudioResource(stream, type) {
71
+ if ([ 'webm/opus', 'ogg/opus' ].includes(type)) {
72
+ return new NodeLinkStream(stream, [
73
+ new prism.opus[type === 'webm/opus' ? 'WebmDemuxer' : 'OggDemuxer'](),
74
+ new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 }),
75
+ new prism.VolumeTransformer({ type: 's16le' }),
76
+ new prism.opus.Encoder({
77
+ rate: constants.opus.samplingRate,
78
+ channels: constants.opus.channels,
79
+ frameSize: constants.opus.frameSize
80
+ })
81
+ ])
82
+ }
83
+
84
+ const ffmpeg = new prism.FFmpeg({
85
+ args: [
86
+ '-loglevel', '0',
87
+ '-analyzeduration', '0',
88
+ '-hwaccel', 'auto',
89
+ '-threads', config.filters.threads,
90
+ '-filter_threads', config.filters.threads,
91
+ '-filter_complex_threads', config.filters.threads,
92
+ '-i', '-',
93
+ '-f', 's16le',
94
+ '-ar', '48000',
95
+ '-ac', '2',
96
+ '-crf', '0'
97
+ ]
98
+ })
99
+
100
+ return new NodeLinkStream(stream, [
101
+ ffmpeg,
102
+ new prism.VolumeTransformer({ type: 's16le' }),
103
+ new prism.opus.Encoder({
104
+ rate: constants.opus.samplingRate,
105
+ channels: constants.opus.channels,
106
+ frameSize: constants.opus.frameSize
107
+ })
108
+ ])
109
+ }
110
+
111
+ export default {
112
+ NodeLinkStream,
113
+ createAudioResource
114
+ }
src/ws.js ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import EventEmitter from 'node:events'
2
+ import crypto from 'node:crypto'
3
+ import { Buffer } from 'node:buffer'
4
+
5
+ const TLS_MAX_SEND_SIZE = 2 ** 14
6
+ const CONTINUE_HEADER_LENGTH = 2
7
+
8
+ function parseFrameHeader(buffer) {
9
+ let startIndex = 2
10
+
11
+ const opcode = buffer[0] & 0b00001111
12
+ const fin = (buffer[0] & 0b10000000) === 0b10000000
13
+ const isMasked = (buffer[1] & 0x80) === 0x80
14
+ let payloadLength = buffer[1] & 0b01111111
15
+
16
+ if (payloadLength === 126) {
17
+ startIndex += 2
18
+ payloadLength = buffer.readUInt16BE(2)
19
+ } else if (payloadLength === 127) {
20
+ const buf = buffer.subarray(startIndex, startIndex + 8)
21
+
22
+ payloadLength = buf.readUInt32BE(0) * Math.pow(2, 32) + buf.readUInt32BE(4)
23
+ startIndex += 8
24
+ }
25
+
26
+ let mask = null
27
+
28
+ if (isMasked) {
29
+ mask = buffer.subarray(startIndex, startIndex + 4)
30
+ startIndex += 4
31
+
32
+ buffer = buffer.subarray(startIndex, startIndex + payloadLength)
33
+
34
+ for (let i = 0; i < buffer.length; i++) {
35
+ buffer[i] ^= mask[i & 3]
36
+ }
37
+ } else {
38
+ buffer = buffer.subarray(startIndex, startIndex + payloadLength)
39
+ }
40
+
41
+ return {
42
+ opcode,
43
+ fin,
44
+ buffer,
45
+ payloadLength
46
+ }
47
+ }
48
+
49
+ class WebsocketConnection extends EventEmitter {
50
+ constructor(req, socket, head, addHeaders) {
51
+ super()
52
+
53
+ this.req = req
54
+ this.socket = socket
55
+
56
+ socket.setNoDelay()
57
+ socket.setKeepAlive(true)
58
+
59
+ if (head.length !== 0) socket.unshift(head)
60
+
61
+ const headers = [
62
+ 'HTTP/1.1 101 Switching Protocols',
63
+ 'Upgrade: websocket',
64
+ 'Connection: Upgrade',
65
+ 'Sec-WebSocket-Accept: ' + crypto.createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'),
66
+ 'Sec-WebSocket-Version: 13',
67
+ ]
68
+
69
+ if (addHeaders) {
70
+ for (const [key, value] of Object.entries(addHeaders)) {
71
+ headers.push(`${key}: ${value}`)
72
+ }
73
+ }
74
+
75
+ socket.write(headers.join('\r\n') + '\r\n\r\n')
76
+
77
+ socket.on('data', (data) => {
78
+ const headers = parseFrameHeader(data)
79
+
80
+ switch (headers.opcode) {
81
+ case 0x0: {
82
+ this.cachedData.push(headers.buffer)
83
+
84
+ if (headers.fin) {
85
+ this.emit('message', Buffer.concat(this.cachedData).toString())
86
+
87
+ this.cachedData = []
88
+ }
89
+
90
+ break
91
+ }
92
+ case 0x1: {
93
+ this.emit('message', headers.buffer.toString())
94
+
95
+ break
96
+ }
97
+ case 0x2: {
98
+ throw new Error('Binary data is not supported.')
99
+
100
+ break
101
+ }
102
+ case 0x8: {
103
+ if (headers.buffer.length === 0) {
104
+ this.emit('close', 1006, '')
105
+ } else {
106
+ const code = headers.buffer.readUInt16BE(0)
107
+ const reason = headers.buffer.subarray(2).toString('utf-8')
108
+
109
+ this.emit('close', code, reason)
110
+ }
111
+
112
+ socket.end()
113
+
114
+ socket.removeAllListeners()
115
+
116
+ break
117
+ }
118
+ case 0x9: {
119
+ const pong = Buffer.allocUnsafe(2)
120
+ pong[0] = 0x8a
121
+ pong[1] = 0x00
122
+
123
+ this.socket.write(pong)
124
+
125
+ break
126
+ }
127
+ case 0x10: {
128
+ this.emit('pong')
129
+ }
130
+ }
131
+
132
+ if (headers.buffer.length > headers.payloadLength)
133
+ this.socket.unshift(headers.buffer)
134
+ })
135
+
136
+ req.on('error', (err) => {
137
+ socket.destroy()
138
+
139
+ this.emit('close', 1006, `Error: ${err.message}`)
140
+
141
+ socket.removeAllListeners()
142
+ })
143
+
144
+ socket.on('error', (err) => {
145
+ socket.destroy()
146
+
147
+ this.emit('close', 1006, `Error: ${err.message}`)
148
+
149
+ socket.removeAllListeners()
150
+ })
151
+
152
+ socket.on('end', () => {
153
+ socket.end()
154
+
155
+ this.emit('close', 1006, '')
156
+
157
+ socket.removeAllListeners()
158
+ })
159
+ }
160
+
161
+ send(data) {
162
+ const payload = Buffer.from(data, 'utf-8')
163
+
164
+ if (payload.length + CONTINUE_HEADER_LENGTH > TLS_MAX_SEND_SIZE) {
165
+ for (let i = 0; i < payload.length; i += TLS_MAX_SEND_SIZE) {
166
+ const buffer = payload.subarray(i, i + TLS_MAX_SEND_SIZE)
167
+ const length = Buffer.byteLength(buffer)
168
+
169
+ let header = null
170
+
171
+ if (i === 0) {
172
+ header = this.makeFHeader({ len: length, fin: false, opcode: 0x1 })
173
+ }
174
+
175
+ else if (i + TLS_MAX_SEND_SIZE >= payload.length) {
176
+ header = this.makeFHeader({ len: length, fin: true, opcode: 0x0 })
177
+ }
178
+
179
+ else {
180
+ header = this.makeFHeader({ len: length, fin: false, opcode: 0x0 })
181
+ }
182
+
183
+ this.socket.write(Buffer.concat([ header, buffer ]))
184
+ }
185
+
186
+ return true
187
+ } else {
188
+ return this.sendFrame(payload, { len: payload.length, fin: true, opcode: 0x01 })
189
+ }
190
+ }
191
+
192
+ destroy() {
193
+ this.socket.destroy()
194
+ this.socket = null
195
+ this.req = null
196
+ }
197
+
198
+ makeFHeader(options) {
199
+ let payloadStartIndex = 2
200
+ let payloadLength = options.len
201
+
202
+ if (options.len >= 65536) {
203
+ payloadStartIndex += 8
204
+ payloadLength = 127
205
+ } else if (options.len > 125) {
206
+ payloadStartIndex += 2
207
+ payloadLength = 126
208
+ }
209
+
210
+ const header = Buffer.allocUnsafe(payloadStartIndex)
211
+ header[0] = options.fin ? options.opcode | 0x80 : options.opcode
212
+ header[1] = payloadLength
213
+
214
+ if (payloadLength === 126) {
215
+ header.writeUInt16BE(options.len, 2)
216
+ } else if (payloadLength === 127) {
217
+ header[2] = header[3] = 0
218
+ header.writeUIntBE(options.len, 4, 6)
219
+ }
220
+
221
+ return header
222
+ }
223
+
224
+ sendFrame(data, options) {
225
+ const header = this.makeFHeader(options)
226
+
227
+ if (this.socket) this.socket.write(Buffer.concat([ header, data ]))
228
+
229
+ return true
230
+ }
231
+
232
+ close(code, reason) {
233
+ const data = Buffer.allocUnsafe(2 + Buffer.byteLength(reason || 'normal close'))
234
+ data.writeUInt16BE(code || 1000)
235
+ data.write(reason || 'normal close', 2)
236
+
237
+ this.sendFrame(data, { len: data.length, fin: true, opcode: 0x08 })
238
+
239
+ return true
240
+ }
241
+ }
242
+
243
+ class WebSocketServer extends EventEmitter {
244
+ constructor() {
245
+ super()
246
+ }
247
+
248
+ handleUpgrade(req, socket, head, headers, callback) {
249
+ const connection = new WebsocketConnection(req, socket, head, headers)
250
+
251
+ if (!socket.readable || !socket.writable) return socket.destroy()
252
+
253
+ callback(connection)
254
+ }
255
+ }
256
+
257
+ export { WebSocketServer }