eirtube/api/video.js
2024-12-19 18:43:19 -06:00

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)
}
}
}
]