From 6d1f91b2fad230dcf4154a9e7357bd4b9ec359b3 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Fri, 17 Dec 2021 09:20:52 +0000 Subject: [PATCH] Add experimental, hacky and ugly video embed player --- api/video_embed.js | 232 +++++++++++++++++++++++++++++++++ pug/includes/layout_embed.pug | 12 ++ pug/video_embed.pug | 58 +++++++++ sass/includes/video-embed.sass | 20 +++ sass/main.sass | 1 + 5 files changed, 323 insertions(+) create mode 100644 api/video_embed.js create mode 100644 pug/includes/layout_embed.pug create mode 100644 pug/video_embed.pug create mode 100644 sass/includes/video-embed.sass diff --git a/api/video_embed.js b/api/video_embed.js new file mode 100644 index 0000000..69e1b33 --- /dev/null +++ b/api/video_embed.js @@ -0,0 +1,232 @@ +const {request} = require("../utils/request") +/** @type {import("node-fetch").default} */ +// @ts-ignore +const fetch = require("node-fetch") +const {render} = require("pinski/plugins") +const db = require("../utils/db") +const {getToken, getUser} = require("../utils/getuser") +const pug = require("pug") +const converters = require("../utils/converters") +const constants = require("../utils/constants") + +class InstanceError extends Error { + constructor(error, identifier) { + super(error) + this.identifier = identifier + } +} + +class MessageError extends Error { +} + +function formatOrder(format) { + // most significant to least significant + // key, max, order, transform + // asc: lower number comes first, desc: higher number comes first + const spec = [ + {key: "second__height", max: 8000, order: "desc", transform: x => x ? Math.floor(x/96) : 0}, + {key: "fps", max: 100, order: "desc", transform: x => x ? Math.floor(x/10) : 0}, + {key: "type", max: " ".repeat(60), order: "asc", transform: x => x.length} + ] + let total = 0 + for (let i = 0; i < spec.length; i++) { + const s = spec[i] + let diff = s.transform(format[s.key]) + if (s.order === "asc") diff = s.transform(s.max) - diff + total += diff + if (i+1 < spec.length) { // not the last spec item? + const s2 = spec[i+1] + total *= s2.transform(s2.max) + } + } + return -total +} + +function sortFormats(video, preference) { + // Add second__ extensions to format objects, required if Invidious was the extractor + let formats = video.formatStreams.concat(video.adaptiveFormats) + for (const format of formats) { + if (!format.second__height && format.resolution) format.second__height = +format.resolution.slice(0, -1) + if (!format.second__order) format.second__order = formatOrder(format) + format.cloudtube__label = `${format.qualityLabel} ${format.container}` + } + + // Properly build and order format list + const standard = video.formatStreams.slice().sort((a, b) => b.second__height - a.second__height) + const adaptive = video.adaptiveFormats.filter(f => f.type.startsWith("video") && f.qualityLabel).sort((a, b) => a.second__order - b.second__order) + for (const format of adaptive) { + if (!format.cloudtube__label.endsWith("*")) format.cloudtube__label += " *" + } + formats = standard.concat(adaptive) + + // Reorder fomats based on user preference + if (preference === 1) { // best dash + formats.sort((a, b) => { + const a1 = a.second__height + a.fps / 100 + const b1 = b.second__height + b.fps / 100 + return b1 - a1 + }) + } else if (preference === 2) { // best <=1080p + formats.sort((a, b) => { + const a1 = a.second__height + a.fps / 100 + const b1 = b.second__height + b.fps / 100 + if (b1 > 1081) { + if (a1 > 1081) return b1 - a1 + return -1 + } + if (a1 > 1081) return 1 + return b1 - a1 + }) + } else if (preference === 3) { // best low-fps + formats.sort((a, b) => { + if (b.fps > 30) { + if (a.fps < 30) return b.second__height - a.second__height + return -1 + } + if (a.fps > 30) return 1 + return b.second__height - a.second__height + }) + } else if (preference === 4) { // 360p only + formats.sort((a, b) => { + if (a.itag == 18) return -1 + if (b.itag == 18) return 1 + return 0 + }) + } else { // preference === 0, best combined + // should already be correct + } + + return formats +} + +module.exports = [ + { + route: `/embed/(${constants.regex.video_id})`, methods: ["GET"], upload: false, code: async ({req, url, body, fill}) => { + // Prepare data needed to render video page + + const user = getUser(req) + const settings = user.getSettingsOrDefaults() + const id = fill[0]; + + // Check if playback is allowed + const videoTakedownInfo = db.prepare("SELECT id, org, url FROM TakedownVideos WHERE id = ?").get(id) + if (videoTakedownInfo) { + return render(451, "pug/takedown-video.pug", videoTakedownInfo) + } + + // 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") === "1" + 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 + if (req.method === "GET") { + if (settings.local) { // skip to the local fetching page, which will then POST video data in a moment + return render(200, "pug/local-video.pug", {id}) + } + var instanceOrigin = settings.instance + var outURL = `${instanceOrigin}/api/v1/videos/${id}` + var videoFuture = request(outURL).then(res => res.json()) + } else { // req.method === "POST" + var instanceOrigin = "http://localhost:3000" + var videoFuture = JSON.parse(new URLSearchParams(body.toString()).get("video")) + } + + try { + // Fetch the video + const video = await videoFuture + + // Error handling + if (!video) throw new MessageError("The instance returned null.") + if (video.error) throw new InstanceError(video.error, video.identifier) + + // 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", channelTakedownInfo) + } + + // process stream list ordering + const formats = sortFormats(video, settings.quality) + + // process length text and view count + for (const rec of video.recommendedVideos) { + converters.normaliseVideoInfo(rec) + } + + // filter list + const {videos, filteredCount} = converters.applyVideoFilters(video.recommendedVideos, user.getFilters()) + video.recommendedVideos = videos + + // 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) + } + } + + // normalise view count + if (!video.second__viewCountText && video.viewCount) { + video.second__viewCountText = converters.viewCountToText(video.viewCount) + } + + // apply media fragment to all sources + for (const format of formats) { + format.url += mediaFragment + } + + // rewrite description + video.descriptionHtml = converters.rewriteVideoDescription(video.descriptionHtml, id) + + // rewrite captions urls so they are served on the same domain via the /proxy route + for (const caption of video.captions) { + caption.url = `/proxy?${new URLSearchParams({"url": caption.url})}` + } + + return render(200, "pug/video_embed.pug", { + url, video, formats, subscribed, instanceOrigin, mediaFragment, autoplay, continuous, + sessionWatched, sessionWatchedNext, settings + }) + + } 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_embed.pug", {video: {videoId: id}, error: true, message}) + } + } + } +] diff --git a/pug/includes/layout_embed.pug b/pug/includes/layout_embed.pug new file mode 100644 index 0000000..3c6cfc7 --- /dev/null +++ b/pug/includes/layout_embed.pug @@ -0,0 +1,12 @@ +doctype html +html + head + meta(charset="utf-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + link(rel="stylesheet" type="text/css" href=getStaticURL("sass", "/main.sass")) + script(type="module" src=getStaticURL("html", "/static/js/focus.js")) + block head + + body.show-focus + div + block content diff --git a/pug/video_embed.pug b/pug/video_embed.pug new file mode 100644 index 0000000..9db32e1 --- /dev/null +++ b/pug/video_embed.pug @@ -0,0 +1,58 @@ +extends includes/layout_embed + +include includes/video-list-item +include includes/subscribe-button + +block head + unless error + title= `${video.title} (embedded) - CloudTube` + else + title Error - CloudTube + script(type="module" src=getStaticURL("html", "/static/js/player.js")) + script const data = !{JSON.stringify({...video, continuous})} + +block content + unless error + .video-embed-page(class={ + "video-page--recommended-below": settings.recommended_mode === 1, + "video-page--recommended-hidden": settings.recommended_mode === 2 + }) + main.embed-video-section + - const format = formats[0] + if format + video(controls preload="auto" width=format.second__width height=format.second__height data-itag=format.itag autoplay=continuous||autoplay)#video.video + source(src=format.url type=format.type) + each t in video.captions + track(label=t.label kind="subtitles" srclang=t.languageCode src=t.url) + // fallback: flash player + - let flashvars = new URLSearchParams({skin: "/static/flash/skin.swf", video: format.url}) + embed(type="application/x-shockwave-flash" src="/static/flash/player.swf" id="f4Player" width=1280 height=720 flashvars=flashvars.toString() allowscriptaccess="always" allowfullscreen="true" bgcolor="#000000") + else + video(src="")#video.video + .stream-notice The server provided no playback streams. + + #current-time-container + #end-cards-container + + audio(preload="auto")#audio + #live-event-notice + #audio-loading-display + + .button-container + select(aria-label="Quality" autocomplete="off").border-look#quality-select + each f in formats + option(value=f.itag)= f.cloudtube__label + //- + a(href="/subscriptions").border-look + img(src="/static/images/search.svg" width=17 height=17 alt="").button-icon + | Search + //- button.border-look#share Share + a(target="_blank", href=`/watch?v=${video.videoId}#cloudtube`).border-look CloudTube (not embedded) + a(target="_blank", href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`).border-look YouTube + a(target="_blank", href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious + else + //- error + main.video-error-page + h2 Error + != message + p: a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`) Watch on YouTube → diff --git a/sass/includes/video-embed.sass b/sass/includes/video-embed.sass new file mode 100644 index 0000000..4a5f5c8 --- /dev/null +++ b/sass/includes/video-embed.sass @@ -0,0 +1,20 @@ +@use "colors.sass" as c +@use "video-list-item.sass" as * + +.video-embed-page + display: block + +.embed-video-section + width: 100% + height: 100vh + display: flex + flex-direction: column + + .video + flex-grow: 1 + display: block + width: 100% + + .stream-notice + background: c.$bg-darkest + padding: 4px diff --git a/sass/main.sass b/sass/main.sass index 17c15df..9e11395 100644 --- a/sass/main.sass +++ b/sass/main.sass @@ -3,6 +3,7 @@ @use "includes/base.sass" @use "sass:selector" @use "includes/video-page.sass" +@use "includes/video-embed.sass" @use "includes/search-page.sass" @use "includes/home-page.sass" @use "includes/channel-page.sass"