Add support for videos with a viewer discretion advisory

This commit is contained in:
absidue 2021-12-27 17:15:48 +01:00
parent e1b0d14477
commit 61cddbce5e
3 changed files with 190 additions and 22 deletions

View File

@ -9,6 +9,7 @@ interface RequestOpts extends RequestOptions {
body?: string; body?: string;
method?: 'GET' | 'POST' | 'HEAD'; method?: 'GET' | 'POST' | 'HEAD';
cookies?: boolean; cookies?: boolean;
cookieJar?: { [key: string]: string };
} }
/** /**
@ -33,7 +34,6 @@ export function request_stream(req_url: string, options: RequestOpts = { method:
/** /**
* Makes a request and follows redirects if necessary * Makes a request and follows redirects if necessary
* @param req_url URL to make https request to * @param req_url URL to make https request to
* @param cookies_added Whether cookies were added or not
* @param options Request options for https request * @param options Request options for https request
* @returns A promise with the final response object * @returns A promise with the final response object
*/ */
@ -69,6 +69,18 @@ export function request(req_url: string, options: RequestOpts = { method: 'GET'
cookies_added = true; cookies_added = true;
} }
} }
if (options.cookieJar) {
const cookies = [];
for (const cookie of Object.entries(options.cookieJar)) {
cookies.push(cookie.join('='));
}
if (cookies.length !== 0) {
if (!options.headers) options.headers = {};
const existingCookies = cookies_added ? `; ${options.headers.cookie}` : '';
Object.assign(options.headers, { cookie: `${cookies.join('; ')}${existingCookies}` });
}
}
if (options.headers) { if (options.headers) {
options.headers = { options.headers = {
...options.headers, ...options.headers,
@ -81,8 +93,16 @@ export function request(req_url: string, options: RequestOpts = { method: 'GET'
reject(res); reject(res);
return; return;
} }
if (res.headers && res.headers['set-cookie'] && cookies_added) { if (res.headers && res.headers['set-cookie']) {
cookieHeaders(res.headers['set-cookie']); if (options.cookieJar) {
for (const cookie of res.headers['set-cookie']) {
const parts = cookie.split(';')[0].trim().split('=');
options.cookieJar[parts.shift() as string] = parts.join('=');
}
}
if (cookies_added) {
cookieHeaders(res.headers['set-cookie']);
}
} }
const data: string[] = []; const data: string[] = [];
let decoder: BrotliDecompress | Gunzip | Deflate | undefined = undefined; let decoder: BrotliDecompress | Gunzip | Deflate | undefined = undefined;

View File

@ -62,6 +62,10 @@ interface VideoOptions {
* YouTube Video tags * YouTube Video tags
*/ */
tags: string[]; tags: string[];
/**
* `true` if the video has been identified by the YouTube community as inappropriate or offensive to some audiences and viewer discretion is advised
*/
discretionAdvised: boolean;
} }
/** /**
* Class for YouTube Video url * Class for YouTube Video url
@ -127,6 +131,10 @@ export class YouTubeVideo {
* YouTube Video tags * YouTube Video tags
*/ */
tags: string[]; tags: string[];
/**
* `true` if the video has been identified by the YouTube community as inappropriate or offensive to some audiences and viewer discretion is advised
*/
discretionAdvised: boolean;
/** /**
* Constructor for YouTube Video Class * Constructor for YouTube Video Class
* @param data JSON parsed data. * @param data JSON parsed data.
@ -153,6 +161,7 @@ export class YouTubeVideo {
this.live = !!data.live; this.live = !!data.live;
this.private = !!data.private; this.private = !!data.private;
this.tags = data.tags || []; this.tags = data.tags || [];
this.discretionAdvised = !!data.discretionAdvised;
} }
/** /**
* Converts class to title name of video. * Converts class to title name of video.
@ -180,7 +189,8 @@ export class YouTubeVideo {
tags: this.tags, tags: this.tags,
likes: this.likes, likes: this.likes,
live: this.live, live: this.live,
private: this.private private: this.private,
discretionAdvised: this.discretionAdvised
}; };
} }
} }

View File

@ -3,6 +3,7 @@ import { format_decipher } from './cipher';
import { YouTubeVideo } from '../classes/Video'; import { YouTubeVideo } from '../classes/Video';
import { YouTubePlayList } from '../classes/Playlist'; import { YouTubePlayList } from '../classes/Playlist';
import { InfoData, StreamInfoData } from './constants'; import { InfoData, StreamInfoData } from './constants';
import { URLSearchParams } from 'node:url';
interface InfoOptions { interface InfoOptions {
htmldata?: boolean; htmldata?: boolean;
@ -104,6 +105,7 @@ export function extractID(url: string): string {
export async function video_basic_info(url: string, options: InfoOptions = {}): Promise<InfoData> { export async function video_basic_info(url: string, options: InfoOptions = {}): Promise<InfoData> {
if (typeof url !== 'string') throw new Error('url parameter is not a URL string or a string of HTML'); if (typeof url !== 'string') throw new Error('url parameter is not a URL string or a string of HTML');
let body: string; let body: string;
const cookieJar = {};
if (options.htmldata) { if (options.htmldata) {
body = url; body = url;
} else { } else {
@ -114,7 +116,8 @@ export async function video_basic_info(url: string, options: InfoOptions = {}):
headers: { headers: {
'accept-language': options.language || 'en-US;q=0.9' 'accept-language': options.language || 'en-US;q=0.9'
}, },
cookies: true cookies: true,
cookieJar
}); });
} }
if (body.indexOf('Our systems have detected unusual traffic from your computer network.') !== -1) if (body.indexOf('Our systems have detected unusual traffic from your computer network.') !== -1)
@ -131,13 +134,35 @@ export async function video_basic_info(url: string, options: InfoOptions = {}):
if (!initial_data) throw new Error('Initial Response Data is undefined.'); if (!initial_data) throw new Error('Initial Response Data is undefined.');
const player_response = JSON.parse(player_data); const player_response = JSON.parse(player_data);
const initial_response = JSON.parse(initial_data); const initial_response = JSON.parse(initial_data);
if (player_response.playabilityStatus.status !== 'OK') const vid = player_response.videoDetails;
throw new Error(
`While getting info from url\n${ let discretionAdvised = false;
player_response.playabilityStatus.errorScreen.playerErrorMessageRenderer?.reason.simpleText ?? if (player_response.playabilityStatus.status !== 'OK') {
player_response.playabilityStatus.errorScreen.playerKavRenderer?.reason.simpleText if (player_response.playabilityStatus.status === 'CONTENT_CHECK_REQUIRED') {
}` if (options.htmldata)
); throw new Error(
`Accepting the viewer discretion is not supported when using htmldata, video: ${vid.videoId}`
);
discretionAdvised = true;
const cookies =
initial_response.topbar.desktopTopbarRenderer.interstitial.consentBumpV2Renderer.agreeButton
.buttonRenderer.command.saveConsentAction;
Object.assign(cookieJar, {
VISITOR_INFO1_LIVE: cookies.visitorCookie,
CONSENT: cookies.consentCookie
});
const updatedValues = await acceptViewerDiscretion(vid.videoId, cookieJar, body, true);
player_response.streamingData = updatedValues.streamingData;
initial_response.contents.twoColumnWatchNextResults.secondaryResults = updatedValues.relatedVideos;
} else
throw new Error(
`While getting info from url\n${
player_response.playabilityStatus.errorScreen.playerErrorMessageRenderer?.reason.simpleText ??
player_response.playabilityStatus.errorScreen.playerKavRenderer?.reason.simpleText
}`
);
}
const ownerInfo = const ownerInfo =
initial_response.contents.twoColumnWatchNextResults.results?.results?.contents[1]?.videoSecondaryInfoRenderer initial_response.contents.twoColumnWatchNextResults.results?.results?.contents[1]?.videoSecondaryInfoRenderer
?.owner?.videoOwnerRenderer; ?.owner?.videoOwnerRenderer;
@ -150,7 +175,6 @@ export async function video_basic_info(url: string, options: InfoOptions = {}):
related.push(`https://www.youtube.com/watch?v=${res.compactVideoRenderer.videoId}`); related.push(`https://www.youtube.com/watch?v=${res.compactVideoRenderer.videoId}`);
} }
); );
const vid = player_response.videoDetails;
const microformat = player_response.microformat.playerMicroformatRenderer; const microformat = player_response.microformat.playerMicroformatRenderer;
const video_details = new YouTubeVideo({ const video_details = new YouTubeVideo({
id: vid.videoId, id: vid.videoId,
@ -179,7 +203,8 @@ export async function video_basic_info(url: string, options: InfoOptions = {}):
?.toggleButtonRenderer.defaultText.accessibility?.accessibilityData.label.replace(/\D+/g, '') ?? 0 ?.toggleButtonRenderer.defaultText.accessibility?.accessibilityData.label.replace(/\D+/g, '') ?? 0
), ),
live: vid.isLiveContent, live: vid.isLiveContent,
private: vid.isPrivate private: vid.isPrivate,
discretionAdvised
}); });
const format = player_response.streamingData.formats ?? []; const format = player_response.streamingData.formats ?? [];
format.push(...(player_response.streamingData.adaptiveFormats ?? [])); format.push(...(player_response.streamingData.adaptiveFormats ?? []));
@ -210,6 +235,7 @@ export async function video_basic_info(url: string, options: InfoOptions = {}):
export async function video_stream_info(url: string, options: InfoOptions = {}): Promise<StreamInfoData> { export async function video_stream_info(url: string, options: InfoOptions = {}): Promise<StreamInfoData> {
if (typeof url !== 'string') throw new Error('url parameter is not a URL string or a string of HTML'); if (typeof url !== 'string') throw new Error('url parameter is not a URL string or a string of HTML');
let body: string; let body: string;
const cookieJar = {};
if (options.htmldata) { if (options.htmldata) {
body = url; body = url;
} else { } else {
@ -218,7 +244,8 @@ export async function video_stream_info(url: string, options: InfoOptions = {}):
const new_url = `https://www.youtube.com/watch?v=${video_id}&has_verified=1`; const new_url = `https://www.youtube.com/watch?v=${video_id}&has_verified=1`;
body = await request(new_url, { body = await request(new_url, {
headers: { 'accept-language': 'en-US,en;q=0.9' }, headers: { 'accept-language': 'en-US,en;q=0.9' },
cookies: true cookies: true,
cookieJar
}); });
} }
if (body.indexOf('Our systems have detected unusual traffic from your computer network.') !== -1) if (body.indexOf('Our systems have detected unusual traffic from your computer network.') !== -1)
@ -229,13 +256,42 @@ export async function video_stream_info(url: string, options: InfoOptions = {}):
.split(/;\s*(var|const|let)\s/)[0]; .split(/;\s*(var|const|let)\s/)[0];
if (!player_data) throw new Error('Initial Player Response Data is undefined.'); if (!player_data) throw new Error('Initial Player Response Data is undefined.');
const player_response = JSON.parse(player_data); const player_response = JSON.parse(player_data);
if (player_response.playabilityStatus.status !== 'OK') if (player_response.playabilityStatus.status !== 'OK') {
throw new Error( if (player_response.playabilityStatus.status === 'CONTENT_CHECK_REQUIRED') {
`While getting info from url\n${ if (options.htmldata)
player_response.playabilityStatus.errorScreen.playerErrorMessageRenderer?.reason.simpleText ?? throw new Error(
player_response.playabilityStatus.errorScreen.playerKavRenderer?.reason.simpleText `Accepting the viewer discretion is not supported when using htmldata, video: ${player_response.videoDetails.videoId}`
}` );
);
const initial_data = body
.split('var ytInitialData = ')?.[1]
?.split(';</script>')[0]
.split(/;\s*(var|const|let)\s/)[0];
if (!initial_data) throw new Error('Initial Response Data is undefined.');
const cookies =
JSON.parse(initial_data).topbar.desktopTopbarRenderer.interstitial.consentBumpV2Renderer.agreeButton
.buttonRenderer.command.saveConsentAction;
Object.assign(cookieJar, {
VISITOR_INFO1_LIVE: cookies.visitorCookie,
CONSENT: cookies.consentCookie
});
const updatedValues = await acceptViewerDiscretion(
player_response.videoDetails.videoId,
cookieJar,
body,
false
);
player_response.streamingData = updatedValues.streamingData;
} else
throw new Error(
`While getting info from url\n${
player_response.playabilityStatus.errorScreen.playerErrorMessageRenderer?.reason.simpleText ??
player_response.playabilityStatus.errorScreen.playerKavRenderer?.reason.simpleText
}`
);
}
const html5player = `https://www.youtube.com${body.split('"jsUrl":"')[1].split('"')[0]}`; const html5player = `https://www.youtube.com${body.split('"jsUrl":"')[1].split('"')[0]}`;
const duration = Number(player_response.videoDetails.lengthSeconds); const duration = Number(player_response.videoDetails.lengthSeconds);
const video_details = { const video_details = {
@ -412,6 +468,88 @@ export function getContinuationToken(data: any): string {
.continuationEndpoint?.continuationCommand?.token; .continuationEndpoint?.continuationCommand?.token;
} }
async function acceptViewerDiscretion(
videoId: string,
cookieJar: { [key: string]: string },
body: string,
extractRelated: boolean
): Promise<{ streamingData: any; relatedVideos?: any }> {
const apiKey =
body.split('INNERTUBE_API_KEY":"')[1]?.split('"')[0] ??
body.split('innertubeApiKey":"')[1]?.split('"')[0] ??
DEFAULT_API_KEY;
const sessionToken =
body.split('"XSRF_TOKEN":"')[1]?.split('"')[0].replaceAll('\\u003d', '=') ??
body.split('"xsrf_token":"')[1]?.split('"')[0].replaceAll('\\u003d', '=');
if (!sessionToken)
throw new Error(`Unable to extract XSRF_TOKEN to accept the viewer discretion popup for video: ${videoId}.`);
const verificationResponse = await request(`https://www.youtube.com/youtubei/v1/verify_age?key=${apiKey}`, {
method: 'POST',
body: JSON.stringify({
context: {
client: {
utcOffsetMinutes: 0,
gl: 'US',
hl: 'en',
clientName: 'WEB',
clientVersion:
body.split('"INNERTUBE_CONTEXT_CLIENT_VERSION":"')[1]?.split('"')[0] ??
body.split('"innertube_context_client_version":"')[1]?.split('"')[0] ??
'<some version>'
},
user: {},
request: {}
},
nextEndpoint: {
urlEndpoint: {
url: `watch?v=${videoId}`
}
},
setControvercy: true
}),
cookieJar
});
const endpoint = JSON.parse(verificationResponse).actions[0].navigateAction.endpoint;
const videoPage = await request(`https://www.youtube.com/${endpoint.urlEndpoint.url}&pbj=1`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams([
['command', JSON.stringify(endpoint)],
['session_token', sessionToken]
]).toString(),
cookieJar
});
if (videoPage.includes('<h1>Something went wrong</h1>'))
throw new Error(`Unable to accept the viewer discretion popup for video: ${videoId}`);
const videoPageData = JSON.parse(videoPage);
if (videoPageData[2].playerResponse.playabilityStatus.status !== 'OK')
throw new Error(
`While getting info from url after trying to accept the discretion popup for video ${videoId}\n${
videoPageData[2].playerResponse.playabilityStatus.errorScreen.playerErrorMessageRenderer?.reason
.simpleText ??
videoPageData[2].playerResponse.playabilityStatus.errorScreen.playerKavRenderer?.reason.simpleText
}`
);
const streamingData = videoPageData[2].playerResponse.streamingData;
if (extractRelated)
return {
streamingData,
relatedVideos: videoPageData[3].response.contents.twoColumnWatchNextResults.secondaryResults
};
return { streamingData };
}
function getWatchPlaylist(response: any, body: any): YouTubePlayList { function getWatchPlaylist(response: any, body: any): YouTubePlayList {
const playlist_details = response.contents.twoColumnWatchNextResults.playlist.playlist; const playlist_details = response.contents.twoColumnWatchNextResults.playlist.playlist;