Spaces:
Runtime error
Runtime error
Upload 25 files
Browse files- Dockerfile +13 -0
- LICENSE +24 -0
- config.js +155 -0
- constants.js +107 -0
- package.json +12 -0
- src/connection/handler.js +829 -0
- src/connection/index.js +125 -0
- src/connection/inputHandler.js +110 -0
- src/connection/voiceHandler.js +383 -0
- src/filters.js +345 -0
- src/sources.js +322 -0
- src/sources/bandcamp.js +203 -0
- src/sources/deezer.js +428 -0
- src/sources/default.js +32 -0
- src/sources/genius.js +58 -0
- src/sources/http.js +61 -0
- src/sources/local.js +53 -0
- src/sources/musixmatch.js +164 -0
- src/sources/pandora.js +919 -0
- src/sources/soundcloud.js +366 -0
- src/sources/spotify.js +472 -0
- src/sources/youtube.js +715 -0
- src/utils.js +943 -0
- src/voice/utils.js +114 -0
- src/ws.js +257 -0
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+&ts=\d+&t=[a-zA-Z0-9]+&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 }
|