/** @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 || startingFormat.qualityLabel}` 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) { 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) } } } ]