EirTube initial commit

This commit is contained in:
Eir Sunny 2024-12-18 17:30:34 -06:00
parent be33a66e8c
commit 9affde5097
215 changed files with 6440 additions and 2663 deletions

View File

@ -1,16 +0,0 @@
# Editor crud files
*~
\#*#
.#*
.vscode
.idea
.git
# Auto-generated files
node_modules
# User configuration
/db
/config/config.js
Dockerfile

7
.gitignore vendored Normal file → Executable file
View File

@ -7,11 +7,10 @@
# Auto-generated files
node_modules
logs
cache
.pnpm-lock.yaml
# User configuration
/db
/config/config.js
# Narration
/html/static/media/cant_think_suricrasia_online.mp3
/html/transparency

View File

@ -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

0
LICENSE Normal file → Executable file
View File

21
README.md Normal file → Executable file
View File

@ -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

24
add-takedown.js Executable file
View File

@ -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;
}
})()

67
api/channels.js Normal file → Executable file
View File

@ -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("<head>") != -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})
}
}
]

38
api/dearrow.js Executable file
View File

@ -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,
}
}
}
]

305
api/downloadApi.js Executable file
View File

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

0
api/filters.js Normal file → Executable file
View File

2
api/formapi.js Normal file → Executable file
View File

@ -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",

12
api/pages.js Normal file → Executable file
View File

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

17
api/playlists.js Executable file
View File

@ -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!",
}
}
}
]

2
api/proxy.js Normal file → Executable file
View File

@ -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"
}
}

0
api/redirects.js Normal file → Executable file
View File

41
api/search.js Normal file → Executable file
View File

@ -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("<head>") != -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})
}
}
]

7
api/settings.js Normal file → Executable file
View File

@ -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})
}
},
{

28
api/subscriptions.js Normal file → Executable file
View File

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

3
api/takedown.js Normal file → Executable file
View File

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

0
api/thumbnails.js Normal file → Executable file
View File

324
api/video.js Normal file → Executable file
View File

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

104
background/cache-reaper.js Executable file
View File

@ -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
}

0
background/feed-update.js Normal file → Executable file
View File

10
background/instances.js Normal file → Executable file
View File

@ -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")

View File

@ -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: "..."
// }
}

65
eirtubeMods/cache-manager.js Executable file
View File

@ -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)

59
eirtubeMods/comments.js Executable file
View File

@ -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
}

89
eirtubeMods/dl-queue.js Executable file
View File

@ -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")

564
eirtubeMods/downloader.js Executable file
View File

@ -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 && 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 && a.resolution.includes("default");
const b_is_default = b.resolution && b.resolution.includes("default");
if (a_is_default || b_is_default)
return ((b_is_default ? 1000000 : 0) + b.bitrate) - ((a_is_default ? 1000000 : 0) + a.bitrate);
}
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;
}
}

37
eirtubeMods/quality-sort.js Executable file
View File

@ -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
}

136
eirtubeMods/ratelimiting.js Executable file
View File

@ -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
}
}

161
eirtubeMods/sb.js Executable file
View File

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

View File

@ -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"
}
}
}

0
html/browserconfig.xml Normal file → Executable file
View File

0
html/robots.txt Normal file → Executable file
View File

8
html/site.webmanifest Normal file → Executable file
View File

@ -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"
}

43
html/static/css/cursors.css Executable file
View File

@ -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;
}

View File

@ -0,0 +1,6 @@
.videoControls {
display: none !important
}
#quality-select {
display: none
}

0
html/static/flash/player.swf Normal file → Executable file
View File

0
html/static/flash/skin.swf Normal file → Executable file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
html/static/fonts/Work-Sans.woff2 Executable file

Binary file not shown.

BIN
html/static/images/android-chrome-192x192.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 859 B

