mirror of https://github.com/FreeTubeApp/FreeTube
1171 lines
36 KiB
JavaScript
1171 lines
36 KiB
JavaScript
import { ClientType, Endpoints, Innertube, Misc, Utils, YT } from 'youtubei.js'
|
|
import Autolinker from 'autolinker'
|
|
import { join } from 'path'
|
|
|
|
import { PlayerCache } from './PlayerCache'
|
|
import {
|
|
CHANNEL_HANDLE_REGEX,
|
|
escapeHTML,
|
|
extractNumberFromString,
|
|
getUserDataPath,
|
|
toLocalePublicationString
|
|
} from '../utils'
|
|
|
|
const TRACKING_PARAM_NAMES = [
|
|
'utm_source',
|
|
'utm_medium',
|
|
'utm_campaign',
|
|
'utm_term',
|
|
'utm_content',
|
|
]
|
|
|
|
/**
|
|
* 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 {import('youtubei.js').ClientType} options.clientType use an alterate client
|
|
* @param {boolean} options.generateSessionLocally generate the session locally or let YouTube generate it (local is faster, remote is more accurate)
|
|
* @returns the Innertube instance
|
|
*/
|
|
async function createInnertube({ withPlayer = false, location = undefined, safetyMode = false, clientType = undefined, generateSessionLocally = true } = {}) {
|
|
let cache
|
|
if (withPlayer) {
|
|
const userData = await getUserDataPath()
|
|
cache = new PlayerCache(join(userData, 'player_cache'))
|
|
}
|
|
|
|
return await Innertube.create({
|
|
retrieve_player: !!withPlayer,
|
|
location: location,
|
|
enable_safety_mode: !!safetyMode,
|
|
client_type: clientType,
|
|
|
|
// use browser fetch
|
|
fetch: (input, init) => fetch(input, init),
|
|
cache,
|
|
generate_session_locally: !!generateSessionLocally
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* @param {Playlist} playlist
|
|
* @returns {Playlist|null} null when no valid playlist can be found (e.g. `empty continuation response`)
|
|
*/
|
|
export async function getLocalPlaylistContinuation(playlist) {
|
|
try {
|
|
return await playlist.getContinuation()
|
|
} catch (error) {
|
|
// Youtube can provide useless continuation data
|
|
if (!error.message.includes('Got empty continuation response.')) {
|
|
// Re-throw unhandled error
|
|
throw error
|
|
}
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Callback for adding two numbers.
|
|
*
|
|
* @callback untilEndOfLocalPlayListCallback
|
|
* @param {Playlist} playlist
|
|
*/
|
|
|
|
/**
|
|
* @param {Playlist} playlist
|
|
* @param {untilEndOfLocalPlayListCallback} callback
|
|
* @param {object} options
|
|
* @param {boolean} options.runCallbackOnceFirst
|
|
*/
|
|
export async function untilEndOfLocalPlayList(playlist, callback, options = { runCallbackOnceFirst: true }) {
|
|
if (options.runCallbackOnceFirst) { callback(playlist) }
|
|
|
|
while (playlist != null && playlist.has_continuation) {
|
|
playlist = await getLocalPlaylistContinuation(playlist)
|
|
|
|
if (playlist != null) { callback(playlist) }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} location
|
|
* @param {'default'|'music'|'gaming'|'movies'} tab
|
|
* @param {import('youtubei.js').Mixins.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])
|
|
|
|
let results
|
|
|
|
// the default tab can have duplicate videos so we need to deduplicate them
|
|
if (tab === 'default') {
|
|
const alreadySeenIds = []
|
|
results = []
|
|
|
|
resultsInstance.videos.forEach(video => {
|
|
if (video.type === 'Video' && !alreadySeenIds.includes(video.id)) {
|
|
alreadySeenIds.push(video.id)
|
|
results.push(parseLocalListVideo(video))
|
|
}
|
|
})
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* @param {YT.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, generateSessionLocally: false })
|
|
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, generateSessionLocally: false })
|
|
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')
|
|
}
|
|
|
|
// I know `type & type` is typescript syntax and not valid jsdoc but I couldn't get @extends or @augments to work
|
|
|
|
/**
|
|
* @typedef {object} _LocalFormat
|
|
* @property {string} freeTubeUrl deciphered streaming URL, stored in a custom property so the DASH manifest generation doesn't break
|
|
*
|
|
* @typedef {Misc.Format & _LocalFormat} LocalFormat
|
|
*/
|
|
|
|
/**
|
|
* @param {Misc.Format[]} formats
|
|
* @param {import('youtubei.js').Player} player
|
|
*/
|
|
function decipherFormats(formats, player) {
|
|
for (const format of formats) {
|
|
// toDash deciphers the format again, so if we overwrite the original URL,
|
|
// it breaks because the n param would get deciphered twice and then be incorrect
|
|
format.freeTubeUrl = format.decipher(player)
|
|
}
|
|
}
|
|
|
|
export async function getLocalChannelId(url) {
|
|
try {
|
|
const innertube = await createInnertube()
|
|
|
|
// Resolve URL and allow 1 redirect, as YouTube should just do 1
|
|
// We want to avoid an endless loop
|
|
for (let i = 0; i < 2; i++) {
|
|
// 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 if (navigationEndpoint.metadata.page_type === 'WEB_PAGE_TYPE_UNKNOWN' && navigationEndpoint.payload.url?.startsWith('https://www.youtube.com/')) {
|
|
// handle redirects like https://www.youtube.com/@wanderbots, which resolves to https://www.youtube.com/Wanderbots, which we need to resolve again
|
|
url = navigationEndpoint.payload.url
|
|
} 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(Endpoints.BrowseEndpoint.PATH, Endpoints.BrowseEndpoint.build({
|
|
browse_id: 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')) {
|
|
const { id: channelId = id, name } = parseLocalChannelHeader(videosTab)
|
|
|
|
return parseLocalChannelVideos(videosTab.videos, channelId, name)
|
|
} else {
|
|
return []
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (error instanceof Utils.ChannelError) {
|
|
return null
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getLocalChannelLiveStreams(id) {
|
|
const innertube = await createInnertube()
|
|
|
|
try {
|
|
const response = await innertube.actions.execute(Endpoints.BrowseEndpoint.PATH, Endpoints.BrowseEndpoint.build({
|
|
browse_id: id,
|
|
params: 'EgdzdHJlYW1z8gYECgJ6AA%3D%3D'
|
|
// protobuf for the live 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 liveStreamsTab = new YT.Channel(null, response)
|
|
|
|
// if the channel doesn't have a live tab, YouTube returns the home tab instead
|
|
// so we need to check that we got the right tab
|
|
if (liveStreamsTab.current_tab?.endpoint.metadata.url?.endsWith('/streams')) {
|
|
const { id: channelId = id, name } = parseLocalChannelHeader(liveStreamsTab)
|
|
|
|
return parseLocalChannelVideos(liveStreamsTab.videos, channelId, name)
|
|
} else {
|
|
return []
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (error instanceof Utils.ChannelError) {
|
|
return null
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getLocalChannelCommunity(id) {
|
|
const innertube = await createInnertube()
|
|
|
|
try {
|
|
const response = await innertube.actions.execute(Endpoints.BrowseEndpoint.PATH, Endpoints.BrowseEndpoint.build({
|
|
browse_id: id,
|
|
params: 'Egljb21tdW5pdHnyBgQKAkoA'
|
|
// protobuf for the community 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 communityTab = new YT.Channel(null, response)
|
|
|
|
// if the channel doesn't have a community tab, YouTube returns the home tab instead
|
|
// so we need to check that we got the right tab
|
|
if (communityTab.current_tab?.endpoint.metadata.url?.endsWith('/community')) {
|
|
return parseLocalCommunityPosts(communityTab.posts)
|
|
} else {
|
|
return []
|
|
}
|
|
} catch (error) {
|
|
console.error(error)
|
|
if (error instanceof Utils.ChannelError) {
|
|
return null
|
|
} else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {YT.Channel} channel
|
|
*/
|
|
export function parseLocalChannelHeader(channel) {
|
|
/** @type {string=} */
|
|
let id
|
|
/** @type {string} */
|
|
let name
|
|
/** @type {string=} */
|
|
let thumbnailUrl
|
|
/** @type {string=} */
|
|
let bannerUrl
|
|
/** @type {string=} */
|
|
let subscriberText
|
|
/** @type {string[]} */
|
|
const tags = []
|
|
|
|
switch (channel.header.type) {
|
|
case 'C4TabbedHeader': {
|
|
// example: Linus Tech Tips
|
|
// https://www.youtube.com/channel/UCXuqSBlHAE6Xw-yeJA0Tunw
|
|
|
|
/**
|
|
* @type {import('youtubei.js').YTNodes.C4TabbedHeader}
|
|
*/
|
|
const header = channel.header
|
|
|
|
id = header.author.id
|
|
name = header.author.name
|
|
thumbnailUrl = header.author.best_thumbnail.url
|
|
bannerUrl = header.banner?.[0]?.url
|
|
subscriberText = header.subscribers?.text
|
|
break
|
|
}
|
|
case 'CarouselHeader': {
|
|
// examples: Music and YouTube Gaming
|
|
// https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ
|
|
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
|
|
|
|
/**
|
|
* @type {import('youtubei.js').YTNodes.CarouselHeader}
|
|
*/
|
|
const header = channel.header
|
|
|
|
/**
|
|
* @type {import('youtubei.js').YTNodes.TopicChannelDetails}
|
|
*/
|
|
const topicChannelDetails = header.contents.find(node => node.type === 'TopicChannelDetails')
|
|
name = topicChannelDetails.title.text
|
|
subscriberText = topicChannelDetails.subtitle.text
|
|
thumbnailUrl = topicChannelDetails.avatar[0].url
|
|
|
|
if (channel.metadata.external_id) {
|
|
id = channel.metadata.external_id
|
|
} else {
|
|
id = topicChannelDetails.subscribe_button.channel_id
|
|
}
|
|
break
|
|
}
|
|
case 'InteractiveTabbedHeader': {
|
|
// example: Minecraft - Topic
|
|
// https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg
|
|
|
|
/**
|
|
* @type {import('youtubei.js').YTNodes.InteractiveTabbedHeader}
|
|
*/
|
|
const header = channel.header
|
|
name = header.title.text
|
|
thumbnailUrl = header.box_art.at(-1).url
|
|
bannerUrl = header.banner[0]?.url
|
|
|
|
const badges = header.badges.map(badge => badge.label).filter(tag => tag)
|
|
tags.push(...badges)
|
|
|
|
id = channel.current_tab?.endpoint.payload.browseId
|
|
break
|
|
}
|
|
case 'PageHeader': {
|
|
// example: YouTube Gaming
|
|
// https://www.youtube.com/channel/UCOpNcN46UbXVtpKMrmU4Abg
|
|
|
|
// User channels (an A/B test at the time of writing)
|
|
|
|
/**
|
|
* @type {import('youtubei.js').YTNodes.PageHeader}
|
|
*/
|
|
const header = channel.header
|
|
|
|
name = header.content.title.text.text
|
|
if (header.content.image) {
|
|
if (header.content.image.type === 'ContentPreviewImageView') {
|
|
/** @type {import('youtubei.js').YTNodes.ContentPreviewImageView} */
|
|
const image = header.content.image
|
|
|
|
thumbnailUrl = image.image[0].url
|
|
} else {
|
|
/** @type {import('youtubei.js').YTNodes.DecoratedAvatarView} */
|
|
const image = header.content.image
|
|
thumbnailUrl = image.avatar?.image[0].url
|
|
}
|
|
}
|
|
|
|
if (!thumbnailUrl && channel.metadata.thumbnail) {
|
|
thumbnailUrl = channel.metadata.thumbnail[0].url
|
|
}
|
|
|
|
if (header.content.banner) {
|
|
bannerUrl = header.content.banner.image[0]?.url
|
|
}
|
|
|
|
if (header.content.actions) {
|
|
const modal = header.content.actions.actions_rows[0].actions[0].on_tap.modal
|
|
|
|
if (modal && modal.type === 'ModalWithTitleAndButton') {
|
|
/** @type {import('youtubei.js').YTNodes.ModalWithTitleAndButton} */
|
|
const typedModal = modal
|
|
|
|
id = typedModal.button.endpoint.next_endpoint?.payload.browseId
|
|
}
|
|
} else if (channel.metadata.external_id) {
|
|
id = channel.metadata.external_id
|
|
}
|
|
|
|
if (header.content.metadata) {
|
|
subscriberText = header.content.metadata.metadata_rows[0].metadata_parts[1].text.text
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
thumbnailUrl,
|
|
bannerUrl,
|
|
subscriberText,
|
|
tags
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.Video[]} videos
|
|
* @param {string} channelId
|
|
* @param {string} channelName
|
|
*/
|
|
export function parseLocalChannelVideos(videos, channelId, channelName) {
|
|
const parsedVideos = videos.map(parseLocalListVideo)
|
|
|
|
// fix empty author info
|
|
parsedVideos.forEach(video => {
|
|
video.author = channelName
|
|
video.authorId = channelId
|
|
})
|
|
|
|
return parsedVideos
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.ReelItem[]} shorts
|
|
* @param {string} channelId
|
|
* @param {string} channelName
|
|
*/
|
|
export function parseLocalChannelShorts(shorts, channelId, channelName) {
|
|
return shorts.map(short => {
|
|
return {
|
|
type: 'video',
|
|
videoId: short.id,
|
|
title: short.title.text,
|
|
author: channelName,
|
|
authorId: channelId,
|
|
viewCount: short.views.isEmpty() ? null : parseLocalSubscriberCount(short.views.text),
|
|
lengthSeconds: ''
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* @typedef {import('youtubei.js').YTNodes.Playlist} Playlist
|
|
* @typedef {import('youtubei.js').YTNodes.GridPlaylist} GridPlaylist
|
|
*/
|
|
|
|
/**
|
|
* @param {Playlist|GridPlaylist} playlist
|
|
* @param {string} channelId
|
|
* @param {string} chanelName
|
|
*/
|
|
export function parseLocalListPlaylist(playlist, channelId = undefined, channelName = undefined) {
|
|
let internalChannelName
|
|
let internalChannelId = null
|
|
|
|
if (playlist.author && playlist.author.id !== 'N/A') {
|
|
if (playlist.author instanceof Misc.Text) {
|
|
internalChannelName = playlist.author.text
|
|
|
|
if (channelId) {
|
|
internalChannelId = channelId
|
|
}
|
|
} else {
|
|
internalChannelName = playlist.author.name
|
|
internalChannelId = playlist.author.id
|
|
}
|
|
} else if (channelId || channelName) {
|
|
internalChannelName = channelName
|
|
internalChannelId = channelId
|
|
} else if (playlist.author?.name) {
|
|
// auto-generated album playlists don't have an author
|
|
// so in search results, the author text is "Playlist" and doesn't have a link or channel ID
|
|
internalChannelName = playlist.author.name
|
|
}
|
|
|
|
/** @type {import('youtubei.js').YTNodes.PlaylistVideoThumbnail} */
|
|
const thumbnailRenderer = playlist.thumbnail_renderer
|
|
|
|
return {
|
|
type: 'playlist',
|
|
dataSource: 'local',
|
|
title: playlist.title.text,
|
|
thumbnail: thumbnailRenderer ? thumbnailRenderer.thumbnail[0].url : playlist.thumbnails[0].url,
|
|
channelName: internalChannelName,
|
|
channelId: internalChannelId,
|
|
playlistId: playlist.id,
|
|
videoCount: extractNumberFromString(playlist.video_count.text)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {YT.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' || item.type === 'HashtagTile' || item.type === 'Movie'
|
|
})
|
|
.map((item) => parseListItem(item))
|
|
|
|
return {
|
|
results,
|
|
// check the length of the results, as there can be continuations for things that we've filtered out, which we don't want
|
|
continuationData: response.has_continuation && results.length > 0 ? response : null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.PlaylistVideo|import('youtubei.js').YTNodes.ReelItem} video
|
|
*/
|
|
export function parseLocalPlaylistVideo(video) {
|
|
if (video.type === 'ReelItem') {
|
|
/** @type {import('youtubei.js').YTNodes.ReelItem} */
|
|
const short = video
|
|
|
|
return {
|
|
type: 'video',
|
|
videoId: short.id,
|
|
title: short.title.text,
|
|
viewCount: parseLocalSubscriberCount(short.views.text),
|
|
lengthSeconds: ''
|
|
}
|
|
} else {
|
|
/** @type {import('youtubei.js').YTNodes.PlaylistVideo} */
|
|
const video_ = video
|
|
|
|
let viewCount = null
|
|
|
|
// the accessiblity label contains the full view count
|
|
// the video info only contains the short view count
|
|
if (video_.accessibility_label) {
|
|
const match = video_.accessibility_label.match(/([\d,.]+|no) views?$/i)
|
|
|
|
if (match) {
|
|
const count = match[1]
|
|
|
|
// as it's rare that a video has no views,
|
|
// checking the length allows us to avoid running toLowerCase unless we have to
|
|
if (count.length === 2 && count.toLowerCase() === 'no') {
|
|
viewCount = 0
|
|
} else {
|
|
const views = extractNumberFromString(count)
|
|
|
|
if (!isNaN(views)) {
|
|
viewCount = views
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let publishedText = null
|
|
|
|
// normal videos have 3 text runs with the last one containing the published date
|
|
// live videos have 2 text runs with the number of people watching
|
|
// upcoming either videos don't have any info text or the number of people waiting,
|
|
// but we have the premiere date for those, so we don't need the published date
|
|
if (video_.video_info.runs && video_.video_info.runs.length === 3) {
|
|
publishedText = video_.video_info.runs[2].text
|
|
}
|
|
|
|
return {
|
|
videoId: video_.id,
|
|
title: video_.title.text,
|
|
author: video_.author.name,
|
|
authorId: video_.author.id,
|
|
viewCount,
|
|
publishedText,
|
|
lengthSeconds: isNaN(video_.duration.seconds) ? '' : video_.duration.seconds,
|
|
liveNow: video_.is_live,
|
|
isUpcoming: video_.is_upcoming,
|
|
premiereDate: video_.upcoming
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.Video | import('youtubei.js').YTNodes.Movie} item
|
|
*/
|
|
export function parseLocalListVideo(item) {
|
|
if (item.type === 'Movie') {
|
|
/** @type {import('youtubei.js').YTNodes.Movie} */
|
|
const movie = item
|
|
|
|
return {
|
|
type: 'video',
|
|
videoId: movie.id,
|
|
title: movie.title.text,
|
|
author: movie.author.name,
|
|
authorId: movie.author.id !== 'N/A' ? movie.author.id : null,
|
|
description: movie.description_snippet?.text,
|
|
lengthSeconds: isNaN(movie.duration.seconds) ? '' : movie.duration.seconds,
|
|
liveNow: false,
|
|
isUpcoming: false,
|
|
}
|
|
} else {
|
|
/** @type {import('youtubei.js').YTNodes.Video} */
|
|
const video = item
|
|
return {
|
|
type: 'video',
|
|
videoId: video.id,
|
|
title: video.title.text,
|
|
author: video.author.name,
|
|
authorId: video.author.id,
|
|
description: video.description,
|
|
viewCount: video.view_count == null ? null : extractNumberFromString(video.view_count.text),
|
|
publishedText: (video.published == null || video.published.isEmpty()) ? null : video.published.text,
|
|
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').Helpers.YTNode} item
|
|
*/
|
|
function parseListItem(item) {
|
|
switch (item.type) {
|
|
case 'Movie':
|
|
case 'Video':
|
|
return parseLocalListVideo(item)
|
|
case 'Channel': {
|
|
/** @type {import('youtubei.js').YTNodes.Channel} */
|
|
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.subscriber_count.text?.startsWith('@')) {
|
|
handle = channel.subscriber_count.text
|
|
|
|
if (!channel.video_count.isEmpty()) {
|
|
subscribers = channel.video_count.text
|
|
}
|
|
} else {
|
|
videos = extractNumberFromString(channel.video_count.text)
|
|
|
|
if (!channel.subscriber_count.isEmpty()) {
|
|
subscribers = channel.subscriber_count.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 'HashtagTile': {
|
|
/** @type {import('youtubei.js').YTNodes.HashtagTile} */
|
|
const hashtag = item
|
|
|
|
return {
|
|
type: 'hashtag',
|
|
title: hashtag.hashtag.text,
|
|
videoCount: hashtag.hashtag_video_count.isEmpty() ? null : parseLocalSubscriberCount(hashtag.hashtag_video_count.text),
|
|
channelCount: hashtag.hashtag_channel_count.isEmpty() ? null : parseLocalSubscriberCount(hashtag.hashtag_channel_count.text)
|
|
}
|
|
}
|
|
case 'Playlist': {
|
|
return parseLocalListPlaylist(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.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: video.view_count == null ? null : extractNumberFromString(video.view_count.text),
|
|
publishedText: (video.published == null || video.published.isEmpty()) ? 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
|
|
}
|
|
|
|
/**
|
|
* @param {(Misc.TextRun|Misc.EmojiRun)[]} runs
|
|
* @param {number} emojiSize
|
|
* @param {{looseChannelNameDetection: boolean}} options
|
|
*/
|
|
export function parseLocalTextRuns(runs, emojiSize = 16, options = { looseChannelNameDetection: false }) {
|
|
if (!Array.isArray(runs)) {
|
|
throw new Error('not an array of text runs')
|
|
}
|
|
|
|
const timestampRegex = /^(?:\d+:){1,2}\d+$/
|
|
const spacesBeforeRegex = /^\s+/
|
|
const spacesAfterRegex = /\s+$/
|
|
const parsedRuns = []
|
|
|
|
for (const run of runs) {
|
|
// may contain HTML, so we need to escape it, as we don't render unwanted HTML
|
|
// example: https://youtu.be/Hh_se2Zqsdk (see pinned comment)
|
|
const text = escapeHTML(run.text)
|
|
|
|
if (run instanceof Misc.EmojiRun) {
|
|
const { emoji } = 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 { bold, italics, strikethrough, endpoint } = run
|
|
|
|
if (endpoint) {
|
|
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()
|
|
// In comments, mention can be `@Channel Name` (not handle, but name)
|
|
if (CHANNEL_HANDLE_REGEX.test(trimmedText) || options.looseChannelNameDetection) {
|
|
// Note that in regex `\s` must be used since the text contain non-default space (the half-width space char when we press spacebar)
|
|
const spacesBefore = (spacesBeforeRegex.exec(text) || [''])[0]
|
|
const spacesAfter = (spacesAfterRegex.exec(text) || [''])[0]
|
|
parsedRuns.push(`${spacesBefore}<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${trimmedText}</a>${spacesAfter}`)
|
|
} else {
|
|
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
|
|
}
|
|
break
|
|
}
|
|
case 'WEB_PAGE_TYPE_PLAYLIST':
|
|
case 'WEB_PAGE_TYPE_SHORTS':
|
|
parsedRuns.push(`https://www.youtube.com${endpoint.metadata.url}`)
|
|
break
|
|
case 'WEB_PAGE_TYPE_BROWSE':
|
|
parsedRuns.push(`<a href="https://www.youtube.com${endpoint.metadata.url}">${text}</a>`)
|
|
break
|
|
case 'WEB_PAGE_TYPE_UNKNOWN':
|
|
default: {
|
|
const url = new URL((endpoint.dialog?.type === 'ConfirmDialog' && endpoint.dialog.confirm_button.endpoint.payload.url) || endpoint.payload.url)
|
|
if (url.hostname === 'www.youtube.com' && url.pathname === '/redirect' && url.searchParams.has('q')) {
|
|
// remove utm tracking parameters
|
|
const realURLStr = url.searchParams.get('q')
|
|
const realURL = new URL(realURLStr)
|
|
let urlChanged = false
|
|
|
|
TRACKING_PARAM_NAMES.forEach((paramName) => {
|
|
if (!realURL.searchParams.has(paramName)) { return }
|
|
|
|
realURL.searchParams.delete(paramName)
|
|
urlChanged = true
|
|
})
|
|
|
|
// `searchParams.delete` changes query string unnecessarily
|
|
// Using original unless there is any change
|
|
parsedRuns.push(urlChanged ? realURL.toString() : realURLStr)
|
|
} 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('')
|
|
}
|
|
|
|
/**
|
|
* @param {LocalFormat} 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,
|
|
width: format.width,
|
|
url: format.freeTubeUrl
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('youtubei.js').YTNodes.Comment} comment
|
|
* @param {import('youtubei.js').YTNodes.CommentThread} 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,
|
|
authorId: comment.author.id,
|
|
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, { looseChannelNameDetection: true })),
|
|
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 {Misc.Format[]} formats
|
|
* @param {boolean} allowAv1 Use the AV1 formats if they are available
|
|
*/
|
|
export function filterLocalFormats(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').YTNodes.BackstagePost[] | import('youtubei.js').YTNodes.SharedPost[] | import('youtubei.js').YTNodes.Post[] } posts
|
|
*/
|
|
export function parseLocalCommunityPosts(posts) {
|
|
const foundIds = []
|
|
// `posts` includes the SharedPost's attached post for some reason so we need to filter that out.
|
|
// see: https://github.com/FreeTubeApp/FreeTube/issues/3252#issuecomment-1546675781
|
|
// we don't currently support SharedPost's so that is also filtered out
|
|
for (const post of posts) {
|
|
if (post.type === 'SharedPost') {
|
|
foundIds.push(post.original_post.id, post.id)
|
|
}
|
|
}
|
|
|
|
return posts.filter(post => {
|
|
return !foundIds.includes(post.id)
|
|
}).map(parseLocalCommunityPost)
|
|
}
|
|
|
|
/**
|
|
* Parse community post
|
|
* @param {import('youtubei.js').YTNodes.BackstagePost} post
|
|
*/
|
|
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.isEmpty() ? '' : Autolinker.link(parseLocalTextRuns(post.content.runs, 16)),
|
|
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: parseLocalSubscriberCount(attachment.total_votes.text) ?? 0,
|
|
content: attachment.choices.map(choice => {
|
|
return {
|
|
text: choice.text.text,
|
|
image: choice.image
|
|
}
|
|
})
|
|
}
|
|
} else if (attachment.type === 'Quiz') {
|
|
return {
|
|
type: 'quiz',
|
|
totalVotes: parseLocalSubscriberCount(attachment.total_votes.text) ?? 0,
|
|
content: Object.values(attachment.choices).map(choice => {
|
|
return {
|
|
text: choice.text.text,
|
|
isCorrect: choice.is_correct,
|
|
image: choice.image
|
|
}
|
|
})
|
|
}
|
|
} else {
|
|
console.error(`Unknown Local community post type: ${attachment.type}`)
|
|
console.error(attachment)
|
|
}
|
|
}
|
|
|
|
export async function getHashtagLocal(hashtag) {
|
|
const innertube = await createInnertube()
|
|
return await innertube.getHashtag(hashtag)
|
|
}
|