diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 0214111..0000000 --- a/.dockerignore +++ /dev/null @@ -1,16 +0,0 @@ -# Editor crud files -*~ -\#*# -.#* -.vscode -.idea -.git - -# Auto-generated files -node_modules - -# User configuration -/db -/config/config.js - -Dockerfile diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index d105801..18c27ca --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,9 @@ # Auto-generated files node_modules +logs +cache # User configuration /db /config/config.js - -# Narration -/html/static/media/cant_think_suricrasia_online.mp3 -/html/transparency diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index cb80a03..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:14-buster - -WORKDIR /workdir - -COPY package.json ./package.json -COPY package-lock.json ./package-lock.json - -RUN npm install - -COPY . . - -EXPOSE 10412 - -CMD npm start diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 2154606..3d41acf --- a/README.md +++ b/README.md @@ -1,10 +1,25 @@ -# CloudTube +# EirTube -## Navigation +A heavily modified personal use version of [CloudTube][cloudtube] by [Cadence Ember](https://cadence.moe/). + +Uses code from CloudTube and [yt2009](https://github.com/ftde0/yt2009/). + +## Features + +- Videos are downloaded and served on the server instead of streamed from YouTube. +- Video data is cached so as to ping YouTube and NewLeaf/Invidious less. +- Higher quality formats can be preloaded while you watch and switched to when ready. +- Volume boosting above 100% and force mono audio (requires JavaScript). +- Custom captions implementation supporting position, style, etc. (requires Javascript) +- Download video to an mp4 file, or audio to an ogg file. +- SponsorBlock and DeArrow integration. +- Has my cool theme :3 + +## Navigation (CloudTube) - [Project hub][hub] - [Announcements][announce] -- › CloudTube repo +- [CloudTube repo][cloudtube] - [NewLeaf repo][newleaf] - [Documentation repo][docs] - [Mailing list][list] for development and discussion diff --git a/add-takedown.js b/add-takedown.js new file mode 100755 index 0000000..48e163b --- /dev/null +++ b/add-takedown.js @@ -0,0 +1,24 @@ +const db = require("./utils/db") + +;(async () => { + if (process.argv.length < 2) { + console.error("Needs at least one argument.") + process.exit(1) + } + + // node add-takedown.js video id [organization] [url] + // node add-takedown.js channel id [organization] [url] + + let args + switch(process.argv[2]) { + case "video": + args = { id: process.argv[3], org: process.argv[4], url: process.argv[5] } + db.prepare("INSERT INTO TakedownVideos (id, org, url) VALUES (@id, @org, @url)").run(args) + break; + + case "channel": + args = { ucid: process.argv[3], org: process.argv[4], url: process.argv[5] } + db.prepare("INSERT INTO TakedownChannels (ucid, org, url) VALUES (@ucid, @org, @url)").run(args) + break; + } +})() diff --git a/api/channels.js b/api/channels.js old mode 100644 new mode 100755 index e230e8e..a07673d --- a/api/channels.js +++ b/api/channels.js @@ -1,13 +1,17 @@ +const fetch = require("node-fetch") const {render} = require("pinski/plugins") const {fetchChannel} = require("../utils/youtube") const {getUser} = require("../utils/getuser") +const {request} = require("../utils/request") const converters = require("../utils/converters") +const dearrow = require("../eirtubeMods/sb") module.exports = [ { - route: `/(c|channel|user)/(.+)`, methods: ["GET"], code: async ({req, fill, url}) => { + route: `/(c|channel|user)/([^/]+)/?([^?]+)?`, methods: ["GET"], code: async ({req, fill, url}) => { const path = fill[0] const id = fill[1] + const subpage = fill[2] const user = getUser(req) const settings = user.getSettingsOrDefaults() const data = await fetchChannel(path, id, settings.instance) @@ -18,24 +22,69 @@ module.exports = [ if (data.error) { const statusCode = data.missing ? 410 : 500 const subscribed = user.isSubscribed(id) - return render(statusCode, "pug/channel-error.pug", {req, settings, data, subscribed, instanceOrigin}) + return render(statusCode, "pug/channel-error.pug", {req, settings, data, subscribed}) } - // everything is fine - - // normalise info, apply watched status - if (!data.second__subCountText && data.subCount) { + // normalise info + if (!data.second__subCountText && data.subCount) data.second__subCountText = converters.subscriberCountToText(data.subCount) + if (!data.second__totalViewText && data.totalViews) + data.second__totalViewText = converters.viewCountToText(data.totalViews) + + // subpages... + if (subpage) { // Get all videos + const fetchURL = `${instanceOrigin}/api/v1/channels/${id}/${subpage}${url.href.split(`${id}/${subpage}`)[1]}` + + let q = await request(fetchURL.toString()) + if (!q.ok) { + // Has error + if (q.result.message) { + // TODO + if (q.result instanceof fetch.FetchError) + return render(500, "pug/errors/fetch-error.pug", { instanceOrigin, error: q.result }) + else + return render(500, "pug/errors/message-error.pug", { instanceOrigin, error: q.result }) + // Just didn't return json + } else { + // TODO + const out = await q.result.text() + // html + if (out.indexOf("
") != -1) + return render(q.result.status, `pug/errors/substitute-html-error.pug`, { content: out }) + // just text + else + return render(q.result.status, "pugs/errors/message-error.pug", { instanceOrigin, error: new Error(out) }) + } + } + + // Retrieve data + if (subpage == "videos" || subpage == "shorts" || subpage == "streams" || subpage == "playlists") { + data.latestVideos = q.result.videos || q.result.playlists || [] + data.continuation = q.result.continuation + } } + + // apply watched status + dearrow data + let toasts = [] const watchedVideos = user.getWatchedVideos() if (data.latestVideos) { - data.latestVideos.forEach(video => { + for (const video of data.latestVideos) { converters.normaliseVideoInfo(video) video.watched = watchedVideos.includes(video.videoId) - }) + } + // Apply DeArrow data + if (settings.dearrow > 0) { + if (settings.dearrow_preload == 1) { + const dearrowOut = await dearrow.applyToAllDeArrow(data.latestVideos) + 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 + dearrow.getAllDeArrowNoBlock(data.latestVideos) + } } const subscribed = user.isSubscribed(data.authorId) - return render(200, "pug/channel.pug", {req, settings, data, subscribed, instanceOrigin}) + return render(200, "pug/channel.pug", {req, settings, data, subscribed, subpage, url, toasts}) } } ] diff --git a/api/dearrow.js b/api/dearrow.js new file mode 100755 index 0000000..f71f0a3 --- /dev/null +++ b/api/dearrow.js @@ -0,0 +1,38 @@ +const cacheManager = require("../eirtubeMods/cache-manager") + +module.exports = [ + { + route: "/getDeArrow", methods: ["GET"], code: async ({req, url, res}) => { + let videoID = url.searchParams.get("v") + if (videoID) { + const segs = videoID.split("/") + videoID = segs[segs.length - 1] + } + + // Check exists + let data = cacheManager.read("dearrow")[videoID] + if (!data) + return { + statusCode: 404, + contentType: "text/html; charset=UTF-8", + content: "Not found" + } + else if (data.loading) { + await new Promise(resolve => { + const int = setInterval(() => { + data = cacheManager.read("dearrow")[videoID] + if (!data.loading) { + clearInterval(int) + resolve() + } + }, 1000) + }) + } + return { + statusCode: 200, + contentType: "application/json", + content: data, + } + } + } +] diff --git a/api/downloadApi.js b/api/downloadApi.js new file mode 100755 index 0000000..cec4694 --- /dev/null +++ b/api/downloadApi.js @@ -0,0 +1,305 @@ +const fs = require('fs') +const {render} = require("pinski/plugins") +const path = require("path") +const fetch = require("node-fetch") +const constants = require("../utils/constants") +const {getUser} = require("../utils/getuser") +const db = require("../utils/db") +const cacheManager = require("../eirtubeMods/cache-manager") +const downloader = require("../eirtubeMods/downloader") +const ratelimiting = require("../eirtubeMods/ratelimiting") + +function serveFile(path, range, res, req, responseHeaders) { + responseHeaders.Location = path + const stats = fs.statSync(path) + + // Download all at once + if (range == "all") + range = `bytes=0-${stats.size}` + + if (!range) + return { + statusCode: 416, + contentType: "text/html; charset=UTF-8", + content: "Wrong range", + headers: responseHeaders + } + + // Stolen DIRECTLY from Pinski internals :3 + let ranged = toRange(stats.size, responseHeaders, req) + responseHeaders["Content-Length"] = ranged.length + res.writeHead(ranged.statusCode, Object.assign({"Content-Type": "video/mp4"}, responseHeaders)) + if (req.method == "HEAD") return res.end() + let stream = fs.createReadStream(path, {start: ranged.start, end: ranged.end}) + stream.pipe(res) + + return null +} + +// Stolen DIRECTLY from Pinski internals :3 +function toRange(length, headers, req) { + if (length === 0) return {statusCode: 200, start: undefined, end: undefined, length: 0} + let start = 0 + let end = length-1 + let statusCode = 200 + if (req.headers.range) { + let match = req.headers.range.match(/^bytes=([0-9]*)-([0-9]*)$/) + if (match) { + if (match[1]) { + let value = +match[1] + if (!isNaN(value) && value >= start) { + start = value + statusCode = 206 + } + } + if (match[2]) { + let value = +match[2] + if (!isNaN(value) && value <= end) { + end = value + statusCode = 206 + } + } + } + } + if (start > end) { + start = 0 + end = length-1 + statusCode = 200 + } + if (statusCode == 206) { + headers["Accept-Ranges"] = "bytes" + headers["Content-Range"] = "bytes "+start+"-"+end+"/"+length + } + return {statusCode, start, end, length: end - start + 1} +} + +module.exports = [ + { + route: "/getVideo", methods: ["GET", "HEAD"], code: async ({req, url, res}) => { + const responseHeaders = { + Location: req.url + } + let videoID = url.searchParams.get("v") + if (videoID) { + const segs = videoID.split("/") + videoID = segs[segs.length - 1] + } + let quality = url.searchParams.get("q") + if (quality) { + const segs = quality.split("/") + quality = segs[segs.length - 1] + } + + // Check file exists + const fname = `${videoID}-${quality}` + const dlStatus = downloader.downloadStatus(fname) + + // Wait to download + if (dlStatus == 1) + await new Promise(resolve => setTimeout(() => { + if (dlStatus.status > 1) + resolve() + }, 1000 * 2)) + // 404 + else if (!downloader.videoExists(fname)) { + console.log(`dlstatus for ${fname}: ${dlStatus}`) + return { + statusCode: 404, + contentType: "text/html; charset=UTF-8", + content: "Not found", + headers: responseHeaders + } + } + + return serveFile(path.join(constants.server_setup.video_dl_path, `${fname}.mp4`), url.searchParams.get("dl") == 1 ? "all" : req.headers.range, res, req, responseHeaders) + } + }, + { + route: "/getCaption", methods: ["GET", "HEAD"], code: async ({req, url, res}) => { + const responseHeaders = { + Location: req.url + } + // NOTE: Default NewLeaf has an oversight where it formats urls with ?lang= instead of ?label= if caption track is not auto-generated. + // Why? I don't know. It means this won't work. Hope users are using my newleaf where it's fixed (or invidious). + const remotePath = url.searchParams.get("url") + const captionExtension = remotePath.split("/api/v1/captions/")[1] + const captionFilename = captionExtension.split("?label=")[0] + "-" + captionExtension.split("?label=")[1] + ".vvt" + const out = path.join(constants.server_setup.video_dl_path, captionFilename) + + // Needs download + const needsDownload = !fs.existsSync(out) + const redownloading = !needsDownload && url.searchParams.get("redownload") == 1 + if (needsDownload && req.method == "HEAD") + return render(404, "pug/errors/message-error.pug", { error: new Error("File not found, needs download") }, responseHeaders) + const checkAuth = ratelimiting.authorize(req, needsDownload || redownloading, responseHeaders) + if (checkAuth != true) + return checkAuth + + // Check if file already exists + if (fs.existsSync(out)) + if (redownloading) + fs.unlinkSync(out) + else + return serveFile(out, req.headers.range || "all", res, req, responseHeaders) + + // Download + const instanceOrigin = getUser(req).getSettingsOrDefaults().instance + const fetchURL = new URL(remotePath, instanceOrigin) + + // Force to match caption string + if (!remotePath.toString().startsWith("/api/v1/captions/")) + return { + statusCode: 401, + content: "EirTube: Unauthorized", + contentType: "text/plain", + headers: responseHeaders + } + + let stream = fs.createWriteStream(out, { flags: "a" }) + let response = await fetch(fetchURL) + await response.body.pipe(stream, { end: false }) + await new Promise(resolve => response.body.on("end", resolve)) + stream.end() + + return serveFile(out, "all", res, req, responseHeaders) + } + }, + { + route: "/getOgg", methods: ["GET", "HEAD"], code: async ({req, url, res}) => { + const responseHeaders = { + Location: req.url + } + + let videoID = url.searchParams.get("videoID") + + const pathBase = path.join(__dirname, "../") + const out = path.join(constants.server_setup.ogg_dl_path, `${videoID}.ogg`) + + // Needs download + const needsDownload = !fs.existsSync(out) + if (needsDownload && req.method == "HEAD") + return render(404, "pug/errors/message-error.pug", { error: new Error("File not found, needs extraction") }, responseHeaders) + const checkAuth = ratelimiting.authorize(req, needsDownload, responseHeaders) + if (checkAuth != true) + return checkAuth + + // Check if file already exists + if (fs.existsSync(out)) + return serveFile(out, "all", res, req, responseHeaders) + + // Get input path + let pathIn = path.join(constants.server_setup.video_dl_path, `${videoID}.mp4`) + if (!fs.existsSync(pathIn)) + return render(404, "pug/errors/message-error.pug", { error: new Error("Input video file missing") }, responseHeaders) + + // Check already downloading + const dlData = cacheManager.read("ogg")[videoID] + if (dlData != undefined && dlData.status == 1) + await new Promise(resolve => { + setTimeout(() => { + if (dlData.status == 2) // It's 2 for ogg. + resolve() + }, 1000) + }) + else { + let result = await downloader.createFfmpegOgg(videoID, path.join(pathBase, pathIn), path.join(pathBase, out)) + if (result.error) + return render(500, "pug/errors/message-error.pug", { error: new Error(result.error) }, responseHeaders) + // Set timer to delete ogg after a bit + else + setTimeout(() => { + if (fs.existsSync(out)) + fs.unlinkSync(out) + }, constants.server_setup.time_before_ogg_delete) + } + + return serveFile(out, "all", res, req, responseHeaders) + } + }, + { + route: "/redownloadVideo", methods: ["GET"], code: async ({req, url}) => { + const responseHeaders = { + Location: req.url + } + const videoID = url.searchParams.get("videoID") + const quality = url.searchParams.get("quality") + let filename = `${videoID}-${quality}` + let dlData = cacheManager.read("download")[filename] + + // Needs download + const needsDownload = (dlData == undefined || dlData.status == 3) && downloader.videoExists(filename) + const checkAuth = ratelimiting.authorize(req, needsDownload, responseHeaders) + if (checkAuth != true) + return checkAuth + + // Video is still downloading + if (dlData != undefined && dlData.status < 3) { + return { + statusCode: 500, + contentType: "application/json", + content: { + filename, + status: "Still downloading!" + }, + headers: responseHeaders + } + // Video doesn't exist + } else if (!downloader.videoExists(filename)) { + return { + statusCode: 500, + contentType: "application/json", + content: { + filename, + status: "Video doesn't exist." + }, + headers: responseHeaders + } + // Video exists + } else { + fs.unlinkSync(path.join(constants.server_setup.video_dl_path, `${filename}.mp4`)) + return { + statusCode: 307, + contentType: "text/html; charset=UTF-8", + content: "Redirecting...", + headers: { + ...responseHeaders, + Location: `/watch?v=${videoID}${quality ? `&quality=${quality}` : ""}` + } + } + } + } + }, + { + route: "/cacheInfo", methods: ["GET"], code: async ({req, url}) => { + const responseHeaders = { + Location: req.url + } + const checkAuth = ratelimiting.authorize(req, false, responseHeaders) + if (checkAuth != true) + return checkAuth + + let videoName = url.searchParams.get("videoName") // Includes quality + let dlData = cacheManager.read("download")[videoName] + + let status = "not found" + if (videoName && videoName != "" && downloader.videoExists(videoName)) { + // Case 1: not in memory but exists as a file. probably downloaded on a previous server run + if (dlData == undefined) + status = "found" + // Case 2: in memory + else if (dlData) + status = dlData.status < 3 ? "downloading" : "found" + } + + return { + statusCode: 200, + contentType: "application/json", + content: { + videoName, + status + }, + headers: responseHeaders + } + } + } +] diff --git a/api/filters.js b/api/filters.js old mode 100644 new mode 100755 diff --git a/api/formapi.js b/api/formapi.js old mode 100644 new mode 100755 index 1a4dd55..1de46f4 --- a/api/formapi.js +++ b/api/formapi.js @@ -5,6 +5,7 @@ const {getUser, setToken} = require("../utils/getuser") const validate = require("../utils/validate") const V = validate.V const {fetchChannel} = require("../utils/youtube") +const ratelimiting = require("../eirtubeMods/ratelimiting") module.exports = [ { @@ -84,6 +85,7 @@ module.exports = [ ;["Subscriptions", "Settings", "SeenTokens", "WatchedVideos"].forEach(table => { db.prepare(`DELETE FROM ${table} WHERE token = ?`).run(token) }) + ratelimiting.remove(req) return { statusCode: 303, contentType: "text/plain", diff --git a/api/pages.js b/api/pages.js old mode 100644 new mode 100755 index 211d18a..2b4fc5d --- a/api/pages.js +++ b/api/pages.js @@ -1,5 +1,6 @@ const {render} = require("pinski/plugins") const {getUser} = require("../utils/getuser") +const converters = require("../utils/converters") module.exports = [ { @@ -18,6 +19,7 @@ module.exports = [ return render(200, "pug/licenses.pug", {req, settings}) } }, + /* { route: "/cant-think", methods: ["GET"], code: async ({req}) => { const user = getUser(req) @@ -25,11 +27,19 @@ module.exports = [ return render(200, "pug/cant-think.pug", {req, settings}) } }, + */ { route: "/privacy", methods: ["GET"], code: async ({req}) => { const user = getUser(req) const settings = user.getSettingsOrDefaults() - return render(200, "pug/privacy.pug", {req, settings}) + return render(200, "pug/privacy.pug", {req, settings, converters}) + } + }, + { + route: "/eirTube", methods: ["GET"], code: async ({req}) => { + const user = getUser(req) + const settings = user.getSettingsOrDefaults() + return render(200, "pug/eirTube.pug", {req, settings, converters}) } } ] diff --git a/api/playlists.js b/api/playlists.js new file mode 100755 index 0000000..ce0fb0c --- /dev/null +++ b/api/playlists.js @@ -0,0 +1,17 @@ +module.exports = [ + { + route: "/playlist", methods: ["GET"], code: async ({req, url, res}) => { + let playlistId = url.searchParams.get("list") + if (playlistId) { + const segs = playlistId.split("/") + playlistId = segs[segs.length - 1] + } + + return { + statusCode: 200, + contentType: "text/plain;charset=UTF-8", + content: "TODO!", + } + } + } +] diff --git a/api/proxy.js b/api/proxy.js old mode 100644 new mode 100755 index a6fecbe..571309e --- a/api/proxy.js +++ b/api/proxy.js @@ -17,7 +17,7 @@ module.exports = [ if (!fetchURL.toString().startsWith(instanceOrigin) || !authorizedPaths.some(element => fetchURL.pathname.match(new RegExp(`^${element}$`)))) { return { statusCode: 401, - content: "CloudTube: Unauthorized", + content: "EirTube: Unauthorized", contentType: "text/plain" } } diff --git a/api/redirects.js b/api/redirects.js old mode 100644 new mode 100755 diff --git a/api/search.js b/api/search.js old mode 100644 new mode 100755 index 9d582b4..f512cf6 --- a/api/search.js +++ b/api/search.js @@ -1,7 +1,9 @@ const {request} = require("../utils/request") -const {render} = require("pinski/plugins") +const {render, instance} = require("pinski/plugins") const {getUser} = require("../utils/getuser") +const fetch = require("node-fetch") const converters = require("../utils/converters") +const dearrow = require("../eirtubeMods/sb") module.exports = [ { @@ -14,7 +16,28 @@ module.exports = [ const fetchURL = new URL(`${instanceOrigin}/api/v1/search`) fetchURL.searchParams.set("q", query) - let results = await request(fetchURL.toString()).then(res => res.json()) + // Fetch search results + let q = await request(fetchURL.toString()) + if (!q.ok) { + // Has error + if (q.result.message) { + if (q.result instanceof fetch.FetchError) + return render(500, "pug/errors/fetch-error.pug", { instanceOrigin, error: q.result }) + else + return render(500, "pug/errors/message-error.pug", { instanceOrigin, error: q.result }) + // Just didn't return json + } else { + const out = await q.result.text() + // html + if (out.indexOf("") != -1) + return render(q.result.status, `pug/errors/substitute-html-error.pug`, { content: out }) + // just text + else + return render(q.result.status, "pugs/errors/message-error.pug", { instanceOrigin, error: new Error(out) }) + } + } + + let results = q.result const error = results.error || results.message || results.code if (error) throw new Error(`Instance said: ${error}`) @@ -23,10 +46,22 @@ module.exports = [ converters.normaliseVideoInfo(video) } + let toasts = [] + // Apply DeArrow data + if (settings.dearrow > 0) { + if (settings.dearrow_preload == 1) { + const dearrowOut = await dearrow.applyToAllDeArrow(results) + 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 + dearrow.getAllDeArrowNoBlock(results) + } + const filters = user.getFilters() results = converters.applyVideoFilters(results, filters).videos - return render(200, "pug/search.pug", {req, settings, url, query, results, instanceOrigin}) + return render(200, "pug/search.pug", {req, settings, url, query, results, instanceOrigin, toasts}) } } ] diff --git a/api/settings.js b/api/settings.js old mode 100644 new mode 100755 index 33736e6..52d76d1 --- a/api/settings.js +++ b/api/settings.js @@ -2,6 +2,7 @@ const {render, redirect} = require("pinski/plugins") const db = require("../utils/db") const {getToken, getUser} = require("../utils/getuser") const constants = require("../utils/constants") +const converters = require("../utils/converters") const {instancesList} = require("../background/instances") const validate = require("../utils/validate") const V = validate.V @@ -21,9 +22,9 @@ module.exports = [ { route: "/settings", methods: ["GET"], code: async ({req}) => { const user = getUser(req) - const settings = user.getSettings() - const instances = instancesList.get() - return render(200, "pug/settings.pug", {req, constants, user, settings, instances}) + const settings = user.getSettingsOrDefaults()//user.getSettings() + const instances = [...constants.extra_inv_instances, ...instancesList.get()] + return render(200, "pug/settings.pug", {req, converters, user, settings, instances}) } }, { diff --git a/api/subscriptions.js b/api/subscriptions.js old mode 100644 new mode 100755 index b92887c..7cfe4b8 --- a/api/subscriptions.js +++ b/api/subscriptions.js @@ -3,16 +3,19 @@ const db = require("../utils/db") const {getUser} = require("../utils/getuser") const {timeToPastText, rewriteVideoDescription, applyVideoFilters} = require("../utils/converters") const {refresher} = require("../background/feed-update") +const dearrow = require("../eirtubeMods/sb") module.exports = [ { route: `/subscriptions`, methods: ["GET"], code: async ({req, url}) => { const user = getUser(req) + const settings = user.getSettingsOrDefaults() let hasSubscriptions = false let videos = [] let channels = [] let missingChannelCount = 0 let refreshed = null + let toasts = [] if (user.token) { // trigger a background refresh, needed if they came back from being inactive refresher.skipWaiting() @@ -27,19 +30,28 @@ module.exports = [ if (channels.length) { hasSubscriptions = true videos = db.prepare(`SELECT Videos.* FROM Videos INNER JOIN Subscriptions ON Videos.authorID = Subscriptions.ucid WHERE token = ? ORDER BY published DESC LIMIT 60`).all(user.token) - .map(video => { - video.publishedText = timeToPastText(video.published * 1000) - video.watched = watchedVideos.includes(video.videoId) - video.descriptionHtml = rewriteVideoDescription(video.descriptionHtml, video.videoId) - return video - }) + for (const video of videos) { + video.publishedText = timeToPastText(video.published * 1000) + video.watched = watchedVideos.includes(video.videoId) + video.descriptionHtml = rewriteVideoDescription(video.descriptionHtml, video.videoId) + } + + // Apply DeArrow data + if (settings.dearrow > 0) { + if (settings.dearrow_preload == 1) { + const dearrowOut = await dearrow.applyToAllDeArrow(videos) + 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 + dearrow.getAllDeArrowNoBlock(videos) + } } const filters = user.getFilters() ;({videos} = applyVideoFilters(videos, filters)) } - const settings = user.getSettingsOrDefaults() const instanceOrigin = settings.instance - return render(200, "pug/subscriptions.pug", {req, url, settings, hasSubscriptions, videos, channels, missingChannelCount, refreshed, timeToPastText, instanceOrigin}) + return render(200, "pug/subscriptions.pug", {req, url, settings, hasSubscriptions, videos, channels, missingChannelCount, refreshed, timeToPastText, instanceOrigin, toasts}) } } ] diff --git a/api/takedown.js b/api/takedown.js old mode 100644 new mode 100755 index ac3a300..2655605 --- a/api/takedown.js +++ b/api/takedown.js @@ -1,10 +1,9 @@ -const constants = require("../utils/constants") const {render} = require("pinski/plugins") module.exports = [ { route: "/takedown", methods: ["GET"], code: async ({req}) => { - return render(200, "pug/takedown.pug", {req, constants}) + return render(200, "pug/takedown.pug", {req}) } } ] diff --git a/api/thumbnails.js b/api/thumbnails.js old mode 100644 new mode 100755 diff --git a/api/video.js b/api/video.js old mode 100644 new mode 100755 index 1389c99..a82d507 --- a/api/video.js +++ b/api/video.js @@ -1,14 +1,22 @@ -const {request} = require("../utils/request") /** @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 {getToken, getUser} = require("../utils/getuser") -const pug = require("pug") +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) @@ -19,98 +27,17 @@ class InstanceError extends Error { 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: "/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` + const dest = `https://www.youtube.com${url.pathname}${url.search}` //#cloudtube` user.addWatchedVideoMaybe(id) return { statusCode: 302, @@ -125,32 +52,48 @@ module.exports = [ // 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") === "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 + // 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 === 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}) + 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) } - 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/download + try { + videoFuture = downloader.fetchVideoData(id, req) + } catch(e) { willDownload = false } } try { @@ -158,8 +101,16 @@ module.exports = [ const video = await videoFuture // Error handling - if (!video) throw new MessageError("The instance returned null.") + 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) @@ -167,20 +118,56 @@ module.exports = [ // 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)) + 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 - const formats = sortFormats(video, settings.quality) + let formats = await downloader.sortFormats(video, ([480, 720, 1080])[settings.quality]) + let startingFormat = formats[0] - // process length text and view count - for (const rec of video.recommendedVideos) { - converters.normaliseVideoInfo(rec) + // 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) @@ -194,29 +181,132 @@ module.exports = [ } } - // normalise view count - if (!video.second__viewCountText && video.viewCount) { - video.second__viewCountText = converters.viewCountToText(video.viewCount) - } + 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"}` - // apply media fragment to all sources 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 } - // rewrite description - video.descriptionHtml = converters.rewriteVideoDescription(video.descriptionHtml, id) + // 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 + } - // 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})}` + // 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, subscribed, instanceOrigin, mediaFragment, autoplay, continuous, - sessionWatched, sessionWatchedNext, settings - }) - + 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. @@ -239,7 +329,7 @@ module.exports = [ // 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}) + return render(500, "pug/video.pug", {video: {videoId: id}, error: true, message, req, settings}, responseHeaders) } } } diff --git a/background/cache-reaper.js b/background/cache-reaper.js new file mode 100755 index 0000000..598f6cd --- /dev/null +++ b/background/cache-reaper.js @@ -0,0 +1,104 @@ +const fs = require("fs") +const constants = require("../utils/constants") +const path = require("path") +const cacheManager = require("../eirtubeMods/cache-manager") + +let maxVideoCacheSize = constants.server_setup.video_cache_max_size +let targetVideoCacheSize = 0.75 * maxVideoCacheSize; +let maxJsonCacheSize = constants.server_setup.cache_json_max_size +let targetJsonCacheSize = 0.75 * maxJsonCacheSize; + +function scanFolder(folder) { + let totalSize = 0 + let files = [] + for (let file of fs.readdirSync(folder)) { + if (!fs.existsSync(path.join(folder, file))) + continue + + let stats = fs.lstatSync(path.join(folder, file)) + if (stats.isDirectory()) + continue + files.push({name: file, size: stats.size, age: stats.atimeMs}) + totalSize += stats.size + } + + return { totalSize, files } +} + + + +let scanCachesTimeout +function scanCaches() { + // Video cache + let vidCacheData = scanFolder(constants.server_setup.video_dl_path) + vidCacheData.files = vidCacheData.files.filter(f => { + const nameInQueue = f.name.split(".mp4")[0].replace("-temp", "").replace("-audio", "") + const dlStatus = cacheManager.read("download")[nameInQueue] + return (dlStatus == undefined || dlStatus != 2) + }) + + if (vidCacheData.totalSize > maxVideoCacheSize) { + // vidCacheData.files = vidCacheData.files.sort((a, b) => b.size - a.size) + // vidCacheData.files = vidCacheData.files.sort((a, b) => (b.size - b.age) - (a.size - a.age)) //vidCacheData.files = vidCacheData.files.sort((a, b) => b.age - a.age) + vidCacheData.files = vidCacheData.files.sort((a, b) => a.age - b.age) + + let i = 0; + let filesToRemove = [] + while (vidCacheData.totalSize >= targetVideoCacheSize) { + let file = vidCacheData.files[i]; + filesToRemove.push(path.join(constants.server_setup.video_dl_path, file.name)); + vidCacheData.totalSize -= file.size + i++; + } + + console.log(`Removing ${filesToRemove.length} files from video cache:`); + console.log(filesToRemove); + + for (let file of filesToRemove) + fs.unlinkSync(file) + } + + // Json cache files + let jsonCacheData = scanFolder(constants.server_setup.json_cache_path) + + if (jsonCacheData.totalSize > maxJsonCacheSize) { + jsonCacheData.files = jsonCacheData.files.sort((a, b) => b.size - a.size) + + let i = 0; + let cachesToClear = [] + while (jsonCacheData.totalSize >= targetJsonCacheSize) { + let file = jsonCacheData.files[i]; + cachesToClear.push(path.join(constants.server_setup.json_cache_path, file.name)); + jsonCacheData.totalSize -= file.size + i++; + } + + console.log(`Emptying ${cachesToClear.length} json files in json cache:`); + console.log(cachesToClear); + + for (let cache of cachesToClear) + cacheManager.clean(cache) + } + + // Repeat cleanup later + clearTimeout(scanCachesTimeout) + scanCachesTimeout = setTimeout(scanCaches, constants.server_setup.time_between_cache_cleanup) +} +scanCaches() + +// Clear ogg cache on startup +for (let file of fs.readdirSync(constants.server_setup.ogg_dl_path)) { + if (!fs.existsSync(path.join(constants.server_setup.ogg_dl_path, file))) + continue + + let stats = fs.lstatSync(path.join(constants.server_setup.ogg_dl_path, file)) + if (stats.isDirectory()) + continue + + if (file.endsWith(".ogg")) + fs.unlinkSync(path.join(constants.server_setup.ogg_dl_path, file)) +} + +module.exports = { + scanCaches +} diff --git a/background/feed-update.js b/background/feed-update.js old mode 100644 new mode 100755 diff --git a/background/instances.js b/background/instances.js old mode 100644 new mode 100755 index 3bed96c..1e06a87 --- a/background/instances.js +++ b/background/instances.js @@ -16,7 +16,15 @@ class InstancesList { * automatically. */ update() { - return this.inflight = request("https://api.invidious.io/instances.json?sort_by=health").then(res => res.json()).then(list => { + return this.inflight = request("https://api.invidious.io/instances.json?sort_by=health") + .then(res => { + if (!res.ok) + throw res.result + return res.result + }).catch(e => { + log(`[background/instances] ${e.message}`, "warning") + return [] + }).then(list => { return list.filter(i => i[1].type === "https").map(i => i[1].uri.replace(/\/+$/, "")) }).catch(e => { log(`[background/instances] ${e.message}`, "warning") diff --git a/config/config.sample.js b/config/config.sample.js index 72c1664..3d90fed 100644 --- a/config/config.sample.js +++ b/config/config.sample.js @@ -2,12 +2,131 @@ module.exports = { /* Copy this file to `config.js`, and add options here. They'll override the options from `utils/constants.js`. - For example, the next block changes the default instance. */ + // extra_inv_instances: [], + + // Default user settings user_settings: { - instance: { - default: "https://example.com" - } - } + // Uncomment this and set the value to the url of your newleaf instance. + // Must be running my fork (https://git.eir-nya.gay/eir/newleaf) + // instance: { + // type: "string", + // default: "..." + // }, + theme: { + type: "integer", + default: 3 + }, + // save_history: { + // type: "boolean", + // default: false + // }, + // local: { + // type: "integer", + // default: 0 + // }, + // autoHD: { + // type: "boolean", + // default: 1 + // }, + // quality: { + // type: "integer", + // default: 0 + // }, + // recommended_mode: { + // type: "integer", + // default: 0 + // }, + // dearrow: { + // type: "integer", + // default: 1 + // }, + // dearrow_thumbnail_instance: { + // type: "string", + // default: "https://dearrow-thumb.ajay.app" + // }, + // dearrow_preload: { + // type: "boolean", + // default: 0 + // }, + // sponsorblock: { + // type: "boolean", + // default: 1 + // }, + // sponsorblock_keybind: { + // type: "string", + // default: "b" + // }, + // sponsorblock_sponsor: { + // type: "integer", + // default: 0 + // }, + // sponsorblock_selfpromo: { + // type: "integer", + // default: 0 + // }, + // sponsorblock_interaction: { + // type: "integer", + // default: 0 + // }, + // sponsorblock_intro: { + // type: "integer", + // default: 2 + // }, + // sponsorblock_outro: { + // type: "integer", + // default: 2 + // }, + // sponsorblock_preview: { + // type: "integer", + // default: 2 + // }, + // sponsorblock_music_offtopic: { + // type: "integer", + // default: 0 + // }, + // sponsorblock_filler: { + // type: "integer", + // default: 2 + // } + }, + + // Default server settings + server_setup: { + // The URL of the local NewLeaf instance, which is always used for subscription updates. + // local_instance_origin: "http://localhost:3000", + // Whether users may filter videos by regular expressions. Unlike square patterns, regular expressions are _not_ bounded in complexity, so this can be used for denial of service attacks. Only enable if this is a private instance and you trust all the members. + // allow_regexp_filters: false, + + // Download cache related vars. + // - Eir + // video_cache_max_size: (1024*1024*1024) * 10, + // cache_json_max_size: (1024*1024) * 128, + // time_between_cache_save_to_disk: (1000 * 60) * 12, + // time_between_cache_cleanup: (1000*60) * 45, + // time_before_ogg_delete: (1000*60) * 5, + // download_queue_threads: 3, + // video_dl_path: "cache/assets", + // ogg_dl_path: "cache/assets/temp", + // json_cache_path: "cache/json", + // ytdlp_cache_path: "cache/ytdlp", + // video_hq_preload_max_time: 60 * 255, + // ratelimiting: { + // enabled: true, + // max_bucket_size: 10, + // bucket_refill_rate_seconds: 60 + // } + }, + + // Various caching timers. + // caching: { + // subscriptions_refresh_loop_min: 5 * (60*1000) + // }, + + // Allow video takedowns + // takedown: { + // contact_url: "...", + // contact_email: "..." + // } } diff --git a/eirtubeMods/cache-manager.js b/eirtubeMods/cache-manager.js new file mode 100755 index 0000000..b0551f8 --- /dev/null +++ b/eirtubeMods/cache-manager.js @@ -0,0 +1,65 @@ +const fs = require("fs") +const constants = require("../utils/constants"); +const path = require("path"); + +const cacheNames = [ "video", "dearrow", "sb", "download", "ogg" ]; +const cachesSaved = [ "video", "dearrow", "sb" ]; +function getCachePath(cacheName) { + return path.join(constants.server_setup.json_cache_path, `${cacheName}_cache.json`); +} + +let caches = {} +try { + for (let cache of cacheNames) { + let cachePath = getCachePath(cache) + if (cachesSaved.includes(cache)) { + if (!fs.existsSync(cachePath)) + fs.writeFileSync(cachePath, "{}") + caches[cache] = JSON.parse(fs.readFileSync(cachePath).toString()) + } else + caches[cache] = {} + } +} catch (error) {} + +module.exports = { + write: function(cacheName, key, value) { + caches[cacheName][key] = value + }, + read: function(cacheName) { + return caches[cacheName] + }, + clean: function(cacheName) { + caches[cacheName] = {} + } +} + +// Save caches every hour +setInterval(() => { + // Clear dearrow entries from videos + for (const vidId in caches["video"]) { + delete caches["video"][vidId].dearrowData + for (const recVidId in caches["video"][vidId].recommendedVideos) + delete caches["video"][vidId].recommendedVideos[recVidId].dearrowData + } + + // Clear empty entries in dearrow cache + for (const vidId in caches["dearrow"]) { + let data = caches["dearrow"][vidId] + if (!data.title && !data.thumbnail) + delete caches["dearrow"][data] + } + + // Save savable caches + for (const cache of cachesSaved) + fs.writeFileSync(getCachePath(cache), JSON.stringify(caches[cache])) + + // Clear download cache + for (const vidName in caches["download"]) + if (caches["download"][vidName].status == 3) + delete caches["download"][vidName] + + // Clear ogg cache + for (const oggName in caches["ogg"]) + if (caches["ogg"][oggName].status == 2) + delete caches["ogg"][oggName] +}, constants.server_setup.time_between_cache_save_to_disk) diff --git a/eirtubeMods/comments.js b/eirtubeMods/comments.js new file mode 100755 index 0000000..4965407 --- /dev/null +++ b/eirtubeMods/comments.js @@ -0,0 +1,59 @@ +const {getUser} = require("../utils/getuser") +const {request} = require("../utils/request") +const cacheManager = require("../eirtubeMods/cache-manager") + +async function getVideoComments(id, req, continuation) { + // Check if video comment data already in cache + let videoData = cacheManager.read("video")[id] + + if (videoData && videoData.comments && !continuation) + return videoData.comments + else { + const user = getUser(req) + const settings = user.getSettingsOrDefaults() + var instanceOrigin = settings.instance + var outURL = `${instanceOrigin}/api/v1/comments/${id}` + + let commentsFuture = request(outURL) + let commentData = await commentsFuture + if (!commentData.ok) + return commentData.result + commentData = await commentData.result + + if (commentData.error) + return commentData + + // Success + if (videoData) { + if (!continuation) + videoData.comments = commentData + else { + videoData.comments.comments = videoData.comments.concat(commentData.comments) + videoData.comments.continuation = commentData.continuation + } + cacheManager.write("video", id, videoData) + } + + return commentData + } +} + +// TODO: ability to use this on community posts? +function getReplies(videoId, commentId, req) { + let videoData = cacheManager.read("video")[videoId] + + if (videoData && videoData.comments) + for (const comment of videoData.comments.comments) + if (comment.commentId == commentId) { + if (!comment.continuation) + return [] + break + } + + // TODO +} + +module.exports = { + getVideoComments, + getReplies +} diff --git a/eirtubeMods/dl-queue.js b/eirtubeMods/dl-queue.js new file mode 100755 index 0000000..a95e35c --- /dev/null +++ b/eirtubeMods/dl-queue.js @@ -0,0 +1,89 @@ +const constants = require("../utils/constants") +const cacheManager = require("./cache-manager") +const cacheReaper = require("../background/cache-reaper") + +const lowQualityLimit = 480 +let dlQueues = [] + +const isLowQuality = q => Number(q.split("p")[0]) <= lowQualityLimit + +class DlQueue { + constructor() { + this.lq = [] + this.hq = [] + this.downloadCount = 0 // Ticks up to 3 then downloads an HQ if available + this.lastOutput = null + + this.updating = false + } + + async enqueue(videoID, quality) { + quality = quality ?? "360p" + let fname = `${videoID}-${quality}` + + // Same download can't be started multiple times + const existingData = cacheManager.read("download")[fname] + if ((!existingData || existingData.status != 2) && !downloader.videoExists(fname)) { + // Sets to queued + cacheManager.write("download", fname, { status: 1 }) + + // Adds to queue + if (isLowQuality(quality)) + this.lq.push({videoID, quality}) + else + this.hq.push({videoID, quality}) + if (!this.updating) + this.update() + } + + await downloader.waitForDownload(fname) + return this.lastOutput + } + + async startDownload(dl) { + this.lastOutput = await downloader.saveMp4Android(dl.videoID, dl.quality) + } + + update() { + this.updating = true + // Scan caches just before downloading + cacheReaper.scanCaches() + this.downloadCount++ + if (this.lq.length > 0 && (this.downloadCount < 3 || this.hq.length == 0)) + this.startDownload(this.lq.pop()).then(() => { + this.updating = false + this.update() + }) + else if (this.hq.length > 0) { + this.startDownload(this.hq.pop()).then(() => { + this.updating = false + this.update() + }) + } else + this.updating = false + if (this.downloadCount == 3) + this.downloadCount = 0 + } +} + +for (let i = 0; i < constants.server_setup.download_queue_threads; i++) + dlQueues.push(new DlQueue()) + +async function enqueue(videoID, quality) { + // Pick the first empty/near-empty download queue + const isLQ = quality == undefined || isLowQuality(quality) + + let sortedQueues + if (isLQ) + sortedQueues = dlQueues.sort((a, b) => a.lq.length - b.lq.length) + else + sortedQueues = dlQueues.sort((a, b) => (a.hq.length + (2 - a.downloadCount)) - (b.hq.length + (2 - b.downloadCount))) + + return await sortedQueues[0].enqueue(videoID, quality) +} + +module.exports = { + enqueue +} + +const downloader = require("./downloader") diff --git a/eirtubeMods/downloader.js b/eirtubeMods/downloader.js new file mode 100755 index 0000000..23c6f44 --- /dev/null +++ b/eirtubeMods/downloader.js @@ -0,0 +1,564 @@ +const {proxy} = require('pinski/plugins') +const fs = require('fs') +const childProcess = require('child_process') +const {request} = require("../utils/request") +const {getUser} = require("../utils/getuser") +const converters = require("../utils/converters") +const constants = require("../utils/constants") +const path = require("path") +const nodeFetch = require("node-fetch") +const yt2009constants = require("./yt2009constants.json") +const cacheManager = require("../eirtubeMods/cache-manager") +const dearrow = require("./sb") +const qualitySort = require("./quality-sort") + +const downloadInPartsSize = 9_000_000 // 9 MB + +const cuda_enabled = childProcess.execSync("ffmpeg -hide_banner -loglevel error -hwaccel cuda -i html/static/images/bow.png bow.png && rm bow.png") == "" + +function downloadStatus(videoID) { + let dlData = cacheManager.read("download")[videoID] + if (!dlData) + return 0; + else + return dlData.status +} + +async function waitForDownload(videoID) { + let dlData = cacheManager.read("download")[videoID] + if (dlData == undefined) + return + + await new Promise(resolve => { + setInterval(() => { + if (dlData.status == 3 || (dlData.status == 0 && videoExists(videoID))) + resolve() + }, 1000 * 2) + }) + return +} + +function videoExists(id) { + if (!fs.existsSync(path.join(constants.server_setup.video_dl_path, `${id}.mp4`))) + return false + const stats = fs.lstatSync(path.join(constants.server_setup.video_dl_path, `${id}.mp4`)) + return stats.size > 5 || stats.isSymbolicLink() +} + +function tryDeleteFromCache(id) { + function del() { + for (const file of fs.readdirSync(constants.server_setup.video_dl_path)) + if (fs.existsSync(path.join(constants.server_setup.video_dl_path, id)) && !fs.lstatSync(path.join(constants.server_setup.video_dl_path, id)).isDirectory() && file.startsWith(id)) + fs.unlinkSync(path.join(constants.server_setup.video_dl_path, file)) + } + + if (downloadStatus(id) > 0) + waitForDownload(id).then(del) + else + del() +} + +// This method is pretty much STOLEN from yt2009. thank you :) +async function saveMp4Android(id, quality) { + if (quality == undefined) + quality = "360p" + + let fname = `${id}-${quality}` + const originalFName = fname + + // Check if file is already downloading + let dlData = cacheManager.read("download")[fname] + + // Account for quality by checking for fname instead of id + if (videoExists(fname)) { + if (dlData != undefined && dlData.status == 2) + await waitForDownload(fname) + dlData.status = 3 + return + } else + dlData = {} + + dlData.status = 2 // Starting download + cacheManager.write("download", fname, dlData) + + // Copies a dict i think + let headers = JSON.parse(JSON.stringify(yt2009constants.headers)) + headers["user-agent"] = "com.google.android.youtube/19.02.39 (Linux; U; Android 14) gzip" + + let response = await nodeFetch("https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", { + headers: headers, + referrer: "https://www.youtube.com/watch?v=" + id, + referrerPolicy: "origin-when-cross-origin", + body: JSON.stringify({ + context: { + "client": { + "hl": "en", + "clientName": "ANDROID", + "clientVersion": "19.02.39", + "androidSdkVersion": 34, + "mainAppWebInfo": { + "graftUrl": "/watch?v=" + id + } + } + }, + videoId: id, + racyCheckOk: true, + contentCheckOk: true + }), + method: "POST", + mode: "cors" + }) + + let r + try { + r = await response.json() + } catch (e) { + if (e instanceof fetch.FetchError) + console.log("FetchError while accessing /youtubei/v1/player:\n" + await response.text()) + r = {} + } + + // Fail state + if (!r.streamingData || !r.streamingData.formats || r.streamingData.formats.length == 0) { + dlData.status = 3 + cacheManager.write("download", fname, dlData) + return + } + + let qualities = {} + let h264DashAudioUrl; + // prefer nondash formats + r.streamingData.formats.forEach(q => { + q.dash = false; + qualities[q.qualityLabel] = q; + }) + // add h264 dash formats + let audioFormats = [] + let aidub = false; + r.streamingData.adaptiveFormats.forEach(q => { + if (q.mimeType.includes("audio/") && q.resolution.includes("default")) + aidub = true; + if(q.mimeType.includes("audio/mp4")) { + audioFormats.push(q) + } else if(q.mimeType.includes("video/mp4") + && q.mimeType.includes("avc") + && !qualities[q.qualityLabel]) { + q.dash = true; + qualities[q.qualityLabel] = q; + } + }) + if(audioFormats.length > 0) { + audioFormats = audioFormats.sort((a, b) => { + if (aidub) { + const a_is_default = a.resolution.includes("default"); + const b_is_default = b.resolution.includes("default"); + + if (a_is_default || b_is_default) + return (b_is_default ? 1 : 0) - (a_is_default ? 1 : 0); + } + return b.bitrate - a.bitrate + }) + } + h264DashAudioUrl = audioFormats[0].url + + // check if dash audio is needed + // we can pull from already download mp4 if not + let downloadAudio = true; + let audioDownloadDone = false; + let videoDownloadDone = false; + /*if(fs.existsSync("../assets/" + id + ".mp4")) { + downloadAudio = false; + } + if(quality == "360p" && !qualities["360p"]) + for(let q in qualities) { + if(qualities[q].itag == 18) { + quality = q; + break; + } + } + */ + if(qualities[quality] && !qualities[quality].dash) + downloadAudio = false; + + // Get best available quality + quality = qualitySort.sort(Object.keys(qualities), quality)[0] + fname = `${id}-${quality}` + + // Does file with new name already exist? + if (originalFName != fname && fs.existsSync(path.join(constants.server_setup.video_dl_path, `${fname}.mp4`))) { + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + if (!fs.existsSync(path.join(constants.server_setup.video_dl_path, `${originalFName}.mp4`))) + fs.symlinkSync(`${fname}.mp4`, path.join(constants.server_setup.video_dl_path, `${originalFName}.mp4`), "file") + return //path.join(constants.server_setup.video_dl_path, `${fname}.mp4`) + } + + let finalReturn + + if(downloadAudio) { + downloadInParts_file(h264DashAudioUrl, path.join(constants.server_setup.video_dl_path, `${fname}-audio.m4a`)) + .then(() => { + audioDownloadDone = true; + if(audioDownloadDone && videoDownloadDone) + onFormatsDone() + }) + } else { + // Use ytdlp as a backup + const result = await downloadInParts_file(qualities[quality].url, path.join(constants.server_setup.video_dl_path, `${fname}.mp4`)) + if (!result) { + let out = path.join(constants.server_setup.video_dl_path, `${fname}.mp4`) + fs.unlinkSync(out) + + // Brief pause to let file delete finish + await new Promise(resolve => setTimeout(resolve, 1000)) + + await downloadWithYtdlp(id, qualities[quality].itag, out) + /* + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + return out + */ + } + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + if (originalFName != fname) + fs.symlinkSync(`${fname}.mp4`, path.join(constants.server_setup.video_dl_path, `${originalFName}.mp4`), "file") + return + } + + if(!qualities[quality]) { + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + return //false; + } + + // Use ytdlp as a backup + const result = await downloadInParts_file(qualities[quality].url, path.join(constants.server_setup.video_dl_path, `${id}-temp-${quality}.mp4`)) + if (!result) { + let out = path.join(constants.server_setup.video_dl_path, `${id}-temp-${quality}.mp4`) + fs.unlinkSync(out) + + // Brief pause to let file delete finish + await new Promise(resolve => setTimeout(resolve, 1000)) + + await downloadWithYtdlp(id, qualities[quality].itag, out) + /* + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + return out + */ + } + videoDownloadDone = true; + if(audioDownloadDone || !downloadAudio) { + onFormatsDone() + } + + // merge formats once both are ready + function onFormatsDone() { + const pathBase = path.join(__dirname, "../") + + let audioPath = path.join(constants.server_setup.video_dl_path, `${fname}-audio.m4a`) + if(!downloadAudio) { + audioPath = path.join(constants.server_setup.video_dl_path, `${fname}.mp4`) + } + let videoPath = path.join(constants.server_setup.video_dl_path, `${id}-temp-${quality}.mp4`) + + let cmd = [ + "ffmpeg", + cuda_enabled ? "-hwaccel cuda -c:v h264_cuvid" : null, + `-y -i ${path.join(pathBase, videoPath)}`, + `-i ${path.join(pathBase, audioPath)}`, + `-c:v copy -c:a copy`, + `-map 0:v -map 1:a`, + path.join(pathBase, path.join(constants.server_setup.video_dl_path, `${fname}.mp4`)) + ].join(" ") + + childProcess.exec(cmd, { maxBuffer: 1024*1024*1024*5 }, (e) => { + if(e) { + console.error(`FFmpeg error while combining streams:\n${e}`) + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + finalReturn = false + return + } + + const videoPathAge = fs.statSync(videoPath).atimeMs + const audioPathAge = fs.statSync(audioPath).atimeMs + + setTimeout(() => { + // delete temp assets + try { + if(!audioPath.includes(".mp4")) { + if (fs.statSync(audioPath).atimeMs == audioPathAge) + fs.unlinkSync(path.join(pathBase, audioPath)) + } + if (fs.statSync(videoPath).atimeMs == videoPathAge) + fs.unlinkSync(path.join(pathBase, videoPath)) + } + catch(error) {} + }, 500) + dlData.status = 3 + cacheManager.write("download", originalFName, dlData) + cacheManager.write("download", fname, dlData) + if (originalFName != fname) + fs.symlinkSync(`${fname}.mp4`, path.join(constants.server_setup.video_dl_path, `${originalFName}.mp4`), "file") + finalReturn = true + }) + } + + await new Promise((resolve) => { + let f + let tries = 3 + f = function() { + if (finalReturn == undefined) + setTimeout(f, 1000 * 1.5) + else { + if (f == false && tries > 0) { + finalReturn = undefined + tries-- + onFormatsDone() + setTimeout(f, 1000 * 1.5) + } else + resolve() + } + } + f() + }) + + return //finalReturn +} + +async function downloadInParts_file(url, out) { + let stream = fs.createWriteStream(out, { flags: "a" }) + return await new Promise(resolve => { + function fetchNextPart(partNumber) { + let partStartB = partNumber * downloadInPartsSize; + if(partNumber !== 0) {partStartB += partNumber} + const newHeaders = { ...yt2009constants.androidHeaders }; + newHeaders.headers.range = `bytes=${partStartB}-${partStartB + downloadInPartsSize}`; + nodeFetch(url, newHeaders).then(r => { + // If request returns 403, cancel... + if (r.status == 403) { + resolve(false) + return + } else if (r.headers.get('Content-Length') === '0') { + stream.end(); + resolve(true) + return + } + + r.body.pipe(stream, { end: false }); + r.body.on('end', () => { + fetchNextPart(partNumber + 1); + }) + }) + } + fetchNextPart(0) + }) +} + +async function downloadWithYtdlp(id, itag, out) { + await new Promise(resolve => { + childProcess.exec(`yt-dlp -f ${itag} --cache-dir "${constants.server_setup.ytdlp_cache_path}" --no-part -o "${out}" "${id}"`, e => { + if (e) { + console.error(`FFmpeg error while downloading ${id}:\n${e}`) + resolve({ error: e }) + } + + resolve({ error: false }) + }) + }) +} + +async function createFfmpegOgg(videoID, pathIn, pathOut) { + if (fs.existsSync(pathOut)) + fs.unlinkSync(pathOut) + + cacheManager.write("ogg", videoID, { status: 1 }) + + return await new Promise(resolve => { + childProcess.exec(`ffmpeg ${cuda_enabled ? "-hwaccel cuda -c:v h264_cuvid ": ""}-i ${pathIn} -b 1500k -ab 128000 -speed 2 -vn ${pathOut}`, e => { + cacheManager.write("ogg", videoID, { status: 2 }) + + if (e) { + console.error(`FFmpeg error while creating ogg:\n${e}`) + resolve({ error: e }) + } + + resolve({ error: false }) + }) + }) +} + +function createThumbStream(key, inFile, outFile, vidLength) { + let data = cacheManager.read("download")[key] // Use download cache for parity with /getVideo endpoint + + if (data.status == 2 || videoExists(outFile)) + return + else { + data.status = 2 + cacheManager.write("download", key, data) + + // const vidLength = Number(childProcess.execSync(`ffprobe -i ${inFile}.mp4 -show_entries format=duration -v quiet -of csv="p=0"`)) + let fps = 1 + if (vidLength < 2 * 60) + fps = 1 + else if (vidLength < 5 * 60) + fps = 0.5 + else if (vidLength < 15 * 60) + fps = 0.2 + else + fps = 0.1 + + const cmd = `nice -n 0 ffmpeg ${cuda_enabled ? "-hwaccel cuda ": ""}-i "${inFile}.mp4" -an -vf "fps=${fps},scale=160:90:flags=lanczos" "${outFile}.mp4"` + childProcess.exec(cmd, e => { + data.status = 3 + cacheManager.write("download", key, data) + + if (e) + console.error(`FFmpeg error while creating thumb stream:\n${e}`) + }) + } +} + +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}, + {key: "container", max: "undefined", order: "desc", transform: x => x ? (x == "mp4" ? -1 : 0) : 1} + ] + 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 +} + +module.exports = { + downloadStatus, + waitForDownload, + videoExists, + tryDeleteFromCache, + createFfmpegOgg, + createThumbStream, + + // Stolen from yt2009 + saveMp4Android, + fetchVideoData: async function(id, req) { + // Check if video data already in cache + let videoData = cacheManager.read("video")[id] + + if (videoData) + return videoData + else { + // Get video data and set to cache + // Uses Cloudtube's api usage stuff + const user = getUser(req) + const settings = user.getSettingsOrDefaults() + var instanceOrigin = settings.instance + var outURL = `${instanceOrigin}/api/v1/videos/${id}` + + let videoFuture = request(outURL) + videoData = await videoFuture + if (!videoData.ok) + return videoData.result + videoData = await videoData.result + + if (videoData.error) + return videoData + + videoData = await module.exports.formatVideoData(videoData, settings) + + return videoData + } + }, + formatVideoData: async function(videoData, settings) { + videoData.formats = await module.exports.sortFormats(videoData, ([480, 720, 1080])[settings.quality]) + // process length text and view count + for (const rec of videoData.recommendedVideos) + converters.normaliseVideoInfo(rec) + // normalise view count + if (!videoData.second__viewCountText && videoData.viewCount) + videoData.second__viewCountText = converters.viewCountToText(videoData.viewCount) + // normalize like count + if (!videoData.second__likeCountText && videoData.likeCount) + videoData.second__likeCountText = converters.likeCountToText(videoData.likeCount) + // rewrite description + videoData.descriptionHtml = converters.rewriteVideoDescription(videoData.descriptionHtml, videoData.videoId) + + // rewrite captions urls so they are served on the same domain via the /proxy route + for (const caption of videoData.captions) + caption.url = `/getCaption?${new URLSearchParams({ url: caption.url })}` + + // Apply SponsorBlock data + if (settings.sponsorblock > 0) { + const sbData = await dearrow.getSB(videoData.videoId) + if (sbData && !sbData.error) + videoData.sbData = sbData.data + } + // Apply DeArrow data + if (settings.dearrow > 0) { + const dearrowData = await dearrow.getDeArrow(videoData.videoId) + if (dearrowData && !dearrowData.error) + videoData.dearrowData = dearrowData.data + } + + return videoData + }, + + sortFormats: async function(video, targetQuality) { + // if (video.formatStreams == undefined || video.formatStreams.length == 0) + // return [] + + // 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) + if (!format.container) format.container = format.type.split("video/")[1].split(";")[0] + format.cloudtube__label = `${format.qualityLabel} ${format.container}` + if (!format.clen) { + try { + const req = await nodeFetch(format.url, { method: "HEAD" }) + format.clen = req.headers.get('content-length') + } catch(e) { + console.error(e) + } + } + if (format.clen) + format.eirtube__size = `${converters.bytesToSizeText(format.clen)}` + } + + // Properly build and order format list + let standard = video.formatStreams ? video.formatStreams.slice().sort((a, b) => (b.second__height + (b.container == "mp4" ? -1 : 0)) - (a.second__height + (a.container == "mp4" ? -1 : 0))) : [] + let 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 += " *" + } + + // Sort both groups separately + standard = qualitySort.sortFormats(standard, targetQuality, 30) + adaptive = qualitySort.sortFormats(adaptive, targetQuality, 30) + + formats = standard.concat(adaptive) + + return formats; + } +} diff --git a/eirtubeMods/quality-sort.js b/eirtubeMods/quality-sort.js new file mode 100755 index 0000000..89aa530 --- /dev/null +++ b/eirtubeMods/quality-sort.js @@ -0,0 +1,37 @@ +// Returns the highest quality <= targetHeight, optionally allows target fps +function sort(formats, targetHeight, targetFps) { + targetFps = targetFps ?? Number(targetHeight.split("p")[1] || 30) + if (typeof(targetHeight) == "string") + targetHeight = Number(targetHeight.split("p")[0]) + + targetHeight += 12 + + return formats.sort((a, b) => { + let aHeight = Number(a.split("p")[0]) + let aFps = Number(a.split("p")[1] || 30) + let aValue = aHeight > targetHeight ? 9999 : (targetHeight - aHeight) + aValue -= aFps > targetFps ? 1 : (targetFps - aFps) + + let bHeight = Number(b.split("p")[0]) + let bFps = Number(b.split("p")[1] || 30) + let bValue = bHeight > targetHeight ? 9999 : (targetHeight - bHeight) + bValue -= bFps > targetFps ? 1 : (targetFps - bFps) + + return aValue - bValue + }) +} + +function sortFormats(formats, targetHeight, targetFps) { + let qualityRefs = {} + const justQualities = formats.map(f => { + qualityRefs[f.qualityLabel] = f + return f.qualityLabel + }) + const result = sort(justQualities, targetHeight, targetFps) + return result.map(q => qualityRefs[q]) +} + +module.exports = { + sort, + sortFormats +} diff --git a/eirtubeMods/ratelimiting.js b/eirtubeMods/ratelimiting.js new file mode 100755 index 0000000..d969423 --- /dev/null +++ b/eirtubeMods/ratelimiting.js @@ -0,0 +1,136 @@ +const {render} = require("pinski/plugins") +const {getToken} = require("../utils/getuser") +const constants = require("../utils/constants") +const db = require("../utils/db") +const crypto = require("crypto") + +let users = {} +let buckets = {} + +class User { + constructor(req) { + this.uuid = crypto.randomUUID() + this.token = User.getToken(req) + this.ip = User.getIP(req) + + users[this.token] = this + users[this.ip] = this + } + + static getToken(req) { + return getToken(req) + } + + static getIP(req) { + return req.headers["x-forwarded-for"] || req.socket.remoteAddress || null + } +} + +class Bucket { + constructor() { + this.left = constants.server_setup.ratelimiting.max_bucket_size, + this.lastModified = Date.now() + } + + drain() { + // Refill bucket + this.left += Math.floor(((Date.now() - this.lastModified) / 1000) / constants.server_setup.ratelimiting.bucket_refill_rate_seconds) + this.left = Math.min(this.left, constants.server_setup.ratelimiting.max_bucket_size) + + if (this.left > 0) { + this.left-- + this.lastModified = Date.now() + return true + } + return false + } +} + +function authorized(req, doRateLimit, responseHeaders) { + if (constants.server_setup.ratelimiting.enabled && doRateLimit) { + let bucket = module.exports.getBucket(req) + if (!bucket.drain()) + return { success: false, message: "ratelimited", timeLeftSeconds: ((bucket.lastModified + (1000 * constants.server_setup.ratelimiting.bucket_refill_rate_seconds)) - Date.now()) / 1000 } + } + + const token = getToken(req, responseHeaders) + let out = db.prepare("SELECT * FROM SeenTokens WHERE token = ?").pluck().all(token) + return { success: Object.keys(req.headers).some((header) => req.headers[header] == "same-origin" || (header == "referer" && req.headers[header].indexOf("/watch?v=") > -1)) || out.length > 0, message: "auth" } +} + +// Clear users and buckets that have been inactive +setInterval(() => { + for (const k of Object.keys(buckets)) { + const b = buckets[k] + + if ((Date.now() - b.lastModified) < 1000 * 60 * 30) + continue + + for (const k2 of Object.keys(users)) { + const u = users[k2] + if (u.uuid == k) + delete users[k2] + } + delete buckets[k] + } +}, 1000 * 60 * 30) + +module.exports = { + getUser: function(req) { + const token = User.getToken(req) + if (token && users[token]) { + delete users[User.getIP(req)] // If a user has tokens enabled, we don't want their IP address to block other users on the same network + return users[token] + } + const ip = User.getIP(req) + if (users[ip]) + return users[ip] + return new User(req) + }, + getBucket: function(req) { + const user = module.exports.getUser(req) + const bucket = buckets[user.uuid] + if (bucket) + return bucket + buckets[user.uuid] = new Bucket() + return buckets[user.uuid] + }, + remove: function(req) { + const token = User.getToken(req) + if (token && users[token]) { + const uuid = users[token].uuid + delete users[token] + delete users[User.getIP(req)] + delete buckets[uuid] + return + } + const ip = User.getIP(req) + if (users[ip]) { + const uuid = users[ip].uuid + delete users[ip] + delete buckets[uuid] + } + }, + + authorize: function(req, doRateLimit, responseHeaders) { + const output = authorized(req, doRateLimit, responseHeaders) + if (output.success == false) { + if (output.message == "ratelimited") + return render(429, "pug/errors/local-rate-limited.pug", { timeLeftSeconds: output.timeLeftSeconds }, { + ...responseHeaders, + "Retry-After": Math.ceil(output.timeLeftSeconds) + }) + else if (output.message == "auth") + // return render(403, "pug/errors/message-error.pug", { error: new Error("Access denied") }, responseHeaders) + return { + statusCode: 403, + headers: responseHeaders, + contentType: "application/json", + content: { + error: "Access denied" + } + } + } + return true + } +} diff --git a/eirtubeMods/sb.js b/eirtubeMods/sb.js new file mode 100755 index 0000000..8697778 --- /dev/null +++ b/eirtubeMods/sb.js @@ -0,0 +1,161 @@ +const fetch = require("node-fetch") +const path = require("path") +const cacheManager = require("./cache-manager") + +const apiInstance = "https://sponsor.ajay.app" + +async function internalGetSB(videoID) { + // Check if data already exists + const existingData = cacheManager.read("sb")[videoID] + if (existingData) + return existingData + + let data = [] + + // Otherwise, fetch + let r + try { + r = await fetch(path.join(apiInstance, `/api/skipSegments?videoID=${videoID}&service=youtube`)) + if (r.status == 400 || r.status == 404) + throw new Error(r.statusText) + } catch (e) { + if (e instanceof fetch.FetchError) + return new Error(`FetchError (${e.code})`) + return e + } + + let sbData = await r.json() + + if (!r.ok && !sbData && !sbData.randomTime) + return new Error(`Status: ${r.status}`) + + if (sbData) + data = sbData + + cacheManager.write("sb", videoID, data) + return data +} + +async function internalGetDeArrow(videoID) { + // Check if data already exists + const existingData = cacheManager.read("dearrow")[videoID] + if (existingData && !existingData.loading) + return existingData + + let data = {} + + // Otherwise, fetch + let r + try { + r = await fetch(path.join(apiInstance, `/api/branding?videoID=${videoID}&service=youtube`)) + } catch (e) { + if (e instanceof fetch.FetchError) + return new Error(`FetchError (${e.code})`) + return e + } + + let dearrowData = await r.json() + + if (!r.ok && !dearrowData && !dearrowData.randomTime) + return new Error(`Status: ${r.status}`) + + if (dearrowData) { + for (const title of dearrowData.titles) + if (title.votes > 0 || title.locked) { + // Use original title + if (title.original) + break + + data.title = title.title + break + } + + for (const thumbnail of dearrowData.thumbnails) + if (thumbnail.votes > 0) { + // Use original thumbnail + if (thumbnail.original) + break + + data.thumbnail = `api/v1/getThumbnail?videoID=${videoID}&time=${thumbnail.timestamp}` + break + } + } + + cacheManager.write("dearrow", videoID, data) + return data +} + +module.exports = { + getSB: async function(id) { + let error = undefined + let outData = undefined + + await internalGetSB(id) + .then(data => { + if (data.message) + error = `SponsorBlock for ${id}: ${data}` + else + outData = data + }) + + return{ error, data: outData } + }, + getDeArrow: async function(id) { + let error = undefined + let outData = undefined + + await internalGetDeArrow(id) + .then(data => { + if (data.message) + error = `DeArrow for ${id}: ${data}` + else + outData = data + }) + + return{ error, data: outData } + }, + + applyToAllDeArrow: async function(videos) { + let errors = {} + + let queue = videos.map(v => { return { id: v.videoId, tries: 0 } }) + + while (queue.length > 0) { + let nextQueue = [] + for (let i = 0; i < Math.min(queue.length, 3) && nextQueue.length < 3; i++) + nextQueue.push(queue.shift()) + + await Promise.all(nextQueue.map(v => internalGetDeArrow(v.id) + .then(data => { + if (data.message) + if (data.message.startsWith("FetchError")) + if (v.tries < 3) { + v.tries++ + queue.unshift(v) + } else + errors[data.name] = `DeArrow for ${v.id}: ${data}` + else + errors[data.name] = `DeArrow for ${v.id}: ${data}` + }))) + } + + return { errors }; + }, + + getAllDeArrowNoBlock: function(videos) { + // Apply a "loading" value to each video before starting the operation + let anyLoading = false + for (const v of videos) { + const oldData = cacheManager.read("dearrow")[v.videoId] + if (!oldData) { + cacheManager.write("dearrow", v.videoId, { loading: true }) + v.dearrowData = { loading: true } + anyLoading = true + } else + v.dearrowData = oldData + } + + if (anyLoading) + this.applyToAllDeArrow(videos) + } +} diff --git a/eirtubeMods/yt2009constants.json b/eirtubeMods/yt2009constants.json new file mode 100755 index 0000000..2424bd9 --- /dev/null +++ b/eirtubeMods/yt2009constants.json @@ -0,0 +1,30 @@ +{ + "headers": { + "accept-encoding": "gzip, deflate, br", + "accept-language": "en-US,en;q=0.9", + "cookie": "GPS=1; YSC=q6STb5ub1CU; VISITOR_INFO1_LIVE=Hbzrltf2qrk; VISITOR_PRIVACY_METADATA=CgJVUxIEGgAgCg%3D%3D; ", + "dnt": 1, + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0" + }, + "headersNew": { + "X-YouTube-Client-Name": 5, + "X-YouTube-Client-Version": "19.09.3", + "origin": "https://www.youtube.com", + "user-agent": "com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)", + "content-type": "application/json" + }, + "androidHeaders": { + "headers": { + "accept": "*/*", + "accept-language": "en-US,en;q=0.9,pl;q=0.8", + "content-type": "application/json", + "cookie": "", + "x-goog-authuser": "0", + "x-origin": "https://www.youtube.com/", + "user-agent": "com.google.android.youtube/19.02.39 (Linux; U; Android 14) gzip" + } + } +} diff --git a/html/browserconfig.xml b/html/browserconfig.xml old mode 100644 new mode 100755 diff --git a/html/robots.txt b/html/robots.txt old mode 100644 new mode 100755 diff --git a/html/site.webmanifest b/html/site.webmanifest old mode 100644 new mode 100755 index 5e7bd86..d855156 --- a/html/site.webmanifest +++ b/html/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "CloudTube", - "short_name": "CloudTube", + "name": "EirTube", + "short_name": "EirTube", "icons": [ { "src": "/static/images/android-chrome-192x192.png", @@ -27,8 +27,8 @@ "purpose": "maskable" } ], - "theme_color": "#36393f", - "background_color": "#36393f", + "theme_color": "#faaaab", + "background_color": "#faaaab", "start_url": "/", "display": "standalone" } diff --git a/html/static/css/cursors.css b/html/static/css/cursors.css new file mode 100755 index 0000000..798c68c --- /dev/null +++ b/html/static/css/cursors.css @@ -0,0 +1,43 @@ +#eirCursorBg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -100; +} + +.eirCursorTrail { + position: fixed; + top: 0; + left: 0; + z-index: 9999; + pointer-events: none; + image-rendering: pixelated; + image-rendering: crisp-edges; + image-rendering: -moz-crisp-edges; +} + +body, button, body::-webkit-scrollbar, input { + cursor: url("../images/cursors/default.cur"), default; +} +a { + cursor: url("../images/cursors/pointer.cur"), pointer; +} +h1, h2, h3, h4, h5, b, i, u, strong, p, textarea, label, +input[type="email"]:not([disabled]), input[type="month"]:not([disabled]), input[type="number"]:not([disabled]), +input[type="password"]:not([disabled]), input[type="search"]:not([disabled]), input[type="tel"]:not([disabled]), +input[type="text"]:not([disabled]), input[type="url"]:not([disabled]), input[type="week"]:not([disabled]) { + cursor: url("../images/cursors/text.cur"), text; +} + +.gallery-image:not(.no-js) { + cursor: url("../images/cursors/zoom-in.cur"), -moz-zoom-in !important; + cursor: url("../images/cursors/zoom-in.cur"), -webkit-zoom-in !important; + cursor: url("../images/cursors/zoom-in.cur"), zoom-in !important; +} +#gallery-popout { + cursor: url("../images/cursors/zoom-out.cur"), -moz-zoom-out !important; + cursor: url("../images/cursors/zoom-out.cur"), -webkit-zoom-out !important; + cursor: url("../images/cursors/zoom-out.cur"), zoom-out !important; +} diff --git a/html/static/css/noscript-video-controls-hider.css b/html/static/css/noscript-video-controls-hider.css new file mode 100755 index 0000000..f2876eb --- /dev/null +++ b/html/static/css/noscript-video-controls-hider.css @@ -0,0 +1,6 @@ +.videoControls { + display: none !important +} +#quality-select { + display: none +} diff --git a/html/static/flash/player.swf b/html/static/flash/player.swf old mode 100644 new mode 100755 diff --git a/html/static/flash/skin.swf b/html/static/flash/skin.swf old mode 100644 new mode 100755 diff --git a/html/static/fonts/TerminessNerdFontMono-Bold.ttf b/html/static/fonts/TerminessNerdFontMono-Bold.ttf new file mode 100755 index 0000000..c3e5513 Binary files /dev/null and b/html/static/fonts/TerminessNerdFontMono-Bold.ttf differ diff --git a/html/static/fonts/TerminessNerdFontMono-BoldItalic.ttf b/html/static/fonts/TerminessNerdFontMono-BoldItalic.ttf new file mode 100755 index 0000000..5e3ffda Binary files /dev/null and b/html/static/fonts/TerminessNerdFontMono-BoldItalic.ttf differ diff --git a/html/static/fonts/TerminessNerdFontMono-Italic.ttf b/html/static/fonts/TerminessNerdFontMono-Italic.ttf new file mode 100755 index 0000000..bc9e2ed Binary files /dev/null and b/html/static/fonts/TerminessNerdFontMono-Italic.ttf differ diff --git a/html/static/fonts/TerminessNerdFontMono-Regular.ttf b/html/static/fonts/TerminessNerdFontMono-Regular.ttf new file mode 100755 index 0000000..7d56730 Binary files /dev/null and b/html/static/fonts/TerminessNerdFontMono-Regular.ttf differ diff --git a/html/static/fonts/Work-Sans.woff2 b/html/static/fonts/Work-Sans.woff2 new file mode 100755 index 0000000..a701d03 Binary files /dev/null and b/html/static/fonts/Work-Sans.woff2 differ diff --git a/html/static/images/android-chrome-192x192.png b/html/static/images/android-chrome-192x192.png old mode 100644 new mode 100755 index efef973..50e6869 Binary files a/html/static/images/android-chrome-192x192.png and b/html/static/images/android-chrome-192x192.png differ diff --git a/html/static/images/android-chrome-512x512.png b/html/static/images/android-chrome-512x512.png old mode 100644 new mode 100755 index 1f6c4d3..445e311 Binary files a/html/static/images/android-chrome-512x512.png and b/html/static/images/android-chrome-512x512.png differ diff --git a/html/static/images/apple-touch-icon.png b/html/static/images/apple-touch-icon.png old mode 100644 new mode 100755 index 9e84171..7c389fa Binary files a/html/static/images/apple-touch-icon.png and b/html/static/images/apple-touch-icon.png differ diff --git a/html/static/images/arrow-down-disabled-wide-dark.svg b/html/static/images/arrow-down-disabled-wide-dark.svg new file mode 100755 index 0000000..bf7fae8 --- /dev/null +++ b/html/static/images/arrow-down-disabled-wide-dark.svg @@ -0,0 +1 @@ + diff --git a/html/static/images/arrow-down-disabled-wide-light.svg b/html/static/images/arrow-down-disabled-wide-light.svg new file mode 100755 index 0000000..9ecfd6a --- /dev/null +++ b/html/static/images/arrow-down-disabled-wide-light.svg @@ -0,0 +1 @@ + diff --git a/html/static/images/arrow-down-wide-dark.svg b/html/static/images/arrow-down-wide-dark.svg old mode 100644 new mode 100755 diff --git a/html/static/images/arrow-down-wide-light.svg b/html/static/images/arrow-down-wide-light.svg old mode 100644 new mode 100755 diff --git a/html/static/images/bow.png b/html/static/images/bow.png new file mode 100755 index 0000000..ec6f11a Binary files /dev/null and b/html/static/images/bow.png differ diff --git a/html/static/images/check.svg b/html/static/images/check.svg new file mode 100755 index 0000000..cbf8753 --- /dev/null +++ b/html/static/images/check.svg @@ -0,0 +1,10 @@ + + + diff --git a/html/static/images/cursors/default-trail-1.png b/html/static/images/cursors/default-trail-1.png new file mode 100755 index 0000000..4388dc6 Binary files /dev/null and b/html/static/images/cursors/default-trail-1.png differ diff --git a/html/static/images/cursors/default-trail-2.png b/html/static/images/cursors/default-trail-2.png new file mode 100755 index 0000000..41c1c46 Binary files /dev/null and b/html/static/images/cursors/default-trail-2.png differ diff --git a/html/static/images/cursors/default-trail-3.png b/html/static/images/cursors/default-trail-3.png new file mode 100755 index 0000000..f87dd17 Binary files /dev/null and b/html/static/images/cursors/default-trail-3.png differ diff --git a/html/static/images/cursors/default.cur b/html/static/images/cursors/default.cur new file mode 100755 index 0000000..d823651 Binary files /dev/null and b/html/static/images/cursors/default.cur differ diff --git a/html/static/images/cursors/pointer-trail-1.png b/html/static/images/cursors/pointer-trail-1.png new file mode 100755 index 0000000..ab475d7 Binary files /dev/null and b/html/static/images/cursors/pointer-trail-1.png differ diff --git a/html/static/images/cursors/pointer-trail-2.png b/html/static/images/cursors/pointer-trail-2.png new file mode 100755 index 0000000..e271a26 Binary files /dev/null and b/html/static/images/cursors/pointer-trail-2.png differ diff --git a/html/static/images/cursors/pointer-trail-3.png b/html/static/images/cursors/pointer-trail-3.png new file mode 100755 index 0000000..6679ca5 Binary files /dev/null and b/html/static/images/cursors/pointer-trail-3.png differ diff --git a/html/static/images/cursors/pointer.cur b/html/static/images/cursors/pointer.cur new file mode 100755 index 0000000..1deb870 Binary files /dev/null and b/html/static/images/cursors/pointer.cur differ diff --git a/html/static/images/cursors/text-trail-1.png b/html/static/images/cursors/text-trail-1.png new file mode 100755 index 0000000..3141b00 Binary files /dev/null and b/html/static/images/cursors/text-trail-1.png differ diff --git a/html/static/images/cursors/text-trail-2.png b/html/static/images/cursors/text-trail-2.png new file mode 100755 index 0000000..ad0800c Binary files /dev/null and b/html/static/images/cursors/text-trail-2.png differ diff --git a/html/static/images/cursors/text-trail-3.png b/html/static/images/cursors/text-trail-3.png new file mode 100755 index 0000000..3be47c5 Binary files /dev/null and b/html/static/images/cursors/text-trail-3.png differ diff --git a/html/static/images/cursors/text.cur b/html/static/images/cursors/text.cur new file mode 100755 index 0000000..a0b3088 Binary files /dev/null and b/html/static/images/cursors/text.cur differ diff --git a/html/static/images/cursors/zoom-in-trail-1.png b/html/static/images/cursors/zoom-in-trail-1.png new file mode 100755 index 0000000..d849d4d Binary files /dev/null and b/html/static/images/cursors/zoom-in-trail-1.png differ diff --git a/html/static/images/cursors/zoom-in-trail-2.png b/html/static/images/cursors/zoom-in-trail-2.png new file mode 100755 index 0000000..ac1ddc3 Binary files /dev/null and b/html/static/images/cursors/zoom-in-trail-2.png differ diff --git a/html/static/images/cursors/zoom-in-trail-3.png b/html/static/images/cursors/zoom-in-trail-3.png new file mode 100755 index 0000000..0302a0e Binary files /dev/null and b/html/static/images/cursors/zoom-in-trail-3.png differ diff --git a/html/static/images/cursors/zoom-in.cur b/html/static/images/cursors/zoom-in.cur new file mode 100755 index 0000000..2133881 Binary files /dev/null and b/html/static/images/cursors/zoom-in.cur differ diff --git a/html/static/images/cursors/zoom-out-trail-1.png b/html/static/images/cursors/zoom-out-trail-1.png new file mode 100755 index 0000000..78a236c Binary files /dev/null and b/html/static/images/cursors/zoom-out-trail-1.png differ diff --git a/html/static/images/cursors/zoom-out-trail-2.png b/html/static/images/cursors/zoom-out-trail-2.png new file mode 100755 index 0000000..04bb425 Binary files /dev/null and b/html/static/images/cursors/zoom-out-trail-2.png differ diff --git a/html/static/images/cursors/zoom-out-trail-3.png b/html/static/images/cursors/zoom-out-trail-3.png new file mode 100755 index 0000000..0302a0e Binary files /dev/null and b/html/static/images/cursors/zoom-out-trail-3.png differ diff --git a/html/static/images/cursors/zoom-out.cur b/html/static/images/cursors/zoom-out.cur new file mode 100755 index 0000000..04b6883 Binary files /dev/null and b/html/static/images/cursors/zoom-out.cur differ diff --git a/html/static/images/dearrow-logo.svg b/html/static/images/dearrow-logo.svg new file mode 100755 index 0000000..f081d14 --- /dev/null +++ b/html/static/images/dearrow-logo.svg @@ -0,0 +1,8 @@ + + diff --git a/html/static/images/eir-stand.png b/html/static/images/eir-stand.png new file mode 100755 index 0000000..fc32477 Binary files /dev/null and b/html/static/images/eir-stand.png differ diff --git a/html/static/images/eir-walk.gif b/html/static/images/eir-walk.gif new file mode 100755 index 0000000..352855a Binary files /dev/null and b/html/static/images/eir-walk.gif differ diff --git a/html/static/images/favicon-16x16.png b/html/static/images/favicon-16x16.png old mode 100644 new mode 100755 index 2ac1667..7206653 Binary files a/html/static/images/favicon-16x16.png and b/html/static/images/favicon-16x16.png differ diff --git a/html/static/images/favicon-32x32.png b/html/static/images/favicon-32x32.png old mode 100644 new mode 100755 index 595af67..20c20f6 Binary files a/html/static/images/favicon-32x32.png and b/html/static/images/favicon-32x32.png differ diff --git a/html/static/images/favicon.ico b/html/static/images/favicon.ico old mode 100644 new mode 100755 diff --git a/html/static/images/instance-blocked.svg b/html/static/images/instance-blocked.svg old mode 100644 new mode 100755 diff --git a/html/static/images/light-off.svg b/html/static/images/light-off.svg new file mode 100755 index 0000000..a682213 --- /dev/null +++ b/html/static/images/light-off.svg @@ -0,0 +1,18 @@ + + + diff --git a/html/static/images/light-on.svg b/html/static/images/light-on.svg new file mode 100755 index 0000000..e2965b0 --- /dev/null +++ b/html/static/images/light-on.svg @@ -0,0 +1,22 @@ + + + diff --git a/html/static/images/loading.gif b/html/static/images/loading.gif new file mode 100755 index 0000000..4301102 Binary files /dev/null and b/html/static/images/loading.gif differ diff --git a/html/static/images/maskable-icon-192x192.png b/html/static/images/maskable-icon-192x192.png old mode 100644 new mode 100755 index 0aac030..778e191 Binary files a/html/static/images/maskable-icon-192x192.png and b/html/static/images/maskable-icon-192x192.png differ diff --git a/html/static/images/maskable-icon-512x512.png b/html/static/images/maskable-icon-512x512.png old mode 100644 new mode 100755 index df2176b..86dc387 Binary files a/html/static/images/maskable-icon-512x512.png and b/html/static/images/maskable-icon-512x512.png differ diff --git a/html/static/images/mini_eir.png b/html/static/images/mini_eir.png new file mode 100755 index 0000000..602aae6 Binary files /dev/null and b/html/static/images/mini_eir.png differ diff --git a/html/static/images/mstile-150x150.png b/html/static/images/mstile-150x150.png old mode 100644 new mode 100755 index b327f87..b85d0a8 Binary files a/html/static/images/mstile-150x150.png and b/html/static/images/mstile-150x150.png differ diff --git a/html/static/images/pin.svg b/html/static/images/pin.svg new file mode 100755 index 0000000..c8e16a9 --- /dev/null +++ b/html/static/images/pin.svg @@ -0,0 +1,4 @@ + + diff --git a/html/static/images/player/back.svg b/html/static/images/player/back.svg new file mode 100755 index 0000000..78bae2e --- /dev/null +++ b/html/static/images/player/back.svg @@ -0,0 +1 @@ + diff --git a/html/static/images/player/captions.svg b/html/static/images/player/captions.svg new file mode 100755 index 0000000..c455e85 --- /dev/null +++ b/html/static/images/player/captions.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/player/eye-closed.svg b/html/static/images/player/eye-closed.svg new file mode 100755 index 0000000..dd31202 --- /dev/null +++ b/html/static/images/player/eye-closed.svg @@ -0,0 +1,8 @@ + + diff --git a/html/static/images/player/eye-open.svg b/html/static/images/player/eye-open.svg new file mode 100755 index 0000000..fa41398 --- /dev/null +++ b/html/static/images/player/eye-open.svg @@ -0,0 +1,11 @@ + + + diff --git a/html/static/images/player/fullscreen.svg b/html/static/images/player/fullscreen.svg new file mode 100755 index 0000000..f3de9f1 --- /dev/null +++ b/html/static/images/player/fullscreen.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/player/gear.svg b/html/static/images/player/gear.svg new file mode 100755 index 0000000..e86d806 --- /dev/null +++ b/html/static/images/player/gear.svg @@ -0,0 +1 @@ + diff --git a/html/static/images/player/hamburger.svg b/html/static/images/player/hamburger.svg new file mode 100755 index 0000000..35c610d --- /dev/null +++ b/html/static/images/player/hamburger.svg @@ -0,0 +1,14 @@ + + + diff --git a/html/static/images/player/headphones.svg b/html/static/images/player/headphones.svg new file mode 100755 index 0000000..821d13e --- /dev/null +++ b/html/static/images/player/headphones.svg @@ -0,0 +1,4 @@ + + diff --git a/html/static/images/player/loop.svg b/html/static/images/player/loop.svg new file mode 100755 index 0000000..01b683c --- /dev/null +++ b/html/static/images/player/loop.svg @@ -0,0 +1,4 @@ + + diff --git a/html/static/images/player/pause.svg b/html/static/images/player/pause.svg new file mode 100755 index 0000000..dcca5e9 --- /dev/null +++ b/html/static/images/player/pause.svg @@ -0,0 +1,3 @@ + + + diff --git a/html/static/images/player/paw.svg b/html/static/images/player/paw.svg new file mode 100755 index 0000000..183e1c8 --- /dev/null +++ b/html/static/images/player/paw.svg @@ -0,0 +1,3 @@ + diff --git a/html/static/images/player/play.svg b/html/static/images/player/play.svg new file mode 100755 index 0000000..15e9f41 --- /dev/null +++ b/html/static/images/player/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/html/static/images/player/refresh.svg b/html/static/images/player/refresh.svg new file mode 100755 index 0000000..5b8528d --- /dev/null +++ b/html/static/images/player/refresh.svg @@ -0,0 +1,4 @@ + + diff --git a/html/static/images/player/speed.svg b/html/static/images/player/speed.svg new file mode 100755 index 0000000..1335118 --- /dev/null +++ b/html/static/images/player/speed.svg @@ -0,0 +1,37 @@ + + + diff --git a/html/static/images/player/volume-high.svg b/html/static/images/player/volume-high.svg new file mode 100755 index 0000000..21659c9 --- /dev/null +++ b/html/static/images/player/volume-high.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/player/volume-low.svg b/html/static/images/player/volume-low.svg new file mode 100755 index 0000000..e87aac0 --- /dev/null +++ b/html/static/images/player/volume-low.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/player/volume-mute.svg b/html/static/images/player/volume-mute.svg new file mode 100755 index 0000000..3964d12 --- /dev/null +++ b/html/static/images/player/volume-mute.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/player/volume-off.svg b/html/static/images/player/volume-off.svg new file mode 100755 index 0000000..f2d9b19 --- /dev/null +++ b/html/static/images/player/volume-off.svg @@ -0,0 +1,2 @@ + + diff --git a/html/static/images/playlists.svg b/html/static/images/playlists.svg new file mode 100755 index 0000000..217059a --- /dev/null +++ b/html/static/images/playlists.svg @@ -0,0 +1,8 @@ + + + diff --git a/html/static/images/safari-pinned-tab.svg b/html/static/images/safari-pinned-tab.svg old mode 100644 new mode 100755 diff --git a/html/static/images/search.svg b/html/static/images/search.svg old mode 100644 new mode 100755 index fa051c7..89b180f --- a/html/static/images/search.svg +++ b/html/static/images/search.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/html/static/images/settings.svg b/html/static/images/settings.svg old mode 100644 new mode 100755 diff --git a/html/static/images/share.svg b/html/static/images/share.svg new file mode 100755 index 0000000..92eca3e --- /dev/null +++ b/html/static/images/share.svg @@ -0,0 +1,9 @@ + + + + diff --git a/html/static/images/sponsorblock-logo.png b/html/static/images/sponsorblock-logo.png new file mode 100755 index 0000000..743ae4f Binary files /dev/null and b/html/static/images/sponsorblock-logo.png differ diff --git a/html/static/images/subscriptions.svg b/html/static/images/subscriptions.svg old mode 100644 new mode 100755 index 0399a04..0a6a95c --- a/html/static/images/subscriptions.svg +++ b/html/static/images/subscriptions.svg @@ -1 +1,5 @@ - + + diff --git a/html/static/images/x.svg b/html/static/images/x.svg new file mode 100755 index 0000000..600c0f5 --- /dev/null +++ b/html/static/images/x.svg @@ -0,0 +1 @@ + diff --git a/html/static/js/channel.js b/html/static/js/channel.js old mode 100644 new mode 100755 diff --git a/html/static/js/chapter-highlight.js b/html/static/js/chapter-highlight.js old mode 100644 new mode 100755 index 5b44cd9..7531881 --- a/html/static/js/chapter-highlight.js +++ b/html/static/js/chapter-highlight.js @@ -19,7 +19,7 @@ function getCurrentChapter(time) { } } -const video = q("#video") +const video = q(".video") const description = q("#description") const regularBackground = "var(--regular-background)" const highlightBackground = "var(--highlight-background)" @@ -43,7 +43,7 @@ setInterval(() => { let gradient = `linear-gradient(to bottom,` + ` ${regularBackground} ${offsetTop - paddingWidth}px, ${highlightBackground} ${offsetTop - paddingWidth}px,` + ` ${highlightBackground} ${offsetBottom + paddingWidth}px, ${regularBackground} ${offsetBottom + paddingWidth}px)` - console.log(gradient) + // console.log(gradient) description.style.background = gradient } else { description.style.background = "" diff --git a/html/static/js/continuous.js b/html/static/js/continuous.js old mode 100644 new mode 100755 index 9a2cc6b..4384b43 --- a/html/static/js/continuous.js +++ b/html/static/js/continuous.js @@ -1,6 +1,6 @@ import {q, ejs, ElemJS} from "/static/js/elemjs/elemjs.js" -const video = q("#video") +const video = q(".video") video.addEventListener("ended", () => { if (data.continuous) { diff --git a/html/static/js/custom-captions.js b/html/static/js/custom-captions.js new file mode 100755 index 0000000..b870b36 --- /dev/null +++ b/html/static/js/custom-captions.js @@ -0,0 +1,139 @@ +const video = document.getElementsByClassName("video")[0] +video.classList.add("hasCustomCaptions") + +// Elements +const customStyle = document.createElement("style") +customStyle.setAttribute("type", "text/css") +document.head.appendChild(customStyle) + +const captionBox = document.createElement("div") +captionBox.className = "caption-box" +video.parentNode.appendChild(captionBox) + +const captionInner = document.createElement("div") +captionInner.className = "caption-inner" +captionBox.appendChild(captionInner) + +// Parseing +const regexVVTSource = /::cue\(([^\. \)])+([^\)]*)\)/g +const regexCueText = /<([^\. />]+)([^>]+?)>/g + +function parseVVTSource(s) { + s = s.split("\nStyle:\n")[1].split("\n##\n")[0] + for (const match of s.matchAll(regexVVTSource)) { + const wholeSelector = match[0] + const className = match[1] + const details = match[2] || null + + s = s.replace(wholeSelector, `.caption-box .cue-container ${className}${details ? `[data-details="${details}"]` : ""}`) + } + + return s +} + +function parseCueText(t) { + if (!t) + return t + for (const match of t.matchAll(regexCueText)) { + const wholeElement = match[0] + const className = match[1] + const details = match[2] + + t = t.replace(wholeElement, `<${className} data-details="${details}">`) + } + + return t +} + +// Detect caption switching +let lastTrack = null +for (const track of video.textTracks) { + track.sourceElement = video.querySelector(`track[label="${track.label}"]`) + + function parseCaptionStyle(raw) { + if (raw.indexOf("\nStyle:\n") > -1) + return parseVVTSource(raw) + return null + } + + function applyCaptionStyle(track) { + customStyle.innerHTML = track.style + } + + // Eugh + track.addEventListener("cuechange", () => { + lastTrack = track + + if (track.raw == undefined) { + fetch(track.sourceElement.src) + .then(r => r.text()).then(r => { + track.style = parseCaptionStyle(r) + if (lastTrack == track && track.style) + applyCaptionStyle(track) + }) + } else if (track.style) + applyCaptionStyle(track) + }) +} + +// Functionality +for (const track of video.textTracks) + track.addEventListener("cuechange", () => { + // Remove previous cues TODO: also do when captions turned off + captionInner.innerHTML = "" + + let cues = [] + for (const c of track.activeCues) { + const cueContainer = document.createElement("div") + cueContainer.className = "cue-container" + captionInner.appendChild(cueContainer) + + cueContainer.innerHTML = parseCueText(c.text) + console.log(c) + + // align: "center" + // line: "auto" + // lineAlign: "start" + // position: "auto" / 89 + // positionAlign: "auto" + // size: 100 + // snapToLines: true + // + // console.log(c.position) + // cueContainer.style.position = c.position == "auto" ? "auto" : `${c.position}%` + + const asHTML = c.getCueAsHTML() + // console.log(track.sourceElement) + console.log(asHTML) + + cueContainer.style.textAlign = c.align + + /* + position: absolute; + writing-mode: horizontal-tb; + top: 86.2709%; + left: 78%; + width: 22%; + height: auto; + overflow-wrap: break-word; + white-space: pre-line; + font: 53.9px sans-serif; + color: rgb(255, 255, 255); + text-align: center; + unicode-bidi: plaintext; + */ + + // for (const child of asHTML.children) + // cueContainer.appendChild(child.cloneNode(true)) + + // captionInner.innerHTML = c.getCueAsHTML() + // const cue = document.createElement("span") + // cue.className = "cue" + // cueContainer.appendChild(cue) + + // console.log(`NEW CUE:\n${c.align}, ${c.line}, ${c.lineAlign}, ${c.position}, ${c.positionAlign}, ${c.region}, ${c.size}, ${c.snapToLines}, ${c.text}, ${c.vertical}`) + // console.log(c.getCueAsHTML()) + } + + // captionRow.innerText = "hi" + }) diff --git a/html/static/js/dark-mode.js b/html/static/js/dark-mode.js new file mode 100755 index 0000000..0f3e312 --- /dev/null +++ b/html/static/js/dark-mode.js @@ -0,0 +1,22 @@ +function updateLights(dark) { + document.body.classList.toggle("dark", dark) + + const date = new Date() + date.setDate(date.getDate() + 30) + + document.cookie = `dark=${!dark ? "" : "1"}; expires=${date.toDateString()}` +} + +window.addEventListener("load", () => { + const lightButton = document.getElementsByClassName("light-button")[0] + lightButton.classList.remove("hidden") + + const lightToggle = lightButton.querySelector(".light-toggle") + + lightToggle.addEventListener("input", () => updateLights(!lightToggle.checked)) + + // Check for existing dark mode cookie + const darkModeCookie = document.cookie.split(" ").some(c => { if (c.startsWith("dark=")) return c.startsWith("dark=1") }) + + // lightToggle.checked = !darkModeCookie +}) diff --git a/html/static/js/dearrow-load.js b/html/static/js/dearrow-load.js new file mode 100755 index 0000000..77b9265 --- /dev/null +++ b/html/static/js/dearrow-load.js @@ -0,0 +1,39 @@ +const className = document.currentScript.getAttribute("video-class") +window.addEventListener("load", () => { + const allVideos = document.getElementsByClassName(className) + let queue = [] + + for (const v of allVideos) { + const dearrowIcon = v.querySelector(".dearrow-button-list.loading") + if (dearrowIcon) + queue.push(v) + } + + new Promise(async resolve => { + for (const v of queue) { + const id = v.querySelector(".info .title .title-link").getAttribute("href").split("?v=")[1] + const dearrowIcon = v.querySelector(".dearrow-button-list.loading") + const titleAlt = v.querySelector(".info .title.alt a") + const thumbAlt = v.querySelector(".thumbnail .thumbnail__link.alt img") + + const response = await fetch(`/getDeArrow?v=${id}`) + if (response.status == 404) { + newToast("red", "x", `Failed to load DeArrow for ${id} (404)`) + continue + } + const j = await response.json() + if (j.title) + titleAlt.innerText = j.title + if (j.thumbnail) + thumbAlt.setAttribute("src", dearrow_thumbnail_instance + (dearrow_thumbnail_instance.endsWith("/") ? "" : "/") + j.thumbnail) + dearrowIcon.classList.remove("loading") + + if (!j.title && !j.thumbnail) { + dearrowIcon.remove() + titleAlt.remove() + thumbAlt.remove() + } + } + resolve() + }) +}) diff --git a/html/static/js/elemjs/.gitrepo b/html/static/js/elemjs/.gitrepo old mode 100644 new mode 100755 diff --git a/html/static/js/elemjs/elemjs.js b/html/static/js/elemjs/elemjs.js old mode 100644 new mode 100755 diff --git a/html/static/js/filters.js b/html/static/js/filters.js old mode 100644 new mode 100755 diff --git a/html/static/js/focus.js b/html/static/js/focus.js old mode 100644 new mode 100755 diff --git a/html/static/js/local-video.js b/html/static/js/local-video.js old mode 100644 new mode 100755 diff --git a/html/static/js/modules/MarkWatchedButton.js b/html/static/js/modules/MarkWatchedButton.js old mode 100644 new mode 100755 diff --git a/html/static/js/modules/SubscribeButton.js b/html/static/js/modules/SubscribeButton.js old mode 100644 new mode 100755 diff --git a/html/static/js/player-new.js b/html/static/js/player-new.js new file mode 100755 index 0000000..b6277a6 --- /dev/null +++ b/html/static/js/player-new.js @@ -0,0 +1,46 @@ +import {q, qa, ElemJS} from "/static/js/elemjs/elemjs.js" +import {SubscribeButton} from "/static/js/modules/SubscribeButton.js" + +const modulePaths = [ "globals", "cacheInfo", "quality", "player", "controls", "volume", "captions" ] + +Promise.all(modulePaths.map(m => import(`/static/js/player/${m}.js`))).then(moduleResults => { + let modules = {} + for (let i = 0; i < moduleResults.length; i++) + modules[modulePaths[i]] = moduleResults[i] + for (let i = 0; i < moduleResults.length; i++) + modules[modulePaths[i]] = modules[modulePaths[i]].default(modules, q, qa) + + // Page + all modules loaded + + modules.quality.videoDownloaded(modules.globals.videoPath, modules.globals.startingFormat.qualityLabel) +}) + +// Hide default controls +document.getElementsByClassName("video")[0].removeAttribute("controls") + +// Hide noscript elements +document.getElementById("quality-select-noscript-parent").remove() +document.querySelector("form[action='/redownloadVideo']").remove() + +///// + +new SubscribeButton(q("#subscribe")) + +const timestamps = qa("[data-clickable-timestamp]") + +class Timestamp extends ElemJS { + constructor(element) { + super(element) + this.on("click", this.onClick.bind(this)) + } + + onClick(event) { + event.preventDefault() + document.getElementsByClassName("video")[0].currentTime = event.target.getAttribute("data-clickable-timestamp") + window.history.replaceState(null, "", event.target.href) + } +} + +timestamps.forEach(el => { + new Timestamp(el) +}) diff --git a/html/static/js/player/cacheInfo.js b/html/static/js/player/cacheInfo.js new file mode 100755 index 0000000..bf13deb --- /dev/null +++ b/html/static/js/player/cacheInfo.js @@ -0,0 +1,21 @@ +export default modules => { + let self + self = { + pingCache: (videoName, callback, interval) => { + let doFetch + doFetch = () => { + fetch(`/cacheInfo?videoName=${videoName}`) + .then(r => r.json().then(r => { + const status = r.status + + if (status == "found") + callback() + else + setTimeout(doFetch, interval) + })) + } + doFetch() + } + } + return self +} diff --git a/html/static/js/player/captions.js b/html/static/js/player/captions.js new file mode 100755 index 0000000..491d48a --- /dev/null +++ b/html/static/js/player/captions.js @@ -0,0 +1,519 @@ +export default modules => { + let self + + const video = modules.globals.video // Shorthand + video.classList.add("hasCustomCaptions") + + // Elements + const customStyle = document.createElement("style") + customStyle.setAttribute("type", "text/css") + document.head.appendChild(customStyle) + + const captionBox = document.createElement("div") + captionBox.className = "caption-box" + video.parentNode.appendChild(captionBox) + + const captionInner = document.createElement("div") + captionInner.className = "caption-inner" + captionBox.appendChild(captionInner) + + // Parsing + const regexVVTStyle = /::cue\(([^\. \)]+)?([^\)]*)\)/g + const regexVVTCue = /([^\n]*)\n((?:\d\d:)?\d\d:\d\d\.\d{3}) --> ((?:\d\d:)?\d\d:\d\d\.\d{3}) ?([^\n]+)?\n((?:.+\n)+)/g + const regexTimedText = /<((?:\d\d:)?\d\d:\d\d\.\d{3})> *<([^>]+)>/g + const regexCueText = /<([^\. />]+)([^>]+?)>/g + + const defaultStyle = ` + .caption-box .cue-container [data-details=".white"] { color: rgba(255, 255, 255, 1); } + .caption-box .cue-container [data-details=".lime"] { color: rgba(0, 255, 0, 1); } + .caption-box .cue-container [data-details=".cyan"] { color: rgba(0, 255, 255, 1); } + .caption-box .cue-container [data-details=".red"] { color: rgba(255, 0, 0, 1); } + .caption-box .cue-container [data-details=".yellow"] { color: rgba(255, 255, 0, 1); } + .caption-box .cue-container [data-details=".magenta"] { color: rgba(255, 0, 255, 1); } + .caption-box .cue-container [data-details=".blue"] { color: rgba(0, 0, 255, 1); } + .caption-box .cue-container [data-details=".black"] { color: rgba(0, 0, 0, 1); } + .caption-box .cue-container [data-details=".bg_white"] { background-color: rgba(255, 255, 255, 1); } + .caption-box .cue-container [data-details=".bg_lime"] { background-color: rgba(0, 255, 0, 1); } + .caption-box .cue-container [data-details=".bg_cyan"] { background-color: rgba(0, 255, 255, 1); } + .caption-box .cue-container [data-details=".bg_red"] { background-color: rgba(255, 0, 0, 1); } + .caption-box .cue-container [data-details=".bg_yellow"] { background-color: rgba(255, 255, 0, 1); } + .caption-box .cue-container [data-details=".bg_magenta"] { background-color: rgba(255, 0, 255, 1); } + .caption-box .cue-container [data-details=".bg_blue"] { background-color: rgba(0, 0, 255, 1); } + .caption-box .cue-container [data-details=".bg_black"] { background-color: rgba(0, 0, 0, 1); } + .caption-box .cue-container timedtext.future { color: rgba(0, 0, 0, 0); } + `.replaceAll("\t", "") + + function timecodeToSeconds(tc) { + let outNum = 0 + + const digits2 = tc.split(".") + const digits1 = digits2[0].split(":") + for (let i = digits1.length - 1; i >= 0; i--) { + const step = digits1.length - i + // Seconds + if (step == 1) + outNum += Number(digits1[i]) + // Minutes + else if (step == 2) + outNum += Number(digits1[i]) * 60 + // Hours + else if (step == 3) + outNum += Number(digits1[i]) * 3600 + } + + outNum += Number(`0.${digits2[1]}`) + + return outNum + } + + function parseVVTStyle(s) { + for (const match of s.matchAll(regexVVTStyle)) { + const wholeSelector = match[0] + let className = match[1] + const details = match[2] || null + + if (className == ":past" || className == ":future") + className = className.replace(":", "timedtext.") + + s = s.replace(wholeSelector, `.caption-box .cue-container ${className || ""}${details ? `[data-details="${details}"]` : ""}`) + } + return s + } + + function parseVVTSource(s) { + // Newlines + s = s.replaceAll("\r\n", "\n").replaceAll("\r", "\n") + if (!s.endsWith("\n")) + s = s + "\n" + + // Style + let style = "" + if (s.indexOf("\nStyle:\n") > -1) { + const origStyleBlock = s.split("\nStyle:\n")[1].split("\n##\n")[0] + let newStyleBlock = parseVVTStyle(origStyleBlock) + + // Add default styles + newStyleBlock = defaultStyle + newStyleBlock + + + style = newStyleBlock + s.replace(origStyleBlock, newStyleBlock) + } + style = defaultStyle + style + + // Cues + let cues = [] + for (const match of s.matchAll(regexVVTCue)) { + const wholeCue = match[0] + const id = match[1] + const startTime = match[2] + const endTime = match[3] + let tags = match[4] + let text = match[5] + + // Cue replacing would go here? + /* + let newCue = wholeCue + + if (newCue != wholeCue) + s = s.replace(wholeCue, newCue) + */ + + // Generate VTTCue + const cueObj = new VTTCue(timecodeToSeconds(startTime), timecodeToSeconds(endTime), text) + if (id) + cueObj.id = id + if (tags) { + for (const tag of tags.split(" ")) { + let results = null + if ((results = /^align:(start|middle|center|end|left|right)$/.exec(tag))) + cueObj.align = results[1] + else if ((results = /^vertical:(lr|rl)$/.exec(tag))) + cueObj.vertical = results[1] + else if ((results = /^size:([\d.]+)%$/.exec(tag))) + cueObj.size = Number(results[1]) + else if ((results = /^position:([\d.]+)%(?:,(line-left|line-right|middle|center|start|end|auto))?$/.exec(tag))) { + cueObj.position = Number(results[1]) + if (results[2]) + cueObj.positionAlign = results[2] + // REGION?? + //} else if () + } else { + if ((results = /^line:([\d.]+)%(?:,(start|end|center))?$/.exec(tag))) { + cueObj.lineInterpretation = "percentage" + // interpret as percentage + cueObj.line = Number(results[1]); + if (results[2]) + cueObj.lineAlign = results[2] + } else if (results = /^line:(-?\d+)(?:,(start|end|center))?$/.exec(tag)) { + cueObj.lineInterpretation = "number" + // interpret as line number + cueObj.line = Number(results[1]); + if (results[2]) + cueObj.lineAlign = results[2] + } + } + } + } + + cues.push(cueObj) + } + + return { s, style, cues } + } + + function parseCueText(t) { + if (!t) + return t + // Parse timestamp tags + let previousTimestamp = null + for (const match of t.matchAll(regexTimedText)) { + const wholeElement = match[0] + const timestamp = match[1] + + t = t.replace(wholeElement, `${previousTimestamp ? `` : ""}CTRL+F5
c
, m
, ,
, .
, f
, Home
, End
, Up
, Down
keys, like YouTube's player.
+ p Custom video player controls wrote for compatibility with SponsorBlock integration.
+ p When JS is disabled, video player is more likely to load a broken video.
+ | Use CTRL+F5 if it suddenly skips to the end.
+ br
+ details
+ summary Links
+ p #[a(href="https://git.sr.ht/~cadence/cloudtube") CloudTube] (base for this project, and like 90% of the work)
+ p #[a(href="https://cadence.moe/") Cadence's personal site] (she deserves the real credit for making CloudTube)
+ p #[a(href="https://github.com/ftde0/yt2009/") yt2009] (lots of download-related code is directly from here)
+ p #[a(href="https://github.com/iv-org/invidious") Invidious] (an inspiration to both of us)
+ p #[a(href="https://sponsor.ajay.app/") SponsorBlock] and #[a(href="https://dearrow.ajay.app/") DeArrow], implemented into EirTube through optional #[a(href="/settings") settings]
+ p #[a(href="https://github.com/shaka-project/shaka-player/") Shaka Player] (I used some of their code as reference for my captions implementation)
+ p #[a(href="https://eir-nya.gay") My personal site] if you want :3
+ br
+ a(href="https://git.eir-nya.gay/eir/eirtube") Source code
diff --git a/pug/errors/fetch-error.pug b/pug/errors/fetch-error.pug
old mode 100644
new mode 100755
index d763020..39d2b08
--- a/pug/errors/fetch-error.pug
+++ b/pug/errors/fetch-error.pug
@@ -1,5 +1,6 @@
-p The selected instance, #[code= instanceOrigin], was unreachable.
-details
- summary If the instance is on a private network
- p The instance URL was resolved by the server. If the instance is on a private network, CloudTube will not be able to connect back to it.
- p To get around this error, you may be able to use local mode, or you can run your own CloudTube within the network.
+main.error
+ p The selected instance, #[code= instanceOrigin], was unreachable.
+ details.fetch-error
+ summary If the instance is on a private network
+ p The instance URL was resolved by the server. If the instance is on a private network, CloudTube will not be able to connect back to it.
+ p To get around this error, you may be able to use local mode, or you can run your own CloudTube within the network.
diff --git a/pug/errors/instance-error.pug b/pug/errors/instance-error.pug
old mode 100644
new mode 100755
index b171e03..95eb49b
--- a/pug/errors/instance-error.pug
+++ b/pug/errors/instance-error.pug
@@ -1,4 +1,5 @@
-p #[strong= error.message]
-if error.identifier
- p #[code= error.identifier]
-p That error was generated by #[code= instanceOrigin].
+main.error
+ p #[strong= error.message]
+ if error.identifier
+ p #[code= error.identifier]
+ p That error was generated by #[code= instanceOrigin].
diff --git a/pug/errors/local-rate-limited.pug b/pug/errors/local-rate-limited.pug
new file mode 100755
index 0000000..0918492
--- /dev/null
+++ b/pug/errors/local-rate-limited.pug
@@ -0,0 +1,10 @@
+extends ../includes/layout
+
+block head
+ title Rate limited - EirTube
+
+block content
+ main.video-error-page
+ p #[strong= "Rate limited"]
+ p Too many requests.
+ p You'll be able to send requests again in #[strong= timeLeftSeconds] seconds.
diff --git a/pug/errors/message-error.pug b/pug/errors/message-error.pug
old mode 100644
new mode 100755
index 2bafe60..e09163d
--- a/pug/errors/message-error.pug
+++ b/pug/errors/message-error.pug
@@ -1 +1,2 @@
-pre= error.toString()
\ No newline at end of file
+main.error
+ pre= error.toString()
diff --git a/pug/errors/rate-limited.pug b/pug/errors/rate-limited.pug
old mode 100644
new mode 100755
index f3cd3b3..3aa2c15
--- a/pug/errors/rate-limited.pug
+++ b/pug/errors/rate-limited.pug
@@ -1,25 +1,26 @@
-.blocked-explanation
- img(src="/static/images/instance-blocked.svg" width=552 height=96)
- .rows
- .row
- h3.actor You
- | Working
- .row
- h3.actor CloudTube
- | Working
- .row
- h3.actor Instance
- | Blocked by YouTube
- .row
- h3.actor YouTube
- | Working
-p.
- CloudTube needs a working NewLeaf/Invidious instance in order to get data about videos.
- However, the selected instance, #[code= instanceOrigin], has been temporarily blocked by YouTube.
-p.
- You will be able to watch this video if you select a working instance in settings.
- #[br]#[a(href="/settings") Go to settings →]
-p.
- (Tip: Try #[code https://invidious.snopyta.org] or #[code https://invidious.site].)
-p.
- This situation #[em will] be improved in the future!
+main.error
+ .blocked-explanation
+ img(src="/static/images/instance-blocked.svg" width=552 height=96)
+ .rows
+ .row
+ h3.actor You
+ | Working
+ .row
+ h3.actor EirTube
+ | Working
+ .row
+ h3.actor Instance
+ | Blocked by YouTube
+ .row
+ h3.actor YouTube
+ | Working
+ p.
+ CloudTube needs a working NewLeaf/Invidious instance in order to get data about videos.
+ However, the selected instance, #[code= instanceOrigin], has been temporarily blocked by YouTube.
+ p.
+ You will be able to watch this video if you select a working instance in settings.
+ #[br]#[a(href="/settings") Go to settings →]
+ p.
+ (Tip: Try #[code https://invidious.snopyta.org] or #[code https://invidious.site].)
+ p.
+ This situation #[em will] be improved in the future!
diff --git a/pug/errors/substitute-html-error.pug b/pug/errors/substitute-html-error.pug
new file mode 100755
index 0000000..3322858
--- /dev/null
+++ b/pug/errors/substitute-html-error.pug
@@ -0,0 +1,8 @@
+extends ../includes/layout.pug
+
+block head
+ title Eir's mods - EirTube
+
+block content
+ main.error
+ != content
diff --git a/pug/errors/unrecognised-error.pug b/pug/errors/unrecognised-error.pug
old mode 100644
new mode 100755
index 811b1a9..c08afcc
--- a/pug/errors/unrecognised-error.pug
+++ b/pug/errors/unrecognised-error.pug
@@ -1 +1,2 @@
-pre= error.stack || error.toString()
+main.error
+ pre= error.stack || error.toString()
diff --git a/pug/filters.pug b/pug/filters.pug
old mode 100644
new mode 100755
index 2c6f2c1..21f63cd
--- a/pug/filters.pug
+++ b/pug/filters.pug
@@ -4,7 +4,7 @@ mixin filter_type_option(label, value)
option(value=value selected=(value === type))= label
block head
- title Filters - CloudTube
+ title Filters - EirTube
script(type="module" src=getStaticURL("html", "static/js/filters.js"))
block content
diff --git a/pug/home.pug b/pug/home.pug
old mode 100644
new mode 100755
index 5abafaf..9bbe5d5
--- a/pug/home.pug
+++ b/pug/home.pug
@@ -1,15 +1,16 @@
extends includes/layout.pug
block head
- title Home - CloudTube
+ title Home - EirTube
block content
main.home-page
- h1.top-header CloudTube
- h2.tagline An alternative front-end for YouTube.
- .encouraging-message
- p You're in control. Watch things your way.
- p Go on. What do you want to watch?
- form(method="get" action="/search").encouraging-search-form
- input(type="text" name="q" placeholder="I'd like to watch..." aria-label="Search a video" autocomplete="off" autofocus=!mobile).search.base-border-look
- p: a(href="/cant-think") ...can't think of anything?
+ .text-content
+ h1.top-header EirTube
+ h2.tagline An alternative front-end for YouTube.
+ .encouraging-message
+ p You're in control. Watch things your way.
+ p Go on. What do you want to watch?
+ form(method="get" action="/search").encouraging-search-form
+ input(type="text" name="q" placeholder="I'd like to watch..." aria-label="Search a video" autocomplete="off" autofocus=!mobile).search.base-border-look
+ p #[img(src="/static/images/bow.png")] About EirTube and how it differs from CloudTube.
diff --git a/pug/includes/comment.pug b/pug/includes/comment.pug
new file mode 100755
index 0000000..ee2f377
--- /dev/null
+++ b/pug/includes/comment.pug
@@ -0,0 +1,39 @@
+mixin comment(className, comment, commentOriginalUrl, videoId)
+ div(class={[className]: true}).comment-base
+ if comment.authorThumbnails && comment.authorThumbnails.length
+ .comment-avatar
+ a(href=comment.authorUrl)
+ img(loading="lazy" src=comment.authorThumbnails[comment.authorThumbnails.length - 1].url).avatar-img
+ .comment-contents
+ a(href=comment.authorUrl class={ "comment-author": true, "is-channel-owner": comment.authorIsChannelOwner })
+ =comment.author
+ if comment.verified
+ .icon.checkmark
+ if comment.isPinned
+ .icon.pin
+ if comment.isSponsor
+ img(loading="lazy" src=comment.sponsorIconUrl).icon.sponsor
+ .comment-content
+ != videoId ? rewriteVideoDescription(comment.contentHtml, videoId) : comment.contentHtml
+ .comment-stats
+ .comment-published
+ - let publishedText = comment.second__publishedText
+ if !publishedText
+ - const publishedTime = comment.published
+ - publishedText = timeToPastText(publishedTime * 1000)
+ a(href=commentOriginalUrl.replace("{}", comment.commentId))
+ != publishedText
+ .comment-likes
+ - let likeText = comment.second__likeText
+ if !likeText
+ - const likeCount = comment.likeCount
+ - likeText = likeCountToText(likeCount)
+ != likeText
+ if comment.creatorHeart
+ .comment-heart(title=`${comment.creatorHeart.creatorName} gave this a heart`)
+ img(loading="lazy" src=comment.creatorHeart.creatorThumbnail).comment-heart-avatar
+ //- .comment-heart-label= comment.creatorHeart.creatorName
+ if comment.replies && comment.replies.replyCount > 0
+ .comment-replies
+ //- TODO:continuation button and such
+
diff --git a/pug/includes/head.pug b/pug/includes/head.pug
old mode 100644
new mode 100755
diff --git a/pug/includes/layout.pug b/pug/includes/layout.pug
old mode 100644
new mode 100755
index 87ada7b..0889c36
--- a/pug/includes/layout.pug
+++ b/pug/includes/layout.pug
@@ -3,39 +3,58 @@ html
head
meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1")
- - const theme = settings && ["dark", "light", "edgeless-light"][settings.theme] || "dark"
+ - const theme = ["dark", "light", "edgeless-light", "eir"][settings && settings.theme || constants.user_settings.theme.default]
link(rel="stylesheet" type="text/css" href=getStaticURL("sass", `/${theme}.sass`))
script(type="module" src=getStaticURL("html", "/static/js/focus.js"))
link(rel="apple-touch-icon" sizes="180x180" href="/static/images/apple-touch-icon.png")
link(rel="icon" type="image/png" sizes="32x32" href="/static/images/favicon-32x32.png")
link(rel="icon" type="image/png" sizes="16x16" href="/static/images/favicon-16x16.png")
- link(rel="manifest" href="/site.webmanifest")
+ link(rel="manifest" href="/site.webmanifest" crossorigin="use-credentials")
link(rel="mask-icon" href="/static/images/safari-pinned-tab.svg" color="#5bbad5")
link(rel="shortcut icon" href="/static/images/favicon.ico")
- meta(name="apple-mobile-web-app-title" content="CloudTube")
- meta(name="application-name" content="CloudTube")
+ meta(name="apple-mobile-web-app-title" content="EirTube")
+ meta(name="application-name" content="EirTube")
meta(name="msapplication-TileColor" content="#2b5797")
meta(name="msapplication-config" content="/browserconfig.xml")
- meta(name="theme-color" content="#36393f")
-
+ meta(name="theme-color" content="#faaaab")
+ //- - Eir
+ - let dark = false
+ if theme == "eir"
+ if req && req.headers && req.headers.cookie && req.headers.cookie.includes("dark=1")
+ - dark = true
+ if !(req && isMobile(req))
+ script(type="text/javascript" res-root="/static" src="https://eir-nya.gay/res/js/cursors.js")
+ script(type="module" src=getStaticURL("html", "/static/js/dark-mode.js"))
+ meta(name="darkreader-lock")
+
block head
- body.show-focus
+ body(class={ "show-focus": true, dark: dark })
- let showNav = true
block pre-nav
if showNav
nav.main-nav
.links
if req && req.headers && "x-insecure" in req.headers
- a(href="/").link.home CloudTube - Insecure
+ a(href="/").link.home #[img(src="/static/images/bow.png")] EirTube - Insecure
else
- a(href="/").link.home CloudTube
+ a(href="/").link.home #[img(src="/static/images/bow.png")] EirTube
a(href="/subscriptions" title="Subscriptions").link.icon-link
!= icons.get("subscriptions")
a(href="/settings" title="Settings").link.icon-link
!= icons.get("settings")
+ if theme == "eir"
+ .light-button.link.icon-link.hidden
+ input(type="checkbox", checked=!dark).light-toggle
+ .light-on
+ != icons.get("light-on")
+ .light-off
+ != icons.get("light-off")
form(method="get" action="/search").search-form
input(type="text" placeholder="Search" aria-label="Search a video" name="q" autocomplete="off" value=query).search
+ input(id="search-subm" type="submit").search-button
+ label(for="search-subm").search-icon.link.icon-link
+ != icons.get("search")
div
block content
@@ -58,3 +77,8 @@ html
li: a(href="/licenses" data-jslicense=1) Licenses
if constants.takedown
li: a(href="/takedown") DMCA
+ div
+ h3.footer__colhead EirTube
+ ul.footer__list
+ li: a(href="/eirTube") About
+ li: a(href="https://git.eir-nya.gay/eir/eirtube") Source Code
diff --git a/pug/includes/subscribe-button.pug b/pug/includes/subscribe-button.pug
old mode 100644
new mode 100755
diff --git a/pug/includes/toasts.pug b/pug/includes/toasts.pug
new file mode 100755
index 0000000..35d06c8
--- /dev/null
+++ b/pug/includes/toasts.pug
@@ -0,0 +1,11 @@
+mixin toast_js()
+ script(type="text/javascript" src=getStaticURL("html", "/static/js/toasts.js"))
+
+mixin toast(color, icon, message, noFade)
+ .toast-container
+ input(type = "checkbox" id = (() => { if (totalToasts == undefined) { totalToasts = 0; } return totalToasts++ })())
+ .toast(class={[color]: true, nofade: noFade })
+ if icon
+ .icon(class=icon)
+ =message
+ label(for=totalToasts - 1).close-overlay
diff --git a/pug/includes/video-list-item.pug b/pug/includes/video-list-item.pug
old mode 100644
new mode 100755
index 84c4d76..293ad80
--- a/pug/includes/video-list-item.pug
+++ b/pug/includes/video-list-item.pug
@@ -1,24 +1,25 @@
-mixin video_list_item(className, video, instanceOrigin, options = {})
+mixin video_list_item(className, video, options = {})
div(class={[className]: true, "video-list-item--watched": video.watched})
- let link = `/watch?v=${video.videoId}`
if options.continuous
- link += `&continuous=1&session-watched=${sessionWatchedNext}`
- div.thumbnail
- a(href=link tabindex="-1").thumbnail__link
- img(src=`/vi/${video.videoId}/mqdefault.jpg` width=320 height=180 alt="").image
- if video.second__lengthText != undefined
- span.duration= video.second__lengthText
- details.thumbnail__more
- summary.thumbnail__show-more ×
- .thumbnail__options-container
- .thumbnail__options-list
- - const paramsBase = {}
- - if (url) paramsBase.referrer = url.pathname + url.search
- a(href=`/filters?${new URLSearchParams({"channel-id": video.authorId, label: video.author, ...paramsBase})}`).menu-look Hide this channel
- a(href=`/filters?${new URLSearchParams({title: video.title, ...paramsBase})}`).menu-look Hide by title
- a(href="/filters").menu-look Edit all filters
+ if video.type == "playlist"
+ - link = `/playlist?list=${video.playlistId}`
+ else if video.type == "channel"
+ - link = `/channel/${video.authorId}`
+ - const showDearrow = options.settings.dearrow > 0 && video.dearrowData && Object.keys(video.dearrowData).length > 0
+ if showDearrow
+ input(type="checkbox" checked=(options.settings.dearrow != 1) class={ "dearrow-button-list": true, "change-title": video.dearrowData.title || video.dearrowData.loading, "change-thumb": video.dearrowData.thumbnail || video.dearrowData.loading, "loading": video.dearrowData.loading })
.info
- div.title: a(href=link).title-link= video.title
+ - let title = video.title
+ - let titleAlt = undefined
+ if showDearrow && (video.dearrowData.title || video.dearrowData.loading) && options.settings.dearrow != 3
+ - titleAlt = video.dearrowData.title || video.title
+ div.title
+ a(href=link).title-link= title
+ if titleAlt
+ div(class={ "title": true, "alt": true })
+ a(href=link).title-link= titleAlt
div.author-line
a(href=`/channel/${video.authorId}`).author= video.author
- const views = video.viewCountText || video.second__viewCountText
@@ -34,3 +35,28 @@ mixin video_list_item(className, video, instanceOrigin, options = {})
button.mark-watched__button Mark watched
if video.descriptionHtml
div.description!= video.descriptionHtml
+ - let thumbnail = `/vi/${video.videoId}/mqdefault.jpg`
+ - let thumbnailAlt = undefined
+ if video.type == "playlist"
+ - thumbnail = video.playlistThumbnail
+ if video.type == "channel"
+ - thumbnail = video.authorThumbnails.slice(-1)[0].url
+ if video.type != "playlist" && showDearrow && (video.dearrowData.thumbnail || video.dearrowData.loading) && options.settings.dearrow != 2
+ - thumbnailAlt = video.dearrowData.thumbnail ? `${options.settings.dearrow_thumbnail_instance + (settings.dearrow_thumbnail_instance.endsWith("/") ? "" : "/")}${video.dearrowData.thumbnail}` : thumbnail
+ div(class={ thumbnail: true, channel: video.type == "channel" ? true : undefined })
+ a(href=link tabindex="-1").thumbnail__link
+ img(loading="lazy" src=thumbnail width=320 height=180 alt="").image
+ if thumbnailAlt
+ a(class={ "thumbnail__link": true, "alt": true } href=link tabindex="-1")
+ img(loading="lazy" src=thumbnailAlt width=320 height=180 alt="").image
+ if video.second__lengthText != undefined || video.type == "playlist"
+ span.duration= video.type != "playlist" ? video.second__lengthText : `${video.videoCount} videos`
+ details.thumbnail__more
+ summary.thumbnail__show-more ×
+ .thumbnail__options-container
+ .thumbnail__options-list
+ - const paramsBase = {}
+ - if (url) paramsBase.referrer = url.pathname + url.search
+ a(href=`/filters?${new URLSearchParams({"channel-id": video.authorId, label: video.author, ...paramsBase})}`).menu-look Hide this channel
+ a(href=`/filters?${new URLSearchParams({title: video.title, ...paramsBase})}`).menu-look Hide by title
+ a(href="/filters").menu-look Edit all filters
diff --git a/pug/includes/video-player-controls.pug b/pug/includes/video-player-controls.pug
new file mode 100755
index 0000000..1af6251
--- /dev/null
+++ b/pug/includes/video-player-controls.pug
@@ -0,0 +1,85 @@
+mixin video-player-controls(video, formats, startingFormat, awaitingThumb)
+ .videoControls
+ button.playBtn.videoControlBtn
+ button.volumeBtn.videoControlBtn
+ .volumePopout.popout.hidden
+ .volumeBarContainer
+ input(type="range" min="0" max="1" step="0.05" value="0.5")#volumeBar.volumeBar
+ label(for="volumeBar").volumeText 100%
+ .timeline
+ input(type="range" min="0" max=video.lengthSeconds step="0.25" value="0").seek.paused
+ .fakeThumb
+ .hoverTimeContainer.hidden
+ .hoverTimeInner.popout
+ video(disablepictureinpicture paused muted type=startingFormat.type src=(awaitingThumb ? null : `/getVideo?v=${video.videoId}&q=thumb&dl=1`) width=160 height=90).hoverTimeVideo
+ .hoverTimeText 00:00:00
+ .timecode 00:00:00 / 00:00:00
+ button.settingsBtn.videoControlBtn
+ //- Player settings
+ div(data-page="main").settingsPopout.popout.hidden
+ //- Settings
+ div(data-name="main").settingsPage
+ h3.setting.header
+ .text Settings
+ .setting.autoHide
+ .icon
+ .text Pin controls (off)
+ .setting.loop
+ .icon
+ .text Loop (off)
+ .setting.speed
+ .icon
+ .text Speed (1)
+ .submenuIcon
+ .setting.tricks
+ .icon
+ .text Tricks
+ .submenuIcon
+ if video.captions.length > 0
+ .setting.cc
+ .icon
+ .text Off
+ .submenuIcon
+ .setting.quality
+ .icon
+ .text !{startingFormat.qualityLabel}
+ .submenuIcon
+ //- Speed
+ div(data-name="speed").settingsPage
+ h3.setting.header.goBack
+ .icon.back
+ .text Speed
+ input(type="range" min="0" max="3" step="0.25" value="1")#speedBar.speedBar
+ label(for="speedBar").speedText 1x
+ //- Tricks
+ div(data-name="tricks").settingsPage
+ h3.setting.header.goBack
+ .icon.back
+ .text Tricks
+ .setting.forceStereo
+ .icon
+ .text Force mono audio (off)
+ //- TODO
+ //- Subtitles
+ if video.captions.length > 0
+ div(data-name="cc").settingsPage
+ h3.setting.header.goBack
+ .icon.back
+ .text Subtitles
+ div(data-label="Off").setting.caption.active
+ .text Off
+ each t in video.captions
+ div(data-label=t.label).setting.caption
+ .text= t.label
+ a(title="Redownload").submenuIcon.redownloadBtn
+ //- Quality
+ div(data-name="quality").settingsPage
+ h3.setting.header.goBack
+ .icon.back
+ .text Quality
+ each f in formats
+ div(data-label=f.qualityLabel data-w=f.second__width data-h=f.second__height data-size=f.eirtube__size class={ setting: true, quality: true, active: f.itag == startingFormat.itag })
+ .text= f.cloudtube__label
+ a(title="Redownload").submenuIcon.redownloadBtn
+
+ button.fullscreen.videoControlBtn
diff --git a/pug/licenses.pug b/pug/licenses.pug
old mode 100644
new mode 100755
index 854ac23..d0f84bd
--- a/pug/licenses.pug
+++ b/pug/licenses.pug
@@ -15,7 +15,7 @@ block content
th(scope="col") Source
tbody
each path of static.keys()
- if path.match(/^html\/static\/js\/.*\.js$/)
+ if path.match(/^html\/static\/js\/.*\.js$/) && path != "html/static/js/toasts.js" && path != "html/static/js/sb.js"
- const file = path.replace(/^html/, "")
tr
td: a(href=file)= file
diff --git a/pug/local-video.pug b/pug/local-video.pug
old mode 100644
new mode 100755
index f58aa70..a2b6b38
--- a/pug/local-video.pug
+++ b/pug/local-video.pug
@@ -4,14 +4,14 @@ include includes/video-list-item
include includes/subscribe-button
block head
- title Fetching... - CloudTube
+ title Fetching... - EirTube
script(type="module" src=getStaticURL("html", "/static/js/local-video.js"))
script const id = !{JSON.stringify(id)}
block content
main.video-error-page
h2 Fetching video
- p (You can also #[a(href=`https://www.youtube.com/watch?v=${id}#cloudtube`) watch on YouTube], #[a(href="https://git.sr.ht/~cadence/tube-docs/tree/main/item/docs/newleaf/Installing%20NewLeaf.md") install NewLeaf], or just #[a(href="/settings") turn off local fetch.])
+ p (You can also #[a(href=`https://www.youtube.com/watch?v=${id}`) watch on YouTube], #[a(href="https://git.sr.ht/~cadence/tube-docs/tree/main/item/docs/newleaf/Installing%20NewLeaf.md") install NewLeaf], or just #[a(href="/settings") turn off local fetch.])
p#status.fetch-status Waiting...
form(method="post")#form.local-video-form
input(type="hidden" name="video")#video-data
diff --git a/pug/privacy.pug b/pug/privacy.pug
old mode 100644
new mode 100755
index 79ea564..c48840d
--- a/pug/privacy.pug
+++ b/pug/privacy.pug
@@ -21,10 +21,33 @@ block content
li the IP address
li the requested URL
li the response status code
+ h2 #[img(src="/static/images/bow.png")] #[a(href="/eirTube") EirTube]-specific
+ p On this fork of CloudTube, YouTube videos and video data are downloaded automatically and stored on the machine running the server.
+ h3 What data is stored
+ ul
+ li actual video files in mp4 format (one for each downloaded video quality)
+ li YouTube's public-facing metadata about a video (title, video id, thumbnail, description, qualities, publish date, tags, view and like count, author, recommended videos)
+ li #[a(href="https://dearrow.ajay.app/") DeArrow] alternate title and thumbnail data for each video
+ li #[a(href="https://sponsor.ajay.app/") SponsorBlock] sponsor segment data for each video
+ h3 What the data is used for
+ ul
+ li providing the core service
+ li allowing switching to different qualities of videos
+ li providing DeArrow integration into recommended videos, search results, and channel pages
+ li providing SponsorBlock integration into watching video
+ h3 When the data is deleted
+ ul
+ li video files are deleted oldest-first as the cache fills up
+ li video metadata, DeArrow data, and SponsorBlock data are deleted when the files grow larger than !{converters.bytesToSizeText(constants.server_setup.cache_json_max_size)} combined
+ if constants.takedown
+ li if a #[a(href="/takedown") takedown request] is issued, video files are deleted automatically
h2 Accounts and cookies
+ i #[img(src="/static/images/bow.png")] Behaves differently on this fork.
p CloudTube does not allow users to create accounts.
- p The first time a user personally provides data to the site, such as changing settings or subscribing to a channel, an ephemeral session is created and linked to a cookie.
- p On future visits, this cookie will be used to look up the session, and provide a response based on that stored information.
+ p When watching videos, subscribing to a channel, or saving settings, an ephemeral session is created and linked to a cookie.
+ p On future visits, the cookie will be used to look up the session, and provide a response based on that stored information.
+ if constants.server_setup.ratelimiting.enabled
+ p Additionally, your IP address is stored in memory along with the cookie to keep track of your requests to the API and apply rate-limiting.
p As described above, no personally identifiable information is linked to sessions.
p If the user never personally provides data to the site, no cookie will be stored.
h2 What the data is used for
diff --git a/pug/search.pug b/pug/search.pug
old mode 100644
new mode 100755
index cbfa60a..4a11b4a
--- a/pug/search.pug
+++ b/pug/search.pug
@@ -1,11 +1,19 @@
extends includes/layout.pug
include includes/video-list-item.pug
+include includes/toasts
block head
- title= `${query} (search) - CloudTube`
+ title= `${query} (search) - EirTube`
+ - if (settings.dearrow > 0 && settings.dearrow_preload == 0)
+ script const dearrow_thumbnail_instance = "!{settings.dearrow_thumbnail_instance}"
+ script(video-class="search-result" src=getStaticURL("html", "/static/js/dearrow-load.js"))
block content
main.search-page
each result in results
- +video_list_item("search-result", result, instanceOrigin)
+ +video_list_item("search-result", result, { settings: settings })
+
+ #toast-container
+ each toast in toasts
+ +toast(toast.color, toast.icon, toast.text)
diff --git a/pug/settings.pug b/pug/settings.pug
old mode 100644
new mode 100755
index f504dba..85d0864
--- a/pug/settings.pug
+++ b/pug/settings.pug
@@ -21,15 +21,15 @@ mixin input({id, label, description, type, placeholder, disabled, list})
mixin select({id, label, description, disabled, options})
- disabled = disabled || false
.field-row
- label.field-row__label(for=id)= label
+ label.field-row__label(for=id)!= label
select(id=id name=id disabled=disabled).border-look.field-row__input
each option in options
- option(value=option.value selected=(option.value == settings[id]))= option.text
+ option(value=option.value selected=(option.value == (settings != undefined ? settings[id] : constants.user_settings[id].default)))= option.text
if description
.field-row__description!= description
block head
- title Settings - CloudTube
+ title Settings - EirTube
block content
main.settings-page
@@ -40,9 +40,10 @@ block content
id: "theme",
label: "Theme",
options: [
- {value: "0", text: "Standard dark"},
- {value: "1", text: "Standard light"},
- {value: "2", text: "Edgeless light"}
+ {value: 0, text: "Standard dark"},
+ {value: 1, text: "Standard light"},
+ {value: 2, text: "Edgeless light"},
+ {value: 3, text: "Eir"}
]
})
@@ -58,24 +59,32 @@ block content
+select({
id: "local",
label: "Play videos on",
- description: 'If CloudTube, the instance above will be used.\nIf YouTube, you will be redirected there.\nIf local, CloudTube will try to connect to a NewLeaf/Invidious instance running on your own computer. This can bypass blocks, but requires you to run the instance software.\nIf you wish to use local mode, read how to install NewLeaf.',
+ description: 'If EirTube, the instance above will be used.\nIf YouTube, you will be redirected there.\nIf local, CloudTube will try to connect to a NewLeaf/Invidious instance running on your own computer. This can bypass blocks, but requires you to run the instance software.\nIf you wish to use local mode, read how to install NewLeaf.',
options: [
- {value: "0", text: "CloudTube"},
+ {value: "0", text: "EirTube"},
{value: "2", text: "YouTube"},
{value: "1", text: "Local"},
]
})
+select({
- id: "quality",
- label: "Preferred qualities",
- description: "All qualities are available on the watch page. This defines their sort order.",
+ id: "autoHD",
+ label: "Auto download preferred quality",
+ description: `Starts downloading the preferred quality chosen above after loading the default stream (if video is shorter than ${converters.lengthSecondsToLengthText(constants.server_setup.video_hq_preload_max_time)}).\nChoose a quality in \"Preferred auto quality\" below.`,
options: [
- {value: "0", text: "720p"},
- {value: "4", text: "360p"},
- {value: "1", text: "Best possible"},
- {value: "2", text: "Best <=1080p"},
- {value: "3", text: "Best <=30fps"}
+ {value: "0", text: "Don't auto download"},
+ {value: "1", text: "Preload preferred quality"}
+ ]
+ })
+
+ +select({
+ id: "quality",
+ label: "Preferred auto quality",
+ description: "Quality to auto download when \"Auto download HQ\" is enabled.\nBigger files means longer load times and more stress on the server, so be careful!",
+ options: [
+ {value: "0", text: "480p"},
+ {value: "1", text: "720p"},
+ {value: "2", text: "1080p"},
]
})
@@ -100,6 +109,73 @@ block content
]
})
+ h2 #[img(src="/static/images/dearrow-logo.svg" height=20)] Dearrow settings
+ p
+ DeArrow is a community-curated database of alternate thumbnails and titles for YouTube videos, meant to make YouTube less sensationalist.
+ section.dearrow-section
+ +select({
+ id: "dearrow",
+ label: "Mode",
+ options: [
+ {value: "0", text: "Disabled"},
+ {value: "1", text: "Off by default"},
+ {value: "2", text: "Use alternate video titles only"},
+ {value: "3", text: "Use alternate video thumbnails only"},
+ {value: "4", text: "Use alternate video titles and thumbnails"}
+ ]
+ })
+
+ +input({
+ id: "dearrow_thumbnail_instance",
+ label: "Dearrow thumbnail instance",
+ type: "url",
+ placeholder: constants.user_settings.dearrow_thumbnail_instance.default
+ })
+
+ +select({
+ id: "dearrow_preload",
+ label: "Preload",
+ description: `Controls whether DeArrow data is retrieved before or after a page loads.\nOnly enable if you're dead set on not using any Javascript at all.`,
+ options: [
+ {value: "0", text: "Disabled (fetch using JavaScript)"},
+ {value: "1", text: "Enabled (fetch all before page load)"}
+ ]
+ })
+
+ h2 #[img(src="/static/images/sponsorblock-logo.png" height=20)] Sponsorblock settings
+ p
+ SponsorBlock is a community-curated database tracking intrusive sponsor segments in YouTube videos.
+ p #[i JavaScript is required to make use of this feature.]
+ section.sb-section
+ +select({
+ id: "sponsorblock",
+ label: "Mode",
+ options: [
+ {value: "0", text: "Disabled"},
+ {value: "1", text: "Enabled"}
+ ]
+ })
+
+ +input({
+ id: "sponsorblock_keybind",
+ label: "Interaction keybind",
+ description: "JavaScript KeyboardEvent.key value to listen for when showing a SponsorBlock prompt.",
+ placeholder: constants.user_settings.sponsorblock_keybind.default
+ })
+
+ each category in ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "music_offtopic", "filler"]
+ +select({
+ id: `sponsorblock_${category}`,
+ label: `${category}
`,
+ options: [
+ {value: "0", text: "Automatic"},
+ {value: "1", text: "Skip"},
+ {value: "2", text: "Prompt skip"},
+ {value: "3", text: "Mute"},
+ {value: "4", text: "Ignore"},
+ ]
+ })
+
.save-settings
button.border-look Save
diff --git a/pug/subscriptions.pug b/pug/subscriptions.pug
old mode 100644
new mode 100755
index 7710f64..0e5ca29
--- a/pug/subscriptions.pug
+++ b/pug/subscriptions.pug
@@ -1,10 +1,14 @@
extends includes/layout.pug
include includes/video-list-item.pug
+include includes/toasts
block head
- title Subscriptions - CloudTube
+ title Subscriptions - EirTube
script(type="module" src=getStaticURL("html", "/static/js/subscriptions.js"))
+ - if (settings.dearrow > 0 && settings.dearrow_preload == 0)
+ script const dearrow_thumbnail_instance = "!{settings.dearrow_thumbnail_instance}"
+ script(video-class="subscriptions-video" src=getStaticURL("html", "/static/js/dearrow-load.js"))
block content
main.subscriptions-page
@@ -20,7 +24,7 @@ block content
.channels-list
for channel in channels
a(href=`/channel/${channel.ucid}`).channel-item
- img(src=channel.icon_url width=512 height=512 alt="").thumbnail
+ img(loading="lazy" src=channel.icon_url width=512 height=512 alt="").thumbnail
div
div.name= channel.name
if channel.missing
@@ -47,9 +51,13 @@ block content
label(for="watched-videos-display").checkbox-hider__label Hide watched videos
each video in videos
- +video_list_item("subscriptions-video", video, instanceOrigin, {showMarkWatched: settings.save_history && !video.watched})
+ +video_list_item("subscriptions-video", video, { showMarkWatched: settings.save_history && !video.watched, settings: settings })
else
.no-subscriptions
h2 You have no subscriptions.
p Subscribing to a channel makes its videos appear here.
p You can find the subscribe button on channels and videos.
+
+ #toast-container
+ each toast in toasts
+ +toast(toast.color, toast.icon, toast.text)
diff --git a/pug/takedown-video.pug b/pug/takedown-video.pug
old mode 100644
new mode 100755
index e72224d..da9cc89
--- a/pug/takedown-video.pug
+++ b/pug/takedown-video.pug
@@ -4,7 +4,7 @@ include includes/video-list-item
include includes/subscribe-button
block head
- title Unavailable for legal reasons - CloudTube
+ title Unavailable for legal reasons - EirTube
block content
main.video-error-page
@@ -15,7 +15,7 @@ block content
if url
p: a(href=url) More information about the notice is available here.
p Any content not covered by this notice is still available.
- p You can still #[a(href=`https://www.youtube.com/watch?v=${id}#cloudtube`) watch the video on YouTube.]
+ p You can still #[a(href=`https://www.youtube.com/watch?v=${id}`) watch the video on YouTube.]
footer.takedown-footer
p: i Let this be a warning of why you shouldn't rely on centralised services!
p: i You will always be able to download the CloudTube source code, or try another instance.
diff --git a/pug/takedown.pug b/pug/takedown.pug
old mode 100644
new mode 100755
index c410788..3939184
--- a/pug/takedown.pug
+++ b/pug/takedown.pug
@@ -1,7 +1,7 @@
extends includes/layout
block head
- title Takedown - CloudTube
+ title Takedown - EirTube
block content
main.takedown-page
@@ -10,10 +10,9 @@ block content
section.important-section
h2 Read this first.
ul
- li CloudTube is a proxy service for YouTube videos.
- li CloudTube does not store any videos. Videos are never saved to these servers.
+ li #[img(src="/static/images/bow.png")] While CloudTube does not store any videos, this instance has been modified to do so.
li CloudTube reproduces data from youtube.com in an interface that looks a little bit different.
- li All video data is streamed directly from YouTube. All video metadata is collected directly from YouTube.
+ li All video data is downloaded directly from YouTube. All video metadata is collected directly from YouTube.
h2 And understand this:
p Anybody can watch or download YouTube videos using many freely available tools. This is just one such tool, and attacking this website will solve none of your problems.
h2.new-section “I still want to remove access.”
@@ -31,3 +30,4 @@ block content
else
p You will need to contact the website owner via their domain or hosting provider.
p Please allow 48 hours for a response.
+ p #[img(src="/static/images/bow.png")] Applicable videos stored on this instance will be automatically removed from the cache and prevented from being redownloaded.
diff --git a/pug/video.pug b/pug/video.pug
old mode 100644
new mode 100755
index 28b8416..99eebf5
--- a/pug/video.pug
+++ b/pug/video.pug
@@ -1,16 +1,46 @@
extends includes/layout
include includes/video-list-item
+include includes/comment
include includes/subscribe-button
+include includes/toasts
+include includes/video-player-controls
block head
unless error
- title= `${video.title} - CloudTube`
+ title= `${video.title} - EirTube`
else
- title Error - CloudTube
- script(type="module" src=getStaticURL("html", "/static/js/player.js"))
+ title Error - EirTube
+ +toast_js()
+ script(type="module" src=getStaticURL("html", "/static/js/player-new.js"))
script(type="module" src=getStaticURL("html", "/static/js/chapter-highlight.js"))
- script const data = !{JSON.stringify({...video, continuous})}
+ //- script const data = !{JSON.stringify({...video, continuous})};
+ script const data = !{JSON.stringify({sbData: video.sbData, videoId: video.videoId, lengthSeconds: video.lengthSeconds, comments: video.comments, continuous})};
+ | const videoPath = !{`"${videoPath}${mediaFragment}"`}; const startingFormat = !{JSON.stringify(startingFormat)};
+ | const targetFormat = !{JSON.stringify(targetFormat)}; let awaitingNewFormat = !{awaitingNewFormat}; const dlStatus = !{dlStatus}; awaitingThumb = !{awaitingThumb};
+ //- Sponsorblock settings
+ - let sbSettings = {}
+ - for (const k of Object.keys(settings))
+ - if (k.startsWith("sponsorblock"))
+ - sbSettings[k] = settings[k]
+ script const sbSettings = !{JSON.stringify(sbSettings)}
+ - if (video.sbData && video.sbData.length > 0)
+ script(type="module" src=getStaticURL("html", "/static/js/sb.js"))
+ - if (settings.dearrow > 0 && settings.dearrow_preload == 0)
+ script const dearrow_thumbnail_instance = "!{settings.dearrow_thumbnail_instance}"
+ script(video-class="related-video" src=getStaticURL("html", "/static/js/dearrow-load.js"))
+
+mixin noscript-quality-select(format, disabled, preloading)
+ a(disabled=disabled href=`/watch?v=${video.videoId}&quality=${format.qualityLabel}`).border-look.quality-select-noscript= `${format.cloudtube__label}${preloading ? " (preloading)" : ""}`
+
+mixin recommended-videos()
+ .related-cols
+ h2.related-header Related videos
+ if video.recommendedVideos.length
+ .continuous-start
+ a(href=`/watch?v=${video.videoId}&continuous=1` nofollow) Continuous mode
+ each r in video.recommendedVideos
+ +video_list_item("related-video", r, { settings: settings })
block content
unless error
@@ -27,24 +57,40 @@ block content
})
main.main-video-section
.video-container
- - 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")
+ if targetFormat
+ .video-container-inner
+ video(controls playsinline preload="auto" width=targetFormat.second__width height=targetFormat.second__height data-itag=targetFormat.itag autoplay=continuous||autoplay).video
+ source(type=targetFormat.type src=`${videoPath}${mediaFragment}`)
+ //- source(type=targetFormat.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: `${videoPath}${mediaFragment}`})
+ 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")
+
+ //- I hate noscript. its so ugly. but this works somehow
+ noscript
+ link(rel="stylesheet" type="text/css" href="/static/css/noscript-video-controls-hider.css")
+ +video-player-controls(video, formats, startingFormat, awaitingThumb)
+ //- SponsorBlock popup
+ #sb-skip-prompt.hidden
+ .row
+ img(src=`/static/images/sponsorblock-logo.png`).sponsorblock-logo-video
+ #reason Skipped sponsor
+ .row
+ #timer 9 seconds
+ #btn= `Unskip (${settings.sponsorblock_keybind} or click)`
else
- video(src="")#video.video
+ video(src="").video
.stream-notice The server provided no playback streams.
- #current-time-container
- #end-cards-container
.info
header.info-main
- h1.title= video.title
+ .video-title-area
+ if settings.dearrow > 0 && video.dearrowData && video.dearrowData.title
+ input(type="checkbox" checked=(settings.dearrow != 1) class={ "dearrow-button-video": true })
+ hi.title.alt= video.dearrowData.title
+ h1.title= video.title
.author
a(href=`/channel/${video.authorId}`).author-link= `Uploaded by ${video.author}`
.info-secondary
@@ -52,10 +98,7 @@ block content
- const month = new Intl.DateTimeFormat("en-US", {month: "short"}).format(date.getTime())
div= `Uploaded ${date.getUTCDate()} ${month} ${date.getUTCFullYear()}`
div= video.second__viewCountText
-
- audio(preload="auto")#audio
- #live-event-notice
- #audio-loading-display
+ div= video.second__likeCountText
if continuous
div#continuous-controls.continuous
@@ -67,30 +110,58 @@ block content
a(href=`/watch?v=${video.videoId}`)#continuous-stop.border-look Turn off
.button-container
- +subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look
- //- button.border-look#theatre Theatre
- 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(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`).border-look YouTube
- a(href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
+ .button-container-left
+ +subscribe_button(video.authorId, subscribed, `/watch?v=${video.videoId}`).border-look
+ a(href=req.url).border-look Share
+ a(href=`https://www.youtube.com/watch?v=${video.videoId}`).border-look YouTube
+ //- a(href=`https://redirect.invidious.io/watch?v=${video.videoId}`).border-look Invidious
+
+ .button-container-right
+ .button-container-right-top
+ #quality-select-noscript-parent
+ each f in formats
+ +noscript-quality-select(f, f.itag == startingFormat.itag, awaitingNewFormat && targetFormat.itag == f.itag)
+ .button-container-right-bottom
+ form(method="get" action="/redownloadVideo")
+ input(type="hidden" name="videoID" value=video.videoId)
+ input(type="hidden" id="data-quality" name="quality" value=quality)
+ button.border-look.redownloadBtn Redownload
+ a(href=`${videoPath}&dl=1` id="download-btn").border-look= `Download ${startingFormat.cloudtube__label.replace(" *", "")} (${startingFormat.eirtube__size})`
+ a(href=`/getOgg?videoID=${video.videoId}-${startingFormat.qualityLabel}` id="download-ogg-btn").border-look= `Download audio`
+
+ noscript
+ if awaitingNewFormat
+ .stream-notice
+ p
+ | Since you are not using JavaScript, your browser will cache the video.
+ br
+ | Press CTRL+F5 if the video errors or skips to the end suddenly.
.description#description!= video.descriptionHtml
+ #below-video-container
+ if !continuous && settings.recommended_mode == 1
+ input(type="radio" name="below" checked=true)#radio-comments
+ input(type="radio" name="below")#radio-recommended
+ .below-video-radio-container
+ label(for="radio-comments").below-video-radio-label Comments
+ label(for="radio-recommended").below-video-radio-label Recommended
+ //- aside#standard-related-videos.related-videos
+ aside#recommended-videos
+ +recommended-videos()
+ aside#comments
+ - const commentCount = (video.comments && video.comments.commentCount) ? video.comments.commentCount : 0
+ h1= `${commentCount.toLocaleString("en-US")} comment${commentCount != 1 ? "s" : ""}`
+ //- TODO: comment sorting buttons
+ if video.comments && video.comments.comments
+ each comment in video.comments.comments
+ +comment("video-comment", comment, `https://www.youtube.com/watch?v=${video.videoId}&lc={}`, video.videoId)
+ //- TODO: comment continuation button
+
//- Standard view
- aside(style=continuous ? "display: none" : "")#standard-related-videos.related-videos
- .related-cols
- h2.related-header Related videos
- if video.recommendedVideos.length
- .continuous-start
- a(href=`/watch?v=${video.videoId}&continuous=1` nofollow) Continuous mode
- each r in video.recommendedVideos
- +video_list_item("related-video", r, instanceOrigin)
+ if !continuous && settings.recommended_mode == 0
+ aside#standard-related-videos.related-videos
+ +recommended-videos()
//- Continuous view
if continuous
@@ -100,16 +171,21 @@ block content
.related-cols
h2.related-header Autoplay next
#continuous-first
- +video_list_item("related-video", column.shift(), instanceOrigin, {continuous: true})
+ +video_list_item("related-video", column.shift(), { continuous: true, settings: settings })
if column.length
.related-cols
h2.related-header Related videos
each r in column
- +video_list_item("related-video", r, instanceOrigin, {continuous: true}) // keep on continuous mode for all recommendations
+ +video_list_item("related-video", r, { continuous: true, settings: settings }) //- keep on continuous mode for all recommendations
+ #toast-container
+ each toast in toasts
+ +toast(toast.color, toast.icon, toast.text, toast.noFade)
+ noscript
+ +toast(undefined, "loading", `Loading ${startingFormat.qualityLabel}...`)
else
//- error
main.video-error-page
h2 Error
!= message
- p: a(href=`https://www.youtube.com/watch?v=${video.videoId}#cloudtube`) Watch on YouTube →
+ p: a(href=`https://www.youtube.com/watch?v=${video.videoId}`) Watch on YouTube →
diff --git a/sass/dark.sass b/sass/dark.sass
old mode 100644
new mode 100755
diff --git a/sass/edgeless-light.sass b/sass/edgeless-light.sass
old mode 100644
new mode 100755
diff --git a/sass/eir.sass b/sass/eir.sass
new file mode 100755
index 0000000..ecb24cc
--- /dev/null
+++ b/sass/eir.sass
@@ -0,0 +1,9 @@
+@use "themes/eir" as *
+@use "includes/main" with ($_theme: $theme)
+
+@use "theme-modules/edgeless" with ($_theme: $theme)
+
+// navigation shadow
+.main-nav
+ position: relative // needed for box shadow to overlap related videos section
+ box-shadow: 0px 0px 20px 5px rgba(0, 0, 0, 0.1)
diff --git a/sass/includes/_base.sass b/sass/includes/_base.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_buttons.sass b/sass/includes/_buttons.sass
old mode 100644
new mode 100755
index 162c573..2f8f527
--- a/sass/includes/_buttons.sass
+++ b/sass/includes/_buttons.sass
@@ -14,9 +14,17 @@ $_theme: () !default
text-decoration: none
line-height: 1.25
+ // -Eir
+ @at-root #{selector.unify(&, "[disabled]")}
+ color: map.get($_theme, "fg-dim")
+ background-color: map.get($_theme, "bg-1")
+ font-style: italic
+
@at-root #{selector.unify(&, "select")}
padding: 8px 27px 8px 8px
background: map.get($_theme, "image-dropdown") right 53% no-repeat map.get($_theme, "bg-4")
+ &[disabled]
+ background-image: map.get($_theme, "disabled-image-dropdown")
@at-root #{selector.unify(&, "a")}
padding: 7px 8px
@@ -30,6 +38,9 @@ $_theme: () !default
margin-right: 8px
margin-left: 2px
+ &at-root #{selector.unify(&, "[disabled]")}
+ cursor: not-allowed
+
@mixin button-bg
@include button-base
@@ -59,6 +70,8 @@ $_theme: () !default
@include button-size
@at-root #{selector.unify(&, "a, button")}
@include button-hover
+ &[disabled]
+ border: 1px solid map.get($_theme, "bg-1")
.menu-look
@include button-size
diff --git a/sass/includes/_cant-think-page.sass b/sass/includes/_cant-think-page.sass
deleted file mode 100644
index 99faa21..0000000
--- a/sass/includes/_cant-think-page.sass
+++ /dev/null
@@ -1,86 +0,0 @@
-$_theme: () !default
-
-@use "sass:list"
-@use "sass:map"
-
-.cant-think-page
- .main-nav
- display: none
-
- .encouraging-message
- text-align: left
- max-width: 572px
- padding-top: 40px
- margin-bottom: 12vh
- border-radius: 0px 0px 16px 16px
- box-sizing: border-box
-
- .page-narration
- background-color: map.get($_theme, "bg-3")
- border: 1px solid map.get($_theme, "edge-grey")
- color: map.get($_theme, "fg-bright")
- border-radius: 0
- padding: 16px
- margin: 40px auto 60px
-
- &__audio-container
- margin-top: 20px
-
- &__audio
- width: 100%
-
- .leave
- margin: 26px 32px !important
- color: map.get($_theme, "fg-dim")
-
- $sizes: 14px 16px 21px 30px 72px
- @each $size in $sizes
- &.leave__stage-#{list.index($sizes, $size)}
- font-size: $size
-
- &.leave__final
- font-weight: bold
- color: map.get($_theme, "fg-bright")
- text-align: center
-
- .leave__actions
- display: block
-
- .leave__actions
- margin-left: 5px
- font-family: monospace
- font-size: 0.9em
-
- & a, & a:visited
- color: #fb8460
-
- .ultimatum
- margin-top: 32px !important
-
- .the-end
- margin: 120px 0px 1em !important
-
- .thumbnail__more
- display: none
-
- #i-understand
- display: flex
- height: 20px
- justify-content: center
- align-items: center
- font-size: 60px
- font-weight: bold
- color: #fff
-
- &:target
- position: fixed
- z-index: 1
- top: 0
- bottom: 0
- left: 0
- right: 0
- height: unset
- background: black
-
- &::after
- content: "Good."
diff --git a/sass/includes/_channel-page.sass b/sass/includes/_channel-page.sass
old mode 100644
new mode 100755
index f1a8952..8e6ebee
--- a/sass/includes/_channel-page.sass
+++ b/sass/includes/_channel-page.sass
@@ -21,7 +21,7 @@ $_theme: () !default
.channel-data
background-color: map.get($_theme, "bg-1")
padding: 24px
- margin: 12px 0px 24px
+ margin-top: 12px
border-radius: 8px
.info
@@ -42,6 +42,8 @@ $_theme: () !default
.about
flex: 1
margin-top: 10px
+ color: map.get($_theme, "fg-main")
+ font-size: 16px
.name
font-size: 30px
@@ -49,9 +51,14 @@ $_theme: () !default
color: map.get($_theme, "fg-bright")
margin: 0
- .subscribers
- color: map.get($_theme, "fg-main")
+ .autogenerated
font-size: 18px
+ font-weight: bold
+ margin-bottom: 0.5em
+
+ .about-inner
+ display: grid
+ // margin-left: 1em
.subscribe-form
margin-top: 24px
@@ -71,6 +78,77 @@ $_theme: () !default
margin-top: 16px
overflow-wrap: break-word
+.channel-tabs
+ padding: 0.5em
+
+ .channel-tabs-header
+ width: 100%
+ display: flex
+
+ .header
+ font-size: 26px
+ padding: 0em 0.5em
+ flex-grow: 1
+ color: map.get($_theme, "fg-dim")
+ text-decoration: none
+ border-bottom: 1px solid map.get($_theme, "edge-grey")
+
+ &:hover,
+ &.selected
+ color: map.get($_theme, "fg-bright")
+
+ &.selected
+ background: map.get($_theme, "bg-5")
+ border: 1px solid map.get($_theme, "edge-grey")
+ border-bottom: none
+ border-radius: 4px 4px 0px 0px
+
+ .channel-tabs-inner
+ background: map.get($_theme, "bg-1")
+ border: 1px solid map.get($_theme, "edge-grey")
+ border-top: none
+
+ .video-sort-header
+ padding-top: 1em
+ width: 100%
+ display: flex
+
+ .video-sort-padding
+ flex-grow: 1
+
+ .video-sort-mode
+ font-size: 18px
+ padding: 0em 0.5em
+ text-decoration: none
+ color: map.get($_theme, "fg-dim")
+
+ &:hover,
+ &.selected
+ color: map.get($_theme, "fg-bright")
+
+ .continuation-btn
+ $padding: 14px
+
+ padding: $padding - 2px $padding $padding
+ line-height: 1
+ border-radius: 8px
+ font-size: 22px
+ background-color: map.get($_theme, "power-deep")
+ border: none
+
+ width: max-content
+ margin: auto
+
+ a
+ text-decoration: none
+ color: map.get($_theme, "fg-bright")
+
+ .no-videos
+ font-size: 22px
+ font-style: italic
+ color: map.get($_theme, "fg-dim")
+ margin: 0.5em
+
.channel-video
@include channel-video
diff --git a/sass/includes/_comment.sass b/sass/includes/_comment.sass
new file mode 100755
index 0000000..588bc12
--- /dev/null
+++ b/sass/includes/_comment.sass
@@ -0,0 +1,84 @@
+$_theme: () !default
+
+@use "sass:map"
+
+.comment-base
+ display: flex
+ flex-direction: row
+ padding-bottom: 1em
+
+ .comment-avatar
+ padding-right: 1em
+
+ .avatar-img
+ border-radius: 50%
+ max-height: 4em
+
+ .comment-contents
+ display: flex
+ flex-direction: column
+
+ > *:not(:first-child)
+ padding-top: 0.5em
+
+ .comment-author
+ display: flex
+ flex-direction: row
+ max-width: fit-content
+
+ &.is-channel-owner
+ color: map.get($_theme, "link")
+
+ .icon
+ padding-left: 0.5em
+ width: 1em
+ height: 1em
+ &.checkmark, &.pin
+ background-size: 100% 100%
+ background-position: center
+
+ &.checkmark
+ background-image: url(/static/images/check.svg)
+ &.pin
+ background-image: url(/static/images/pin.svg)
+ &.sponsor
+ max-height: 2em
+
+ .comment-content
+ color: map.get($_theme, "fg-dim")
+ padding-right: 5em
+ cursor: text
+
+ .comment-stats
+ display: flex
+ flex-direction: row
+ max-width: fit-content
+ cursor: text
+
+ > *:not(:first-child)
+ padding-left: 0.5em
+
+ .comment-heart
+ position: relative
+ display: flex
+ flex-direction: row
+
+ > img
+ max-height: 1.25em
+ border-radius: 50%
+
+ &:after
+ content: "❤️"
+ position: absolute
+ left: 62.5%
+ top: 62.5%
+ font-size: 0.75em
+
+ // .comment-heart-label
+ // padding-left: 0.5em
+ // font-style: italic
+
+ a
+ color: map.get($_theme, "fg-bright")
+ text-decoration: none
+ font-weight: bold
diff --git a/sass/includes/_dimensions.sass b/sass/includes/_dimensions.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_filters-page.sass b/sass/includes/_filters-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_footer.sass b/sass/includes/_footer.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_forms.sass b/sass/includes/_forms.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_home-page.sass b/sass/includes/_home-page.sass
old mode 100644
new mode 100755
index 0b8b42b..2f6954d
--- a/sass/includes/_home-page.sass
+++ b/sass/includes/_home-page.sass
@@ -5,6 +5,10 @@ $_theme: () !default
.home-page
padding: 40px
+.text-content
+ width: fit-content
+ margin: 0 auto
+
.top-header
font-size: 48px
text-align: center
diff --git a/sass/includes/_licenses-page.sass b/sass/includes/_licenses-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_main.sass b/sass/includes/_main.sass
old mode 100644
new mode 100755
index c2a076f..6e57a80
--- a/sass/includes/_main.sass
+++ b/sass/includes/_main.sass
@@ -5,8 +5,11 @@ $_theme: () !default
// preload second-level includes with the theme (there will be conflicts due to reconfiguration they are loaded individually)
// this isn't _exactly_ what @forward is supposed to be used for, but it's the best option here
@forward "video-list-item" show _ with ($_theme: $_theme)
+@forward "comment" show _ with ($_theme: $_theme)
@forward "forms" show _ with ($_theme: $_theme)
@forward "buttons" show _ with ($_theme: $_theme)
+@forward "toasts" show _ with ($_theme: $_theme)
+@forward "video-player-controls" show _ with ($_theme: $_theme)
@use "base" with ($_theme: $_theme)
@use "video-page" with ($_theme: $_theme)
@@ -15,7 +18,6 @@ $_theme: () !default
@use "channel-page" with ($_theme: $_theme)
@use "subscriptions-page" with ($_theme: $_theme)
@use "settings-page" with ($_theme: $_theme)
-@use "cant-think-page" with ($_theme: $_theme)
@use "privacy-page" with ($_theme: $_theme)
@use "licenses-page" with ($_theme: $_theme)
@use "filters-page" with ($_theme: $_theme)
@@ -27,6 +29,8 @@ $_theme: () !default
font-family: "Bariol"
src: url(/static/fonts/bariol.woff?statichash=1)
-.button-container
- display: flex
- flex-wrap: wrap
+// - Eir
+img[src="/static/images/bow.png"]
+ image-rendering: -moz-crisp-edges
+ image-rendering: crisp-edges
+ image-rendering: pixelated
diff --git a/sass/includes/_nav.sass b/sass/includes/_nav.sass
old mode 100644
new mode 100755
index f1eab5f..9b936a4
--- a/sass/includes/_nav.sass
+++ b/sass/includes/_nav.sass
@@ -56,3 +56,13 @@ $_theme: () !default
&:hover, &:focus
border-color: map.get($_theme, "edge-grey")
+
+ .search-button
+ display: none
+ .search-icon
+ @include button-base
+ color: map.get($_theme, "fg-dim")
+ cursor: pointer
+
+ &:hover, &:focus
+ color: map.get($_theme, "fg-bright")
diff --git a/sass/includes/_privacy-page.sass b/sass/includes/_privacy-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_search-page.sass b/sass/includes/_search-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_settings-page.sass b/sass/includes/_settings-page.sass
old mode 100644
new mode 100755
index a3eb77f..b9f5be7
--- a/sass/includes/_settings-page.sass
+++ b/sass/includes/_settings-page.sass
@@ -8,6 +8,12 @@ $_theme: () !default
max-width: 600px
margin: 0 auto
+ .dearrow-section, .sb-section
+ border-bottom: 1px solid map.get($_theme, "edge-grey")
+
+ .field-row
+ border-bottom: none
+
.save-settings
margin-top: 24px
diff --git a/sass/includes/_subscriptions-page.sass b/sass/includes/_subscriptions-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_takedown-page.sass b/sass/includes/_takedown-page.sass
old mode 100644
new mode 100755
diff --git a/sass/includes/_toasts.sass b/sass/includes/_toasts.sass
new file mode 100755
index 0000000..407d4fa
--- /dev/null
+++ b/sass/includes/_toasts.sass
@@ -0,0 +1,94 @@
+$_theme: () !default
+
+@use "sass:selector"
+@use "sass:map"
+
+@keyframes toast-fade-out
+ 0%
+ opacity: 1
+ 80%
+ opacity: 1
+ 100%
+ opacity: 0
+ display: none
+
+#toast-container
+ position: fixed
+ right: 1em
+ bottom: 1em
+ display: flex
+ flex-direction: column
+
+ .toast-container
+ $container: &
+ position: relative
+ align-self: end
+
+ &:not(.nofade)
+ animation: toast-fade-out 5s forwards
+
+ input[type="checkbox"]
+ display: none
+
+ input[type="checkbox"]:checked + .toast
+ display: none
+
+ .toast
+ font-size: 22px
+ border: 1px solid #938b04
+ background-color: #4d4503
+ display: flex
+ flex-direction: row
+ padding: 0.25em
+ margin: 0.25em
+ max-width: fit-content
+
+ *
+ padding: 0 0.25em
+
+ &.green
+ border-color: #189304
+ background-color: #104404
+
+ &.red
+ border-color: #930f04
+ background-color: #440604
+
+ .icon
+ width: 22px
+ height: 22px
+ background-size: 22px 22px
+ background-repeat: no-repeat
+
+ &.loading
+ background-image: url(/static/images/loading.gif)
+
+ &.check
+ background-image: url(/static/images/check.svg)
+
+ &.x
+ background-image: url(/static/images/x.svg)
+
+ .close-overlay
+ display: none
+
+ // Don't ask me how this works...
+ @at-root
+ .toast
+ #{$container}:hover &
+ filter: brightness(0.5)
+
+ .close-overlay
+ #{$container}:hover &
+ position: absolute
+ top: 0
+ left: 0
+ display: block
+ width: 100%
+ height: 100%
+ z-index: 1
+ background-image: url(/static/images/x.svg)
+ background-size: 11px 11px
+ background-repeat: no-repeat
+ background-position: 50% 50%
+ cursor: pointer
diff --git a/sass/includes/_video-list-item.sass b/sass/includes/_video-list-item.sass
old mode 100644
new mode 100755
index 321a964..1f8d1f7
--- a/sass/includes/_video-list-item.sass
+++ b/sass/includes/_video-list-item.sass
@@ -33,7 +33,7 @@ $_theme: () !default
display: block
height: $more-size
color: #fff
- line-height: 16px
+ line-height: 100%
font-size: 25px
text-align: center
@@ -75,13 +75,37 @@ $_theme: () !default
pointer-events: none
@mixin video-list-item
- display: grid
+ display: flex
+ flex-direction: row-reverse
grid-template-columns: 160px 1fr
grid-gap: 8px
align-items: start
align-content: start
margin-bottom: 12px
+ .dearrow-button-list
+ min-width: 20px
+ height: 20px
+ appearance: none
+ cursor: pointer
+ background-color: transparent
+ background-image: url(/static/images/dearrow-logo.svg)
+ background-size: contain
+
+ &.loading
+ background-image: url(/static/images/loading.gif)
+ &:not(.loading):not(:checked)
+ filter: grayscale(1)
+
+ &.change-title:checked + .info .title:not(.alt)
+ display: none
+ &.change-title:not(:checked) + .info .title.alt
+ display: none
+ &.change-thumb:checked ~ .thumbnail .thumbnail__link:not(.alt)
+ display: none
+ &.change-thumb:not(:checked) ~ .thumbnail .thumbnail__link.alt
+ display: none
+
@at-root .video-list-item--watched#{&}
background: map.get($_theme, "bg-dim")
padding: 4px 4px 0px
@@ -98,9 +122,22 @@ $_theme: () !default
display: flex
background: map.get($_theme, "bg-0")
+ &.channel
+ background: transparent
+ img
+ border-radius: 50%
+ width: auto
+
&__link
font-size: 0 // remove whitespace around the image
+ &:hover
+ .duration
+ display: none
+
+ .info
+ flex-grow: 1
+
.image
width: 160px
height: 90px
@@ -120,10 +157,12 @@ $_theme: () !default
.title
font-size: 15px
line-height: 1.2
+ display: flex
.title-link
color: map.get($_theme, "fg-main")
text-decoration: none
+ flex-grow: 1
.author-line
margin-top: 4px
diff --git a/sass/includes/_video-page.sass b/sass/includes/_video-page.sass
old mode 100644
new mode 100755
index 6774a83..d45ee7b
--- a/sass/includes/_video-page.sass
+++ b/sass/includes/_video-page.sass
@@ -13,6 +13,9 @@ $_theme: () !default
&--recommended-below, &--recommended-hidden
grid-template-columns: none
+ .related-videos
+ height: fit-content
+
&--recommended-side
.related-videos
border-left: 1px solid map.get($_theme, "edge-grey")
@@ -29,17 +32,150 @@ $_theme: () !default
.related-videos
display: none
+ #below-video-container
+ height: fit-content
+
+ input[type=radio]
+ display: none
+
+ &[id="radio-comments"]:checked ~ .below-video-radio-container label[for="radio-comments"],
+ &[id="radio-recommended"]:checked ~ .below-video-radio-container label[for="radio-recommended"]
+ border-bottom: 1px solid map.get($_theme, "edge-grey")
+
+ &[id="radio-recommended"]:checked ~ aside#recommended-videos,
+ &[id="radio-comments"]:checked ~ aside#comments
+ display: inline
+
+ .below-video-radio-container
+ display: flex
+ margin-bottom: 20px
+
+ label
+ font-size: 32px
+ flex-grow: 1
+ cursor: pointer
+
+ & ~ aside
+ display: none
+
+ #comments
+ // padding-left: 2em
+ padding-right: 2em
+
+ > h1
+ margin-top: 0
+
.main-video-section
padding: 20px
.video-container
text-align: center
+ position: relative
- .video
- display: inline-block
+ .video-container-inner
+ height: 100%
+ display: grid
+
+ .video
+ align-self: center
+ position: relative
+ display: inline-block
+ width: 100%
+ height: 100%
+
+ max-height: 80vh
+
+ // For custom captions
+ &.hasCustomCaptions::cue
+ display: none
+ font-size: 0px
+ background: transparent
+ color: transparent
+
+ &.hideCursor
+ cursor: none
+
+ &.fullscreen
+ .video
+ max-height: 100%
+
+ // SponsorBlock
+ #sb-skip-prompt
+ background-color: map.get($_theme, "bg-4")
+ border-radius: 4px
+ position: absolute
+ right: 1em
+ bottom: 4em
+ cursor: pointer
+
+ &.hidden
+ display: none
+
+ .row
+ display: flex
+ margin: 8px
+
+ &:not(:last-child)
+ border-bottom: 1px solid map.get($_theme, "edge-grey")
+
+ *
+ padding: 8px
+
+ &:nth-child(2)
+ flex-grow: 1
+
+ .sponsorblock-logo-video
+ height: 20px
+
+ // Custom captions
+ .caption-box
+ position: absolute
+ left: 0
+ top: 0
width: 100%
- height: auto
- max-height: 80vh
+ height: calc(100% - 35px)
+ pointer-events: none
+ z-index: 1
+
+ .caption-inner
+ position: relative
+ width: calc(100% - 2em) // 95%
+ height: calc(100% - 2em) // 95%
+ inset: 0px
+ line-height: normal
+ top: 1em // 2.5%
+ bottom: 1em // 2.5%
+ left: 1em // 2.5%
+ right: 1em // 2.5%
+
+ .cue-container
+ display: flex
+ flex-direction: row
+ position: absolute
+ width: 100%
+ height: 100%
+
+ .cue-container-v
+ display: flex
+ flex-direction: column
+ // position: absolute
+ flex-grow: 1
+
+ .cue-inner
+ unicode-bidi: plaintext
+ overflow-wrap: break-word
+ text-wrap: balance
+ text-align: center
+ flex-grow: 1
+ color: white
+ fill: white
+
+ *
+ display: inline-block
+ white-space: pre-wrap
+ background: rgba(8, 8, 8, 0.75)
+ font-size: 39px
+ font-family: "YouTube Noto", Roboto, Arial, Helvetica, Verdana, "PT Sans Caption", sans-serif
.stream-notice
background: map.get($_theme, "bg-0")
@@ -56,12 +192,32 @@ $_theme: () !default
.info-main
flex: 1
- .title
- margin: 0px 0px 4px
- font-size: 30px
- font-weight: normal
- color: map.get($_theme, "fg-bright")
- word-break: break-word
+ .video-title-area
+ display: flex
+
+ .dearrow-button-video
+ min-width: 26px
+ height: 26px
+ appearance: none
+ cursor: pointer
+ background-color: transparent
+ background-image: url(/static/images/dearrow-logo.svg)
+ background-size: contain
+
+ &:not(:checked)
+ filter: grayscale(1)
+
+ &:checked ~ .title:not(.alt)
+ display: none
+ &:not(:checked) ~ .title.alt
+ display: none
+
+ .title
+ margin: 0px 0px 4px
+ font-size: 30px
+ font-weight: normal
+ color: map.get($_theme, "fg-bright")
+ word-break: break-word
.author-link
color: map.get($_theme, "fg-main")
@@ -106,6 +262,30 @@ $_theme: () !default
display: flex
flex-shrink: 0
+ // - Eir
+ .button-container
+ display: flex
+
+ .button-container-left,
+ .button-container-right
+ display: flex
+ flex-direction: row
+ flex-wrap: wrap
+ height: min-content
+ .button-container-left
+ flex-grow: 1
+ .button-container-right
+ margin-left: 2em
+ flex-direction: column
+ .button-container-right-top,
+ .button-container-right-bottom
+ display: flex
+ flex-direction: row
+ align-self: flex-end
+
+ #quality-select-noscript-parent
+ display: contents
+
.description
position: relative
font-size: 17px
@@ -118,6 +298,9 @@ $_theme: () !default
--regular-background: #{map.get($_theme, "bg-5")}
--highlight-background: #{map.get($_theme, "bg-1")}
+ white-space: pre-line
+ *
+ white-space: pre-line
.subscribe-form
display: inline-block
@@ -125,16 +308,16 @@ $_theme: () !default
.related-cols
display: flex
align-items: center
- justify-content: space-between
margin-bottom: 8px
.continuous-start
- font-size: 15px
+ font-size: 18px
.related-header
margin: 4px 0px 4px 2px
font-weight: normal
font-size: 26px
+ flex-grow: 1
.related-video
@include video-list-item
@@ -183,6 +366,7 @@ $_theme: () !default
position: absolute
left: 0
right: 0
+ height: -webkit-calc(1.4em + 4px)
height: calc(1.4em + 4px)
transform: translateY(-1.4em) translateY(-4px)
diff --git a/sass/includes/_video-player-controls.sass b/sass/includes/_video-player-controls.sass
new file mode 100755
index 0000000..82f3743
--- /dev/null
+++ b/sass/includes/_video-player-controls.sass
@@ -0,0 +1,371 @@
+$_theme: () !default
+
+@use "sass:selector"
+@use "sass:map"
+
+.videoControls
+ position: absolute
+ bottom: 0
+ left: 0
+ display: flex
+ height: 35px
+ width: 100%
+ opacity: 1
+ transition: opacity 0.2s
+ background: linear-gradient(transparent, black)
+ z-index: 2
+ //background-color: #30303030
+
+ --accent-color: cyan
+
+ &.hidden
+ opacity: 0
+
+ @mixin svg-button
+ background-color: transparent
+ background-size: cover
+ background-repeat: no-repeat
+ filter: saturate(0) brightness(2)
+ border: none
+ transition: filter 0.2s
+ transition: scale 0.2s
+ @mixin svg-button-hover
+ filter: saturate(0) brightness(4)
+ @mixin svg-button-active-or-click
+ filter: saturate(0) hue-rotate(200deg) brightness(4)
+
+ > button
+ @include svg-button
+ width: 32px
+ height: 32px
+
+ &:hover
+ @include svg-button-hover
+ scale: 1.1
+ &:active
+ scale: 0.9
+ &.active, &:active
+ @include svg-button-active-or-click
+
+ .playBtn
+ background-image: url(/static/images/player/play.svg)
+ margin-left: 0.5em
+ margin-right: 0.5em
+
+ &.pause
+ background-image: url(/static/images/player/pause.svg)
+
+ .settingsBtn
+ background-image: url(/static/images/player/hamburger.svg)
+ margin-left: 0.5em
+ margin-right: 0.5em
+
+ .fullscreen
+ background-image: url(/static/images/player/fullscreen.svg)
+ margin-right: 0.5em
+
+ .volumeBtn
+ background-image: url(/static/images/player/volume-high.svg)
+ margin-right: 1em
+
+ &.low
+ background-image: url(/static/images/player/volume-low.svg)
+
+ &.off
+ background-image: url(/static/images/player/volume-off.svg)
+
+ &.mute
+ background-image: url(/static/images/player/volume-mute.svg)
+
+ // Bars
+ input[type=range]
+ -webkit-appearance: none
+ appearance: none
+ height: 35px
+ background-color: transparent
+
+ &::-webkit-slider-runnable-track
+ background-color: #bbb
+ height: 2px
+ &::-moz-range-track
+ background-color: #bbb
+ height: 2px
+ &::-webkit-slider-thumb
+ -webkit-appearance: none
+ appearance: none
+ width: 16px
+ height: 16px
+ margin-top: -6px
+ &::-moz-range-thumb
+ width: 16px
+ height: 16px
+ margin-top: -6px
+
+ .timeline
+ flex-grow: 1
+ height: 100%
+ position: relative
+ cursor: pointer
+
+ .seek
+ width: 100%
+ height: 100%
+ margin-top: -0.0625em
+ background-color: transparent
+
+ // For SponsorBlock integration
+ --seek-background: #bbb
+ --sponsorblock-segment-sponsor: green
+ --sponsorblock-segment-selfpromo: yellow
+ --sponsorblock-segment-interaction: purple
+ --sponsorblock-segment-intro: cyan
+ --sponsorblock-segment-outro: blue
+ --sponsorblock-segment-preview: indigo
+ --sponsorblock-segment-music_offtopic: orange
+ --sponsorblock-segment-music_filler: orange
+
+ &::-webkit-slider-runnable-track
+ background-color: var(--seek-background)
+ &::-moz-range-track
+ background-color: var(--seek-background)
+ &::-moz-range-progress
+ background-color: var(--accent-color)
+
+ &::-webkit-slider-thumb
+ width: 0px
+ height: 0px
+ border: none
+ &::-moz-range-thumb
+ width: 0px
+ height: 0px
+ border: none
+
+ .fakeThumb
+ position: absolute
+ bottom: 0.75em
+ width: 16px
+ height: 16px
+ margin-left: -8px
+ background-color: white
+ border-radius: 50%
+ pointer-events: none
+
+ .hoverTimeContainer
+ position: absolute
+ width: 0
+ bottom: 0.5em
+ opacity: 1
+ transition: opacity 0.2s
+ pointer-events: none
+ align-content: center
+
+ &.hidden
+ opacity: 0
+
+ .hoverTimeInner
+ display: flex
+ flex-direction: column
+ width: max-content //11em
+ transform: translateX(-50%)
+ border-radius: 6px
+ position: relative
+ padding: 0.5em
+
+ .hoverTimeVideo:not([src])
+ display: none
+ & + .hoverTimeText
+ padding-top: unset
+
+ .hoverTimeText
+ margin: auto
+ padding-top: 0.5em
+
+ .timecode
+ margin: auto
+ margin-left: 1em
+ margin-right: 0.5em
+
+ .popout
+ background-color: map.get($_theme, "bg-4")
+ position: absolute
+ bottom: 3.5em
+ border-radius: 6px
+ display: flex
+ flex-direction: column
+
+ &.hidden
+ display: none
+
+ .volumePopout
+ left: 3em
+ width: 3em
+ height: 8em
+ padding: 0.25em
+
+ &.boost
+ height: 14em
+
+ .volumeBarContainer
+ height: 100%
+ position: relative
+
+ .volumeBar
+ -webkit-transform: rotate(-90deg)
+ -webkit-transform-origin: 52.5% 240%
+ transform: rotate(-90deg)
+ transform-origin: 52.5% 240%
+ width: 8em
+
+ --volumeBar-bg: #333
+ --volumeBar-boost-bg: #fa1f21
+
+ &::-webkit-slider-runnable-track
+ background-color: var(--volumeBar-bg)
+ &::-moz-range-track
+ background-color: var(--volumeBar-bg)
+ &::-moz-range-progress
+ background-color: var(--accent-color)
+
+ // Normalize thumb
+ @mixin normalized-thumb
+ background-color: white
+ border-radius: 50%
+ &::-webkit-slider-thumb
+ @include normalized-thumb
+ &::-moz-range-thumb
+ @include normalized-thumb
+
+ &.boost
+ width: 10em
+ @mixin boost-bg
+ background-color: none
+ background-image: linear-gradient(90deg, var(--volumeBar-bg) 33%, var(--volumeBar-boost-bg) 33%, var(--volumeBar-boost-bg) 100%)
+ &::-webkit-slider-runnable-track
+ @include boost-bg
+ &::-moz-range-track
+ @include boost-bg
+
+ .settingsPopout
+ right: 3em
+ width: fit-content
+ height: fit-content
+
+ &[data-page="main"] .settingsPage[data-name="main"],
+ &[data-page="speed"] .settingsPage[data-name="speed"],
+ &[data-page="tricks"] .settingsPage[data-name="tricks"],
+ &[data-page="cc"] .settingsPage[data-name="cc"],
+ &[data-page="quality"] .settingsPage[data-name="quality"]
+ display: flex
+
+ .settingsPage
+ display: none
+ flex-direction: column
+ padding: 0.25em
+
+ .setting
+ display: flex
+ flex-direction: row
+
+ &:not(.header:not(.goBack))
+ cursor: pointer
+
+ &:not(.header)
+ color: map.get($_theme, "fg-dim")
+
+ &:not([disabled]):hover
+ color: map.get($_theme, "fg-main")
+
+ *
+ padding: 0.5em
+
+ .icon, .submenuIcon
+ @include svg-button
+ width: 1em
+ height: 1em
+ background-color: transparent
+ background-size: contain
+ background-position: center center
+
+ &.submenuIcon
+ background-image: url(/static/images/player/back.svg)
+ transform: rotateY(180deg)
+ transform-origin: 50% 50%
+
+ &.redownloadBtn
+ background-image: url(/static/images/player/refresh.svg)
+ transform: unset
+ &:hover:not(.active)
+ .icon, .submenuIcon
+ @include svg-button-hover
+ &.active, &:active
+ .icon, .submenuIcon
+ @include svg-button-active-or-click
+
+ .text
+ text-align: left
+ align-self: center
+ flex-grow: 1
+
+ // Specific icons...
+ .header
+ margin-top: 0
+ border-bottom: 1px solid map.get($_theme, "edge-grey")
+ margin-bottom: 0.25em
+
+ .icon.back
+ background-image: url(/static/images/player/back.svg)
+
+ .autoHide
+ .icon
+ background-image: url(/static/images/player/eye-closed.svg)
+ &.active .icon
+ background-image: url(/static/images/player/eye-open.svg)
+ .loop .icon
+ background-image: url(/static/images/player/loop.svg)
+ .speed .icon
+ background-image: url(/static/images/player/speed.svg)
+ .tricks .icon
+ background-image: url(/static/images/player/paw.svg)
+ .cc .icon
+ background-image: url(/static/images/player/captions.svg)
+ .quality .icon
+ background-image: url(/static/images/player/gear.svg)
+
+ // Specific pages
+ // Tricks
+ .forceStereo .icon
+ background-image: url(/static/images/player/headphones.svg)
+
+ .setting
+ &.active
+ color: map.get($_theme, "fg-bright")
+
+ .setting[disabled]
+ &.active
+ color: map.get($_theme, "fg-main")
+ &:not(.active)
+ color: map.get($_theme, "bg-2")
+ .icon, .submenuIcon,
+ &:hover:not(.active) .icon, &:hover:not(.active) .submenuIcon
+ filter: saturate(0) brightness(1)
+ &.active, &:active
+ .icon, .submenuIcon
+ filter: saturate(0) brightness(2.5)
+
+ .speedBar
+ --speedBar-bg: #333
+ --speedBar-boost-bg: #fa1f21
+
+ &::-webkit-slider-runnable-track
+ background-color: var(--volumeBar-bg)
+ &::-moz-range-track
+ background-color: var(--volumeBar-bg)
+ &::-moz-range-progress
+ background-color: var(--accent-color)
+ @mixin boost-bg
+ background-color: none
+ background-image: linear-gradient(90deg, var(--speedBar-bg) 33%, var(--speedBar-boost-bg) 33%, var(--speedBar-boost-bg) 100%)
+ &::-webkit-slider-runnable-track
+ @include boost-bg
+ &::-moz-range-track
+ @include boost-bg
diff --git a/sass/light.sass b/sass/light.sass
old mode 100644
new mode 100755
diff --git a/sass/theme-modules/_edgeless.sass b/sass/theme-modules/_edgeless.sass
old mode 100644
new mode 100755
diff --git a/sass/themes/_dark.scss b/sass/themes/_dark.scss
old mode 100644
new mode 100755
index 0308b83..fcbbbfa
--- a/sass/themes/_dark.scss
+++ b/sass/themes/_dark.scss
@@ -18,6 +18,7 @@ $theme: (
"fg-bright": #fff,
"fg-main": #ddd,
"fg-dim": #bbb,
+ "fg-dimmer": #888,
"fg-warning": #fdca6d,
"edge-grey": #a0a0a0,
@@ -28,7 +29,8 @@ $theme: (
"power-deep": #c62727,
"power-fg": "#fff",
- "image-dropdown": url(/static/images/arrow-down-wide-dark.svg)
+ "image-dropdown": url(/static/images/arrow-down-wide-dark.svg),
+ "disabled-image-dropdown": url(/static/images/arrow-down-disabled-wide-dark.svg)
);
// This section is for colour meanings
diff --git a/sass/themes/_edgeless-light.scss b/sass/themes/_edgeless-light.scss
old mode 100644
new mode 100755
diff --git a/sass/themes/_eir.scss b/sass/themes/_eir.scss
new file mode 100755
index 0000000..c299821
--- /dev/null
+++ b/sass/themes/_eir.scss
@@ -0,0 +1,342 @@
+// Defined in scss file instead of sass because indented syntax does not have multiline maps
+// https://github.com/sass/sass/issues/216
+
+@use "sass:map";
+
+// This section is for colour shades
+$theme: (
+ // darker
+ "bg-0": #252628,
+ "bg-1": #303336,
+ // regular
+ "bg-2": #36393f,
+ // lighter
+ "bg-3": #3f4247, // slightly
+ "bg-4": #44474b, // noticably
+ "bg-5": #4f5359, // brightly
+
+ "fg-bright": #fff,
+ "fg-main": #ddd,
+ "fg-dim": #bbb,
+ "fg-dimmer": #888,
+ "fg-warning": #fdca6d,
+
+ "edge-grey": #a0a0a0,
+ "placeholder": #c4c4c4,
+
+ "link": #8ac2f9,
+
+ "power-deep": #c62727,
+ "power-fg": "#fff",
+
+ "image-dropdown": url(/static/images/arrow-down-wide-dark.svg),
+ "disabled-image-dropdown": url(/static/images/arrow-down-disabled-wide-dark.svg),
+
+ /////
+
+ "bg-overlay": #20202080,
+ "bg-panel": rgba(0.9803921568627451, 0.6666666666666666, 0.6705882352941176, 0.875),
+ "bg-hover": #2f323f,
+ "bg-input": #292b36,
+ "bg-input-disabled": #15161c
+);
+
+// This section is for colour meanings
+$theme: map.merge($theme, (
+ "bg-dim": map.get($theme, "bg-0"),
+ "bg-nav": map.get($theme, "bg-5"),
+));
+
+// Eir's stuff...
+
+@font-face {
+ font-family: Terminess;
+ src: url(/static/fonts/TerminessNerdFontMono-Regular.ttf);
+}
+@font-face {
+ font-family: Terminess;
+ src: url(/static/fonts/TerminessNerdFontMono-Bold.ttf);
+ font-weight: bold;
+}
+@font-face {
+ font-family: Terminess;
+ src: url(/static/fonts/TerminessNerdFontMono-Italic.ttf);
+ font-style: italic;
+}
+@font-face {
+ font-family: Terminess;
+ src: url(/static/fonts/TerminessNerdFontMono-BoldItalic.ttf);
+ font-weight: bold;
+ font-style: italic;
+}
+
+body {
+ background: linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5)), url("https://eir-nya.gay/res/images/cubes.png");
+ background: -webkit-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5)), url("https://eir-nya.gay/res/images/cubes.png");
+ background: -moz-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5)), url("https://eir-nya.gay/res/images/cubes.png");
+ background: -o-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5)), url("https://eir-nya.gay/res/images/cubes.png");
+ background: -ms-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5)), url("https://eir-nya.gay/res/images/cubes.png");
+ background-color: transparent !important;
+ background-attachment: fixed !important;
+
+ font-family: Terminess, hack, 'Courier New', courier, monospace !important;
+ font-size: 14px !important;
+}
+body.dark {
+ background: url("https://eir-nya.gay/res/images/cubes-dark.png"), linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5));
+ background: url("https://eir-nya.gay/res/images/cubes-dark.png"), -webkit-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5));
+ background: url("https://eir-nya.gay/res/images/cubes-dark.png"), -moz-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5));
+ background: url("https://eir-nya.gay/res/images/cubes-dark.png"), -o-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5));
+ background: url("https://eir-nya.gay/res/images/cubes-dark.png"), -ms-linear-gradient(rgba(130, 88, 88, 0.875), rgba(130, 88, 88, 0.5));
+}
+
+/* Light toggle */
+.light-button {
+ position: relative;
+ margin: 0 !important;
+ padding-right: 16px !important;
+
+ .light-toggle {
+ z-index: 1;
+ position: absolute;
+ padding: 0;
+ margin: 0;
+ bottom: 8px;
+ left: 0;
+ width: calc(100% - 8px);
+ height: calc(100% - 16px);
+ appearance: none;
+ -webkit-appearance: none;
+
+ cursor: url("/static/images/cursors/pointer.cur"), pointer;
+
+ &:checked ~ .light-off,
+ &:not(:checked) ~ .light-on {
+ display: none;
+ }
+ }
+
+ * {
+ transform: translateY(1px);
+ }
+
+ &.hidden {
+ display: none
+ }
+}
+
+button:not([disabled]),
+.main-nav .search-form .search-icon {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
+button[disabled], select {
+ cursor: url("/static/images/cursors/default.cur"), default !important;
+}
+button:not(.videoControlBtn):not(.captionBtn):not(.redownloadBtn), select, input:not([type="range"]):not(.dearrow-button-list):not(.dearrow-button-video):not(.light-toggle), .continuation-btn {
+ background-color: map.get($theme, "bg-input") !important;
+}
+button[disabled]:not(.redownloadBtn), select[disabled], input[disabled] {
+ background-color: map.get($theme, "bg-input-disabled") !important;
+}
+.checkbox-hider__container, .data-management .delete-confirm-container {
+ background-color: map.get($theme, "bg-input") !important;
+ border-radius: 0px;
+}
+.checkbox-hider__label {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
+.border-look:not([disabled]) {
+ background-color: map.get($theme, "bg-input") !important;
+}
+
+details:not(.thumbnail__more):not(.fetch-error) {
+ background-color: map.get($theme, "bg-input") !important;
+ border-radius: 0px !important;
+ border: none;
+}
+details summary {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
+.toast-container .close-overlay {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
+.description, .info-secondary {
+ cursor: url("/static/images/cursors/text.cur"), text !important;
+}
+
+body > div > main:not(.home-page)/*, main.error*/ {
+ background-color: map.get($theme, "bg-overlay") !important;
+ padding: 40px 20px 20px;
+}
+
+.main-nav {
+ background-color: map.get($theme, "bg-overlay") !important;
+
+ .link:focus,
+ .link:hover {
+ background-color: transparent !important;
+ }
+
+ .search-form .search {
+ background-color: map.get($theme, "bg-input") !important;
+ }
+}
+.home-page {
+ .text-content {
+ background-color: map.get($theme, "bg-overlay");
+ }
+ .encouraging-message {
+ background-color: transparent;
+ }
+ input {
+ background-color: map.get($theme, "bg-input");
+ }
+}
+.settings-page {
+ .more-settings {
+ border-radius: 0px;
+ background-color: map.get($theme, "bg-input") !important;
+ }
+}
+.related-videos, .search-page {
+ background-color: map.get($theme, "bg-panel") !important;
+ margin-top: 20px;
+}
+#below-video-container .below-video-radio-container label {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
+.channel-page {
+ .channel-data {
+ background-color: transparent !important;
+ border-radius: 0px;
+ }
+ .channel-tabs-header .header.selected {
+ background: none !important;
+ }
+ .channel-tabs-inner {
+ background: none !important;
+ border: none !important;
+ }
+ .videos {
+ background-color: transparent !important;
+ padding: 24px;
+ padding-bottom: 0px;
+ }
+}
+.video-list-item--watched {
+ background: map.get($theme, "bg-overlay") !important;
+}
+.main-video-section {
+ background-color: map.get($theme, "bg-panel");
+ padding: none;
+ margin: 20px;
+ height: fit-content;
+
+ .ctrlf5notice {
+ border-bottom: 1px solid map.get($theme, "edge-grey")
+ }
+
+ .videoControls {
+ --accent-color: #b44c4d !important;
+
+ // Can't replace mixin so manually apply to the same elements.
+ > button.active, > button:active,
+ .settingsPopout .setting.active:not([disabled]) .icon,
+ .settingsPopout .setting:active:not([disabled]) .icon,
+ .settingsPopout .setting.active:not([disabled]) .submenuIcon,
+ .settingsPopout .setting:active:not([disabled]) .submenuIcon {
+ filter: saturate(0.25) brightness(4) !important;
+ }
+
+ .settingsPopout .setting:not(.header:not(.goBack)) {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+ }
+
+ .popout {
+ background-color: map.get($theme, "bg-overlay") !important;
+ backdrop-filter: blur(10px);
+ }
+
+ .timeline {
+ .seek {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+
+ & + .fakeThumb {
+ background-image: url(/static/images/eir-walk.gif);
+ background-color: transparent !important;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center bottom;
+ image-rendering: -moz-crisp-edges;
+ image-rendering: crisp-edges;
+ image-rendering: pixelated;
+ transform: translateY(-0.375em);
+ scale: 2;
+ border: none;
+
+ transition: transform 0.2s;
+
+ --outline-width: 1px;
+ --outline-color: #00000080;
+ filter:
+ drop-shadow(calc(var(--outline-width) * -1) 0px var(--outline-color))
+ drop-shadow(calc(var(--outline-width) * 1) 0px var(--outline-color))
+ drop-shadow(0px calc(var(--outline-width) * -1) var(--outline-color))
+ drop-shadow(0px calc(var(--outline-width) * 1) var(--outline-color));
+ }
+
+ &.paused + .fakeThumb {
+ background-image: url(/static/images/eir-stand.png);
+ }
+
+ &:active + .fakeThumb {
+ background-image: url(/static/images/eir-stand.png);
+ transform: translateY(-0.625em);
+ }
+ }
+ }
+ .volumePopout .volumeBar {
+ background-color: transparent !important;
+ }
+ }
+
+ #sb-skip-prompt {
+ background-color: map.get($theme, "bg-overlay") !important;
+ backdrop-filter: blur(10px);
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+ }
+
+ /* This doesn't seem to work... */
+ .video::-webkit-media-controls-volume-slider,
+ .video::-webkit-media-controls-play-button,
+ .video::-webkit-media-controls-mute-button,
+ .video::-webkit-media-controls-seek-back-button,
+ .video::-webkit-media-controls-seek-forward-button,
+ .video::-webkit-media-controls-fullscreen-button,
+ .video::-webkit-media-controls-rewind-button,
+ .video::-webkit-media-controls-toggle-closed-captions-button {
+ cursor: url("/static/images/cursors/default.cur"), default !important;
+ }
+}
+.description {
+ background-color: transparent !important;
+ --regular-background: transparent !important;
+ --highlight-background: #2f323f !important; // uggghhh why
+ font-size: inherit !important;
+
+ .timestamp--active::after {
+ border-color: var(--highlight-background);
+ }
+}
+.comment-base {
+ .comment-content, .comment-stats {
+ cursor: url("/static/images/cursors/text.cur"), text !important;
+ }
+}
+.footer__center {
+ background-color: map.get($theme, "bg-overlay") !important;
+}
+
+.dearrow-button-list, .dearrow-button-video {
+ cursor: url("/static/images/cursors/pointer.cur"), pointer !important;
+}
diff --git a/sass/themes/_light.scss b/sass/themes/_light.scss
old mode 100644
new mode 100755
index a927ce0..a433d77
--- a/sass/themes/_light.scss
+++ b/sass/themes/_light.scss
@@ -18,6 +18,7 @@ $theme: (
"fg-bright": #000,
"fg-main": #202020,
"fg-dim": #454545,
+ "fg-dimmer": #666,
"fg-warning": #ce8600,
"edge-grey": #909090,
@@ -28,7 +29,8 @@ $theme: (
"power-deep": #c62727,
"power-fg": #fff,
- "image-dropdown": url(/static/images/arrow-down-wide-light.svg)
+ "image-dropdown": url(/static/images/arrow-down-wide-light.svg),
+ "disabled-image-dropdown": url(/static/images/arrow-down-disabled-wide-light.svg)
);
// this section is for colour meanings
diff --git a/scripts/min-video-data.js b/scripts/min-video-data.js
deleted file mode 100644
index ad81806..0000000
--- a/scripts/min-video-data.js
+++ /dev/null
@@ -1,67 +0,0 @@
-const fs = require("fs")
-const zlib = require("zlib")
-const progress = require("cli-progress")
-const {promisify} = require("util")
-const {pipeline} = require("stream")
-const pipe = promisify(pipeline)
-
-const db = require("../utils/db")
-
-const cutoff = new Date("2023-01-01").getTime() / 1000
-
-function* toRows(stmt) {
- yield* stmt.raw().iterate(cutoff);
-}
-
-(async () => {
- const countToMin = db.prepare("select count(*) from Videos where published < ?").pluck().get(cutoff)
- const countTotal = db.prepare("select count(*) from Videos").pluck().get()
- console.log("want to trim", countToMin, "out of", countTotal, "videos");
-
- // ensure that we're not trimming the entire content
- if (Math.abs(countTotal - countToMin) <= 10) {
- throw new Error("failsafe: not trimming everything")
- }
-
- // export
- const backupName = "video-descriptions-backup.jsonl.gz"
- console.log(`exporting a backup to ${backupName}...`)
- const contents = db.prepare("select videoId, descriptionHtml from Videos where published < ? order by author asc, published asc")
-
- await new Promise((resolve, reject) => {
- const rowsProgress = new progress.SingleBar({fps: 3}, progress.Presets.shades_classic)
- const gzipProgress = new progress.SingleBar({fps: 3}, progress.Presets.shades_classic)
-
- // write rows into gzip
- const gzip = zlib.createGzip()
- const dest = fs.createWriteStream(backupName)
- gzip.pipe(dest)
- rowsProgress.start(countToMin, 0)
- for (const row of toRows(contents)) {
- gzip.write(JSON.stringify(row))
- rowsProgress.increment()
- }
- gzip.end()
- rowsProgress.stop()
-
- // track gzip progress
- console.log(" compressing backup...")
- const max = gzip._writableState.length
- gzipProgress.start(max, 0)
- const interval = setInterval(() => {
- gzipProgress.update(max - gzip._writableState.length)
- }, 100)
- dest.on("finish", () => {
- clearInterval(interval)
- gzipProgress.stop()
- resolve()
- })
- })
-
- // do it!
- console.log("removing descriptions...")
- db.prepare("update videos set descriptionHtml = null where published < ?").run(cutoff)
-
- console.log("reclaiming disk space from database...")
- db.prepare("vacuum").run()
-})()
diff --git a/server.js b/server.js
old mode 100644
new mode 100755
index 3dd550b..317f216
--- a/server.js
+++ b/server.js
@@ -2,8 +2,20 @@ const {Pinski} = require("pinski")
const {setInstance} = require("pinski/plugins")
const constants = require("./utils/constants")
const iconLoader = require("./utils/icon-loader").icons
+const path = require("path")
+const fs = require("fs")
;(async () => {
+ // Create directories
+ if (!fs.existsSync(constants.server_setup.video_dl_path))
+ fs.mkdirSync(constants.server_setup.video_dl_path, { recursive: true })
+ if (!fs.existsSync(constants.server_setup.ogg_dl_path))
+ fs.mkdirSync(constants.server_setup.ogg_dl_path, { recursive: true })
+ if (!fs.existsSync(constants.server_setup.json_cache_path))
+ fs.mkdirSync(constants.server_setup.json_cache_path, { recursive: true })
+ if (!fs.existsSync(constants.server_setup.ytdlp_cache_path))
+ fs.mkdirSync(constants.server_setup.ytdlp_cache_path, { recursive: true })
+
await require("./utils/upgradedb")()
const icons = await iconLoader
@@ -16,7 +28,9 @@ const iconLoader = require("./utils/icon-loader").icons
setInstance(server)
server.pugDefaultLocals.constants = constants
server.pugDefaultLocals.icons = icons
+ server.pugDefaultLocals.isMobile = req => req.headers["user-agent"].match(/phone|mobile/i)
+ server.muteLogsStartingWith("/watch")
server.muteLogsStartingWith("/vi/")
server.muteLogsStartingWith("/favicon")
server.muteLogsStartingWith("/static")
@@ -25,6 +39,7 @@ const iconLoader = require("./utils/icon-loader").icons
server.addRoute("/static/css/dark.css", "sass/dark.sass", "sass")
server.addRoute("/static/css/light.css", "sass/light.sass", "sass")
server.addRoute("/static/css/edgeless-light.css", "sass/edgeless-light.sass", "sass")
+ server.addRoute("/static/css/eir.css", "sass/eir.sass", "sass")
server.addPugDir("pug", ["pug/includes"])
server.addPugDir("pug/errors")
@@ -36,7 +51,21 @@ const iconLoader = require("./utils/icon-loader").icons
server.addAPIDir("api")
+ // Special paths. DO NOT INCLUDE video cache location, or pinski watcher will lag downloads
+ // - Eir
+ server.muteLogsStartingWith("/assets")
+ server.muteLogsStartingWith("/getVideo")
+ server.muteLogsStartingWith("/getCaption")
+ server.muteLogsStartingWith("/getOgg")
+ server.muteLogsStartingWith("/redownloadVideo")
+ server.muteLogsStartingWith("/cacheInfo")
+ server.muteLogsStartingWith("/getDeArrow")
+
server.startServer()
require("./background/feed-update")
+ // Keep cache under a certain size.
+ // - Eir
+ require("./background/cache-reaper")
+ require("./eirtubeMods/dl-queue")
})()
diff --git a/utils/constants.js b/utils/constants.js
old mode 100644
new mode 100755
index fe06882..9808ba5
--- a/utils/constants.js
+++ b/utils/constants.js
@@ -3,6 +3,7 @@ const mixin = require("mixin-deep")
// Configuration is in the following block.
let constants = {
+ extra_inv_instances: [],
// The default user settings. Should be self-explanatory.
user_settings: {
instance: {
@@ -21,6 +22,10 @@ let constants = {
type: "integer",
default: 0
},
+ autoHD: {
+ type: "boolean",
+ default: 1
+ },
quality: {
type: "integer",
default: 0
@@ -28,6 +33,58 @@ let constants = {
recommended_mode: {
type: "integer",
default: 0
+ },
+ dearrow: {
+ type: "integer",
+ default: 1
+ },
+ dearrow_thumbnail_instance: {
+ type: "string",
+ default: "https://dearrow-thumb.ajay.app"
+ },
+ dearrow_preload: {
+ type: "boolean",
+ default: 0
+ },
+ sponsorblock: {
+ type: "boolean",
+ default: 1
+ },
+ sponsorblock_keybind: {
+ type: "string",
+ default: "b"
+ },
+ sponsorblock_sponsor: {
+ type: "integer",
+ default: 0
+ },
+ sponsorblock_selfpromo: {
+ type: "integer",
+ default: 0
+ },
+ sponsorblock_interaction: {
+ type: "integer",
+ default: 0
+ },
+ sponsorblock_intro: {
+ type: "integer",
+ default: 2
+ },
+ sponsorblock_outro: {
+ type: "integer",
+ default: 2
+ },
+ sponsorblock_preview: {
+ type: "integer",
+ default: 2
+ },
+ sponsorblock_music_offtopic: {
+ type: "integer",
+ default: 0
+ },
+ sponsorblock_filler: {
+ type: "integer",
+ default: 2
}
},
@@ -38,7 +95,26 @@ let constants = {
// Whether users may filter videos by regular expressions. Unlike square patterns, regular expressions are _not_ bounded in complexity, so this can be used for denial of service attacks. Only enable if this is a private instance and you trust all the members.
allow_regexp_filters: false,
// Audio narration on the "can't think" page. `null` to disable narration, or a URL to enable with that audio file.
- cant_think_narration_url: null
+ // cant_think_narration_url: null,
+
+ // Download cache related vars.
+ // - Eir
+ video_cache_max_size: (1024*1024*1024) * 10,
+ cache_json_max_size: (1024*1024) * 128,
+ time_between_cache_save_to_disk: (1000*60) * 12,
+ time_between_cache_cleanup: (1000*60) * 45,
+ time_before_ogg_delete: (1000*60) * 5,
+ download_queue_threads: 3,
+ video_dl_path: "cache/assets",
+ ogg_dl_path: "cache/assets/temp",
+ json_cache_path: "cache/json",
+ ytdlp_cache_path: "cache/ytdlp",
+ video_hq_preload_max_time: 60 * 255,
+ ratelimiting: {
+ enabled: true,
+ max_bucket_size: 10,
+ bucket_refill_rate_seconds: 60
+ }
},
// *** ***
diff --git a/utils/converters.js b/utils/converters.js
old mode 100644
new mode 100755
index 84e4535..416f477
--- a/utils/converters.js
+++ b/utils/converters.js
@@ -28,6 +28,21 @@ function lengthSecondsToLengthText(seconds) {
return parts.map((x, i) => i === 0 ? x : (x+"").padStart(2, "0")).join(":")
}
+function bytesToSizeText(bytes) {
+ // Bytes
+ if (bytes < 1024)
+ return `${bytes}b`
+ // KB
+ else if (bytes >= 1024 && bytes < 1048576)
+ return `${Math.round(bytes / 102.4) / 10}KB`
+ // MB
+ else if (bytes >= 1048576 && bytes < 1073741824)
+ return `${Math.round(bytes / 104857.6) / 10}MB`
+ // GB
+ else
+ return `${Math.round(bytes / 107374182.4) / 10}GB`
+}
+
/**
* NewLeaf and Invidious don't return quite the same data. This
* function normalises them so that all the useful properties are
@@ -38,6 +53,7 @@ function lengthSecondsToLengthText(seconds) {
* - second__lengthText is added, may be [hh:]mm:ss or "LIVE"
* - publishedText may be changed to "Live now"
* - second__viewCountText is added
+ * - second__likeCountText is added
*/
function normaliseVideoInfo(video) {
if (!video.second__lengthText && video.lengthSeconds > 0) {
@@ -53,6 +69,9 @@ function normaliseVideoInfo(video) {
if (!video.second__viewCountText) {
video.second__viewCountText = viewCountToText(video.viewCount)
}
+ if (!video.second__likeCountText) {
+ video.second__likeCountText = likeCountToText(video.likeCount)
+ }
if (video.descriptionHtml) {
video.descriptionHtml = rewriteVideoDescription(video.descriptionHtml, video.videoId)
}
@@ -151,6 +170,11 @@ function viewCountToText(viewCount) {
return viewCount.toLocaleString("en-US") + " views"
}
+function likeCountToText(likeCount) {
+ if (typeof likeCount !== "number") return null
+ return "👍 " + preroundedCountToText(likeCount)
+}
+
/**
* YT does not give the exact count sometimes but a rounded value,
* e.g. for the subscriber count.
@@ -192,9 +216,11 @@ function applyVideoFilters(videos, filters) {
module.exports.timeToPastText = timeToPastText
module.exports.lengthSecondsToLengthText = lengthSecondsToLengthText
+module.exports.bytesToSizeText = bytesToSizeText
module.exports.normaliseVideoInfo = normaliseVideoInfo
module.exports.rewriteVideoDescription = rewriteVideoDescription
module.exports.tToMediaFragment = tToMediaFragment
module.exports.viewCountToText = viewCountToText
+module.exports.likeCountToText = likeCountToText
module.exports.subscriberCountToText = subscriberCountToText
module.exports.applyVideoFilters = applyVideoFilters
diff --git a/utils/db.js b/utils/db.js
old mode 100644
new mode 100755
diff --git a/utils/getuser.js b/utils/getuser.js
old mode 100644
new mode 100755
index 4fc1d24..0facdb9
--- a/utils/getuser.js
+++ b/utils/getuser.js
@@ -34,7 +34,7 @@ class User {
this.token = token
}
- /** @return {{instance?: string, save_history?: boolean, local?: boolean, quality?: number}} */
+ /** @return {{instance?: string, theme?: integer, save_history?: boolean, local?: boolean, autoHD?: integer, quality?: number, recommended_mode?: integer, dearrow?: integer, dearrow_thumbnail_instance?: string}} */
getSettings() {
if (this.token) {
return db.prepare("SELECT * FROM Settings WHERE token = ?").get(this.token) || {}
@@ -43,11 +43,11 @@ class User {
}
}
- /** @return {{instance?: string, save_history?: boolean, local?: boolean, quality?: number}} */
+ /** @return {{instance?: string, theme?: integer, save_history?: boolean, local?: boolean, autoHD?: integer, quality?: number, recommended_mode?: integer, dearrow?: integer, dearrow_thumbnail_instance?: string}} */
getSettingsOrDefaults() {
const settings = this.getSettings()
for (const key of Object.keys(constants.user_settings)) {
- if (settings[key] == null) settings[key] = constants.user_settings[key].default
+ if (settings[key] == undefined) settings[key] = constants.user_settings[key].default
}
return settings
}
diff --git a/utils/icon-loader.js b/utils/icon-loader.js
old mode 100644
new mode 100755
index 8e712ad..fda0c07
--- a/utils/icon-loader.js
+++ b/utils/icon-loader.js
@@ -1,6 +1,6 @@
const fs = require("fs").promises
-const names = ["subscriptions", "settings"]
+const names = ["subscriptions", "settings", "light-on", "light-off", "search"]
const icons = names.map(name => fs.readFile(`html/static/images/${name}.svg`, "utf8"))
module.exports.icons = Promise.all(icons).then(resolvedIcons => {
diff --git a/utils/matcher.js b/utils/matcher.js
old mode 100644
new mode 100755
diff --git a/utils/parser.js b/utils/parser.js
old mode 100644
new mode 100755
diff --git a/utils/request.js b/utils/request.js
old mode 100644
new mode 100755
index 3b94c53..265b034
--- a/utils/request.js
+++ b/utils/request.js
@@ -2,12 +2,23 @@
// @ts-ignore
const fetch = require("node-fetch")
-function request(url, options = {}) {
+async function request(url, options = {}) {
if (!options.headers) options.headers = {}
options.headers = {
"user-agent": "CloudTubeBackend/1.0"
}
- return fetch(url, options)
+ let result
+ try {
+ result = await fetch(url, options)
+ } catch (error) {
+ return { ok: false, result: error }
+ }
+
+ // not json?
+ if (result.headers.get("content-type").indexOf("application/json") == -1)
+ return { ok: false, result: result }
+
+ return { ok: true, result: await result.json() }
}
module.exports.request = request
diff --git a/utils/upgradedb.js b/utils/upgradedb.js
old mode 100644
new mode 100755
index 545ed61..5713a3e
--- a/utils/upgradedb.js
+++ b/utils/upgradedb.js
@@ -95,6 +95,45 @@ const deltas = [
db.prepare("ALTER TABLE NEW_Subscriptions RENAME TO Subscriptions")
.run()
})()
+ },
+ // 13: Settings +autoHD +dearrow +dearrow_thumbnail_instance
+ function() {
+ db.prepare("ALTER TABLE Settings ADD COLUMN autoHD INTEGER DEFAULT 1")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN dearrow INTEGER DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN dearrow_thumbnail_instance TEXT DEFAULT 0")
+ .run()
+ db.prepare("UPDATE Settings SET dearrow_thumbnail_instance = REPLACE(REPLACE(dearrow_thumbnail_instance, '/', ''), ':', '://') WHERE dearrow_thumbnail_instance LIKE '%/'")
+ .run()
+ },
+ // 14: sponsorblock settings
+ function() {
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock INTEGER DEFAULT 1")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_keybind TEXT DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_sponsor INTEGER DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_selfpromo INTEGER DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_interaction INTEGER DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_intro INTEGER DEFAULT 2")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_outro INTEGER DEFAULT 2")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_preview INTEGER DEFAULT 2")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_music_offtopic INTEGER DEFAULT 0")
+ .run()
+ db.prepare("ALTER TABLE Settings ADD COLUMN sponsorblock_filler INTEGER DEFAULT 2")
+ .run()
+ },
+ // 14: Settings +dearrow_preload
+ function() {
+ db.prepare("ALTER TABLE Settings ADD COLUMN dearrow_preload INTEGER DEFAULT 0")
+ .run()
}
]
diff --git a/utils/validate.js b/utils/validate.js
old mode 100644
new mode 100755
diff --git a/utils/words.txt b/utils/words.txt
deleted file mode 100644
index f78ccaf..0000000
--- a/utils/words.txt
+++ /dev/null
@@ -1,2048 +0,0 @@
-abandon
-ability
-able
-about
-above
-absent
-absorb
-abstract
-absurd
-abuse
-access
-accident
-account
-accuse
-achieve
-acid
-acoustic
-acquire
-across
-act
-action
-actor
-actress
-actual
-adapt
-add
-addict
-address
-adjust
-admit
-adult
-advance
-advice
-aerobic
-affair
-afford
-afraid
-again
-age
-agent
-agree
-ahead
-aim
-air
-airport
-aisle
-alarm
-album
-alcohol
-alert
-alien
-all
-alley
-allow
-almost
-alone
-alpha
-already
-also
-alter
-always
-amateur
-amazing
-among
-amount
-amused
-analyst
-anchor
-ancient
-anger
-angle
-angry
-animal
-ankle
-announce
-annual
-another
-answer
-antenna
-antique
-anxiety
-any
-apart
-apology
-appear
-apple
-approve
-april
-arch
-arctic
-area
-arena
-argue
-arm
-armed
-armor
-army
-around
-arrange
-arrest
-arrive
-arrow
-art
-artefact
-artist
-artwork
-ask
-aspect
-assault
-asset
-assist
-assume
-asthma
-athlete
-atom
-attack
-attend
-attitude
-attract
-auction
-audit
-august
-aunt
-author
-auto
-autumn
-average
-avocado
-avoid
-awake
-aware
-away
-awesome
-awful
-awkward
-axis
-baby
-bachelor
-bacon
-badge
-bag
-balance
-balcony
-ball
-bamboo
-banana
-banner
-bar
-barely
-bargain
-barrel
-base
-basic
-basket
-battle
-beach
-bean
-beauty
-because
-become
-beef
-before
-begin
-behave
-behind
-believe
-below
-belt
-bench
-benefit
-best
-betray
-better
-between
-beyond
-bicycle
-bid
-bike
-bind
-biology
-bird
-birth
-bitter
-black
-blade
-blame
-blanket
-blast
-bleak
-bless
-blind
-blood
-blossom
-blouse
-blue
-blur
-blush
-board
-boat
-body
-boil
-bomb
-bone
-bonus
-book
-boost
-border
-boring
-borrow
-boss
-bottom
-bounce
-box
-boy
-bracket
-brain
-brand
-brass
-brave
-bread
-breeze
-brick
-bridge
-brief
-bright
-bring
-brisk
-broccoli
-broken
-bronze
-broom
-brother
-brown
-brush
-bubble
-buddy
-budget
-buffalo
-build
-bulb
-bulk
-bullet
-bundle
-bunker
-burden
-burger
-burst
-bus
-business
-busy
-butter
-buyer
-buzz
-cabbage
-cabin
-cable
-cactus
-cage
-cake
-call
-calm
-camera
-camp
-can
-canal
-cancel
-candy
-cannon
-canoe
-canvas
-canyon
-capable
-capital
-captain
-car
-carbon
-card
-cargo
-carpet
-carry
-cart
-case
-cash
-casino
-castle
-casual
-cat
-catalog
-catch
-category
-cattle
-caught
-cause
-caution
-cave
-ceiling
-celery
-cement
-census
-century
-cereal
-certain
-chair
-chalk
-champion
-change
-chaos
-chapter
-charge
-chase
-chat
-cheap
-check
-cheese
-chef
-cherry
-chest
-chicken
-chief
-child
-chimney
-choice
-choose
-chronic
-chuckle
-chunk
-churn
-cigar
-cinnamon
-circle
-citizen
-city
-civil
-claim
-clap
-clarify
-claw
-clay
-clean
-clerk
-clever
-click
-client
-cliff
-climb
-clinic
-clip
-clock
-clog
-close
-cloth
-cloud
-clown
-club
-clump
-cluster
-clutch
-coach
-coast
-coconut
-code
-coffee
-coil
-coin
-collect
-color
-column
-combine
-come
-comfort
-comic
-common
-company
-concert
-conduct
-confirm
-congress
-connect
-consider
-control
-convince
-cook
-cool
-copper
-copy
-coral
-core
-corn
-correct
-cost
-cotton
-couch
-country
-couple
-course
-cousin
-cover
-coyote
-crack
-cradle
-craft
-cram
-crane
-crash
-crater
-crawl
-crazy
-cream
-credit
-creek
-crew
-cricket
-crime
-crisp
-critic
-crop
-cross
-crouch
-crowd
-crucial
-cruel
-cruise
-crumble
-crunch
-crush
-cry
-crystal
-cube
-culture
-cup
-cupboard
-curious
-current
-curtain
-curve
-cushion
-custom
-cute
-cycle
-dad
-damage
-damp
-dance
-danger
-daring
-dash
-daughter
-dawn
-day
-deal
-debate
-debris
-decade
-december
-decide
-decline
-decorate
-decrease
-deer
-defense
-define
-defy
-degree
-delay
-deliver
-demand
-demise
-denial
-dentist
-deny
-depart
-depend
-deposit
-depth
-deputy
-derive
-describe
-desert
-design
-desk
-despair
-destroy
-detail
-detect
-develop
-device
-devote
-diagram
-dial
-diamond
-diary
-dice
-diesel
-diet
-differ
-digital
-dignity
-dilemma
-dinner
-dinosaur
-direct
-dirt
-disagree
-discover
-disease
-dish
-dismiss
-disorder
-display
-distance
-divert
-divide
-divorce
-dizzy
-doctor
-document
-dog
-doll
-dolphin
-domain
-donate
-donkey
-donor
-door
-dose
-double
-dove
-draft
-dragon
-drama
-drastic
-draw
-dream
-dress
-drift
-drill
-drink
-drip
-drive
-drop
-drum
-dry
-duck
-dumb
-dune
-during
-dust
-dutch
-duty
-dwarf
-dynamic
-eager
-eagle
-early
-earn
-earth
-easily
-east
-easy
-echo
-ecology
-economy
-edge
-edit
-educate
-effort
-egg
-eight
-either
-elbow
-elder
-electric
-elegant
-element
-elephant
-elevator
-elite
-else
-embark
-embody
-embrace
-emerge
-emotion
-employ
-empower
-empty
-enable
-enact
-end
-endless
-endorse
-enemy
-energy
-enforce
-engage
-engine
-enhance
-enjoy
-enlist
-enough
-enrich
-enroll
-ensure
-enter
-entire
-entry
-envelope
-episode
-equal
-equip
-era
-erase
-erode
-erosion
-error
-erupt
-escape
-essay
-essence
-estate
-eternal
-ethics
-evidence
-evil
-evoke
-evolve
-exact
-example
-excess
-exchange
-excite
-exclude
-excuse
-execute
-exercise
-exhaust
-exhibit
-exile
-exist
-exit
-exotic
-expand
-expect
-expire
-explain
-expose
-express
-extend
-extra
-eye
-eyebrow
-fabric
-face
-faculty
-fade
-faint
-faith
-fall
-false
-fame
-family
-famous
-fan
-fancy
-fantasy
-farm
-fashion
-fat
-fatal
-father
-fatigue
-fault
-favorite
-feature
-february
-federal
-fee
-feed
-feel
-female
-fence
-festival
-fetch
-fever
-few
-fiber
-fiction
-field
-figure
-file
-film
-filter
-final
-find
-fine
-finger
-finish
-fire
-firm
-first
-fiscal
-fish
-fit
-fitness
-fix
-flag
-flame
-flash
-flat
-flavor
-flee
-flight
-flip
-float
-flock
-floor
-flower
-fluid
-flush
-fly
-foam
-focus
-fog
-foil
-fold
-follow
-food
-foot
-force
-forest
-forget
-fork
-fortune
-forum
-forward
-fossil
-foster
-found
-fox
-fragile
-frame
-frequent
-fresh
-friend
-fringe
-frog
-front
-frost
-frown
-frozen
-fruit
-fuel
-fun
-funny
-furnace
-fury
-future
-gadget
-gain
-galaxy
-gallery
-game
-gap
-garage
-garbage
-garden
-garlic
-garment
-gas
-gasp
-gate
-gather
-gauge
-gaze
-general
-genius
-genre
-gentle
-genuine
-gesture
-ghost
-giant
-gift
-giggle
-ginger
-giraffe
-girl
-give
-glad
-glance
-glare
-glass
-glide
-glimpse
-globe
-gloom
-glory
-glove
-glow
-glue
-goat
-goddess
-gold
-good
-goose
-gorilla
-gospel
-gossip
-govern
-gown
-grab
-grace
-grain
-grant
-grape
-grass
-gravity
-great
-green
-grid
-grief
-grit
-grocery
-group
-grow
-grunt
-guard
-guess
-guide
-guilt
-guitar
-gun
-gym
-habit
-hair
-half
-hammer
-hamster
-hand
-happy
-harbor
-hard
-harsh
-harvest
-hat
-have
-hawk
-hazard
-head
-health
-heart
-heavy
-hedgehog
-height
-hello
-helmet
-help
-hen
-hero
-hidden
-high
-hill
-hint
-hip
-hire
-history
-hobby
-hockey
-hold
-hole
-holiday
-hollow
-home
-honey
-hood
-hope
-horn
-horror
-horse
-hospital
-host
-hotel
-hour
-hover
-hub
-huge
-human
-humble
-humor
-hundred
-hungry
-hunt
-hurdle
-hurry
-hurt
-husband
-hybrid
-ice
-icon
-idea
-identify
-idle
-ignore
-ill
-illegal
-illness
-image
-imitate
-immense
-immune
-impact
-impose
-improve
-impulse
-inch
-include
-income
-increase
-index
-indicate
-indoor
-industry
-infant
-inflict
-inform
-inhale
-inherit
-initial
-inject
-injury
-inmate
-inner
-innocent
-input
-inquiry
-insane
-insect
-inside
-inspire
-install
-intact
-interest
-into
-invest
-invite
-involve
-iron
-island
-isolate
-issue
-item
-ivory
-jacket
-jaguar
-jar
-jazz
-jealous
-jeans
-jelly
-jewel
-job
-join
-joke
-journey
-joy
-judge
-juice
-jump
-jungle
-junior
-junk
-just
-kangaroo
-keen
-keep
-ketchup
-key
-kick
-kid
-kidney
-kind
-kingdom
-kiss
-kit
-kitchen
-kite
-kitten
-kiwi
-knee
-knife
-knock
-know
-lab
-label
-labor
-ladder
-lady
-lake
-lamp
-language
-laptop
-large
-later
-latin
-laugh
-laundry
-lava
-law
-lawn
-lawsuit
-layer
-lazy
-leader
-leaf
-learn
-leave
-lecture
-left
-leg
-legal
-legend
-leisure
-lemon
-lend
-length
-lens
-leopard
-lesson
-letter
-level
-liar
-liberty
-library
-license
-life
-lift
-light
-like
-limb
-limit
-link
-lion
-liquid
-list
-little
-live
-lizard
-load
-loan
-lobster
-local
-lock
-logic
-lonely
-long
-loop
-lottery
-loud
-lounge
-love
-loyal
-lucky
-luggage
-lumber
-lunar
-lunch
-luxury
-lyrics
-machine
-mad
-magic
-magnet
-maid
-mail
-main
-major
-make
-mammal
-man
-manage
-mandate
-mango
-mansion
-manual
-maple
-marble
-march
-margin
-marine
-market
-marriage
-mask
-mass
-master
-match
-material
-math
-matrix
-matter
-maximum
-maze
-meadow
-mean
-measure
-meat
-mechanic
-medal
-media
-melody
-melt
-member
-memory
-mention
-menu
-mercy
-merge
-merit
-merry
-mesh
-message
-metal
-method
-middle
-midnight
-milk
-million
-mimic
-mind
-minimum
-minor
-minute
-miracle
-mirror
-misery
-miss
-mistake
-mix
-mixed
-mixture
-mobile
-model
-modify
-mom
-moment
-monitor
-monkey
-monster
-month
-moon
-moral
-more
-morning
-mosquito
-mother
-motion
-motor
-mountain
-mouse
-move
-movie
-much
-muffin
-mule
-multiply
-muscle
-museum
-mushroom
-music
-must
-mutual
-myself
-mystery
-myth
-naive
-name
-napkin
-narrow
-nasty
-nation
-nature
-near
-neck
-need
-negative
-neglect
-neither
-nephew
-nerve
-nest
-net
-network
-neutral
-never
-news
-next
-nice
-night
-noble
-noise
-nominee
-noodle
-normal
-north
-nose
-notable
-note
-nothing
-notice
-novel
-now
-nuclear
-number
-nurse
-nut
-oak
-obey
-object
-oblige
-obscure
-observe
-obtain
-obvious
-occur
-ocean
-october
-odor
-off
-offer
-office
-often
-oil
-okay
-old
-olive
-olympic
-omit
-once
-one
-onion
-online
-only
-open
-opera
-opinion
-oppose
-option
-orange
-orbit
-orchard
-order
-ordinary
-organ
-orient
-original
-orphan
-ostrich
-other
-outdoor
-outer
-output
-outside
-oval
-oven
-over
-own
-owner
-oxygen
-oyster
-ozone
-pact
-paddle
-page
-pair
-palace
-palm
-panda
-panel
-panic
-panther
-paper
-parade
-parent
-park
-parrot
-party
-pass
-patch
-path
-patient
-patrol
-pattern
-pause
-pave
-payment
-peace
-peanut
-pear
-peasant
-pelican
-pen
-penalty
-pencil
-people
-pepper
-perfect
-permit
-person
-pet
-phone
-photo
-phrase
-physical
-piano
-picnic
-picture
-piece
-pig
-pigeon
-pill
-pilot
-pink
-pioneer
-pipe
-pistol
-pitch
-pizza
-place
-planet
-plastic
-plate
-play
-please
-pledge
-pluck
-plug
-plunge
-poem
-poet
-point
-polar
-pole
-police
-pond
-pony
-pool
-popular
-portion
-position
-possible
-post
-potato
-pottery
-poverty
-powder
-power
-practice
-praise
-predict
-prefer
-prepare
-present
-pretty
-prevent
-price
-pride
-primary
-print
-priority
-prison
-private
-prize
-problem
-process
-produce
-profit
-program
-project
-promote
-proof
-property
-prosper
-protect
-proud
-provide
-public
-pudding
-pull
-pulp
-pulse
-pumpkin
-punch
-pupil
-puppy
-purchase
-purity
-purpose
-purse
-push
-put
-puzzle
-pyramid
-quality
-quantum
-quarter
-question
-quick
-quit
-quiz
-quote
-rabbit
-raccoon
-race
-rack
-radar
-radio
-rail
-rain
-raise
-rally
-ramp
-ranch
-random
-range
-rapid
-rare
-rate
-rather
-raven
-raw
-razor
-ready
-real
-reason
-rebel
-rebuild
-recall
-receive
-recipe
-record
-recycle
-reduce
-reflect
-reform
-refuse
-region
-regret
-regular
-reject
-relax
-release
-relief
-rely
-remain
-remember
-remind
-remove
-render
-renew
-rent
-reopen
-repair
-repeat
-replace
-report
-require
-rescue
-resemble
-resist
-resource
-response
-result
-retire
-retreat
-return
-reunion
-reveal
-review
-reward
-rhythm
-rib
-ribbon
-rice
-rich
-ride
-ridge
-rifle
-right
-rigid
-ring
-riot
-ripple
-risk
-ritual
-rival
-river
-road
-roast
-robot
-robust
-rocket
-romance
-roof
-rookie
-room
-rose
-rotate
-rough
-round
-route
-royal
-rubber
-rude
-rug
-rule
-run
-runway
-rural
-sad
-saddle
-sadness
-safe
-sail
-salad
-salmon
-salon
-salt
-salute
-same
-sample
-sand
-satisfy
-satoshi
-sauce
-sausage
-save
-say
-scale
-scan
-scare
-scatter
-scene
-scheme
-school
-science
-scissors
-scorpion
-scout
-scrap
-screen
-script
-scrub
-sea
-search
-season
-seat
-second
-secret
-section
-security
-seed
-seek
-segment
-select
-sell
-seminar
-senior
-sense
-sentence
-series
-service
-session
-settle
-setup
-seven
-shadow
-shaft
-shallow
-share
-shed
-shell
-sheriff
-shield
-shift
-shine
-ship
-shiver
-shock
-shoe
-shoot
-shop
-short
-shoulder
-shove
-shrimp
-shrug
-shuffle
-shy
-sibling
-sick
-side
-siege
-sight
-sign
-silent
-silk
-silly
-silver
-similar
-simple
-since
-sing
-siren
-sister
-situate
-six
-size
-skate
-sketch
-ski
-skill
-skin
-skirt
-skull
-slab
-slam
-sleep
-slender
-slice
-slide
-slight
-slim
-slogan
-slot
-slow
-slush
-small
-smart
-smile
-smoke
-smooth
-snack
-snake
-snap
-sniff
-snow
-soap
-soccer
-social
-sock
-soda
-soft
-solar
-soldier
-solid
-solution
-solve
-someone
-song
-soon
-sorry
-sort
-soul
-sound
-soup
-source
-south
-space
-spare
-spatial
-spawn
-speak
-special
-speed
-spell
-spend
-sphere
-spice
-spider
-spike
-spin
-spirit
-split
-spoil
-sponsor
-spoon
-sport
-spot
-spray
-spread
-spring
-spy
-square
-squeeze
-squirrel
-stable
-stadium
-staff
-stage
-stairs
-stamp
-stand
-start
-state
-stay
-steak
-steel
-stem
-step
-stereo
-stick
-still
-sting
-stock
-stomach
-stone
-stool
-story
-stove
-strategy
-street
-strike
-strong
-struggle
-student
-stuff
-stumble
-style
-subject
-submit
-subway
-success
-such
-sudden
-suffer
-sugar
-suggest
-suit
-summer
-sun
-sunny
-sunset
-super
-supply
-supreme
-sure
-surface
-surge
-surprise
-surround
-survey
-suspect
-sustain
-swallow
-swamp
-swap
-swarm
-swear
-sweet
-swift
-swim
-swing
-switch
-sword
-symbol
-symptom
-syrup
-system
-table
-tackle
-tag
-tail
-talent
-talk
-tank
-tape
-target
-task
-taste
-tattoo
-taxi
-teach
-team
-tell
-ten
-tenant
-tennis
-tent
-term
-test
-text
-thank
-that
-theme
-then
-theory
-there
-they
-thing
-this
-thought
-three
-thrive
-throw
-thumb
-thunder
-ticket
-tide
-tiger
-tilt
-timber
-time
-tiny
-tip
-tired
-tissue
-title
-toast
-tobacco
-today
-toddler
-toe
-together
-toilet
-token
-tomato
-tomorrow
-tone
-tongue
-tonight
-tool
-tooth
-top
-topic
-topple
-torch
-tornado
-tortoise
-toss
-total
-tourist
-toward
-tower
-town
-toy
-track
-trade
-traffic
-tragic
-train
-transfer
-trap
-trash
-travel
-tray
-treat
-tree
-trend
-trial
-tribe
-trick
-trigger
-trim
-trip
-trophy
-trouble
-truck
-true
-truly
-trumpet
-trust
-truth
-try
-tube
-tuition
-tumble
-tuna
-tunnel
-turkey
-turn
-turtle
-twelve
-twenty
-twice
-twin
-twist
-two
-type
-typical
-ugly
-umbrella
-unable
-unaware
-uncle
-uncover
-under
-undo
-unfair
-unfold
-unhappy
-uniform
-unique
-unit
-universe
-unknown
-unlock
-until
-unusual
-unveil
-update
-upgrade
-uphold
-upon
-upper
-upset
-urban
-urge
-usage
-use
-used
-useful
-useless
-usual
-utility
-vacant
-vacuum
-vague
-valid
-valley
-valve
-van
-vanish
-vapor
-various
-vast
-vault
-vehicle
-velvet
-vendor
-venture
-venue
-verb
-verify
-version
-very
-vessel
-veteran
-viable
-vibrant
-vicious
-victory
-video
-view
-village
-vintage
-violin
-virtual
-virus
-visa
-visit
-visual
-vital
-vivid
-vocal
-voice
-void
-volcano
-volume
-vote
-voyage
-wage
-wagon
-wait
-walk
-wall
-walnut
-want
-warfare
-warm
-warrior
-wash
-wasp
-waste
-water
-wave
-way
-wealth
-weapon
-wear
-weasel
-weather
-web
-wedding
-weekend
-weird
-welcome
-west
-wet
-whale
-what
-wheat
-wheel
-when
-where
-whip
-whisper
-wide
-width
-wife
-wild
-will
-win
-window
-wine
-wing
-wink
-winner
-winter
-wire
-wisdom
-wise
-wish
-witness
-wolf
-woman
-wonder
-wood
-wool
-word
-work
-world
-worry
-worth
-wrap
-wreck
-wrestle
-wrist
-write
-wrong
-yard
-year
-yellow
-you
-young
-youth
-zebra
-zero
-zone
-zoo
\ No newline at end of file
diff --git a/utils/youtube.js b/utils/youtube.js
old mode 100644
new mode 100755
index 8742f9e..54d082c
--- a/utils/youtube.js
+++ b/utils/youtube.js
@@ -30,7 +30,15 @@ async function fetchChannel(path, ucid, instance) {
// so we need to fetch new data from the web either way...
/** @type {any} */
- const channel = await request(`${instance}/api/v1/channels/${ucid}?second__path=${path}`).then(res => res.json())
+ let channel
+ try {
+ channel = await request(`${instance}/api/v1/channels/${ucid}?second__path=${path}`)
+ if (!channel.ok)
+ throw channel.result
+ channel = channel.result
+ } catch (error) {
+ channel = { error: error }
+ }
// handle the case where the just-fetched channel has an error
if (channel.error) {