134 lines
3.9 KiB
JavaScript
134 lines
3.9 KiB
JavaScript
import debug from 'debug';
|
|
import util from 'util';
|
|
import fetch from 'node-fetch';
|
|
|
|
import { WATCH_BASE } from './util.mjs';
|
|
import { retryable, exclusive } from './retry.mjs';
|
|
import { TemporaryError } from './retry.mjs';
|
|
|
|
const log = debug('youtube-maze:api');
|
|
const PLAYER_RESP_REGEX = /window\["ytInitialPlayerResponse"\] = (\{.*?\});/;
|
|
const PLAYER_CONFIG_REGEX = /ytplayer\.config = (\{.*?\});/;
|
|
|
|
/**
|
|
* Fetch the `ytplayer.config` object for a YouTube video.
|
|
*
|
|
* @async
|
|
* @param String videoId Identifier of the video to fetch.
|
|
* @return Object The player configuration object.
|
|
*/
|
|
const bareGetPlayerConfig = async (videoId) =>
|
|
{
|
|
const url = util.format(WATCH_BASE, encodeURIComponent(videoId));
|
|
log(`Fetching ${videoId} (${url})`);
|
|
const res = await fetch(url);
|
|
|
|
if (res.status === 429)
|
|
{
|
|
throw new TemporaryError('Too many requests');
|
|
}
|
|
else if (res.status !== 200)
|
|
{
|
|
throw new Error(`Invalid YouTube HTTP status: ${res.status}`);
|
|
}
|
|
|
|
const body = await res.text();
|
|
|
|
// Look for the initial player response object to check whether the
|
|
// video was found
|
|
const responseSearch = body.match(PLAYER_RESP_REGEX);
|
|
|
|
if (responseSearch === null)
|
|
{
|
|
throw new TemporaryError(`Invalid YouTube response for video ${videoId}`);
|
|
}
|
|
|
|
const response = JSON.parse(responseSearch[1]);
|
|
|
|
if (response.playabilityStatus.status !== 'OK')
|
|
{
|
|
throw new Error(`Video ${videoId} is not available; \
|
|
status: ${response.playabilityStatus.status}; \
|
|
reason: "${response.playabilityStatus.reason}"`);
|
|
}
|
|
|
|
// Look for the definition of ytplayer.config and unserialize it
|
|
const configSearch = body.match(PLAYER_CONFIG_REGEX);
|
|
|
|
if (configSearch === null)
|
|
{
|
|
throw new TemporaryError(`Unable to find player for video ${videoId}`);
|
|
}
|
|
|
|
const config = JSON.parse(configSearch[1]);
|
|
config.args.player_response = JSON.parse(config.args.player_response);
|
|
const actualId = config.args.player_response.videoDetails.videoId;
|
|
|
|
if (videoId !== actualId)
|
|
{
|
|
throw new Error(`Video ${videoId} has actual id ${actualId}`);
|
|
}
|
|
|
|
log(`Done fetching ${videoId}`);
|
|
return config;
|
|
};
|
|
|
|
export const getPlayerConfig = exclusive(retryable(bareGetPlayerConfig));
|
|
|
|
/**
|
|
* Get metadata about a YouTube video.
|
|
*
|
|
* @param config The `ytplayer.config` object corresponding to the source
|
|
* YouTube video, as obtained from `getPlayerConfig`.
|
|
* @return Object containing the video metadata.
|
|
*/
|
|
export const getVideoMeta = config => ({
|
|
videoId: config.args.player_response.videoDetails.videoId,
|
|
title: config.args.player_response.videoDetails.title,
|
|
});
|
|
|
|
/**
|
|
* Find videos linked from the endscreen of a YouTube video.
|
|
*
|
|
* @param config The `ytplayer.config` object corresponding to the source
|
|
* YouTube video, as obtained from `getPlayerConfig`.
|
|
* @return List of identifiers of linked videos.
|
|
*/
|
|
export const getEndScreenVideos = config =>
|
|
{
|
|
const response = config.args.player_response;
|
|
|
|
if (!('endscreen' in response))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return response.endscreen.endscreenRenderer.elements
|
|
.map(elt => elt.endscreenElementRenderer)
|
|
.filter(rdr => 'watchEndpoint' in rdr.endpoint)
|
|
.map(rdr => rdr.endpoint.watchEndpoint.videoId);
|
|
};
|
|
|
|
/**
|
|
* Find videos linked from as cards from a YouTube video.
|
|
*
|
|
* @param config The `ytplayer.config` object corresponding to the source
|
|
* YouTube video, as obtained from `getPlayerConfig`.
|
|
* @return List of identifiers of linked videos.
|
|
*/
|
|
export const getCardVideos = config =>
|
|
{
|
|
const response = config.args.player_response;
|
|
|
|
if (!('cards' in response))
|
|
{
|
|
return [];
|
|
}
|
|
|
|
return response.cards.cardCollectionRenderer.cards
|
|
.map(card => card.cardRenderer.content)
|
|
.filter(content => 'videoInfoCardContentRenderer' in content)
|
|
.map(content => content.videoInfoCardContentRenderer)
|
|
.map(rdr => rdr.action.watchEndpoint.videoId);
|
|
};
|