BIN
html/static/images/android-chrome-512x512.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
html/static/images/apple-touch-icon.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 425 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="8" viewBox="0 0 5.821 2.117"><path d="M 1.269 0 l 0.767 0.793 h 0.161 l 0.767 -0.793 h 0.211 v 0.265 l -0.767 0.793 v 0.161 l 0.767 0.793 v 0.265 h -0.211 l -0.767 -0.793 h -0.161 l -0.767 0.793 h -0.211 v -0.265 l 0.767 -0.793 v -0.161 l -0.767 -0.794 v -0.265 z" fill="#d7d7d7" paint-order="markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="8" viewBox="0 0 5.821 2.117"><path d="M 1.269 0 l 0.767 0.793 h 0.161 l 0.767 -0.793 h 0.211 v 0.265 l -0.767 0.793 v 0.161 l 0.767 0.793 v 0.265 h -0.211 l -0.767 -0.793 h -0.161 l -0.767 0.793 h -0.211 v -0.265 l 0.767 -0.793 v -0.161 l -0.767 -0.794 v -0.265 z" fill="#202020" paint-order="markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 381 B

0
html/static/images/arrow-down-wide-dark.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 225 B

0
html/static/images/arrow-down-wide-light.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

BIN
html/static/images/bow.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 B

10
html/static/images/check.svg Executable file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 17.837 17.837" xml:space="preserve">
<g>
<path style="fill:#d7d7d7;" d="M16.145,2.571c-0.272-0.273-0.718-0.273-0.99,0L6.92,10.804l-4.241-4.27
c-0.272-0.274-0.715-0.274-0.989,0L0.204,8.019c-0.272,0.271-0.272,0.717,0,0.99l6.217,6.258c0.272,0.271,0.715,0.271,0.99,0
L17.63,5.047c0.276-0.273,0.276-0.72,0-0.994L16.145,2.571z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="800px" height="800px" viewBox="0 0 36 36" aria-hidden="true" role="img" class="iconify iconify--twemoji" preserveAspectRatio="xMidYMid meet" version="1.1" id="svg10" inkscape:export-xdpi="96" inkscape:export-ydpi="96" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><title/>
<defs id="defs14"/>
<sodipodi:namedview id="namedview12" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" showgrid="false" inkscape:zoom="0.65479573" inkscape:cx="493.2836" inkscape:cy="514.66432" inkscape:window-width="1920" inkscape:window-height="983" inkscape:window-x="435" inkscape:window-y="768" inkscape:window-maximized="1" inkscape:current-layer="svg10"/>
<path fill="#1213BD" d="M36 18.302c0 4.981-2.46 9.198-5.655 12.462s-7.323 5.152-12.199 5.152s-9.764-1.112-12.959-4.376S0 23.283 0 18.302s2.574-9.38 5.769-12.644S13.271 0 18.146 0s9.394 2.178 12.589 5.442C33.931 8.706 36 13.322 36 18.302z" id="path2"/>
<path fill="#ffffff" d="m 30.394282,18.410186 c 0,3.468849 -1.143025,6.865475 -3.416513,9.137917 -2.273489,2.272442 -5.670115,2.92874 -9.137918,2.92874 -3.467803,0 -6.373515,-1.147212 -8.6470033,-3.419654 -2.2734888,-2.272442 -3.5871299,-5.178154 -3.5871299,-8.647003 0,-3.46885 0.9420533,-6.746149 3.2144954,-9.0196379 2.2724418,-2.2734888 5.5507878,-3.9513905 9.0196378,-3.9513905 3.46885,0 6.492841,1.9322561 8.76633,4.204698 2.273489,2.2724424 3.788101,5.2974804 3.788101,8.7663304 z" id="path4" style="fill:#88c9f9;fill-opacity:1;stroke-width:1.04673"/>
<path fill="#292f33" d="m 23.95823,17.818306 c 0,3.153748 -2.644888,5.808102 -5.798635,5.808102 -3.153748,0 -5.599825,-2.654354 -5.599825,-5.808102 0,-3.153747 2.446077,-5.721714 5.599825,-5.721714 3.153747,0 5.798635,2.567967 5.798635,5.721714 z" id="path8" style="stroke-width:1.18339;fill:#0a62a5;fill-opacity:1"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
html/static/images/eir-stand.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

BIN
html/static/images/eir-walk.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

BIN
html/static/images/favicon-16x16.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 B

After

Width:  |  Height:  |  Size: 569 B

BIN
html/static/images/favicon-32x32.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

0
html/static/images/favicon.ico Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

