FreeTube/src/renderer/helpers/api/local.js

740 lines
21 KiB
JavaScript

import { Innertube, ClientType, Misc, Utils, YT } from 'youtubei.js'
import Autolinker from 'autolinker'
import { join } from 'path'
import { PlayerCache } from './PlayerCache'
import {
CHANNEL_HANDLE_REGEX,
extractNumberFromString,
getUserDataPath,
toLocalePublicationString
} from '../utils'
/**
* Creates a lightweight Innertube instance, which is faster to create or
* an instance that can decode the streaming URLs, which is slower to create
* the lightweight one only needs a single web request to create the new session
* the full one needs 3 (or 2 if the player is cached) web requests to create:
* 1. the request for the session
* 2. fetch a page that contains a link to the player
* 3. if the player isn't cached, it is downloaded and transformed
* @param {object} options
* @param {boolean} options.withPlayer set to true to get an Innertube instance that can decode the streaming URLs
* @param {string|undefined} options.location the geolocation to pass to YouTube get different content
* @param {boolean} options.safetyMode whether to hide mature content
* @param {string} options.clientType use an alterate client
* @returns the Innertube instance
*/
async function createInnertube(options = { withPlayer: false, location: undefined, safetyMode: false, clientType: undefined }) {
let cache
if (options.withPlayer) {
const userData = await getUserDataPath()
cache = new PlayerCache(join(userData, 'player_cache'))
}
return await Innertube.create({
retrieve_player: !!options.withPlayer,
location: options.location,
enable_safety_mode: !!options.safetyMode,
client_type: options.clientType,
// use browser fetch
fetch: (input, init) => fetch(input, init),
cache,
generate_session_locally: true
})
}
let searchSuggestionsSession = null
export async function getLocalSearchSuggestions(query) {
// reuse innertube instance to keep the search suggestions snappy
if (searchSuggestionsSession === null) {
searchSuggestionsSession = await createInnertube()
}
return await searchSuggestionsSession.getSearchSuggestions(query)
}
export function clearLocalSearchSuggestionsSession() {
searchSuggestionsSession = null
}
export async function getLocalPlaylist(id) {
const innertube = await createInnertube()
return await innertube.getPlaylist(id)
}
/**
* @typedef {import('youtubei.js/dist/src/core/TabbedFeed').default} TabbedFeed
*/
/**
* @param {string} location
* @param {string} tab
* @param {TabbedFeed|null} instance
*/
export async function getLocalTrending(location, tab, instance) {
if (instance === null) {
const innertube = await createInnertube({ location })
instance = await innertube.getTrending()
}
// youtubei.js's tab names are localised, so we need to use the index to get tab name that youtubei.js expects
const tabIndex = ['default', 'music', 'gaming', 'movies'].indexOf(tab)
const resultsInstance = await instance.getTabByName(instance.tabs[tabIndex])
const results = resultsInstance.videos
.filter((video) => video.type === 'Video')
.map(parseLocalListVideo)
return {
results,
instance: resultsInstance
}
}
/**
* @param {string} query
* @param {object} filters
* @param {boolean} safetyMode
*/
export async function getLocalSearchResults(query, filters, safetyMode) {
const innertube = await createInnertube({ safetyMode })
const response = await innertube.search(query, convertSearchFilters(filters))
return handleSearchResponse(response)
}
/**
* @typedef {import('youtubei.js/dist/src/parser/youtube/Search').default} Search
*/
/**
* @param {Search} continuationData
*/
export async function getLocalSearchContinuation(continuationData) {
const response = await continuationData.getContinuation()
return handleSearchResponse(response)
}
export async function getLocalVideoInfo(id, attemptBypass = false) {
let info
let player
if (attemptBypass) {
const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED })
player = innertube.actions.session.player
// the second request that getInfo makes 404s with the bypass, so we use getBasicInfo instead
// that's fine as we have most of the information from the original getInfo request
info = await innertube.getBasicInfo(id, 'TV_EMBEDDED')
} else {
const innertube = await createInnertube({ withPlayer: true })
player = innertube.actions.session.player
info = await innertube.getInfo(id)
}
if (info.streaming_data) {
decipherFormats(info.streaming_data.adaptive_formats, player)
decipherFormats(info.streaming_data.formats, player)
}
return info
}
export async function getLocalComments(id, sortByNewest = false) {
const innertube = await createInnertube()
return innertube.getComments(id, sortByNewest ? 'NEWEST_FIRST' : 'TOP_COMMENTS')
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/misc/Format').default[]} formats
* @param {import('youtubei.js/dist/index').Player} player
*/
function decipherFormats(formats, player) {
for (const format of formats) {
format.url = format.decipher(player)
// set these to undefined so that toDash doesn't try to decipher them again, throwing an error
format.cipher = undefined
format.signature_cipher = undefined
}
}
export async function getLocalChannelId(url) {
try {
const innertube = await createInnertube()
// resolveURL throws an error if the URL doesn't exist
const navigationEndpoint = await innertube.resolveURL(url)
if (navigationEndpoint.metadata.page_type === 'WEB_PAGE_TYPE_CHANNEL') {
return navigationEndpoint.payload.browseId
} else {
return null
}
} catch {
return null
}
}
/**
* Returns the channel or the channel termination reason
* @param {string} id
*/
export async function getLocalChannel(id) {
const innertube = await createInnertube()
let result
try {
result = await innertube.getChannel(id)
} catch (error) {
if (error instanceof Utils.ChannelError) {
result = {
alert: error.message
}
} else {
throw error
}
}
return result
}
export async function getLocalChannelVideos(id) {
const innertube = await createInnertube()
try {
const response = await innertube.actions.execute('/browse', {
browseId: id,
params: 'EgZ2aWRlb3PyBgQKAjoA'
// protobuf for the videos tab (this is the one that YouTube uses,
// it has some empty fields in the protobuf but it doesn't work if you remove them)
})
const videosTab = new YT.Channel(null, response)
// if the channel doesn't have a videos tab, YouTube returns the home tab instead
// so we need to check that we got the right tab
if (videosTab.current_tab?.endpoint.metadata.url?.endsWith('/videos')) {
return parseLocalChannelVideos(videosTab.videos, videosTab.header.author)
} else {
return []
}
} catch (error) {
console.error(error)
if (error instanceof Utils.ChannelError) {
return null
} else {
throw error
}
}
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/Video').default[]} videos
* @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author
*/
export function parseLocalChannelVideos(videos, author) {
const parsedVideos = videos.map(parseLocalListVideo)
// fix empty author info
parsedVideos.forEach(video => {
video.author = author.name
video.authorId = author.id
})
return parsedVideos
}
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/Playlist').default} Playlist
* @typedef {import('youtubei.js/dist/src/parser/classes/GridPlaylist').default} GridPlaylist
*/
/**
* @param {Playlist|GridPlaylist} playlist
* @param {import('youtubei.js/dist/src/parser/classes/misc/Author').default} author
*/
export function parseLocalListPlaylist(playlist, author = undefined) {
let channelName
let channelId = null
if (playlist.author) {
if (playlist.author instanceof Misc.Text) {
channelName = playlist.author.text
if (author) {
channelId = author.id
}
} else {
channelName = playlist.author.name
channelId = playlist.author.id
}
} else {
channelName = author.name
channelId = author.id
}
return {
type: 'playlist',
dataSource: 'local',
title: playlist.title.text,
thumbnail: playlist.thumbnails[0].url,
channelName,
channelId,
playlistId: playlist.id,
videoCount: extractNumberFromString(playlist.video_count.text)
}
}
/**
* @param {Search} response
*/
function handleSearchResponse(response) {
if (!response.results) {
return {
results: [],
continuationData: null
}
}
const results = response.results
.filter((item) => {
return item.type === 'Video' || item.type === 'Channel' || item.type === 'Playlist'
})
.map((item) => parseListItem(item))
return {
results,
continuationData: response.has_continuation ? response : null
}
}
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlaylistVideo').default} PlaylistVideo
*/
/**
* @param {PlaylistVideo} video
*/
export function parseLocalPlaylistVideo(video) {
return {
videoId: video.id,
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_upcoming,
premiereDate: video.upcoming
}
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/Video').default} video
*/
export function parseLocalListVideo(video) {
return {
type: 'video',
videoId: video.id,
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
description: video.description,
viewCount: extractNumberFromString(video.view_count.text),
publishedText: video.published.text !== 'N/A' ? video.published.text : null,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_upcoming || video.is_premiere,
premiereDate: video.upcoming
}
}
/**
* @param {import('youtubei.js/dist/src/parser/helpers').YTNode} item
*/
function parseListItem(item) {
switch (item.type) {
case 'Video':
return parseLocalListVideo(item)
case 'Channel': {
/** @type {import('youtubei.js/dist/src/parser/classes/Channel').default} */
const channel = item
// see upstream TODO: https://github.com/LuanRT/YouTube.js/blob/main/src/parser/classes/Channel.ts#L33
// according to https://github.com/iv-org/invidious/issues/3514#issuecomment-1368080392
// the response can be the new or old one, so we currently need to handle both here
let subscribers = null
let videos = null
let handle = null
if (channel.subscribers.text.startsWith('@')) {
handle = channel.subscribers.text
if (channel.videos.text !== 'N/A') {
subscribers = channel.videos.text
}
} else {
videos = extractNumberFromString(channel.videos.text)
if (channel.subscribers.text !== 'N/A') {
subscribers = channel.subscribers.text
}
}
return {
type: 'channel',
dataSource: 'local',
thumbnail: channel.author.best_thumbnail?.url,
name: channel.author.name,
id: channel.author.id,
subscribers,
videos,
handle,
descriptionShort: channel.description_snippet.text
}
}
case 'Playlist': {
return parseLocalListPlaylist(item)
}
}
}
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/CompactVideo').default} CompactVideo
*/
/**
* @param {CompactVideo} video
*/
export function parseLocalWatchNextVideo(video) {
return {
type: 'video',
videoId: video.id,
title: video.title.text,
author: video.author.name,
authorId: video.author.id,
viewCount: extractNumberFromString(video.view_count.text),
publishedText: video.published.text === 'N/A' ? null : video.published.text,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds,
liveNow: video.is_live,
isUpcoming: video.is_premiere
}
}
function convertSearchFilters(filters) {
const convertedFilters = {}
// some of the fields have different names and
// others have empty strings that we don't want to pass to youtubei.js
if (filters) {
if (filters.sortBy) {
convertedFilters.sort_by = filters.sortBy
}
if (filters.time) {
convertedFilters.upload_date = filters.time
}
if (filters.type) {
convertedFilters.type = filters.type
}
if (filters.duration) {
convertedFilters.duration = filters.duration
}
}
return convertedFilters
}
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/misc/TextRun').default} TextRun
* @typedef {import('youtubei.js/dist/src/parser/classes/misc/EmojiRun').default} EmojiRun
*/
/**
* @param {(TextRun|EmojiRun)[]} runs
* @param {number} emojiSize
*/
export function parseLocalTextRuns(runs, emojiSize = 16) {
if (!Array.isArray(runs)) {
throw new Error('not an array of text runs')
}
const timestampRegex = /^(?:\d+:){1,2}\d+$/
const parsedRuns = []
for (const run of runs) {
if (run instanceof Misc.EmojiRun) {
const { emoji, text } = run
// empty array if video creator removes a channel emoji so we ignore.
// eg: pinned comment here https://youtu.be/v3wm83zoSSY
if (emoji.image.length > 0) {
let altText
if (emoji.is_custom) {
if (emoji.shortcuts.length > 0) {
altText = emoji.shortcuts[0]
} else if (emoji.search_terms.length > 0) {
altText = emoji.search_terms.join(', ')
} else {
altText = 'Custom emoji'
}
} else {
altText = text
}
// lazy load the emoji image so it doesn't delay rendering of the text
// by defining a height and width, that space is reserved until the image is loaded
// that way we avoid layout shifts when it loads
parsedRuns.push(`<img src="${emoji.image[0].url}" alt="${altText}" width="${emojiSize}" height="${emojiSize}" loading="lazy" style="vertical-align: middle">`)
}
} else {
const { text, bold, italics, strikethrough, endpoint } = run
if (endpoint && !text.startsWith('#')) {
switch (endpoint.metadata.page_type) {
case 'WEB_PAGE_TYPE_WATCH':
if (timestampRegex.test(text)) {
parsedRuns.push(text)
} else {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_CHANNEL': {
const trimmedText = text.trim()
if (CHANNEL_HANDLE_REGEX.test(trimmedText)) {
parsedRuns.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>`)
} else {
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
}
case 'WEB_PAGE_TYPE_PLAYLIST':
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
break
case 'WEB_PAGE_TYPE_UNKNOWN':
default: {
const url = new URL(endpoint.payload.url)
if (url.hostname === 'www.youtube.com' && url.pathname === '/redirect' && url.searchParams.has('q')) {
// remove utm tracking parameters
const realURL = new URL(url.searchParams.get('q'))
realURL.searchParams.delete('utm_source')
realURL.searchParams.delete('utm_medium')
realURL.searchParams.delete('utm_campaign')
realURL.searchParams.delete('utm_term')
realURL.searchParams.delete('utm_content')
parsedRuns.push(realURL.toString())
} else {
// this is probably a special YouTube URL like http://www.youtube.com/approachingnirvana
parsedRuns.push(endpoint.payload.url)
}
break
}
}
} else {
let formattedText = text
if (bold) {
formattedText = `<b>${formattedText}</b>`
}
if (italics) {
formattedText = `<i>${formattedText}</i>`
}
if (strikethrough) {
formattedText = `<s>${formattedText}</s>`
}
parsedRuns.push(formattedText)
}
}
}
return parsedRuns.join('')
}
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/misc/Format').default} Format
*/
/**
* @param {Format} format
*/
export function mapLocalFormat(format) {
return {
itag: format.itag,
qualityLabel: format.quality_label,
fps: format.fps,
bitrate: format.bitrate,
mimeType: format.mime_type,
height: format.height,
url: format.url
}
}
/**
* @param {import('youtubei.js/dist/src/parser/classes/comments/Comment').default} comment
* @param {import('youtubei.js/dist/src/parser/classes/comments/CommentThread').default} commentThread
*/
export function parseLocalComment(comment, commentThread = undefined) {
let hasOwnerReplied = false
let replyToken = null
if (commentThread?.has_replies) {
hasOwnerReplied = commentThread.comment_replies_data.has_channel_owner_replied
replyToken = commentThread
}
return {
dataType: 'local',
authorLink: comment.author.id,
author: comment.author.name,
authorThumb: comment.author.best_thumbnail.url,
isPinned: comment.is_pinned,
isOwner: comment.author_is_channel_owner,
isMember: comment.is_member,
memberIconUrl: comment.is_member ? comment.sponsor_comment_badge.custom_badge[0].url : '',
text: Autolinker.link(parseLocalTextRuns(comment.content.runs, 16)),
time: toLocalePublicationString({ publishText: comment.published.text.replace('(edited)', '').trim() }),
likes: comment.vote_count,
isHearted: comment.is_hearted,
numReplies: comment.reply_count,
hasOwnerReplied,
replyToken,
showReplies: false,
replies: []
}
}
/**
* video.js only supports MP4 DASH not WebM DASH
* so we filter out the WebM DASH formats
* @param {Format[]} formats
* @param {boolean} allowAv1 Use the AV1 formats if they are available
*/
export function filterFormats(formats, allowAv1 = false) {
const audioFormats = []
const h264Formats = []
const av1Formats = []
formats.forEach(format => {
const mimeType = format.mime_type
if (mimeType.startsWith('audio/mp4')) {
audioFormats.push(format)
} else if (allowAv1 && mimeType.startsWith('video/mp4; codecs="av01')) {
av1Formats.push(format)
} else if (mimeType.startsWith('video/mp4; codecs="avc')) {
h264Formats.push(format)
}
})
if (allowAv1 && av1Formats.length > 0) {
return [...audioFormats, ...av1Formats]
} else {
return [...audioFormats, ...h264Formats]
}
}
/**
* Really not a fan of this :(, YouTube returns the subscribers as "15.1M subscribers"
* so we have to parse it somehow
* @param {string} text
*/
export function parseLocalSubscriberCount(text) {
const match = text
.replace(',', '.')
.toUpperCase()
.match(/([\d.]+)\s*([KM]?)/)
let subscribers
if (match) {
subscribers = parseFloat(match[1])
if (match[2] === 'K') {
subscribers *= 1000
} else if (match[2] === 'M') {
subscribers *= 1000_000
}
subscribers = Math.trunc(subscribers)
} else {
subscribers = extractNumberFromString(text)
}
return subscribers
}
/**
* Parse community posts
* @param {import('youtubei.js/dist/src/parser/classes/BackstagePost').default} post
*/
export function parseLocalCommunityPost(post) {
let replyCount = post.action_buttons.reply_button?.text ?? null
if (replyCount !== null) {
replyCount = parseLocalSubscriberCount(post?.action_buttons.reply_button.text)
}
return {
postText: post.content.text === 'N/A' ? '' : post.content.text,
postId: post.id,
authorThumbnails: post.author.thumbnails,
publishedText: post.published.text,
voteCount: post.vote_count,
postContent: parseLocalAttachment(post.attachment),
commentCount: replyCount,
author: post.author.name,
type: 'community'
}
}
function parseLocalAttachment(attachment) {
if (!attachment) {
return null
}
// image post
if (attachment.type === 'BackstageImage') {
return {
type: 'image',
content: attachment.image
}
} else if (attachment.type === 'Video') {
return {
type: 'video',
content: parseLocalListVideo(attachment)
}
} else if (attachment.type === 'Playlist') {
return {
type: 'playlist',
content: parseLocalListPlaylist(attachment)
}
} else if (attachment.type === 'PostMultiImage') {
return {
type: 'multiImage',
content: attachment.images.map(thumbnail => thumbnail.image)
}
} else if (attachment.type === 'Poll') {
return {
type: 'poll',
totalVotes: attachment.total_votes ?? 0,
content: attachment.choices.map(choice => {
return {
text: choice.text.text,
image: choice.image
}
})
}
} else {
console.error(attachment)
console.error('unknown type')
}
}