337 lines
14 KiB
JavaScript
Executable File
337 lines
14 KiB
JavaScript
Executable File
/** @type {import("node-fetch").default} */
|
|
// @ts-ignore
|
|
const fetch = require("node-fetch")
|
|
const path = require("path")
|
|
const {render} = require("pinski/plugins")
|
|
const db = require("../utils/db")
|
|
const {getUser, getToken} = require("../utils/getuser")
|
|
// const pug = require("pug")
|
|
const converters = require("../utils/converters")
|
|
const constants = require("../utils/constants")
|
|
|
|
const cacheManager = require("../eirtubeMods/cache-manager")
|
|
const downloader = require("../eirtubeMods/downloader")
|
|
const dlQueue = require("../eirtubeMods/dl-queue")
|
|
const sb = require("../eirtubeMods/sb")
|
|
const comments = require("../eirtubeMods/comments")
|
|
const ratelimiting = require("../eirtubeMods/ratelimiting")
|
|
const qualitySort = require("../eirtubeMods/quality-sort")
|
|
|
|
class InstanceError extends Error {
|
|
constructor(error, identifier) {
|
|
super(error)
|
|
this.identifier = identifier
|
|
}
|
|
}
|
|
|
|
class MessageError extends Error {
|
|
}
|
|
|
|
module.exports = [
|
|
{
|
|
route: "/watch", methods: ["GET", "POST"], upload: true, code: async ({req, url, body}) => {
|
|
// Prepare data needed to render video page
|
|
const user = getUser(req)
|
|
const settings = user.getSettingsOrDefaults()
|
|
const id = url.searchParams.get("v")
|
|
|
|
// Check if should watch on YouTube
|
|
if (settings.local === 2) {
|
|
const dest = `https://www.youtube.com${url.pathname}${url.search}` //#cloudtube`
|
|
user.addWatchedVideoMaybe(id)
|
|
return {
|
|
statusCode: 302,
|
|
contentType: "text/plain",
|
|
headers: {
|
|
"Location": dest
|
|
},
|
|
content: `Redirecting to ${dest}...`
|
|
}
|
|
}
|
|
|
|
// Check if playback is allowed
|
|
const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id)
|
|
if (videoTakedownInfo) {
|
|
downloader.tryDeleteFromCache(id);
|
|
return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, videoTakedownInfo))
|
|
}
|
|
|
|
const responseHeaders = {
|
|
Location: req.url
|
|
}
|
|
|
|
// Media fragment
|
|
const t = url.searchParams.get("t")
|
|
let mediaFragment = converters.tToMediaFragment(t)
|
|
|
|
// Continuous mode
|
|
const continuous = url.searchParams.get("continuous") === "1"
|
|
const autoplay = url.searchParams.get("autoplay") != "0"
|
|
const swp = url.searchParams.get("session-watched")
|
|
const sessionWatched = swp ? swp.split(" ") : []
|
|
const sessionWatchedNext = sessionWatched.concat([id]).join("+")
|
|
// if (continuous) settings.quality = 0 // autoplay with synced streams does not work
|
|
|
|
// Work out how to fetch the video
|
|
let destName = `${id}-${url.searchParams.get("quality") || "360p"}`
|
|
let willDownload = !downloader.videoExists(destName) && (downloader.downloadStatus(destName) == 0 || downloader.downloadStatus(destName) == 3)
|
|
var videoFuture = cacheManager.read("video")[id];
|
|
if (videoFuture == undefined || Object.keys(videoFuture).length == 0 || !downloader.videoExists(id)) {
|
|
if (req.method === "GET") {
|
|
if (settings.local === 1) { // skip to the local fetching page, which will then POST video data in a moment
|
|
return render(200, "pug/local-video.pug", {req, settings, id}, responseHeaders)
|
|
}
|
|
var instanceOrigin = settings.instance
|
|
} else { // req.method === "POST"
|
|
willDownload = true
|
|
var instanceOrigin = "http://localhost:3000"
|
|
videoFuture = JSON.parse(new URLSearchParams(body.toString()).get("video"))
|
|
videoFuture = await downloader.formatVideoData(videoFuture, settings)
|
|
cacheManager.write("video", id, videoFuture)
|
|
}
|
|
|
|
// Try fetch/download
|
|
try {
|
|
videoFuture = downloader.fetchVideoData(id, req)
|
|
} catch(e) { willDownload = false }
|
|
}
|
|
|
|
try {
|
|
// Fetch the video
|
|
const video = await videoFuture
|
|
|
|
// Error handling
|
|
if (!video || Object.keys(video).length == 0) throw new MessageError("The instance returned null.")
|
|
if (video.error) throw new InstanceError(video.error, video.identifier)
|
|
if (video.errno) throw video
|
|
if (video.liveNow || video.isUpcoming) throw new MessageError("Live playback is not supported.")
|
|
if (video.isListed == false || video.paid || video.premium) throw new MessageError("Video is private or premium.")
|
|
if ((!video.formatStreams || video.formatStreams.length == 0) && (!video.adaptiveFormats || video.adaptiveFormats.length == 0)) throw new MessageError("Video returned no streams.")
|
|
|
|
// Set data to video cache
|
|
if (video.descriptionHtml && video.recommendedVideos && video.recommendedVideos.length > 0)
|
|
cacheManager.write("video", id, video)
|
|
|
|
// Check if channel playback is allowed
|
|
const channelTakedownInfo = db.prepare("SELECT ucid, org, url FROM TakedownChannels WHERE ucid = ?").get(video.authorId)
|
|
if (channelTakedownInfo) {
|
|
// automatically add the entry to the videos list, so it won't be fetched again
|
|
const args = {id, ...channelTakedownInfo}
|
|
db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args)
|
|
return render(451, "pug/takedown-video.pug", Object.assign({req, settings}, channelTakedownInfo), responseHeaders)
|
|
}
|
|
|
|
// Fetch comments
|
|
// TODO: allow disabling comments via setting
|
|
if (!video.comments)
|
|
video.comments = await comments.getVideoComments(id, req)
|
|
|
|
// process stream list ordering
|
|
let formats = await downloader.sortFormats(video, ([480, 720, 1080])[settings.quality])
|
|
let startingFormat = formats[0]
|
|
|
|
// Order formats like youtube.
|
|
// - Eir
|
|
let uniqueFormats = []
|
|
for (let i = 0; i < formats.length; i++) {
|
|
if (!uniqueFormats.some(format => format.qualityLabel == formats[i].qualityLabel))
|
|
uniqueFormats.push(formats[i])
|
|
}
|
|
uniqueFormats.sort((a, b) => b.second__height - a.second__height)
|
|
|
|
// Any errors to display in toasts when the page actually loads
|
|
let toasts = []
|
|
|
|
// Apply SponsorBlock data
|
|
if (settings.sponsorblock > 0) {
|
|
const sbData = await sb.getSB(id)
|
|
if (sbData && !sbData.error)
|
|
video.sbData = sbData.data
|
|
}
|
|
// Apply DeArrow data
|
|
// if (settings.dearrow > 0) {
|
|
// const dearrowData = await sb.getDeArrow(id)
|
|
// if (dearrowData && !dearrowData.error)
|
|
// video.dearrowData = dearrowData.data
|
|
// }
|
|
|
|
// filter list
|
|
const {videos, filteredCount} = converters.applyVideoFilters(video.recommendedVideos, user.getFilters())
|
|
video.recommendedVideos = videos
|
|
// Apply DeArrow data (recommended)
|
|
if (settings.dearrow > 0) {
|
|
if (settings.dearrow_preload == 1) {
|
|
const dearrowOut = await sb.applyToAllDeArrow(video.recommendedVideos)
|
|
if (Object.keys(dearrowOut.errors).length > 0)
|
|
for (const deE of Object.keys(dearrowOut.errors))
|
|
toasts.push({ color: "red", icon: "x", text: dearrowOut.errors[deE] })
|
|
} else
|
|
sb.getAllDeArrowNoBlock(video.recommendedVideos)
|
|
}
|
|
|
|
// get subscription data
|
|
const subscribed = user.isSubscribed(video.authorId)
|
|
|
|
// process watched videos
|
|
user.addWatchedVideoMaybe(video.videoId)
|
|
const watchedVideos = user.getWatchedVideos()
|
|
if (watchedVideos.length) {
|
|
for (const rec of video.recommendedVideos) {
|
|
rec.watched = watchedVideos.includes(rec.videoId)
|
|
}
|
|
}
|
|
|
|
let targetFormat = startingFormat // By default, same as video format. Will be set to another quality if using autoHD or &quality= in url
|
|
let awaitingNewFormat = false
|
|
|
|
// Allow specifying "&quality" in the URL to choose a specific quality
|
|
// Needed to fetch a new quality from the same page
|
|
let q = url.searchParams.get("quality")
|
|
let qSet = false
|
|
let largestSavedFormat
|
|
let qualities = {} // When searching for a specific quality, if the specified one doesn't exist, retrieve the closest match
|
|
|
|
let videoPath = `/getVideo?v=${id}&q=${q || "360p"}`
|
|
|
|
for (const format of formats) {
|
|
// apply media fragment to all sources + get largest format that is currently on disk
|
|
format.url += mediaFragment
|
|
|
|
// If a specific quality is chosen, find it
|
|
if (!qSet && q != undefined) {
|
|
if (format.type.startsWith("video")) {
|
|
if (format.qualityLabel == q) {
|
|
targetFormat = format
|
|
qSet = true
|
|
break
|
|
}
|
|
qualities[format.qualityLabel] = format
|
|
} else
|
|
continue
|
|
}
|
|
|
|
// Find the largest downloaded version of the video
|
|
let fName = `${id}-${format.qualityLabel}`
|
|
if (downloader.videoExists(fName) && (downloader.downloadStatus(fName) == 0 || downloader.downloadStatus(fName) == 3))
|
|
if (largestSavedFormat == undefined || format.second__height + (format.fps / 100) > largestSavedFormat.second__height + (format.fps / 100))
|
|
largestSavedFormat = format
|
|
}
|
|
|
|
// Still looking for a specific quality? match it here:
|
|
if (!qSet && q) {
|
|
q = qualitySort.sort(Object.keys(qualities), q)[0]
|
|
targetFormat = qualities[q]
|
|
qSet = true
|
|
}
|
|
|
|
// If a large format is already on disk, just use it instead
|
|
if (largestSavedFormat) {
|
|
startingFormat = largestSavedFormat
|
|
videoPath = `/getVideo?v=${id}&q=${largestSavedFormat.qualityLabel}`
|
|
willDownload = false
|
|
|
|
// Highest available format <= target height
|
|
if (largestSavedFormat.second__height > 360)
|
|
formats = await downloader.sortFormats(video, largestSavedFormat.second__height)
|
|
}
|
|
|
|
if (settings.autoHD == 1 && !qSet) {
|
|
// Get first adaptive format
|
|
// Sorted already by user preferences to be their chosen quality
|
|
for (const format of formats) {
|
|
if (format.cloudtube__label.endsWith(" *") && format.type.startsWith("video") && format.second__height >= startingFormat.second__height) {
|
|
targetFormat = format
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rate limiting
|
|
const checkAuth = ratelimiting.authorize(req, willDownload, responseHeaders)
|
|
if (checkAuth != true)
|
|
return checkAuth
|
|
|
|
// Download base video
|
|
if (!qSet && willDownload && !largestSavedFormat) {
|
|
console.log(`Starting download for ${id} (${startingFormat.qualityLabel})`);
|
|
dlQueue.enqueue(id, startingFormat.qualityLabel)
|
|
// Wait for a second to give the default noscript player at least a small portion of bytes to play
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
|
|
// Only preload if new quality is better than what we already have, unless using &quality
|
|
// TODO: replace max video length with max file size
|
|
if ((targetFormat.second__height > startingFormat.second__height && video.lengthSeconds < constants.server_setup.video_hq_preload_max_time) || qSet) {
|
|
destName = `${id}-${targetFormat.qualityLabel}`
|
|
|
|
// Check video already exists
|
|
if (downloader.videoExists(destName)) {
|
|
startingFormat = targetFormat
|
|
videoPath = `/getVideo?v=${id}&q=${targetFormat.qualityLabel}`
|
|
} else if (downloader.downloadStatus(destName) == 0 || downloader.downloadStatus(destName) == 3) {
|
|
const checkAuth = ratelimiting.authorize(req, true, responseHeaders)
|
|
if (checkAuth != true && checkAuth.statusCode == 429) {
|
|
// Rate limited, don't preload higher quality
|
|
awaitingNewFormat = true
|
|
toasts.push({ color: "red", icon: "x", text: `Too many requests. You may download ${targetFormat.qualityLabel} in ${checkAuth.headers["Retry-After"]} seconds...` })
|
|
} else {
|
|
dlQueue.enqueue(id, targetFormat.qualityLabel)
|
|
awaitingNewFormat = !downloader.videoExists(destName)
|
|
}
|
|
} else {
|
|
awaitingNewFormat = downloader.downloadStatus(destName) < 3
|
|
}
|
|
}
|
|
|
|
// Start preparing thumbnail stream if it doesn't exist.
|
|
const sourceName = `${id}-${startingFormat.qualityLabel}`
|
|
if (!downloader.videoExists(`${id}-thumb`) && downloader.downloadStatus(`${id}-thumb`) == 0) {
|
|
cacheManager.write("download", `${id}-thumb`, { status: 1 }) // Makes /getVideo wait for thumb stream to finish
|
|
const pathBase = path.join(path.join(__dirname, "../"), constants.server_setup.video_dl_path)
|
|
|
|
// Wait until video finishes downloading
|
|
const preloadForThumb = setInterval(() => {
|
|
const status = downloader.downloadStatus(sourceName)
|
|
if ((status == 0 || status == 3) && downloader.videoExists(sourceName)) {
|
|
clearInterval(preloadForThumb)
|
|
|
|
// Finally start creating thumb stream
|
|
downloader.createThumbStream(`${id}-thumb`, path.join(pathBase, sourceName), path.join(pathBase, `${id}-thumb`), video.lengthSeconds)
|
|
}
|
|
}, 1000 * 2)
|
|
}
|
|
|
|
return render(200, "pug/video.pug", {
|
|
req, url, video, formats: uniqueFormats, subscribed, mediaFragment, autoplay, continuous, sessionWatched,
|
|
sessionWatchedNext, settings, videoPath, startingFormat, targetFormat, awaitingNewFormat, toasts,
|
|
dlStatus: downloader.downloadStatus(sourceName), awaitingThumb: downloader.downloadStatus(`${id}-thumb`) > 0 && downloader.downloadStatus(`${id}-thumb`) < 3,
|
|
timeToPastText: converters.timeToPastText, likeCountToText: converters.likeCountToText, rewriteVideoDescription: converters.rewriteVideoDescription
|
|
}, responseHeaders)
|
|
} catch (error) {
|
|
// Something went wrong, somewhere! Find out where.
|
|
|
|
let errorType = "unrecognised-error"
|
|
const locals = {instanceOrigin, error}
|
|
|
|
// Sort error category
|
|
if (error instanceof fetch.FetchError) {
|
|
errorType = "fetch-error"
|
|
} else if (error instanceof MessageError) {
|
|
errorType = "message-error"
|
|
} else if (error instanceof InstanceError) {
|
|
if (error.identifier === "RATE_LIMITED_BY_YOUTUBE" || error.message === "Could not extract video info. Instance is likely blocked.") {
|
|
errorType = "rate-limited"
|
|
} else {
|
|
errorType = "instance-error"
|
|
}
|
|
}
|
|
|
|
// Create appropriate formatted message
|
|
const message = render(0, `pug/errors/${errorType}.pug`, locals).content
|
|
|
|
return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message, req, settings}, responseHeaders)
|
|
}
|
|
}
|
|
}
|
|
]
|