0
html/static/images/instance-blocked.svg Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="currentColor" height="25" width="25" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 511.999 511.999" xml:space="preserve">
<g>
<g>
<path d="M360.819,34.266c-60.848-45.895-150.896-46.233-212.451,2.289c-82.192,64.789-95.816,198.164-4.023,273.583v88.229
c0,23.72,18.219,46.42,48.59,46.42c0,43.188,31.195,67.212,63.025,67.212c31.692,0,63.024-23.83,63.024-67.212
c29.847,0,48.59-22.204,48.59-46.42v-88.228C460.499,233.814,445.52,98.154,360.819,34.266z M255.961,476.284
c-13.6,0-27.31-9.357-27.31-31.497h54.619C283.269,465.81,270.635,476.284,255.961,476.284z M331.861,398.367
c-0.001,5.902-4.802,10.705-10.705,10.705c-14.878,0-116.535,0-130.39,0c-5.903,0-10.705-4.801-10.705-10.705v-20.773h151.8
V398.367z M339.06,287.111c-4.53,3.37-7.199,8.684-7.199,14.329v40.44h-58.042V258.51l43.382-75.141
c4.931-8.541,2.005-19.462-6.536-24.393c-8.542-4.931-19.464-2.005-24.393,6.536l-30.309,52.499l-30.31-52.499
c-4.93-8.541-15.853-11.467-24.393-6.536c-8.541,4.932-11.467,15.853-6.536,24.393l43.382,75.141v83.371h-58.042v-40.44
c0-5.645-2.67-10.959-7.199-14.329c-80.441-59.835-68.482-170.404-2.382-222.508c48.157-37.963,120.495-38.283,168.833-1.824
C407.271,114.039,420.593,226.465,339.06,287.111z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

22
html/static/images/light-on.svg Executable file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="currentColor" height="25" width="25" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<g>
<g>
<g>
<path d="M355.682,368.647H156.229c-6.576,0-11.907,5.331-11.907,11.907c0,0,0,17.795,0,17.795
c0,25.434,20.562,46.148,45.936,46.414h131.397c25.372-0.267,45.935-20.98,45.935-46.414v-17.795
C367.589,373.978,362.259,368.647,355.682,368.647z"/>
<path d="M421.434,118.871C397.745,48.985,334.605-1.122,253.436,0.019C173.763,1.141,112.235,51.894,89.744,121.45
c-21.624,66.875-3.794,140.697,54.577,188.656v28.773c0,6.576,5.331,11.907,11.907,11.907h78.889
c6.576,0,11.907-5.331,11.907-11.907v-82.803l-44.586-77.225c-2.466-4.271-1.003-9.733,3.268-12.199
c4.27-2.465,9.733-1.003,12.199,3.268l38.049,65.903l37.862-65.579c2.445-4.235,7.841-6.043,12.146-3.726
c4.456,2.398,6.02,7.982,3.508,12.332l-44.586,77.225v82.805c0,6.576,5.331,11.907,11.907,11.907h78.888
c6.576,0,11.907-5.331,11.907-11.907v-28.772C426.066,262.074,444.596,187.205,421.434,118.871z"/>
<path d="M300.201,462.624h-88.49c-9.081,0-14.937,9.826-10.407,17.697C212.193,499.236,232.607,512,255.956,512
s43.763-12.764,54.652-31.678C315.138,472.451,309.282,462.624,300.201,462.624z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
html/static/images/loading.gif Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
html/static/images/maskable-icon-192x192.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 877 B

BIN
html/static/images/maskable-icon-512x512.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
html/static/images/mini_eir.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
html/static/images/mstile-150x150.png Normal file → Executable file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 346 B

