366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
|
const utils = require('./utils');
|
||
|
const qs = require('querystring');
|
||
|
const { parseTimestamp } = require('m3u8stream');
|
||
|
|
||
|
|
||
|
const BASE_URL = 'https://www.youtube.com/watch?v=';
|
||
|
const TITLE_TO_CATEGORY = {
|
||
|
song: { name: 'Music', url: 'https://music.youtube.com/' },
|
||
|
};
|
||
|
|
||
|
const getText = obj => obj ? obj.runs ? obj.runs[0].text : obj.simpleText : null;
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Get video media.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {Object}
|
||
|
*/
|
||
|
exports.getMedia = info => {
|
||
|
let media = {};
|
||
|
let results = [];
|
||
|
try {
|
||
|
results = info.response.contents.twoColumnWatchNextResults.results.results.contents;
|
||
|
} catch (err) {
|
||
|
// Do nothing
|
||
|
}
|
||
|
|
||
|
let result = results.find(v => v.videoSecondaryInfoRenderer);
|
||
|
if (!result) { return {}; }
|
||
|
|
||
|
try {
|
||
|
let metadataRows =
|
||
|
(result.metadataRowContainer || result.videoSecondaryInfoRenderer.metadataRowContainer)
|
||
|
.metadataRowContainerRenderer.rows;
|
||
|
for (let row of metadataRows) {
|
||
|
if (row.metadataRowRenderer) {
|
||
|
let title = getText(row.metadataRowRenderer.title).toLowerCase();
|
||
|
let contents = row.metadataRowRenderer.contents[0];
|
||
|
media[title] = getText(contents);
|
||
|
let runs = contents.runs;
|
||
|
if (runs && runs[0].navigationEndpoint) {
|
||
|
media[`${title}_url`] = new URL(
|
||
|
runs[0].navigationEndpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString();
|
||
|
}
|
||
|
if (title in TITLE_TO_CATEGORY) {
|
||
|
media.category = TITLE_TO_CATEGORY[title].name;
|
||
|
media.category_url = TITLE_TO_CATEGORY[title].url;
|
||
|
}
|
||
|
} else if (row.richMetadataRowRenderer) {
|
||
|
let contents = row.richMetadataRowRenderer.contents;
|
||
|
let boxArt = contents
|
||
|
.filter(meta => meta.richMetadataRenderer.style === 'RICH_METADATA_RENDERER_STYLE_BOX_ART');
|
||
|
for (let { richMetadataRenderer } of boxArt) {
|
||
|
let meta = richMetadataRenderer;
|
||
|
media.year = getText(meta.subtitle);
|
||
|
let type = getText(meta.callToAction).split(' ')[1];
|
||
|
media[type] = getText(meta.title);
|
||
|
media[`${type}_url`] = new URL(
|
||
|
meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString();
|
||
|
media.thumbnails = meta.thumbnail.thumbnails;
|
||
|
}
|
||
|
let topic = contents
|
||
|
.filter(meta => meta.richMetadataRenderer.style === 'RICH_METADATA_RENDERER_STYLE_TOPIC');
|
||
|
for (let { richMetadataRenderer } of topic) {
|
||
|
let meta = richMetadataRenderer;
|
||
|
media.category = getText(meta.title);
|
||
|
media.category_url = new URL(
|
||
|
meta.endpoint.commandMetadata.webCommandMetadata.url, BASE_URL).toString();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} catch (err) {
|
||
|
// Do nothing.
|
||
|
}
|
||
|
|
||
|
return media;
|
||
|
};
|
||
|
|
||
|
|
||
|
const isVerified = badges => !!(badges && badges.find(b => b.metadataBadgeRenderer.tooltip === 'Verified'));
|
||
|
|
||
|
|
||
|
/**
|
||
|
* Get video author.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {Object}
|
||
|
*/
|
||
|
exports.getAuthor = info => {
|
||
|
let channelId, thumbnails = [], subscriberCount, verified = false;
|
||
|
try {
|
||
|
let results = info.response.contents.twoColumnWatchNextResults.results.results.contents;
|
||
|
let v = results.find(v2 =>
|
||
|
v2.videoSecondaryInfoRenderer &&
|
||
|
v2.videoSecondaryInfoRenderer.owner &&
|
||
|
v2.videoSecondaryInfoRenderer.owner.videoOwnerRenderer);
|
||
|
let videoOwnerRenderer = v.videoSecondaryInfoRenderer.owner.videoOwnerRenderer;
|
||
|
channelId = videoOwnerRenderer.navigationEndpoint.browseEndpoint.browseId;
|
||
|
thumbnails = videoOwnerRenderer.thumbnail.thumbnails.map(thumbnail => {
|
||
|
thumbnail.url = new URL(thumbnail.url, BASE_URL).toString();
|
||
|
return thumbnail;
|
||
|
});
|
||
|
subscriberCount = utils.parseAbbreviatedNumber(getText(videoOwnerRenderer.subscriberCountText));
|
||
|
verified = isVerified(videoOwnerRenderer.badges);
|
||
|
} catch (err) {
|
||
|
// Do nothing.
|
||
|
}
|
||
|
try {
|
||
|
let videoDetails = info.player_response.microformat && info.player_response.microformat.playerMicroformatRenderer;
|
||
|
let id = (videoDetails && videoDetails.channelId) || channelId || info.player_response.videoDetails.channelId;
|
||
|
let author = {
|
||
|
id: id,
|
||
|
name: videoDetails ? videoDetails.ownerChannelName : info.player_response.videoDetails.author,
|
||
|
user: videoDetails ? videoDetails.ownerProfileUrl.split('/').slice(-1)[0] : null,
|
||
|
channel_url: `https://www.youtube.com/channel/${id}`,
|
||
|
external_channel_url: videoDetails ? `https://www.youtube.com/channel/${videoDetails.externalChannelId}` : '',
|
||
|
user_url: videoDetails ? new URL(videoDetails.ownerProfileUrl, BASE_URL).toString() : '',
|
||
|
thumbnails,
|
||
|
verified,
|
||
|
subscriber_count: subscriberCount,
|
||
|
};
|
||
|
if (thumbnails.length) {
|
||
|
utils.deprecate(author, 'avatar', author.thumbnails[0].url, 'author.avatar', 'author.thumbnails[0].url');
|
||
|
}
|
||
|
return author;
|
||
|
} catch (err) {
|
||
|
return {};
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const parseRelatedVideo = (details, rvsParams) => {
|
||
|
if (!details) return;
|
||
|
try {
|
||
|
let viewCount = getText(details.viewCountText);
|
||
|
let shortViewCount = getText(details.shortViewCountText);
|
||
|
let rvsDetails = rvsParams.find(elem => elem.id === details.videoId);
|
||
|
if (!/^\d/.test(shortViewCount)) {
|
||
|
shortViewCount = (rvsDetails && rvsDetails.short_view_count_text) || '';
|
||
|
}
|
||
|
viewCount = (/^\d/.test(viewCount) ? viewCount : shortViewCount).split(' ')[0];
|
||
|
let browseEndpoint = details.shortBylineText.runs[0].navigationEndpoint.browseEndpoint;
|
||
|
let channelId = browseEndpoint.browseId;
|
||
|
let name = getText(details.shortBylineText);
|
||
|
let user = (browseEndpoint.canonicalBaseUrl || '').split('/').slice(-1)[0];
|
||
|
let video = {
|
||
|
id: details.videoId,
|
||
|
title: getText(details.title),
|
||
|
published: getText(details.publishedTimeText),
|
||
|
author: {
|
||
|
id: channelId,
|
||
|
name,
|
||
|
user,
|
||
|
channel_url: `https://www.youtube.com/channel/${channelId}`,
|
||
|
user_url: `https://www.youtube.com/user/${user}`,
|
||
|
thumbnails: details.channelThumbnail.thumbnails.map(thumbnail => {
|
||
|
thumbnail.url = new URL(thumbnail.url, BASE_URL).toString();
|
||
|
return thumbnail;
|
||
|
}),
|
||
|
verified: isVerified(details.ownerBadges),
|
||
|
|
||
|
[Symbol.toPrimitive]() {
|
||
|
console.warn(`\`relatedVideo.author\` will be removed in a near future release, ` +
|
||
|
`use \`relatedVideo.author.name\` instead.`);
|
||
|
return video.author.name;
|
||
|
},
|
||
|
|
||
|
},
|
||
|
short_view_count_text: shortViewCount.split(' ')[0],
|
||
|
view_count: viewCount.replace(/,/g, ''),
|
||
|
length_seconds: details.lengthText ?
|
||
|
Math.floor(parseTimestamp(getText(details.lengthText)) / 1000) :
|
||
|
rvsParams && `${rvsParams.length_seconds}`,
|
||
|
thumbnails: details.thumbnail.thumbnails,
|
||
|
richThumbnails:
|
||
|
details.richThumbnail ?
|
||
|
details.richThumbnail.movingThumbnailRenderer.movingThumbnailDetails.thumbnails : [],
|
||
|
isLive: !!(details.badges && details.badges.find(b => b.metadataBadgeRenderer.label === 'LIVE NOW')),
|
||
|
};
|
||
|
|
||
|
utils.deprecate(video, 'author_thumbnail', video.author.thumbnails[0].url,
|
||
|
'relatedVideo.author_thumbnail', 'relatedVideo.author.thumbnails[0].url');
|
||
|
utils.deprecate(video, 'ucid', video.author.id, 'relatedVideo.ucid', 'relatedVideo.author.id');
|
||
|
utils.deprecate(video, 'video_thumbnail', video.thumbnails[0].url,
|
||
|
'relatedVideo.video_thumbnail', 'relatedVideo.thumbnails[0].url');
|
||
|
return video;
|
||
|
} catch (err) {
|
||
|
// Skip.
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Get related videos.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {Array.<Object>}
|
||
|
*/
|
||
|
exports.getRelatedVideos = info => {
|
||
|
let rvsParams = [], secondaryResults = [];
|
||
|
try {
|
||
|
rvsParams = info.response.webWatchNextResponseExtensionData.relatedVideoArgs.split(',').map(e => qs.parse(e));
|
||
|
} catch (err) {
|
||
|
// Do nothing.
|
||
|
}
|
||
|
try {
|
||
|
secondaryResults = info.response.contents.twoColumnWatchNextResults.secondaryResults.secondaryResults.results;
|
||
|
} catch (err) {
|
||
|
return [];
|
||
|
}
|
||
|
let videos = [];
|
||
|
for (let result of secondaryResults || []) {
|
||
|
let details = result.compactVideoRenderer;
|
||
|
if (details) {
|
||
|
let video = parseRelatedVideo(details, rvsParams);
|
||
|
if (video) videos.push(video);
|
||
|
} else {
|
||
|
let autoplay = result.compactAutoplayRenderer || result.itemSectionRenderer;
|
||
|
if (!autoplay || !Array.isArray(autoplay.contents)) continue;
|
||
|
for (let content of autoplay.contents) {
|
||
|
let video = parseRelatedVideo(content.compactVideoRenderer, rvsParams);
|
||
|
if (video) videos.push(video);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
return videos;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Get like count.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
exports.getLikes = info => {
|
||
|
try {
|
||
|
let contents = info.response.contents.twoColumnWatchNextResults.results.results.contents;
|
||
|
let video = contents.find(r => r.videoPrimaryInfoRenderer);
|
||
|
let buttons = video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons;
|
||
|
let like = buttons.find(b => b.toggleButtonRenderer &&
|
||
|
b.toggleButtonRenderer.defaultIcon.iconType === 'LIKE');
|
||
|
return parseInt(like.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D+/g, ''));
|
||
|
} catch (err) {
|
||
|
return null;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Get dislike count.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {number}
|
||
|
*/
|
||
|
exports.getDislikes = info => {
|
||
|
try {
|
||
|
let contents = info.response.contents.twoColumnWatchNextResults.results.results.contents;
|
||
|
let video = contents.find(r => r.videoPrimaryInfoRenderer);
|
||
|
let buttons = video.videoPrimaryInfoRenderer.videoActions.menuRenderer.topLevelButtons;
|
||
|
let dislike = buttons.find(b => b.toggleButtonRenderer &&
|
||
|
b.toggleButtonRenderer.defaultIcon.iconType === 'DISLIKE');
|
||
|
return parseInt(dislike.toggleButtonRenderer.defaultText.accessibility.accessibilityData.label.replace(/\D+/g, ''));
|
||
|
} catch (err) {
|
||
|
return null;
|
||
|
}
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Cleans up a few fields on `videoDetails`.
|
||
|
*
|
||
|
* @param {Object} videoDetails
|
||
|
* @param {Object} info
|
||
|
* @returns {Object}
|
||
|
*/
|
||
|
exports.cleanVideoDetails = (videoDetails, info) => {
|
||
|
videoDetails.thumbnails = videoDetails.thumbnail.thumbnails;
|
||
|
delete videoDetails.thumbnail;
|
||
|
utils.deprecate(videoDetails, 'thumbnail', { thumbnails: videoDetails.thumbnails },
|
||
|
'videoDetails.thumbnail.thumbnails', 'videoDetails.thumbnails');
|
||
|
videoDetails.description = videoDetails.shortDescription || getText(videoDetails.description);
|
||
|
delete videoDetails.shortDescription;
|
||
|
utils.deprecate(videoDetails, 'shortDescription', videoDetails.description,
|
||
|
'videoDetails.shortDescription', 'videoDetails.description');
|
||
|
|
||
|
// Use more reliable `lengthSeconds` from `playerMicroformatRenderer`.
|
||
|
videoDetails.lengthSeconds =
|
||
|
(info.player_response.microformat &&
|
||
|
info.player_response.microformat.playerMicroformatRenderer.lengthSeconds) ||
|
||
|
info.player_response.videoDetails.lengthSeconds;
|
||
|
return videoDetails;
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Get storyboards info.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {Array.<Object>}
|
||
|
*/
|
||
|
exports.getStoryboards = info => {
|
||
|
const parts = info.player_response.storyboards &&
|
||
|
info.player_response.storyboards.playerStoryboardSpecRenderer &&
|
||
|
info.player_response.storyboards.playerStoryboardSpecRenderer.spec &&
|
||
|
info.player_response.storyboards.playerStoryboardSpecRenderer.spec.split('|');
|
||
|
|
||
|
if (!parts) return [];
|
||
|
|
||
|
const url = new URL(parts.shift());
|
||
|
|
||
|
return parts.map((part, i) => {
|
||
|
let [
|
||
|
thumbnailWidth,
|
||
|
thumbnailHeight,
|
||
|
thumbnailCount,
|
||
|
columns,
|
||
|
rows,
|
||
|
interval,
|
||
|
nameReplacement,
|
||
|
sigh,
|
||
|
] = part.split('#');
|
||
|
|
||
|
url.searchParams.set('sigh', sigh);
|
||
|
|
||
|
thumbnailCount = parseInt(thumbnailCount, 10);
|
||
|
columns = parseInt(columns, 10);
|
||
|
rows = parseInt(rows, 10);
|
||
|
|
||
|
const storyboardCount = Math.ceil(thumbnailCount / (columns * rows));
|
||
|
|
||
|
return {
|
||
|
templateUrl: url.toString().replace('$L', i).replace('$N', nameReplacement),
|
||
|
thumbnailWidth: parseInt(thumbnailWidth, 10),
|
||
|
thumbnailHeight: parseInt(thumbnailHeight, 10),
|
||
|
thumbnailCount,
|
||
|
interval: parseInt(interval, 10),
|
||
|
columns,
|
||
|
rows,
|
||
|
storyboardCount,
|
||
|
};
|
||
|
});
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Get chapters info.
|
||
|
*
|
||
|
* @param {Object} info
|
||
|
* @returns {Array.<Object>}
|
||
|
*/
|
||
|
exports.getChapters = info => {
|
||
|
const playerOverlayRenderer = info.response &&
|
||
|
info.response.playerOverlays &&
|
||
|
info.response.playerOverlays.playerOverlayRenderer;
|
||
|
const playerBar = playerOverlayRenderer &&
|
||
|
playerOverlayRenderer.decoratedPlayerBarRenderer &&
|
||
|
playerOverlayRenderer.decoratedPlayerBarRenderer.decoratedPlayerBarRenderer &&
|
||
|
playerOverlayRenderer.decoratedPlayerBarRenderer.decoratedPlayerBarRenderer.playerBar;
|
||
|
const markersMap = playerBar &&
|
||
|
playerBar.multiMarkersPlayerBarRenderer &&
|
||
|
playerBar.multiMarkersPlayerBarRenderer.markersMap;
|
||
|
const marker = Array.isArray(markersMap) && markersMap.find(m => m.value && Array.isArray(m.value.chapters));
|
||
|
if (!marker) return [];
|
||
|
const chapters = marker.value.chapters;
|
||
|
|
||
|
return chapters.map(chapter => ({
|
||
|
title: getText(chapter.chapterRenderer.title),
|
||
|
start_time: chapter.chapterRenderer.timeRangeStartMillis / 1000,
|
||
|
}));
|
||
|
};
|