EirTube initial commit
@ -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
@ -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
|
||||
|
14
Dockerfile
@ -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
|
21
README.md
Normal file → Executable 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
@ -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
@ -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
@ -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
@ -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
2
api/formapi.js
Normal file → Executable 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
@ -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
@ -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
@ -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
41
api/search.js
Normal file → Executable 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
@ -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
@ -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
@ -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
324
api/video.js
Normal file → Executable 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
@ -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
10
background/instances.js
Normal file → Executable 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")
|
||||
|
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
||||
}
|
||||
}
|
30
eirtubeMods/yt2009constants.json
Executable 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
0
html/robots.txt
Normal file → Executable file
8
html/site.webmanifest
Normal file → Executable 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
@ -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;
|
||||
}
|
6
html/static/css/noscript-video-controls-hider.css
Executable file
@ -0,0 +1,6 @@
|
||||
.videoControls {
|
||||
display: none !important
|
||||
}
|
||||
#quality-select {
|
||||
display: none
|
||||
}
|
0
html/static/flash/player.swf
Normal file → Executable file
0
html/static/flash/skin.swf
Normal file → Executable file
BIN
html/static/fonts/TerminessNerdFontMono-Bold.ttf
Executable file
BIN
html/static/fonts/TerminessNerdFontMono-BoldItalic.ttf
Executable file
BIN
html/static/fonts/TerminessNerdFontMono-Italic.ttf
Executable file
BIN
html/static/fonts/TerminessNerdFontMono-Regular.ttf
Executable file
BIN
html/static/fonts/Work-Sans.woff2
Executable file
BIN
html/static/images/android-chrome-192x192.png
Normal file → Executable file
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
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
Before Width: | Height: | Size: 578 B After Width: | Height: | Size: 425 B |
1
html/static/images/arrow-down-disabled-wide-dark.svg
Executable 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 |
1
html/static/images/arrow-down-disabled-wide-light.svg
Executable 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
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
Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 226 B |
BIN
html/static/images/bow.png
Executable file
After Width: | Height: | Size: 243 B |
10
html/static/images/check.svg
Executable 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 |
BIN
html/static/images/cursors/default-trail-1.png
Executable file
After Width: | Height: | Size: 214 B |
BIN
html/static/images/cursors/default-trail-2.png
Executable file
After Width: | Height: | Size: 194 B |
BIN
html/static/images/cursors/default-trail-3.png
Executable file
After Width: | Height: | Size: 186 B |
BIN
html/static/images/cursors/default.cur
Executable file
After Width: | Height: | Size: 284 B |
BIN
html/static/images/cursors/pointer-trail-1.png
Executable file
After Width: | Height: | Size: 266 B |
BIN
html/static/images/cursors/pointer-trail-2.png
Executable file
After Width: | Height: | Size: 237 B |
BIN
html/static/images/cursors/pointer-trail-3.png
Executable file
After Width: | Height: | Size: 220 B |
BIN
html/static/images/cursors/pointer.cur
Executable file
After Width: | Height: | Size: 348 B |
BIN
html/static/images/cursors/text-trail-1.png
Executable file
After Width: | Height: | Size: 192 B |
BIN
html/static/images/cursors/text-trail-2.png
Executable file
After Width: | Height: | Size: 163 B |
BIN
html/static/images/cursors/text-trail-3.png
Executable file
After Width: | Height: | Size: 144 B |
BIN
html/static/images/cursors/text.cur
Executable file
After Width: | Height: | Size: 262 B |
BIN
html/static/images/cursors/zoom-in-trail-1.png
Executable file
After Width: | Height: | Size: 224 B |
BIN
html/static/images/cursors/zoom-in-trail-2.png
Executable file
After Width: | Height: | Size: 212 B |
BIN
html/static/images/cursors/zoom-in-trail-3.png
Executable file
After Width: | Height: | Size: 181 B |
BIN
html/static/images/cursors/zoom-in.cur
Executable file
After Width: | Height: | Size: 300 B |
BIN
html/static/images/cursors/zoom-out-trail-1.png
Executable file
After Width: | Height: | Size: 223 B |
BIN
html/static/images/cursors/zoom-out-trail-2.png
Executable file
After Width: | Height: | Size: 214 B |
BIN
html/static/images/cursors/zoom-out-trail-3.png
Executable file
After Width: | Height: | Size: 181 B |
BIN
html/static/images/cursors/zoom-out.cur
Executable file
After Width: | Height: | Size: 299 B |
8
html/static/images/dearrow-logo.svg
Executable 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
After Width: | Height: | Size: 339 B |
BIN
html/static/images/eir-walk.gif
Executable file
After Width: | Height: | Size: 974 B |
BIN
html/static/images/favicon-16x16.png
Normal file → Executable file
Before Width: | Height: | Size: 710 B After Width: | Height: | Size: 569 B |
BIN
html/static/images/favicon-32x32.png
Normal file → Executable file
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
0
html/static/images/favicon.ico
Normal file → Executable file
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
0
html/static/images/instance-blocked.svg
Normal file → Executable file
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
18
html/static/images/light-off.svg
Executable 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
@ -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
After Width: | Height: | Size: 40 KiB |
BIN
html/static/images/maskable-icon-192x192.png
Normal file → Executable file
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
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.1 KiB |
BIN
html/static/images/mini_eir.png
Executable file
After Width: | Height: | Size: 16 KiB |
BIN
html/static/images/mstile-150x150.png
Normal file → Executable file
Before Width: | Height: | Size: 491 B After Width: | Height: | Size: 346 B |
4
html/static/images/pin.svg
Executable 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 |
1
html/static/images/player/back.svg
Executable 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 |
2
html/static/images/player/captions.svg
Executable 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 |
8
html/static/images/player/eye-closed.svg
Executable 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 |
11
html/static/images/player/eye-open.svg
Executable 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 |
2
html/static/images/player/fullscreen.svg
Executable 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 |
1
html/static/images/player/gear.svg
Executable 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 |
14
html/static/images/player/hamburger.svg
Executable 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 |
4
html/static/images/player/headphones.svg
Executable 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 |
4
html/static/images/player/loop.svg
Executable 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 |
3
html/static/images/player/pause.svg
Executable 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 |
3
html/static/images/player/paw.svg
Executable 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 |