4
html/static/images/pin.svg Executable file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1218 1.87023C15.7573 0.505682 13.4779 0.76575 12.4558 2.40261L9.61062 6.95916C9.61033 6.95965 9.60913 6.96167 9.6038 6.96549C9.59728 6.97016 9.58336 6.97822 9.56001 6.9848C9.50899 6.99916 9.44234 6.99805 9.38281 6.97599C8.41173 6.61599 6.74483 6.22052 5.01389 6.87251C4.08132 7.22378 3.61596 8.03222 3.56525 8.85243C3.51687 9.63502 3.83293 10.4395 4.41425 11.0208L7.94975 14.5563L1.26973 21.2363C0.879206 21.6269 0.879206 22.26 1.26973 22.6506C1.66025 23.0411 2.29342 23.0411 2.68394 22.6506L9.36397 15.9705L12.8995 19.5061C13.4808 20.0874 14.2853 20.4035 15.0679 20.3551C15.8881 20.3044 16.6966 19.839 17.0478 18.9065C17.6998 17.1755 17.3043 15.5086 16.9444 14.5375C16.9223 14.478 16.9212 14.4114 16.9355 14.3603C16.9421 14.337 16.9502 14.3231 16.9549 14.3165C16.9587 14.3112 16.9606 14.31 16.9611 14.3098L21.5177 11.4645C23.1546 10.4424 23.4147 8.16307 22.0501 6.79853L17.1218 1.87023ZM14.1523 3.46191C14.493 2.91629 15.2528 2.8296 15.7076 3.28445L20.6359 8.21274C21.0907 8.66759 21.0041 9.42737 20.4584 9.76806L15.9019 12.6133C14.9572 13.2032 14.7469 14.3637 15.0691 15.2327C15.3549 16.0037 15.5829 17.1217 15.1762 18.2015C15.1484 18.2752 15.1175 18.3018 15.0985 18.3149C15.0743 18.3316 15.0266 18.3538 14.9445 18.3589C14.767 18.3699 14.5135 18.2916 14.3137 18.0919L5.82846 9.6066C5.62872 9.40686 5.55046 9.15333 5.56144 8.97583C5.56651 8.8937 5.58877 8.84605 5.60548 8.82181C5.61855 8.80285 5.64516 8.7719 5.71886 8.74414C6.79869 8.33741 7.91661 8.56545 8.68762 8.85128C9.55668 9.17345 10.7171 8.96318 11.3071 8.01845L14.1523 3.46191Z" fill="#D7D7D7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="-1 -1 4 4"><path d="M 1.322 0.211 l -0.793 0.767 v 0.161 L 1.322 1.906 v 0.211 h -0.265 L 0 1.059 L 1.058 0 H 1.323 z" fill="#ff0000" paint-order="markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ff0000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6 10v4c0 1.103.897 2 2 2h3v-2H8v-4h3V8H8c-1.103 0-2 .897-2 2zm7 0v4c0 1.103.897 2 2 2h3v-2h-3v-4h3V8h-3c-1.103 0-2 .897-2 2z"/><path d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zM4 18V6h16l.002 12H4z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 10C2 10 5.5 14 12 14C18.5 14 22 10 22 10" stroke="#ff0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 11.6445L2 14" stroke="#ff0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 14L20.0039 11.6484" stroke="#ff0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.91406 13.6797L8 16.5" stroke="#ff0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.0625 13.6875L16 16.5" stroke="#ff0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 825 B

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ff0000" height="800px" width="800px" version="1.2" baseProfile="tiny" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 256 256" xml:space="preserve">
<path id="XMLID_28_" d="M244.7,101.5C218.1,66,175.8,42.9,128,42.9S37.9,66,11.3,101.5c-4.9,6.9-8,15.3-8,24.5s3.1,17.6,8,24.3
C37.9,185.9,80.2,209,128,209s90.1-23.1,116.7-58.7c4.9-6.9,8-15.3,8-24.5S249.6,108.5,244.7,101.5z M227.5,138.7
c-23.7,31.3-60.3,49.7-99.5,49.7c-39.4,0-75.8-18.4-99.5-49.7c-2.7-3.7-4.3-8.2-4.3-12.7c0-4.3,1.6-9,4.3-12.7
C52.2,82.1,88.6,63.7,128,63.7c39.2,0,75.8,18.4,99.5,49.7c1.8,2.5,4.3,7.2,4.3,12.7S229.4,136.1,227.5,138.7z M128,79.2
c-25.8,0-46.8,20.8-46.8,46.8s21.1,46.8,46.8,46.8s46.8-21.1,46.8-46.8S153.8,79.2,128,79.2z M144.6,119.9
c-5.7,0-10.4-4.7-10.4-10.4c0-5.7,4.7-10.4,10.4-10.4c5.7,0,10.4,4.7,10.4,10.4C155,115.2,150.3,119.9,144.6,119.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M18 4.654v.291a10 10 0 0 0-1.763 1.404l-2.944 2.944a1 1 0 0 0 1.414 1.414l2.933-2.932A9.995 9.995 0 0 0 19.05 6h.296l-.09.39A9.998 9.998 0 0 0 19 8.64v.857a1 1 0 1 0 2 0V4.503a1.5 1.5 0 0 0-1.5-1.5L14.5 3a1 1 0 1 0 0 2h.861a10 10 0 0 0 2.249-.256l.39-.09zM4.95 18a10 10 0 0 1 1.41-1.775l2.933-2.932a1 1 0 0 1 1.414 1.414l-2.944 2.944A9.998 9.998 0 0 1 6 19.055v.291l.39-.09A9.998 9.998 0 0 1 8.64 19H9.5a1 1 0 1 1 0 2l-5-.003a1.5 1.5 0 0 1-1.5-1.5V14.5a1 1 0 1 1 2 0v.861a10 10 0 0 1-.256 2.249l-.09.39h.295z" fill="#ff0000"/></svg>

