import debug from 'debug'; import util from 'util'; import fetch from 'node-fetch'; import { WATCH_BASE } from './util.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. */ export const getPlayerConfig = async (videoId) => { const url = util.format(WATCH_BASE, encodeURIComponent(videoId)); log(`Fetching ${videoId} (${url})`); const res = await fetch(url); debugger; 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}`); } return config; }; /** * 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); };