Skip to content

Commit 2087e0a

Browse files
committed
add: implement SabrStream for adaptive bitrate streaming
1 parent 299035f commit 2087e0a

7 files changed

Lines changed: 1683 additions & 56 deletions

File tree

src/playback/player.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,11 @@ export class Player {
421421
if (startTime)
422422
urlData.additionalData = { startTime, ...urlData.additionalData }
423423

424+
urlData.additionalData = {
425+
...urlData.additionalData,
426+
positionCallback: () => this._realPosition()
427+
}
428+
424429
const track = urlData?.newTrack ? urlData?.newTrack?.info : info
425430
const fetched = await this.nodelink.sources.getTrackStream(
426431
track,

src/sources/youtube/YouTube.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TVEmbedded from './clients/TVEmbedded.js'
1010
import Web from './clients/Web.js'
1111
import { checkURLType, YOUTUBE_CONSTANTS } from './common.js'
1212
import OAuth from './OAuth.js'
13+
import { SabrStream } from './sabr.js'
1314

1415
const CHUNK_SIZE = 64 * 1024
1516
const MAX_RETRIES = 3
@@ -1010,6 +1011,10 @@ export default class YouTubeSource {
10101011
continue
10111012
}
10121013

1014+
if (urlData.protocol === 'sabr') {
1015+
return urlData
1016+
}
1017+
10131018
if (urlData.url) {
10141019
const check = await http1makeRequest(urlData.url, {
10151020
method: 'GET',
@@ -1155,6 +1160,38 @@ export default class YouTubeSource {
11551160
this.activeStreams.set(streamKey, cancelSignal)
11561161

11571162
try {
1163+
if (protocol === 'sabr') {
1164+
const sabr = new SabrStream({
1165+
videoId: decodedTrack.identifier,
1166+
accessToken: additionalData.accessToken,
1167+
visitorData: additionalData.visitorData,
1168+
serverAbrStreamingUrl: additionalData.serverAbrStreamingUrl,
1169+
videoPlaybackUstreamerConfig: additionalData.videoPlaybackUstreamerConfig,
1170+
poToken: additionalData.poToken,
1171+
clientInfo: additionalData.clientInfo,
1172+
formats: additionalData.formats,
1173+
startTime: additionalData.startTime || 0,
1174+
positionCallback: additionalData.positionCallback
1175+
})
1176+
1177+
const stream = new PassThrough()
1178+
1179+
sabr.on('data', (chunk) => stream.write(chunk))
1180+
sabr.on('end', () => stream.end())
1181+
sabr.on('error', (err) => {
1182+
logger('error', 'YouTube', `SABR stream error: ${err.message}`)
1183+
stream.destroy(err)
1184+
})
1185+
1186+
const bestAudio = additionalData.formats.filter(f => f.mimeType?.includes('audio')).sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0]
1187+
1188+
sabr.start(bestAudio.itag)
1189+
1190+
const type = bestAudio.mimeType?.includes('webm') ? 'webm/opus' : 'm4a'
1191+
1192+
return { stream, type }
1193+
}
1194+
11581195
if (protocol === 'hls') {
11591196
const stream = new PassThrough()
11601197
_manageYoutubeHlsStream(url, stream, cancelSignal, streamKey, this)

src/sources/youtube/clients/Web.js

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,22 @@ import {
55
buildTrack,
66
checkURLType
77
} from '../common.js'
8+
import { PoTokenManager } from '../potoke.js'
89

910
export default class Web extends BaseClient {
1011
constructor(nodelink, oauth) {
1112
super(nodelink, 'WEB', oauth)
13+
this.poTokenManager = new PoTokenManager()
1214
}
1315

1416
getClient(context) {
1517
return {
1618
client: {
1719
clientName: 'WEB',
18-
clientVersion: '2.20260101.00.00',
20+
clientVersion: '2.20260107.01.00',
1921
platform: 'DESKTOP',
2022
userAgent:
21-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36,gzip(gfe)',
23+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
2224
hl: context.client.hl,
2325
gl: context.client.gl
2426
},
@@ -248,33 +250,120 @@ export default class Web extends BaseClient {
248250
}
249251

250252
async getTrackUrl(decodedTrack, context, cipherManager, itag) {
251-
const sourceName = decodedTrack.sourceName || 'youtube'
252-
logger(
253-
'debug',
254-
'youtube-web',
255-
`Getting stream URL for: ${decodedTrack.title} (ID: ${decodedTrack.identifier}) on ${sourceName}`
256-
)
253+
if (this.oauth && this.oauth.accessToken) {
254+
await this.oauth.getAccessToken()
255+
}
257256

258-
const { body: playerResponse, statusCode } = await this._makePlayerRequest(
259-
decodedTrack.identifier,
260-
context,
261-
{},
262-
cipherManager
263-
)
257+
const { poToken, visitorData } = await this.poTokenManager.generate(decodedTrack.identifier)
258+
259+
if (poToken) {
260+
const client = this.getClient(context)
261+
client.client.visitorData = visitorData
262+
263+
let signatureTimestamp = null
264+
try {
265+
const playerScript = await cipherManager.getCachedPlayerScript()
266+
if (playerScript) {
267+
signatureTimestamp = await cipherManager.getTimestamp(playerScript.url)
268+
}
269+
} catch (e) {
270+
logger('warn', 'YouTube-Web', `Failed to get STS: ${e.message}`)
271+
}
272+
273+
const requestBody = {
274+
context: client,
275+
videoId: decodedTrack.identifier,
276+
contentCheckOk: true,
277+
racyCheckOk: true,
278+
serviceIntegrityDimensions: { poToken }
279+
}
264280

265-
if (statusCode !== 200) {
266-
const message = `Failed to get player data for stream. Status: ${statusCode}`
267-
logger('error', 'youtube-web', message)
268-
return { exception: { message, severity: 'common', cause: 'Upstream' } }
281+
if (signatureTimestamp) {
282+
requestBody.playbackContext = {
283+
contentPlaybackContext: {
284+
signatureTimestamp
285+
}
286+
}
287+
}
288+
289+
try {
290+
const { body: playerResponse } = await makeRequest(
291+
'https://youtubei.googleapis.com/youtubei/v1/player?prettyPrint=false',
292+
{
293+
method: 'POST',
294+
headers: {
295+
'User-Agent': client.client.userAgent,
296+
'X-Goog-Visitor-Id': visitorData,
297+
'X-Youtube-Client-Name': '1',
298+
'X-Youtube-Client-Version': client.client.clientVersion,
299+
'Origin': 'https://www.youtube.com',
300+
'Referer': `https://www.youtube.com/watch?v=${decodedTrack.identifier}`
301+
},
302+
body: requestBody,
303+
disableBodyCompression: true
304+
}
305+
)
306+
307+
const streamingData = playerResponse.streamingData || playerResponse.streaming_data
308+
const serverAbrUrl = streamingData?.serverAbrStreamingUrl || streamingData?.server_abr_streaming_url
309+
const ustreamerConfig = playerResponse.playerConfig?.mediaCommonConfig?.mediaUstreamerRequestConfig?.videoPlaybackUstreamerConfig
310+
311+
if (serverAbrUrl) {
312+
const playerScript = await cipherManager.getCachedPlayerScript()
313+
314+
let resolvedUrl = serverAbrUrl
315+
if (playerScript) {
316+
try {
317+
resolvedUrl = await cipherManager.resolveUrl(
318+
serverAbrUrl,
319+
null,
320+
null,
321+
null,
322+
playerScript,
323+
context
324+
)
325+
} catch (e) {
326+
logger('warn', 'YouTube-Web', `Failed to resolve SABR URL via cipher server: ${e.message}`)
327+
}
328+
}
329+
330+
const formats = [...(streamingData.formats || []), ...(streamingData.adaptiveFormats || streamingData.adaptive_formats || [])].map(f => ({
331+
itag: f.itag,
332+
lastModified: f.lastModified || f.last_modified_ms,
333+
xtags: f.xtags,
334+
width: f.width,
335+
height: f.height,
336+
mimeType: f.mimeType || f.mime_type,
337+
audioQuality: f.audioQuality || f.audio_quality,
338+
bitrate: f.bitrate,
339+
averageBitrate: f.averageBitrate || f.average_bitrate,
340+
quality: f.quality,
341+
qualityLabel: f.qualityLabel || f.quality_label,
342+
audioTrackId: f.audioTrack?.id,
343+
approxDurationMs: f.approxDurationMs || f.approx_duration_ms,
344+
contentLength: f.contentLength || f.content_length,
345+
isDrc: !!f.isDrc
346+
}))
347+
348+
return {
349+
protocol: 'sabr',
350+
url: resolvedUrl,
351+
additionalData: {
352+
serverAbrStreamingUrl: resolvedUrl,
353+
videoPlaybackUstreamerConfig: ustreamerConfig,
354+
poToken,
355+
visitorData,
356+
clientInfo: { clientName: 1, clientVersion: client.client.clientVersion },
357+
formats,
358+
accessToken: null,
359+
userAgent: client.client.userAgent
360+
}
361+
}
362+
}
363+
} catch (e) {}
269364
}
270365

271-
return await this._extractStreamData(
272-
playerResponse,
273-
decodedTrack,
274-
context,
275-
cipherManager,
276-
itag
277-
)
366+
return super.getTrackUrl(decodedTrack, context, cipherManager, itag)
278367
}
279368

280369
async getChapters(trackInfo, context) {

src/sources/youtube/potoke.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Buffer } from 'node:buffer'
2+
import { base64ToU8 } from './protor.js'
3+
4+
const PO_CONFIG = {
5+
apiKey: 'AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw',
6+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
7+
clientVersion: '2.20260107.01.00',
8+
integrityUrl: 'https://www.youtube.com/api/jnn/v1/GenerateIT'
9+
};
10+
11+
export class PoTokenManager {
12+
async generate(videoId) {
13+
try {
14+
const { visitorData, botguardData } = await this.getPageContext(videoId);
15+
const { snapshot } = await this.createSnapshot();
16+
const integrityToken = await this.requestIntegrityToken(snapshot, botguardData.requestKey);
17+
const poToken = await this.bindToken(integrityToken, visitorData);
18+
return { poToken, visitorData };
19+
} catch (error) {
20+
return { poToken: null, visitorData: null };
21+
}
22+
}
23+
24+
async getPageContext(videoId) {
25+
const res = await fetch(`https://www.youtube.com/watch?v=${videoId}`, {
26+
headers: { 'User-Agent': PO_CONFIG.userAgent }
27+
});
28+
const html = await res.text();
29+
const visitorDataMatch = html.match(/"visitorData":"([^"]+)"/);
30+
if (!visitorDataMatch) throw new Error('Could not find visitorData');
31+
const visitorData = visitorDataMatch[1];
32+
const stsMatch = html.match(/"signatureTimestamp":(\d+)/);
33+
const signatureTimestamp = stsMatch ? parseInt(stsMatch[1]) : 20455;
34+
35+
const playerRes = await fetch('https://youtubei.googleapis.com/youtubei/v1/player', {
36+
method: 'POST',
37+
headers: {
38+
'Content-Type': 'application/json',
39+
'User-Agent': PO_CONFIG.userAgent,
40+
'X-Goog-Visitor-Id': visitorData
41+
},
42+
body: JSON.stringify({
43+
videoId,
44+
playbackContext: { contentPlaybackContext: { signatureTimestamp } },
45+
context: { client: { clientName: 'WEB', clientVersion: PO_CONFIG.clientVersion, visitorData, userAgent: PO_CONFIG.userAgent } }
46+
})
47+
});
48+
49+
const body = await playerRes.json();
50+
const botguardData = body.botguardData || { requestKey: 'fixed_key' };
51+
return { visitorData, botguardData };
52+
}
53+
54+
async createSnapshot() {
55+
const signals = new Uint8Array([0x12, 0x1a, 0x0a, 0x08, 0x01, 0x12, 0x04, 0x08, 0x01, 0x10, 0x01]);
56+
return { snapshot: Buffer.from(signals).toString('base64'), signals };
57+
}
58+
59+
async requestIntegrityToken(snapshot, requestKey) {
60+
const res = await fetch(PO_CONFIG.integrityUrl, {
61+
method: 'POST',
62+
headers: {
63+
'Content-Type': 'application/json+protobuf',
64+
'X-Goog-Api-Key': PO_CONFIG.apiKey,
65+
'X-User-Agent': 'grpc-web-javascript/0.1'
66+
},
67+
body: JSON.stringify([requestKey, snapshot])
68+
});
69+
const body = await res.json();
70+
return body[0];
71+
}
72+
73+
async bindToken(integrityToken, visitorData) {
74+
const itBytes = base64ToU8(integrityToken);
75+
const vdBytes = Buffer.from(visitorData, 'utf8');
76+
const buffer = new Uint8Array(10 + itBytes.length + vdBytes.length);
77+
buffer[0] = 0x22; buffer[1] = buffer.length - 2;
78+
buffer.set([0x5a, 0xb3, 0x00, 0x01], 2);
79+
new DataView(buffer.buffer, 6, 4).setUint32(0, Math.floor(Date.now() / 1000), false);
80+
buffer.set(itBytes, 10); buffer.set(vdBytes, 10 + itBytes.length);
81+
const payload = buffer.subarray(2);
82+
for (let i = 2; i < payload.length; i++) payload[i] ^= i % 2 === 0 ? 0x5a : 0xb3;
83+
return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
84+
}
85+
}

0 commit comments

Comments
 (0)