After

Width:  |  Height:  |  Size: 804 B

View File

@ -0,0 +1 @@
<svg fill="#ff0000" xmlns="http://www.w3.org/2000/svg" width="25" height="25" viewBox="0 0 6.615 6.615"><title>Settings</title><path d="M2.64 0s-.03.94-.424 1.215C1.823 1.49.885.894.885.894L.112 2.232.109 2.23l.003.002c.01.006.797.499.838.974.042.478-.944.992-.944.992l.77 1.34s.83-.442 1.265-.24c.435.204.387 1.314.387 1.314l1.546.003s.032-.94.425-1.215c.393-.275 1.331.321 1.331.321l.775-1.338s-.798-.496-.84-.974c-.041-.478.944-.993.944-.993l-.77-1.34s-.83.443-1.265.24C4.14 1.113 4.187.002 4.187.002zm.688 2.25a1.106 1.106 0 110 2.211 1.106 1.106 0 010-2.21z" paint-order="fill markers stroke"/></svg>

After

Width:  |  Height:  |  Size: 606 B

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-4 -4 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
<title>hamburger</title>
<desc>Created with Sketch Beta.</desc>
<g id="Page-1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
<g id="Icon-Set-Filled" sketch:type="MSLayerGroup" transform="translate(-212.000000, -888.000000)" fill="#ff0000">
<path d="M230,904 L214,904 C212.896,904 212,904.896 212,906 C212,907.104 212.896,908 214,908 L230,908 C231.104,908 232,907.104 232,906 C232,904.896 231.104,904 230,904 L230,904 Z M230,896 L214,896 C212.896,896 212,896.896 212,898 C212,899.104 212.896,900 214,900 L230,900 C231.104,900 232,899.104 232,898 C232,896.896 231.104,896 230,896 L230,896 Z M214,892 L230,892 C231.104,892 232,891.104 232,890 C232,888.896 231.104,888 230,888 L214,888 C212.896,888 212,888.896 212,890 C212,891.104 212.896,892 214,892 L214,892 Z" id="hamburger" sketch:type="MSShapeGroup">
</path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 7C3 4.23858 5.23858 2 8 2C10.7614 2 13 4.23858 13 7V8H10V15H12C13.6569 15 15 13.6569 15 12V7C15 3.13401 11.866 0 8 0C4.13401 0 1 3.13401 1 7V12C1 13.6569 2.34315 15 4 15H6V8H3V7Z" fill="#ff0000"/>
</svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="-2 -2 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2929 3.00001L11.1464 0.853561L11.8536 0.146454L14.8536 3.14645C15.0488 3.34172 15.0488 3.6583 14.8536 3.85356L11.8536 6.85356L11.1464 6.14645L13.2929 4.00001H3.5C2.11929 4.00001 1 5.1193 1 6.50001V8.00001H0V6.50001C0 4.56701 1.567 3.00001 3.5 3.00001H13.2929ZM15 7.00001V8.50001C15 10.433 13.433 12 11.5 12H1.70711L3.85355 14.1465L3.14644 14.8536L0.146447 11.8536C-0.0488153 11.6583 -0.0488153 11.3417 0.146447 11.1465L3.14645 8.14645L3.85355 8.85356L1.70711 11H11.5C12.8807 11 14 9.88072 14 8.50001V7.00001H15Z" fill="#ff0000"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ff0000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M5.92 24.096q0 0.832 0.576 1.408t1.44 0.608h4.032q0.832 0 1.44-0.608t0.576-1.408v-16.16q0-0.832-0.576-1.44t-1.44-0.576h-4.032q-0.832 0-1.44 0.576t-0.576 1.44v16.16zM18.016 24.096q0 0.832 0.608 1.408t1.408 0.608h4.032q0.832 0 1.44-0.608t0.576-1.408v-16.16q0-0.832-0.576-1.44t-1.44-0.576h-4.032q-0.832 0-1.408 0.576t-0.608 1.44v16.16z"></path></svg>

