Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate the watch page to YouTube.js #3035

Merged
merged 2 commits into from
Jan 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions _scripts/webpack.web.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ const config = {
filename: '[name].js',
},
externals: {
electron: '{}',
'youtubei.js': '{}'
electron: '{}'
},
module: {
rules: [
Expand Down Expand Up @@ -124,6 +123,10 @@ const config = {
filename: isDevMode ? '[name].css' : '[name].[contenthash].css',
chunkFilename: isDevMode ? '[id].css' : '[id].[contenthash].css',
}),
// ignore all youtubei.js imports, even the ones with paths in them
new webpack.IgnorePlugin({
resourceRegExp: /^youtubei\.js/
})
],
resolve: {
alias: {
Expand Down
9 changes: 2 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,10 @@
"browserify": "^17.0.0",
"browserify-zlib": "^0.2.0",
"electron-context-menu": "^3.6.1",
"http-proxy-agent": "^5.0.0",
"https-proxy-agent": "^5.0.0",
"lodash.debounce": "^4.0.8",
"marked": "^4.2.5",
"nedb-promises": "^6.2.1",
"process": "^0.11.10",
"socks-proxy-agent": "^7.0.0",
"video.js": "7.20.3",
"videojs-contrib-quality-levels": "^3.0.0",
"videojs-http-source-selector": "^1.1.6",
Expand All @@ -80,10 +77,8 @@
"vue-observe-visibility": "^1.0.0",
"vue-router": "^3.6.5",
"vuex": "^3.6.2",
"youtubei.js": "^2.7.0",
"yt-channel-info": "^3.2.1",
"yt-dash-manifest-generator": "1.1.0",
"ytdl-core": "https://github.com/absidue/node-ytdl-core#fix-likes-extraction"
"youtubei.js": "^2.8.0",
"yt-channel-info": "^3.2.1"
},
"devDependencies": {
"@babel/core": "^7.20.7",
Expand Down
14 changes: 7 additions & 7 deletions src/renderer/components/ft-video-player/ft-video-player.js
Original file line number Diff line number Diff line change
Expand Up @@ -1571,10 +1571,10 @@ export default Vue.extend({

sortCaptions: function (captionList) {
return captionList.sort((captionA, captionB) => {
const aCode = captionA.languageCode.split('-') // ex. [en,US]
const bCode = captionB.languageCode.split('-')
const aName = (captionA.label || captionA.name.simpleText) // ex: english (auto-generated)
const bName = (captionB.label || captionB.name.simpleText)
const aCode = captionA.language_code.split('-') // ex. [en,US]
const bCode = captionB.language_code.split('-')
const aName = (captionA.label) // ex: english (auto-generated)
const bName = (captionB.label)
const userLocale = this.currentLocale.split(/-|_/) // ex. [en,US]
if (aCode[0] === userLocale[0]) { // caption a has same language as user's locale
if (bCode[0] === userLocale[0]) { // caption b has same language as user's locale
Expand Down Expand Up @@ -1622,9 +1622,9 @@ export default Vue.extend({
for (const caption of this.sortCaptions(captionList)) {
this.player.addRemoteTextTrack({
kind: 'subtitles',
src: caption.baseUrl || caption.url,
srclang: caption.languageCode,
label: caption.label || caption.name.simpleText,
src: caption.url,
srclang: caption.language_code,
label: caption.label,
type: caption.type
}, true)
}
Expand Down
123 changes: 122 additions & 1 deletion src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Innertube } from 'youtubei.js'
import { ClientType } from 'youtubei.js/dist/src/core/Session'
import { join } from 'path'

import { PlayerCache } from './PlayerCache'
Expand All @@ -16,9 +17,10 @@ import { extractNumberFromString, getUserDataPath } from '../utils'
* @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 }) {
async function createInnertube(options = { withPlayer: false, location: undefined, safetyMode: false, clientType: undefined }) {
let cache
if (options.withPlayer) {
const userData = await getUserDataPath()
Expand All @@ -29,6 +31,8 @@ async function createInnertube(options = { withPlayer: false, location: undefine
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
Expand Down Expand Up @@ -109,6 +113,18 @@ export async function getLocalSearchContinuation(continuationData) {
return handleSearchResponse(response)
}

export async function getLocalVideoInfo(id, attemptBypass = false) {
if (attemptBypass) {
const innertube = await createInnertube({ withPlayer: true, clientType: ClientType.TV_EMBEDDED })
// 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
return await innertube.getBasicInfo(id, 'TV_EMBEDDED')
} else {
const innertube = await createInnertube({ withPlayer: true })
return await innertube.getInfo(id)
}
}

/**
* @param {Search} response
*/
Expand Down Expand Up @@ -234,6 +250,28 @@ function parseListItem(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),
// CompactVideo doesn't have is_live, is_upcoming or is_premiere,
// so we have to make do with this for the moment, to stop toLocalePublicationString erroring
publishedText: video.published.text === 'N/A' ? null : video.published.text,
lengthSeconds: isNaN(video.duration.seconds) ? '' : video.duration.seconds
}
}

function convertSearchFilters(filters) {
const convertedFilters = {}

Expand All @@ -260,3 +298,86 @@ function convertSearchFilters(filters) {

return convertedFilters
}

/**
* @typedef {import('youtubei.js/dist/src/parser/classes/misc/TextRun').default} TextRun
*/

/**
* @param {TextRun[]} textRuns
*/
export function parseLocalTextRuns(textRuns) {
if (!Array.isArray(textRuns)) {
throw new Error('not an array of text runs')
}

const timestampRegex = /^(?:\d+:){1,2}\d+$/
const runs = []

for (const { text, endpoint } of textRuns) {
if (endpoint && !text.startsWith('#')) {
switch (endpoint.metadata.page_type) {
case 'WEB_PAGE_TYPE_WATCH':
if (timestampRegex.test(text)) {
runs.push(text)
} else {
runs.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_CHANNEL':
if (text.startsWith('@')) {
runs.push(`<a href="https://www.youtube.com/channel/${endpoint.payload.browseId}">${text}</a>`)
} else {
runs.push(`https://www.youtube.com${endpoint.metadata.url}`)
}
break
case 'WEB_PAGE_TYPE_PLAYLIST':
runs.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')

runs.push(realURL.toString())
} else {
// this is probably a special YouTube URL like http://www.youtube.com/approachingnirvana
runs.push(endpoint.payload.url)
}
break
}
}
} else {
runs.push(text)
}
}

return runs.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
}
}
45 changes: 37 additions & 8 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,18 @@ export function toLocalePublicationString ({ publishText, isLive = false, isUpco
export function buildVTTFileLocally(storyboard) {
let vttString = 'WEBVTT\n\n'
// how many images are in one image
const numberOfSubImagesPerImage = storyboard.sWidth * storyboard.sHeight
const numberOfSubImagesPerImage = storyboard.columns * storyboard.rows
// the number of storyboard images
const numberOfImages = Math.ceil(storyboard.count / numberOfSubImagesPerImage)
const numberOfImages = Math.ceil(storyboard.thumbnail_count / numberOfSubImagesPerImage)
const intervalInSeconds = storyboard.interval / 1000
let currentUrl = storyboard.url
let startHours = 0
let startMinutes = 0
let startSeconds = 0
let endHours = 0
let endMinutes = 0
let endSeconds = intervalInSeconds
for (let i = 0; i < numberOfImages; i++) {
const currentUrl = storyboard.template_url.replace('$M.jpg', `${i}.jpg`)
let xCoord = 0
let yCoord = 0
for (let j = 0; j < numberOfSubImagesPerImage; j++) {
Expand All @@ -116,7 +116,7 @@ export function buildVTTFileLocally(storyboard) {
const paddedEndSeconds = endSeconds.toString().padStart(2, '0')
vttString += `${paddedStartHours}:${paddedStartMinutes}:${paddedStartSeconds}.000 --> ${paddedEndHours}:${paddedEndMinutes}:${paddedEndSeconds}.000\n`
// add the current image url as well as the x, y, width, height information
vttString += currentUrl + `#xywh=${xCoord},${yCoord},${storyboard.width},${storyboard.height}\n\n`
vttString += `${currentUrl}#xywh=${xCoord},${yCoord},${storyboard.thumbnail_width},${storyboard.thumbnail_height}\n\n`
// update the variables
startHours = endHours
startMinutes = endMinutes
Expand All @@ -131,18 +131,47 @@ export function buildVTTFileLocally(storyboard) {
endHours += 1
}
// x coordinate can only be smaller than the width of one subimage * the number of subimages per row
xCoord = (xCoord + storyboard.width) % (storyboard.width * storyboard.sWidth)
xCoord = (xCoord + storyboard.thumbnail_width) % (storyboard.thumbnail_width * storyboard.columns)
// only if the x coordinate is , so in a new row, we have to update the y coordinate
if (xCoord === 0) {
yCoord += storyboard.height
yCoord += storyboard.thumbnail_height
}
}
// make sure that there is no value like M0 or M1 in the parameters that gets replaced
currentUrl = currentUrl.replace('M' + i.toString() + '.jpg', 'M' + (i + 1).toString() + '.jpg')
}
return vttString
}

export async function getFormatsFromHLSManifest(manifestUrl) {
const response = await fetch(manifestUrl)
const text = await response.text()

const lines = text.split('\n').filter(line => line)

const formats = []
let currentHeight = 0

for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('#EXT-X-STREAM-INF:')) {
continue
}

const height = line
.split(',')
.find(part => part.startsWith('RESOLUTION'))
.split('x')[1]
currentHeight = parseInt(height)
} else {
formats.push({
height: currentHeight,
url: line.trim()
})
}
}

return formats
}

export function showToast(message, time = null, action = null) {
FtToastEvents.$emit('toast-open', message, time, action)
}
Expand Down
67 changes: 0 additions & 67 deletions src/renderer/store/modules/ytdl.js

This file was deleted.

Loading