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 METADATA_REGEXES = [ /window\["ytInitialPlayerResponse"\] = (\{.*?\});/, /var ytInitialPlayerResponse = (\{.*?\});/, ]; /** * Fetch metadata about a YouTube video. * * @async * @param String videoId Identifier of the video to fetch. * @return Object The video metadata object. */ const bareGetMetadata = 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(); const searchResults = METADATA_REGEXES.map(regex => body.match(regex)); const searchResult = searchResults.find(item => item !== null); if (searchResult === null) { throw new TemporaryError(`Incomplete YouTube response for video ${videoId}`); } const metadata = JSON.parse(searchResult[1]); if (metadata.playabilityStatus.status !== 'OK') { throw new Error(`Video ${videoId} is not available; \ status: ${metadata.playabilityStatus.status}; \ reason: "${metadata.playabilityStatus.reason}"`); } const actualId = metadata.videoDetails.videoId; if (videoId !== actualId) { throw new Error(`Video ${videoId} has actual id ${actualId}`); } log(`Done fetching ${videoId}`); return metadata; }; export const getMetadata = exclusive(retryable(bareGetMetadata)); /** * Get video ID and title. * * @param Object metadata The video metadata object from `getMetadata`. * @return Object Containing the video ID and title. */ export const getVideoInfo = metadata => ({ videoId: metadata.videoDetails.videoId, title: metadata.videoDetails.title, }); /** * Find videos linked from the endscreen of a YouTube video. * * @param Object metadata The video metadata object from `getMetadata`. * @return List of identifiers of linked videos. */ export const getEndScreenVideos = metadata => { if (!('endscreen' in metadata)) { return []; } return metadata.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 Object metadata The video metadata object from `getMetadata`. * @return List of identifiers of linked videos. */ export const getCardVideos = metadata => { if (!('cards' in metadata)) { return []; } return metadata.cards.cardCollectionRenderer.cards .map(card => card.cardRenderer.content) .filter(content => 'videoInfoCardContentRenderer' in content) .map(content => content.videoInfoCardContentRenderer) .map(rdr => rdr.action.watchEndpoint.videoId); };