After

Width:  |  Height:  |  Size: 598 B

View File

@ -0,0 +1,3 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.3624 23.0011C22.0589 22.9807 20.8029 22.5082 19.8091 21.6644C17.7362 19.7633 16.4804 17.1331 16.3053 14.3259C15.7584 8.71575 18.8189 5.40545 22.1247 5.03639C22.9213 4.94808 23.7274 5.02091 24.4952 5.25055C25.263 5.48019 25.9767 5.86195 26.594 6.37311C27.5148 7.20313 28.2624 8.207 28.7938 9.327C29.3252 10.447 29.63 11.661 29.6905 12.8992C30.1946 18.1291 27.4086 23.0011 23.3624 23.0011ZM11.4107 36.5034C10.2135 36.4749 9.07059 35.9978 8.20843 35.1667C6.30297 33.1827 5.18332 30.5739 5.0579 27.8259C4.56282 22.2158 7.32178 18.9055 10.3035 18.5364C11.0303 18.4544 11.7662 18.5315 12.4602 18.7623C13.1543 18.9932 13.7898 19.3722 14.3227 19.8731C16.0344 21.6244 17.0303 23.9518 17.1154 26.3992C17.5677 31.6313 14.7998 36.5034 11.4107 36.5034ZM40.6273 23.0011C36.5541 23.0011 33.7997 18.1268 34.3105 12.8924C34.371 11.6543 34.6758 10.4402 35.2072 9.32025C35.7386 8.20025 36.4862 7.19637 37.407 6.36636C38.0237 5.85827 38.7358 5.47882 39.5015 5.2504C40.2671 5.02198 41.0708 4.94921 41.865 5.03639C45.1708 5.40545 48.2313 8.71575 47.6845 14.3259C47.5099 17.1339 46.2541 19.765 44.1806 21.6667C43.1865 22.5097 41.9306 22.9814 40.6273 23.0011V23.0011ZM52.3675 36.5034C48.1233 36.5034 46.0057 31.6291 46.4828 26.3947C46.5774 23.9316 47.6085 21.5974 49.3655 19.8686C49.9236 19.364 50.5826 18.9839 51.2988 18.7534C52.015 18.523 52.7721 18.4475 53.5197 18.5319C56.5982 18.901 59.4494 22.2113 58.9408 27.8214C58.7985 30.5882 57.6351 33.2038 55.6755 35.1622C54.7742 35.999 53.5972 36.4762 52.3675 36.5034V36.5034ZM40.3527 59.0071C38.3221 59.0431 36.3213 58.5141 34.5738 57.4791C33.7971 57.0052 32.9048 56.7544 31.9949 56.7544C31.085 56.7544 30.1926 57.0052 29.4159 57.4791C22.4443 61.6513 13.7668 57.1798 13.9919 47.3795C13.9919 37.1425 24.1343 27.5019 31.9949 27.5019C39.8554 27.5019 49.9978 37.1425 49.9978 47.3795C49.9978 54.2251 46.0327 59.0071 40.3527 59.0071ZM31.9949 50.0056C34.0256 49.9696 36.0263 50.4987 37.7738 51.5336C42.2251 54.0833 43.285 49.5983 43.2467 47.3795C43.2467 40.7408 36.1265 34.253 31.9949 34.253C27.8632 34.253 20.743 40.7408 20.743 47.3795C20.6912 49.5646 21.7827 54.0991 26.2159 51.5336C27.9634 50.4987 29.9642 49.9696 31.9949 50.0056V50.0056Z" fill="#ff0000"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Some files were not shown because too many files have changed in this diff Show More