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(`${altText}`) } } 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}${trimmedText}${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(`${text}`) 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 = `${formattedText}` } if (italics) { formattedText = `${formattedText}` } if (strikethrough) { formattedText = `${formattedText}` } 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) }