internal changes only

- remade config module
- renamed loc to i18n because that's what all developers do
- moved code to src to make repo look cleaner
- fixed some i18n strings
This commit is contained in:
wukko
2022-07-17 17:08:49 +06:00
parent 227dc8436c
commit 67223b3acd
62 changed files with 50 additions and 55 deletions

127
src/cobalt.js Normal file
View File

@@ -0,0 +1,127 @@
import "dotenv/config"
import express from "express";
import cors from "cors";
import * as fs from "fs";
import rateLimit from "express-rate-limit";
import { shortCommit } from "./modules/sub/current-commit.js";
import { appName, genericUserAgent, version, internetExplorerRedirect } from "./modules/config.js";
import { getJSON } from "./modules/api.js";
import renderPage from "./modules/page-renderer.js";
import { apiJSON } from "./modules/sub/api-helper.js";
import loc from "./modules/sub/i18n.js";
import { Bright, Cyan } from "./modules/sub/console-text.js";
import stream from "./modules/stream/stream.js";
const commitHash = shortCommit();
const app = express();
app.disable('x-powered-by');
if (fs.existsSync('./.env')) {
const apiLimiter = rateLimit({
windowMs: 20 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res, next, opt) => {
res.status(429).json({ "status": "error", "text": loc('en', 'apiError', 'rateLimit') });
}
})
const apiLimiterStream = rateLimit({
windowMs: 6 * 60 * 1000,
max: 24,
standardHeaders: true,
legacyHeaders: false,
handler: (req, res, next, opt) => {
res.status(429).json({ "status": "error", "text": loc('en', 'apiError', 'rateLimit') });
}
})
app.use('/api/', apiLimiter);
app.use('/api/stream', apiLimiterStream);
app.use('/', express.static('./src/static'));
app.use((req, res, next) => {
try {
decodeURIComponent(req.path)
}
catch(e) {
return res.redirect(process.env.selfURL);
}
next();
});
app.get('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), async (req, res) => {
try {
switch (req.params.type) {
case 'json':
if (req.query.url && req.query.url.length < 150) {
let j = await getJSON(
req.query.url.trim(),
req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip,
req.header('Accept-Language') ? req.header('Accept-Language').slice(0, 2) : "en",
req.query.format ? req.query.format.slice(0, 5) : "mp4",
req.query.quality ? req.query.quality.slice(0, 3) : "max"
)
res.status(j.status).json(j.body);
} else {
let j = apiJSON(3, { t: loc('en', 'apiError', 'noURL', process.env.selfURL) })
res.status(j.status).json(j.body);
}
break;
case 'stream':
if (req.query.p) {
res.status(200).json({ "status": "continue" });
} else if (req.query.t) {
let ip = req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip
stream(res, ip, req.query.t, req.query.h, req.query.e);
} else {
let j = apiJSON(0, { t: loc('en', 'apiError', 'noStreamID') })
res.status(j.status).json(j.body);
}
break;
default:
let j = apiJSON(0, { t: loc('en', 'apiError', 'noType') })
res.status(j.status).json(j.body);
break;
}
} catch (e) {
res.status(500).json({ 'status': 'error', 'text': 'something went wrong.' })
}
});
app.get("/api", async (req, res) => {
res.redirect('/api/json')
});
app.get("/", async (req, res) => {
// redirect masochists to a page where they can install a proper browser
if (req.header("user-agent") && req.header("user-agent").includes("Trident")) {
if (internetExplorerRedirect.newNT.includes(req.header("user-agent").split('NT ')[1].split(';')[0])) {
res.redirect(internetExplorerRedirect.new)
return
} else {
res.redirect(internetExplorerRedirect.old)
return
}
} else {
res.send(renderPage({
"hash": commitHash,
"type": "default",
"lang": req.header('Accept-Language') ? req.header('Accept-Language').slice(0, 2) : "en",
"useragent": req.header('user-agent') ? req.header('user-agent') : genericUserAgent
}))
}
});
app.get("/favicon.ico", async (req, res) => {
res.redirect('/icons/favicon.ico');
});
app.get("/*", async (req, res) => {
res.redirect('/')
});
app.listen(process.env.port, () => {
console.log(`\n${Bright(`${appName} (${version})`)}\n\nURL: ${Cyan(`${process.env.selfURL}`)}\nPort: ${process.env.port}\nCurrent commit: ${Bright(`${commitHash}`)}\nStart time: ${Bright(Math.floor(new Date().getTime()))}\n`)
});
} else {
console.log('Required config files are missing. Please run "npm run setup" first.')
}

35
src/config.json Normal file
View File

@@ -0,0 +1,35 @@
{
"appName": "cobalt",
"version": "2.2",
"streamLifespan": 1800000,
"maxVideoDuration": 1920000,
"genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
"repo": "https://github.com/wukko/cobalt",
"supportedLanguages": ["en"],
"authorInfo": {
"name": "wukko",
"link": "https://wukko.me/",
"contact": "https://wukko.me/contacts"
},
"internetExplorerRedirect": {
"newNT": ["6.1", "6.2", "6.3", "10.0"],
"old": "https://mypal-browser.org/",
"new": "https://vivaldi.com/"
},
"donations": {
"ethereum": "0x4B4cF23051c78c7A7E0eA09d39099621c46bc302",
"bitcoin": "bc1q64amsn0wd60urem3jkhpywed8q8kqwssw6ta5j",
"litecoin": "ltc1qvp0xhrk2m7pa6p6z844qcslfyxv4p3vf95rhna",
"bitcoin cash": "bitcoincash:qph0d7d02mvg5xxqjwjv5ahjx2254yx5kv0zfg0xsj",
"monero": "4B1SNB6s8Pq1hxjNeKPEe8Qa8EP3zdL16Sqsa7QDoJcUecKQzEj9BMxWnEnTGu12doKLJBKRDUqnn6V9qfSdXpXi3Nw5Uod"
},
"quality": {
"hig": "1080",
"mid": "720",
"low": "480"
},
"ffmpegArgs": {
"webm": ["-c:v", "copy", "-c:a", "copy"],
"mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "frag_keyframe+empty_moov"]
}
}

View File

@@ -0,0 +1,11 @@
{
"about": "View about",
"settings": "Open settings",
"input": "Link input area",
"download": "Download button",
"changelog": "View changelog",
"close": "Close the popup",
"alwaysVisibleButton": "Keep the download button always visible",
"downloadPopup": "Ask what to do with downloads",
"donate": "Open donation popup"
}

21
src/i18n/en/apiError.json Normal file
View File

@@ -0,0 +1,21 @@
{
"generic": "something went wrong and i couldn't get the video. you can try again,",
"notSupported": "it seems like this service is not supported yet or your link is invalid.",
"brokenLink": "{s} is supported, but something is wrong with your link.",
"noURL": "i can't guess what you want to download! please give me a link.",
"tryAgain": "\ncheck the link and try again.",
"letMeKnow": "but if issue persists, please <a class=\"text-backdrop nowrap\" href=\"{repo}\">let me know</a>.",
"fatal": "something went wrong and page couldn't render. if you want me to fix this, please <a href=\"https://wukko.me/contacts\">contact me</a>. it'd be useful if you provided the commit hash ({s}) along with recreation steps. thank you :D",
"rateLimit": "you're making way too many requests. calm down and try again in a few minutes.",
"youtubeFetch": "couldn't fetch metadata. check if your link is correct and try again.",
"youtubeLimit": "current length limit is {s} minutes. what you tried to download was longer than that. pick something else to download!",
"youtubeBroke": "something went wrong with info fetching. you can try a different format and resoltuion or just try again later.",
"corruptedVideo": "oddly enough the requested video is corrupted on its origin server. youtube does this sometimes because it's a hot pile of mess.",
"corruptedAudio": "oddly enough the requested audio is corrupted on its origin server. youtube does this sometimes because it's a hot pile of mess.",
"noInternet": "it seems like there's no internet or {appName} api is down. check your connection and try again.",
"liveVideo": "i can't download a live video. wait for stream to finish and try again.",
"nothingToDownload": "it seems like there's nothing to download. try another link!",
"cantConnectToAPI": "i couldn't connect to {s} api. seems like either {s} is down or {appName} server ip got blocked. try again later.",
"noStreamID": "there's no such stream id.",
"noType": "there's no such expected response type."
}

19
src/i18n/en/desc.json Normal file
View File

@@ -0,0 +1,19 @@
{
"input": "paste the link here",
"aboutSummary": "{appName} is your go-to place for social media downloads. zero ads or other creepy bullshit attached. simply paste a share link and you're ready to rock!",
"embed": "save content from social media without creeps following you around",
"supportedServices": "currently supported services:",
"sourceCode": "&gt;&gt; report issues and check out the source code on github",
"popupBottom": "made with <3 by wukko",
"noScript": "{appName} uses javascript for api requests and interactive interface. you have to allow javascript to use this site. we don't have any ads or trackers, pinky promise.",
"donationsSub": "it's a little complicated to pay for hosting right now",
"donations": "i don't like crypto how it is right now, but it's the only way for me to pay for anything (including hosting) online. services similar paypal are no longer available too.",
"donateDm": "&gt;&gt; let me know if currency you want to donate isn't listed",
"clickToCopy": "click to copy",
"iosDownload": "you have to press and hold the download button and then select \"download video\" in appeared popup to save the video. this is required because you have an ios device.",
"normalDownload": "download button opens a new tab with requested video. you can disable this popup in settings.",
"download": "download",
"copy": "copy url",
"open": "open",
"github": "&gt;&gt; see previous changes and contribute on github"
}

21
src/i18n/en/settings.json Normal file
View File

@@ -0,0 +1,21 @@
{
"appearance": "appearance",
"alwaysVisibleButton": "keep >> visible",
"downloadPopupButton": "ask for a way to save",
"format": "download format",
"formatInfo": "select webm if you need max quality available. webm videos are usually higher quality but ios devices can't play them natively. all \"audio only\" downloads are max quality.",
"theme": "theme",
"themeAuto": "auto",
"themeLight": "light",
"themeDark": "dark",
"misc": "miscellaneous",
"general": "downloads",
"quality": "quality",
"qmax": "max",
"qhig": "high\n",
"qmid": "medium\n",
"qlow": "low\n",
"qlos": "lowest",
"qualityDesc": "all resolutions listed here are max values. if there's no video of preferred quality, closest one gets picked instead.",
"extra": "extra"
}

9
src/i18n/en/title.json Normal file
View File

@@ -0,0 +1,9 @@
{
"about": "what's {appName}?",
"settings": "settings",
"error": "uh-oh...",
"changelog": "what's new?",
"donate": "support {appName}",
"download": "download",
"pickDownload": "pick a way to save"
}

35
src/modules/api.js Normal file
View File

@@ -0,0 +1,35 @@
import UrlPattern from "url-pattern";
import { services as patterns } from "./config.js";
import { cleanURL, apiJSON } from "./sub/api-helper.js";
import { errorUnsupported } from "./sub/errors.js";
import loc from "./sub/i18n.js";
import match from "./match.js";
export async function getJSON(originalURL, ip, lang, format, quality) {
try {
let url = decodeURI(originalURL);
if (!url.includes('http://')) {
let hostname = url.replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."),
host = hostname[hostname.length - 2],
patternMatch;
if (host == "youtu") {
host = "youtube";
url = `https://youtube.com/watch?v=${url.replace("youtu.be/", "").replace("https://", "")}`;
}
if (host && host.length < 20 && host in patterns && patterns[host]["enabled"]) {
for (let i in patterns[host]["patterns"]) {
patternMatch = new UrlPattern(patterns[host]["patterns"][i]).match(cleanURL(url, host).split(".com/")[1]);
}
if (patternMatch) {
return await match(host, patternMatch, url, ip, lang, format, quality);
} else throw Error()
} else throw Error()
} else {
return apiJSON(0, { t: errorUnsupported(lang) } )
}
} catch (e) {
return apiJSON(0, { t: loc(lang, 'apiError', 'generic') + loc(lang, 'apiError', 'letMeKnow') });
}
}

17
src/modules/config.js Normal file
View File

@@ -0,0 +1,17 @@
import loadJson from "./sub/load-json.js";
const config = loadJson("./src/config.json");
export const
services = loadJson("./src/modules/services/all.json"),
appName = config.appName,
version = config.version,
streamLifespan = config.streamLifespan,
maxVideoDuration = config.maxVideoDuration,
genericUserAgent = config.genericUserAgent,
repo = config.repo,
authorInfo = config.authorInfo,
supportedLanguages = config.supportedLanguages,
quality = config.quality,
internetExplorerRedirect = config.internetExplorerRedirect,
donations = config.donations,
ffmpegArgs = config.ffmpegArgs

90
src/modules/match.js Normal file
View File

@@ -0,0 +1,90 @@
import { apiJSON } from "./sub/api-helper.js";
import { errorUnsupported, genericError } from "./sub/errors.js";
import bilibili from "./services/bilibili.js";
import reddit from "./services/reddit.js";
import twitter from "./services/twitter.js";
import youtube from "./services/youtube.js";
import vk from "./services/vk.js";
export default async function (host, patternMatch, url, ip, lang, format, quality) {
try {
switch (host) {
case "twitter":
if (patternMatch["id"] && patternMatch["id"].length < 20) {
let r = await twitter({
id: patternMatch["id"],
lang: lang
});
return (!r.error) ? apiJSON(1, { u: r.split('?')[0] }) : apiJSON(0, { t: r.error })
} else throw Error()
case "vk":
if (patternMatch["userId"] && patternMatch["videoId"] && patternMatch["userId"].length <= 10 && patternMatch["videoId"].length == 9) {
let r = await vk({
userId: patternMatch["userId"],
videoId: patternMatch["videoId"],
lang: lang, quality: quality
});
return (!r.error) ? apiJSON(2, { type: "bridge", urls: r.url, filename: r.filename, service: host, ip: ip, salt: process.env.streamSalt }) : apiJSON(0, { t: r.error });
} else throw Error()
case "bilibili":
if (patternMatch["id"] && patternMatch["id"].length >= 12) {
let r = await bilibili({
id: patternMatch["id"].slice(0, 12),
lang: lang
});
return (!r.error) ? apiJSON(2, {
type: "render", urls: r.urls,
service: host, ip: ip,
filename: r.filename,
salt: process.env.streamSalt, time: r.time
}) : apiJSON(0, { t: r.error });
} else throw Error()
case "youtube":
if (patternMatch["id"] && patternMatch["id"].length >= 11) {
let fetchInfo = {
id: patternMatch["id"].slice(0,11),
lang: lang, quality: quality,
format: "mp4"
};
if (url.match('music.youtube.com')) {
format = "audio"
}
switch (format) {
case "webm":
fetchInfo["format"] = "webm";
break;
case "audio":
fetchInfo["format"] = "webm";
fetchInfo["isAudioOnly"] = true;
fetchInfo["quality"] = "max";
break;
}
let r = await youtube(fetchInfo);
return (!r.error) ? apiJSON(2, {
type: r.type, urls: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt,
isAudioOnly: fetchInfo["isAudioOnly"] ? fetchInfo["isAudioOnly"] : false,
time: r.time,
}) : apiJSON(0, { t: r.error });
} else throw Error()
case "reddit":
if (patternMatch["sub"] && patternMatch["id"] && patternMatch["title"] && patternMatch["sub"].length <= 22 && patternMatch["id"].length <= 10 && patternMatch["title"].length <= 96) {
let r = await reddit({
sub: patternMatch["sub"],
id: patternMatch["id"],
title: patternMatch["title"]
});
return (!r.error) ? apiJSON(2, {
type: "render", urls: r.urls,
service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt
}) : apiJSON(0, { t: r.error });
} else throw Error()
default:
return apiJSON(0, { t: errorUnsupported(lang) })
}
} catch (e) {
return apiJSON(0, { t: genericError(lang, host) })
}
}

View File

@@ -0,0 +1,202 @@
import { services, appName, authorInfo, version, quality, repo, donations } from "./config.js";
import { getCommitInfo } from "./sub/current-commit.js";
import loc from "./sub/i18n.js";
let s = services
let enabledServices = Object.keys(s).filter((p) => {
if (s[p].enabled) {
return true
}
}).sort().map((p) => {
if (s[p].alias) {
return s[p].alias
} else {
return p
}
}).join(', ')
let donate = ``
for (let i in donations) {
donate += `<div class="subtitle">${i} (${loc("en", 'desc', 'clickToCopy').trim()})</div><div id="don-${i}" class="text-to-copy" onClick="copy('don-${i}')">${donations[i]}</div>`
}
let com = getCommitInfo();
export default function(obj) {
let isIOS = obj.useragent.toLowerCase().match("iphone os")
try {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=${isIOS ? `1` : `5`}" />
<title>${appName}</title>
<meta property="og:url" content="${process.env.selfURL}" />
<meta property="og:title" content="${appName}" />
<meta property="og:description" content="${loc(obj.lang, 'desc', 'embed')}" />
<meta property="og:image" content="${process.env.selfURL}icons/generic.png" />
<meta name="title" content="${appName}" />
<meta name="description" content="${loc(obj.lang, 'desc', 'embed')}" />
<meta name="theme-color" content="#000000" />
<meta name="twitter:card" content="summary" />
<link rel="icon" type="image/x-icon" href="icons/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png" />
<link rel="manifest" href="cobalt.webmanifest" />
<link rel="stylesheet" href="cobalt.css" />
<link rel="stylesheet" href="fonts/notosansmono/notosansmono.css" />
<noscript><div style="margin: 2rem;">${loc(obj.lang, 'desc', 'noScript')}</div></noscript>
</head>
<body id="cobalt-body">
<div id="popup-download" class="popup center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('download', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="title" class="popup-subtitle">${loc(obj.lang, 'title', 'download')}</div>
</div>
<div id="content" class="popup-content">
<div id="theme-switcher" class="switch-container small-padding">
<div class="subtitle">${loc(obj.lang, 'title', 'pickDownload')}</div>
<div class="switches">
<a id="pd-download" class="switch full space-right" target="_blank"">${loc(obj.lang, 'desc', 'download')}</a>
<div id="pd-copy" class="switch full">${loc(obj.lang, 'desc', 'copy')}</div>
</div>
</div>
<div id="desc" class="explanation about-padding">${isIOS ? loc(obj.lang, 'desc', 'iosDownload') : loc(obj.lang, 'desc', 'normalDownload')}</div>
</div>
</div>
<div id="popup-about" class="popup center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('about', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'about')}</div>
</div>
<div id="content" class="popup-content with-footer">
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'aboutSummary')}</div>
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'supportedServices')} ${enabledServices}.</div>
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${repo}">${loc(obj.lang, 'desc', 'sourceCode')}</a></div>
</div>
<div id="popup-footer" class="popup-footer">
<a id="popup-bottom" class="popup-footer-content" href="${authorInfo.link}">${loc(obj.lang, 'desc', 'popupBottom')}</a>
</div>
</div>
<div id="popup-changelog" class="popup center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('changelog', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'changelog')}</div>
<div id="desc" class="popup-subtitle">${com[0]} (${obj.hash})</div>
</div>
<div id="content" class="popup-content">
<div id="desc" class="popup-desc about-padding">${com[1]}</div>
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${repo}/commits">${loc(obj.lang, 'desc', 'github')}</a></div>
</div>
</div>
<div id="popup-donate" class="popup scrollable center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('donate', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'donate')}</div>
<div id="desc" class="little-subtitle">${loc(obj.lang, 'desc', 'donationsSub')}</div>
</div>
<div id="content" class="popup-content">
${donate}
<div id="desc" class="explanation about-padding">${loc(obj.lang, 'desc', 'donations')}</div>
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${authorInfo.contact}">${loc(obj.lang, 'desc', 'donateDm')}</a></div>
</div>
</div>
<div id="popup-settings" class="popup scrollable center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('settings', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="version" class="popup-above-title">v.${version} ~ ${obj.hash}</div>
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'settings')}</div>
</div>
<div id="content" class="popup-content">
<div id="settings-appearance" class="settings-category">
<div class="title">${loc(obj.lang, 'settings', 'appearance')}</div>
<div class="settings-category-content">
<div id="theme-switcher" class="switch-container">
<div class="subtitle">${loc(obj.lang, 'settings', 'theme')}</div>
<div class="switches">
<div id="theme-auto" class="switch full" onclick="changeSwitcher('theme', 'auto', 1)">${loc(obj.lang, 'settings', 'themeAuto')}</div>
<div id="theme-dark" class="switch" onclick="changeSwitcher('theme', 'dark', 1)">${loc(obj.lang, 'settings', 'themeDark')}</div>
<div id="theme-light" class="switch full" onclick="changeSwitcher('theme', 'light', 1)">${loc(obj.lang, 'settings', 'themeLight')}</div>
</div>
</div>
<div class="subtitle">${loc(obj.lang, 'settings', 'misc')}</div>
<label class="checkbox">
<input id="alwaysVisibleButton" type="checkbox" aria-label="${loc(obj.lang, 'accessibility', 'alwaysVisibleButton')}" onclick="checkbox('alwaysVisibleButton', 'always-visible-button')">
<span>${loc(obj.lang, 'settings', 'alwaysVisibleButton')}</span>
</label>
</div>
</div>
<div id="settings-downloads" class="settings-category">
<div class="title">${loc(obj.lang, 'settings', 'general')}</div>
<div class="settings-category-content">
<div id="quality-switcher" class="switch-container">
<div class="subtitle">${loc(obj.lang, 'settings', 'quality')}</div>
<div class="switches">
<div id="quality-max" class="switch full" onclick="changeSwitcher('quality', 'max', 1)">${loc(obj.lang, 'settings', 'qmax')}</div>
<div id="quality-hig" class="switch" onclick="changeSwitcher('quality', 'hig', 1)">${loc(obj.lang, 'settings', 'qhig')}(${quality.hig}p)</div>
<div id="quality-mid" class="switch full" onclick="changeSwitcher('quality', 'mid', 1)">${loc(obj.lang, 'settings', 'qmid')}(${quality.mid}p)</div>
<div id="quality-low" class="switch right" onclick="changeSwitcher('quality', 'low', 1)">${loc(obj.lang, 'settings', 'qlow')}(${quality.low}p)</div>
</div>
<div class="explanation">${loc(obj.lang, 'settings', 'qualityDesc')}</div>
</div>
${!isIOS ? `<div class="subtitle">${loc(obj.lang, 'settings', 'extra')}</div>
<label class="checkbox">
<input id="downloadPopup" type="checkbox" aria-label="${loc(obj.lang, 'accessibility', 'downloadPopup')}" onclick="checkbox('downloadPopup', 'always-visible-button')">
<span>${loc(obj.lang, 'settings', 'downloadPopupButton')}</span>
</label>` : ``}
</div>
</div>
<div id="settings-youtube" class="settings-category">
<div class="title">youtube</div>
<div class="settings-category-content">
<div id="youtube-switcher" class="switch-container">
<div class="subtitle">${loc(obj.lang, 'settings', 'format')}</div>
<div class="switches">
<div id="youtubeFormat-mp4" class="switch full" onclick="changeSwitcher('youtubeFormat', 'mp4', 1)">mp4</div>
<div id="youtubeFormat-webm" class="switch" onclick="changeSwitcher('youtubeFormat', 'webm', 1)">webm</div>
<div id="youtubeFormat-audio" class="switch full" onclick="changeSwitcher('youtubeFormat', 'audio', 1)">audio only</div>
</div>
<div class="explanation">${loc(obj.lang, 'settings', 'formatInfo')}</div>
</div>
</div>
</div>
</div>
</div>
<div id="popup-error" class="popup center box" style="visibility: hidden;">
<div id="popup-header" class="popup-header">
<button id="close" class="button mono" onclick="popup('error', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'error')}</div>
</div>
<div id="content" class="popup-content">
<div id="desc-error" class="popup-desc"></div>
</div>
</div>
<div id="popup-backdrop" style="visibility: hidden;"></div>
<div id="cobalt-main-box" class="center box" style="visibility: hidden;">
<div id="logo-area">${appName}</div>
<div id="download-area" class="mobile-center">
<input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="110" autocapitalize="off" placeholder="${loc(obj.lang, 'desc', 'input')}" aria-label="${loc(obj.lang, 'accessibility', 'input')}" oninput="button()">
<input id="download-button" class="mono dontRead" onclick="download(document.getElementById('url-input-area').value)" type="submit" value="" disabled=true aria-label="${loc(obj.lang, 'accessibility', 'download')}">
</div>
</div>
<footer id="footer" style="visibility: hidden;">
<div id="footer-buttons">
<button id="about-open" class="button footer-button" onclick="popup('about', 1)" aria-label="${loc(obj.lang, 'accessibility', 'about')}">?</button>
<button id="changelog-open" class="button footer-button" onclick="popup('changelog', 1)" aria-label="${loc(obj.lang, 'accessibility', 'changelog')}">!</button>
<button id="donate-open" class="button footer-button" onclick="popup('donate', 1)" aria-label="${loc(obj.lang, 'accessibility', 'donate')}">$</button>
<button id="settings-open" class="button footer-button" onclick="popup('settings', 1)" aria-label="${loc(obj.lang, 'accessibility', 'settings')}">+</button>
</div>
</footer>
</body>
<script type="text/javascript">const loc = {noInternet:"${loc(obj.lang, 'apiError', 'noInternet')}"}</script>
<script type="text/javascript" src="cobalt.js"></script>
</html>`;
} catch (err) {
return `${loc('en', 'apiError', 'fatal', obj.hash)}`;
}
}

View File

@@ -0,0 +1,72 @@
{
"bilibili": {
"alias": "bilibili.com",
"patterns": ["video/:id"],
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
"enabled": true
},
"reddit": {
"patterns": ["r/:sub/comments/:id/:title"],
"enabled": true
},
"twitter": {
"patterns": [":user/status/:id"],
"quality_match": ["1080", "720", "480", "360", "240", "144"],
"enabled": true,
"api": "api.twitter.com",
"token": "AAAAAAAAAAAAAAAAAAAAAIK1zgAAAAAA2tUWuhGZ2JceoId5GwYWU5GspY4%3DUq7gzFoCZs1QfwGoVdvSac3IniczZEYXIcDyumCauIXpcAPorE",
"apiURLs": {
"activate": "1.1/guest/activate.json",
"status_show": "1.1/statuses/show.json"
}
},
"vk": {
"patterns": ["video-:userId_:videoId"],
"quality_match": {
"2160": 7,
"1440": 6,
"1080": 5,
"720": 3,
"480": 2,
"360": 1,
"240": 0,
"144": 4
},
"quality": {
"1080": "hig",
"720": "mid",
"480": "low"
},
"enabled": true
},
"youtube": {
"patterns": ["watch?v=:id"],
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
"quality": {
"1080": "hig",
"720": "mid",
"480": "low"
},
"enabled": true
},
"youtube music": {
"patterns": ["watch?v=:id"],
"enabled": true
},
"tumblr": {
"patterns": ["post/:id"],
"enabled": false
},
"facebook": {
"patterns": [":pageid/:type/:postid"],
"enabled": false
},
"instagram": {
"patterns": [":type/:id"],
"enabled": false
},
"tiktok": {
"patterns": [":pageid/:type/:postid", ":id"],
"enabled": false
}
}

View File

@@ -0,0 +1,34 @@
import got from "got";
import loc from "../sub/i18n.js";
import { genericUserAgent, maxVideoDuration } from "../config.js";
export default async function(obj) {
try {
let html = await got.get(`https://bilibili.com/video/${obj.id}`, {
headers: { "user-agent": genericUserAgent }
});
html.on('error', (err) => {
return { error: loc('en', 'apiError', 'youtubeFetch') };
});
html = html.body;
if (html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"')) {
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
if (streamData.data.timelength <= maxVideoDuration) {
let video = streamData["data"]["dash"]["video"].filter((v) => {
if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/") && v["height"] != 4320) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter((a) => {
if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
return { urls: [video[0]["baseUrl"], audio[0]["baseUrl"]], time: streamData.data.timelength, filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4` };
} else {
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
}
} else {
return { error: loc('en', 'apiError', 'youtubeFetch') };
}
} catch (e) {
return { error: loc('en', 'apiError', 'youtubeFetch') };
}
}

View File

@@ -0,0 +1,17 @@
import got from "got";
import loc from "../sub/i18n.js";
import { genericUserAgent, maxVideoDuration } from "../config.js";
export default async function(obj) {
try {
let req = await got.get(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`, { headers: { "user-agent": genericUserAgent } });
let data = (JSON.parse(req.body))[0]["data"]["children"][0]["data"];
if ("reddit_video" in data["secure_media"] && data["secure_media"]["reddit_video"]["duration"] * 1000 < maxVideoDuration) {
return { urls: [data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0], `${data["secure_media"]["reddit_video"]["fallback_url"].split('_')[0]}_audio.mp4`], filename: `reddit_${data["secure_media"]["reddit_video"]["fallback_url"].split('/')[3]}.mp4` };
} else {
return { error: loc('en', 'apiError', 'nothingToDownload') };
}
} catch (err) {
return { error: loc("en", "apiError", "nothingToDownload") };
}
}

View File

@@ -0,0 +1,57 @@
import got from "got";
import loc from "../sub/i18n.js";
import { services } from "../config.js";
const configSt = services.twitter;
async function fetchTweetInfo(obj) {
let cantConnect = { error: loc('en', 'apiError', 'cantConnectToAPI', 'twitter') }
try {
let _headers = {
"Authorization": `Bearer ${configSt.token}`,
"Host": configSt.api,
"Content-Type": "application/json",
"Content-Length": 0
};
let req_act = await got.post(`https://${configSt.api}/${configSt.apiURLs.activate}`, {
headers: _headers
});
req_act.on('error', (err) => {
return cantConnect
})
_headers["x-guest-token"] = req_act.body["guest_token"];
let req_status = await got.get(`https://${configSt.api}/${configSt.apiURLs.status_show}?id=${obj.id}&tweet_mode=extended`, {
headers: _headers
});
req_status.on('error', (err) => {
return cantConnect
})
return JSON.parse(req_status.body);
} catch (err) {
return { error: cantConnect };
}
}
export default async function (obj) {
let nothing = { error: loc('en', 'apiError', 'nothingToDownload') }
try {
let parsbod = await fetchTweetInfo(obj);
if (!parsbod.error) {
if (parsbod.hasOwnProperty("extended_entities") && parsbod["extended_entities"].hasOwnProperty("media")) {
if (parsbod["extended_entities"]["media"][0]["type"] === "video" || parsbod["extended_entities"]["media"][0]["type"] === "animated_gif") {
let variants = parsbod["extended_entities"]["media"][0]["video_info"]["variants"]
return variants.filter((v) => {
if (v["content_type"] == "video/mp4") {
return true
}
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"]
} else {
return nothing
}
} else {
return nothing
}
} else return parsbod;
} catch (err) {
return { error: loc("en", "apiError", "youtubeBroke") };
}
}

View File

@@ -0,0 +1,47 @@
import got from "got";
import { xml2json } from "xml-js";
import loc from "../sub/i18n.js";
import { genericUserAgent, maxVideoDuration, services } from "../config.js";
import selectQuality from "../stream/select-quality.js";
export default async function(obj) {
try {
let html = await got.get(`https://vk.com/video-${obj.userId}_${obj.videoId}`, { headers: { "user-agent": genericUserAgent } });
html.on('error', (err) => {
return false;
});
html = html.body;
if (html.includes(`{"lang":`)) {
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
if (js["mvData"]["is_active_live"] == '0') {
if (js["mvData"]["duration"] <= maxVideoDuration / 1000) {
let mpd = JSON.parse(xml2json(js["player"]["params"][0]["manifest"], { compact: true, spaces: 4 }));
let repr = mpd["MPD"]["Period"]["AdaptationSet"]["Representation"];
if (!mpd["MPD"]["Period"]["AdaptationSet"]["Representation"]) {
repr = mpd["MPD"]["Period"]["AdaptationSet"][0]["Representation"];
}
let attr = repr[repr.length - 1]["_attributes"];
let selectedQuality = `url${attr["height"]}`;
let maxQuality = js["player"]["params"][0][selectedQuality].split('type=')[1].slice(0, 1)
let userQuality = selectQuality('vk', obj.quality, Object.entries(services.vk.quality_match).reduce((r, [k, v]) => { r[v] = k; return r;})[maxQuality])
if (selectedQuality in js["player"]["params"][0]) {
return { url: js["player"]["params"][0][selectedQuality].replace(`type=${maxQuality}`, `type=${services.vk.quality_match[userQuality]}`), filename: `vk_${js["player"]["params"][0][selectedQuality].split("id=")[1]}_${attr['width']}x${attr['height']}.mp4` };
} else {
return { error: loc('en', 'apiError', 'nothingToDownload') };
}
} else {
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
}
} else {
return { error: loc('en', 'apiError', 'liveVideo') };
}
} else {
return { error: loc('en', 'apiError', 'nothingToDownload') };
}
} catch (err) {
return { error: loc('en', 'apiError', 'youtubeFetch') };
}
}

View File

@@ -0,0 +1,74 @@
import ytdl from "ytdl-core";
import loc from "../sub/i18n.js";
import { maxVideoDuration, quality as mq } from "../config.js";
import selectQuality from "../stream/select-quality.js";
export default async function (obj) {
try {
let info = await ytdl.getInfo(obj.id);
if (info) {
info = info.formats;
if (!info[0]["isLive"]) {
let videoMatch = [], fullVideoMatch = [], video = [], audio = info.filter((a) => {
if (!a["isHLS"] && !a["isDashMPD"] && a["hasAudio"] && !a["hasVideo"] && a["container"] == obj.format) return true;
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
if (!obj.isAudioOnly) {
video = info.filter((a) => {
if (!a["isHLS"] && !a["isDashMPD"] && a["hasVideo"] && a["container"] == obj.format && a["height"] != 4320) {
if (obj.quality != "max") {
if (a["hasAudio"] && mq[obj.quality] == a["height"]) {
fullVideoMatch.push(a)
} else if (!a["hasAudio"] && mq[obj.quality] == a["height"]) {
videoMatch.push(a);
}
}
return true
}
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
if (obj.quality != "max") {
if (videoMatch.length == 0) {
let ss = selectQuality("youtube", obj.quality, video[0]["height"])
videoMatch = video.filter((a) => {
if (a["height"] == ss) return true;
})
} else if (fullVideoMatch.length > 0) {
videoMatch = [fullVideoMatch[0]]
}
} else videoMatch = [video[0]];
if (obj.quality == "los") videoMatch = [video[video.length - 1]];
}
if (audio[0]["approxDurationMs"] <= maxVideoDuration) {
if (!obj.isAudioOnly && videoMatch.length > 0) {
if (video.length > 0 && audio.length > 0) {
if (videoMatch[0]["hasVideo"] && videoMatch[0]["hasAudio"]) {
return { type: "bridge", urls: videoMatch[0]["url"], time: videoMatch[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${videoMatch[0]["width"]}x${videoMatch[0]["height"]}.${obj.format}` };
} else {
return { type: "render", urls: [videoMatch[0]["url"], audio[0]["url"]], time: videoMatch[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${videoMatch[0]["width"]}x${videoMatch[0]["height"]}.${obj.format}` };
}
} else {
return { error: loc('en', 'apiError', 'youtubeBroke') };
}
} else if (!obj.isAudioOnly) {
return { type: "render", urls: [video[0]["url"], audio[0]["url"]], time: video[0]["approxDurationMs"],
filename: `youtube_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.${video[0]["container"]}` };
} else if (audio.length > 0) {
return { type: "render", isAudioOnly: true, urls: [audio[0]["url"]], filename: `youtube_${obj.id}_${audio[0]["audioBitrate"]}kbps.opus` };
} else {
return { error: loc('en', 'apiError', 'youtubeBroke') };
}
} else {
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
}
} else {
return { error: loc('en', 'apiError', 'liveVideo') };
}
} else {
return { error: loc('en', 'apiError', 'youtubeFetch') };
}
} catch (e) {
return { error: loc('en', 'apiError', 'youtubeFetch') };
}
}

54
src/modules/setup.js Normal file
View File

@@ -0,0 +1,54 @@
import { randomBytes } from "crypto";
import { existsSync, unlinkSync, appendFileSync } from "fs";
import { createInterface } from "readline";
import { Cyan, Bright, Green } from "./sub/console-text.js";
import { execSync } from "child_process";
let envPath = './.env';
let q = `${Cyan('?')} \x1b[1m`;
let ob = { streamSalt: randomBytes(64).toString('hex') }
let rl = createInterface({ input: process.stdin,output: process.stdout });
console.log(
`${Cyan("Welcome to cobalt!")}\n${Bright("We'll get you up and running in no time.\nLet's start by creating a ")}${Cyan(".env")}${Bright(" file. You can always change it later.")}`
)
console.log(
Bright("\nWhat's the selfURL we'll be running on? (localhost)")
)
rl.question(q, r1 => {
if (r1) {
ob['selfURL'] = `https://${r1}/`
} else {
ob['selfURL'] = `http://localhost`
}
console.log(Bright("\nGreat! Now, what's the port we'll be running on? (9000)"))
rl.question(q, r2 => {
if (!r1 && !r2) {
ob['selfURL'] = `http://localhost:9000/`
ob['port'] = 9000
} else if (!r1 && r2) {
ob['selfURL'] = `http://localhost:${r2}/`
ob['port'] = r2
} else {
ob['port'] = r2
}
final()
});
})
let final = () => {
if (existsSync(envPath)) {
unlinkSync(envPath)
}
for (let i in ob) {
appendFileSync(envPath, `${i}=${ob[i]}\n`)
}
console.log(Bright("\nI've created a .env file with selfURL, port, and stream salt."))
console.log(`${Bright("Now I'll run")} ${Cyan("npm install")} ${Bright("to install all dependencies. It shouldn't take long.\n\n")}`)
execSync('npm install',{stdio:[0,1,2]});
console.log(`\n\n${Green("All done!\n")}`)
console.log("You can re-run this script any time to update the configuration.")
console.log("\nYou're now ready to start the main project.\nHave fun!")
rl.close()
}

View File

@@ -0,0 +1,45 @@
import NodeCache from "node-cache";
import { UUID, encrypt } from "../sub/crypto.js";
import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({ stdTTL: streamLifespan, checkperiod: 120 });
export function createStream(obj) {
let streamUUID = UUID(),
exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = encrypt(`${streamUUID},${obj.url},${obj.ip},${exp}`, obj.salt),
iphmac = encrypt(`${obj.ip}`, obj.salt);
streamCache.set(streamUUID, {
id: streamUUID,
service: obj.service,
type: obj.type,
urls: obj.urls,
filename: obj.filename,
hmac: ghmac,
ip: iphmac,
exp: exp,
isAudioOnly: obj.isAudioOnly ? true : false,
time: obj.time
});
return `${process.env.selfURL}api/stream?t=${streamUUID}&e=${exp}&h=${ghmac}`;
}
export function verifyStream(ip, id, hmac, exp, salt) {
try {
let streamInfo = streamCache.get(id);
if (streamInfo) {
let ghmac = encrypt(`${id},${streamInfo.url},${ip},${exp}`, salt);
if (hmac == ghmac && encrypt(`${ip}`, salt) == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) {
return streamInfo;
} else {
return { error: 'Unauthorized', status: 401 };
}
} else {
return { error: 'this stream token does not exist', status: 400 };
}
} catch (e) {
return { status: 500, body: { status: "error", text: "Internal Server Error" } };
}
}

View File

@@ -0,0 +1,32 @@
import { services, quality as mq } from "../config.js";
function closest(goal, array) {
return array.sort().reduce(function(prev, curr) {
return (Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev);
});
}
export default function(service, quality, maxQuality) {
if (quality == "max") {
return maxQuality
}
quality = parseInt(mq[quality])
maxQuality = parseInt(maxQuality)
if (quality >= maxQuality || quality == maxQuality) {
return maxQuality
}
if (quality < maxQuality) {
if (services[service]["quality"][quality]) {
return quality
} else {
let s = Object.keys(services[service]["quality_match"]).filter((q) => {
if (q <= quality) {
return true
}
})
return closest(quality, s)
}
}
}

View File

@@ -0,0 +1,27 @@
import { apiJSON } from "../sub/api-helper.js";
import { verifyStream } from "./manage.js";
import { streamAudioOnly, streamDefault, streamLiveRender } from "./types.js";
export default function(res, ip, id, hmac, exp) {
try {
let streamInfo = verifyStream(ip, id, hmac, exp, process.env.streamSalt);
if (!streamInfo.error) {
if (streamInfo.isAudioOnly) {
streamAudioOnly(streamInfo, res);
} else {
switch (streamInfo.type) {
case "render":
streamLiveRender(streamInfo, res);
break;
default:
streamDefault(streamInfo, res);
break;
}
}
} else {
res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
}
} catch (e) {
internalError(res)
}
}

112
src/modules/stream/types.js Normal file
View File

@@ -0,0 +1,112 @@
import { spawn } from "child_process";
import ffmpeg from "ffmpeg-static";
import got from "got";
import { ffmpegArgs, genericUserAgent } from "../config.js";
import { msToTime } from "../sub/api-helper.js";
import { internalError } from "../sub/errors.js";
import loc from "../sub/i18n.js";
export async function streamDefault(streamInfo, res) {
try {
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.filename}"`);
const stream = got.get(streamInfo.urls, {
headers: {
"user-agent": genericUserAgent
},
isStream: true
});
stream.pipe(res).on('error', (err) => {
throw Error("File stream pipe error.");
});
stream.on('error', (err) => {
throw Error("File stream error.")
});
} catch (e) {
internalError(res);
}
}
export async function streamLiveRender(streamInfo, res) {
try {
if (streamInfo.urls.length == 2) {
let headers = {};
if (streamInfo.service == "bilibili") {
headers = { "user-agent": genericUserAgent };
}
const audio = got.get(streamInfo.urls[1], { isStream: true, headers: headers });
const video = got.get(streamInfo.urls[0], { isStream: true, headers: headers });
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [
'-loglevel', '-8',
'-i', 'pipe:3',
'-i', 'pipe:4',
'-map', '0:v',
'-map', '1:a',
];
args = args.concat(ffmpegArgs[format])
args.push('-t', msToTime(streamInfo.time), '-f', format, 'pipe:5');
const ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe', 'pipe', 'pipe'
],
});
ffmpegProcess.on('error', (err) => {
ffmpegProcess.kill();
});
audio.on('error', (err) => {
ffmpegProcess.kill();
});
video.on('error', (err) => {
ffmpegProcess.kill();
});
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
ffmpegProcess.stdio[5].pipe(res);
video.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
ffmpegProcess.kill();
});
audio.pipe(ffmpegProcess.stdio[4]).on('error', (err) => {
ffmpegProcess.kill();
});
} else {
res.status(400).json({ status: "error", text: loc('en', 'apiError', 'corruptedVideo') });
}
} catch (e) {
internalError(res);
}
}
export async function streamAudioOnly(streamInfo, res) {
try {
let headers = {};
if (streamInfo.service == "bilibili") {
headers = { "user-agent": genericUserAgent };
}
const audio = got.get(streamInfo.urls[0], { isStream: true, headers: headers });
const ffmpegProcess = spawn(ffmpeg, [
'-loglevel', '-8',
'-i', 'pipe:3',
'-vn',
'-c:a', 'copy',
'-f', `${streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]}`,
'pipe:4',
], {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe', 'pipe'
],
});
ffmpegProcess.on('error', (err) => {
ffmpegProcess.kill();
});
audio.on('error', (err) => {
ffmpegProcess.kill();
});
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
ffmpegProcess.stdio[4].pipe(res);
audio.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
ffmpegProcess.kill();
});
} catch (e) {
internalError(res);
}
}

View File

@@ -0,0 +1,51 @@
import { createStream } from "../stream/manage.js";
export function apiJSON(type, obj) {
try {
switch (type) {
case 0:
return { status: 400, body: { status: "error", text: obj.t } };
case 1:
return { status: 200, body: { status: "redirect", url: obj.u } };
case 2:
return { status: 200, body: { status: "stream", url: createStream(obj) } };
case 3:
return { status: 200, body: { status: "success", text: obj.t } };
case 4:
return { status: 429, body: { status: "rate-limit", text: obj.t } };
default:
return { status: 400, body: { status: "error", text: "Bad Request" } };
}
} catch (e) {
return { status: 500, body: { status: "error", text: "Internal Server Error" } };
}
}
export function msToTime(d) {
let milliseconds = parseInt((d % 1000) / 100),
seconds = parseInt((d / 1000) % 60),
minutes = parseInt((d / (1000 * 60)) % 60),
hours = parseInt((d / (1000 * 60 * 60)) % 24),
r;
hours = (hours < 10) ? "0" + hours : hours;
minutes = (minutes < 10) ? "0" + minutes : minutes;
seconds = (seconds < 10) ? "0" + seconds : seconds;
r = hours + ":" + minutes + ":" + seconds;
milliseconds ? r += "." + milliseconds : r += "";
return r;
}
export function cleanURL(url, host) {
url = url.replace('}', '').replace('{', '').replace(')', '').replace('(', '').replace(' ', '');
if (url.includes('youtube.com/shorts/')) {
url = url.split('?')[0].replace('shorts/', 'watch?v=');
}
if (host == "youtube") {
url = url.split('&')[0];
} else {
url = url.split('?')[0];
if (url.substring(url.length - 1) == "/") {
url = url.substring(0, url.length - 1);
}
}
return url
}

View File

@@ -0,0 +1,49 @@
export function t(color, tt) {
return color + tt + "\x1b[0m"
}
export function Reset(tt) {
return "\x1b[0m" + tt
}
export function Bright(tt) {
return t("\x1b[1m", tt)
}
export function Dim(tt) {
return t("\x1b[2m", tt)
}
export function Underscore(tt) {
return t("\x1b[4m", tt)
}
export function Blink(tt) {
return t("\x1b[5m", tt)
}
export function Reverse(tt) {
return t("\x1b[7m", tt)
}
export function Hidden(tt) {
return t("\x1b[8m", tt)
}
export function Black(tt) {
return t("\x1b[30m", tt)
}
export function Red(tt) {
return t("\x1b[31m", tt)
}
export function Green(tt) {
return t("\x1b[32m", tt)
}
export function Yellow(tt) {
return t("\x1b[33m", tt)
}
export function Blue(tt) {
return t("\x1b[34m", tt)
}
export function Magenta(tt) {
return t("\x1b[35m", tt)
}
export function Cyan(tt) {
return t("\x1b[36m", tt)
}
export function White(tt) {
return t("\x1b[37m", tt)
}

11
src/modules/sub/crypto.js Normal file
View File

@@ -0,0 +1,11 @@
import { createHmac, createHash, randomUUID } from "crypto";
export function encrypt(str, salt) {
return createHmac("sha256", salt).update(str).digest("hex");
}
export function md5(string) {
return createHash('md5').update(string).digest('hex');
}
export function UUID() {
return randomUUID();
}

View File

@@ -0,0 +1,10 @@
import { execSync } from "child_process";
export function shortCommit() {
return execSync('git rev-parse --short HEAD').toString().trim()
}
export function getCommitInfo() {
let d = execSync(`git show -s --format='%s;;;%B'`).toString().trim().replace(/[\r\n]/gm, '\n').split(';;;')
d[1] = d[1].replace(d[0], '').trim().toString().replace(/[\r\n]/gm, '<br>')
return d
}

11
src/modules/sub/errors.js Normal file
View File

@@ -0,0 +1,11 @@
import loc from "./i18n.js";
export function internalError(res) {
res.status(501).json({ status: "error", text: "Internal Server Error" });
}
export function errorUnsupported(lang) {
return loc(lang, 'apiError', 'notSupported') + loc(lang, 'apiError', 'letMeKnow');
}
export function genericError(lang, host) {
return loc(lang, 'apiError', 'brokenLink', host) + loc(lang, 'apiError', 'letMeKnow');
}

22
src/modules/sub/i18n.js Normal file
View File

@@ -0,0 +1,22 @@
import { supportedLanguages, appName, repo } from "../config.js";
import loadJson from "./load-json.js";
export default function(lang, cat, string, replacement) {
if (!supportedLanguages.includes(lang)) {
lang = 'en'
}
try {
let str = loadJson(`./src/i18n/${lang}/${cat}.json`);
if (str && str[string]) {
let s = str[string].replace(/\n/g, '<br/>').replace(/{appName}/g, appName).replace(/{repo}/g, repo)
if (replacement) {
s = s.replace(/{s}/g, replacement)
}
return s + ' '
} else {
return string
}
} catch (e) {
return string
}
}

View File

@@ -0,0 +1,9 @@
import * as fs from "fs";
export default function(path) {
try {
return JSON.parse(fs.readFileSync(path, 'utf-8'))
} catch(e) {
return false
}
}

485
src/static/cobalt.css Normal file
View File

@@ -0,0 +1,485 @@
:root {
--transparent: rgba(0, 0, 0, 0);
--without-padding: calc(100% - 4rem);
--border-15: 0.15rem solid var(--accent);
}
@media (prefers-color-scheme: dark) {
:root {
--accent: rgb(225, 225, 225);
--accent-hover: rgb(20, 20, 20);
--accent-press: rgb(10, 10, 10);
--accent-unhover: rgb(100, 100, 100);
--accent-unhover-2: rgb(110, 110, 110);
--background: rgb(0, 0, 0);
}
}
@media (prefers-color-scheme: light) {
:root {
--accent: rgb(25, 25, 25);
--accent-hover: rgb(230 230 230);
--accent-press: rgb(240 240 240);
--accent-unhover: rgb(190, 190, 190);
--accent-unhover-2: rgb(110, 110, 110);
--background: rgb(255, 255, 255);
}
}
[data-theme="dark"] {
--accent: rgb(225, 225, 225);
--accent-hover: rgb(20, 20, 20);
--accent-press: rgb(10, 10, 10);
--accent-unhover: rgb(100, 100, 100);
--accent-unhover-2: rgb(110, 110, 110);
--background: rgb(0, 0, 0);
}
[data-theme="light"] {
--accent: rgb(25, 25, 25);
--accent-hover: rgb(230 230 230);
--accent-press: rgb(240 240 240);
--accent-unhover: rgb(190, 190, 190);
--accent-unhover-2: rgb(110, 110, 110);
--background: rgb(255, 255, 255);
}
html,
body {
margin: 0;
background: var(--background);
color: var(--accent);
font-family: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace;
user-select: none;
-webkit-tap-highlight-color: var(--transparent);
overflow: hidden;
-ms-overflow-style: none;
scrollbar-width: none;
}
a {
color: var(--accent);
text-decoration: none;
}
::placeholder {
color: var(--accent-unhover-2);
}
::-webkit-scrollbar {
display: none;
}
:focus-visible {
outline: var(--border-15);
}
[type=checkbox] {
margin-right: 0.8rem;
}
[type="checkbox"] {
-webkit-appearance: none;
margin-right: 0.8rem;
z-index: 0;
}
[type="checkbox"]::before {
content: "";
width: 15px;
height: 15px;
border: var(--border-15);
background-color: var(--background);
display: block;
z-index: 5;
position: relative;
}
[type="checkbox"]:checked::before {
box-shadow: inset 0 0 0 0.2rem var(--background);
background-color: var(--accent);
}
button {
background: none;
border: none;
font-family: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace;
color: var(--accent);
font-size: 0.9rem;
}
input {
border-radius: none;
}
button:hover,
.switch:hover,
.checkbox:hover,
.text-to-copy:hover {
background: var(--accent-hover);
cursor: pointer;
}
.switch.text-backdrop:hover,
.switch.text-backdrop:active,
.text-to-copy.text-backdrop:hover,
.text-to-copy.text-backdrop:active {
background: var(--accent);
color: var(--background);
}
button:active,
.switch:active,
.checkbox:active,
.text-to-copy:active {
background: var(--accent-press);
cursor: pointer;
}
input[type="checkbox"] {
cursor: pointer;
}
.button {
background: none;
border: var(--border-15);
color: var(--accent);
padding: 0.3rem 0.75rem 0.5rem;
font-size: 1rem;
}
.mono {
font-family: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace;
}
.center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
#cobalt-main-box {
position: fixed;
width: 60%;
height: auto;
display: inline-flex;
padding: 3rem;
}
#logo-area {
padding-right: 3rem;
padding-top: 0.1rem;
text-align: left;
font-size: 1rem;
white-space: nowrap;
}
#download-area {
display: inline-flex;
height: 2rem;
width: 100%;
margin-top: -0.6rem;
}
.box {
background: var(--background);
border: var(--border-15);
color: var(--accent);
}
#url-input-area {
background: var(--background);
padding: 1.2rem 1rem;
width: 100%;
color: var(--accent);
border: 0;
float: right;
border-bottom: 0.1rem solid var(--accent-unhover);
transition: border-bottom 0.2s;
outline: none;
}
#url-input-area:focus {
outline: none;
border-bottom: 0.1rem solid var(--accent);
}
#download-button {
height: 2.5rem;
color: var(--accent);
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
padding: 0;
letter-spacing: -0.1rem;
}
#download-button:disabled {
color: var(--accent-unhover);
cursor: not-allowed;
}
#footer {
bottom: 0rem;
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.9rem;
text-align: center;
width: 90%;
}
.footer-button {
cursor: pointer;
color: var(--accent-unhover-2);
border: 0.15rem var(--accent-unhover-2) solid;
padding: 0.4rem 0.8rem 0.5rem;
margin-bottom: 0.5rem;
}
.footer-button:hover {
color: var(--accent);
border: var(--border-15);
}
.text-backdrop {
background: var(--accent);
color: var(--background);
padding: 0 0.1rem;
}
::-moz-selection {
background-color: var(--accent);
color: var(--background);
}
::selection {
background-color: var(--accent);
color: var(--background);
}
.popup {
visibility: hidden;
position: fixed;
height: auto;
width: 30%;
z-index: 999;
padding: 3rem 2rem 2rem 2rem;
font-size: 0.9rem;
max-height: 80%;
}
.popup-big {
width: 55%;
}
#popup-backdrop {
opacity: 0.5;
background-color: var(--background);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 998;
}
.popup.scrollable {
height: 80%;
}
.nowrap {
white-space: nowrap;
}
.about-padding {
padding-bottom: 1.5rem;
}
.popup-subtitle {
font-size: 1.1rem;
padding-bottom: 0.5rem;
}
.little-subtitle {
font-size: 1.05rem;
}
.popup-desc {
width: 100%;
text-align: left;
float: left;
line-height: 1.7rem;
}
.popup-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
line-height: 1.85em;
}
.popup-footer {
bottom: 0;
position: fixed;
margin-bottom: 1.5rem;
background: var(--background);
width: var(--without-padding);
}
.popup-footer-content {
font-size: 0.8rem;
line-height: 1.7rem;
color: var(--accent-unhover-2);
border-top: 0.05rem solid var(--accent-unhover-2);
padding-top: 0.4rem;
}
.popup-above-title {
color: var(--accent-unhover-2);
font-size: 0.8rem;
}
.popup-content {
overflow-x: hidden;
overflow-y: auto;
height: var(--without-padding);
scrollbar-width: none;
}
.popup-header {
position: relative;
background: var(--background);
z-index: 999;
}
.popup-content.with-footer {
margin-bottom: 3rem;
}
#close {
cursor: pointer;
float: right;
right: 0rem;
position: absolute;
}
.settings-category {
padding-bottom: 1.2rem;
}
.title {
width: 100%;
text-align: left;
line-height: 1.7rem;
color: var(--accent-unhover-2);
border-bottom: 0.05rem solid var(--accent-unhover-2);
padding-bottom: 0.25rem;
}
.checkbox {
display: inline-flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
align-content: center;
padding: 0.6rem;
padding-right: 1rem;
border: 0.1rem solid;
width: auto;
margin: 0 1rem 0 0;
}
.checkbox-label {
line-height: 1.3rem;
}
.switch-container {
width: 100%;
}
.subtitle {
width: 100%;
text-align: left;
line-height: 1.7rem;
padding-bottom: 0.4rem;
color: var(--accent);
margin-top: 1rem;
}
.small-padding .subtitle {
margin-top: 0.5rem;
}
.explanation {
padding-top: 1rem;
width: 100%;
font-size: 0.8rem;
text-align: left;
line-height: 1.3rem;
color: var(--accent-unhover-2);
}
.switch {
border-top: solid 0.1rem var(--accent);
border-bottom: solid 0.1rem var(--accent);
padding: 0.8rem;
width: 100%;
text-align: center;
color: var(--accent);
background: var(--background);
display: grid;
align-items: center;
cursor: pointer;
}
.switch.full {
border: solid 0.1rem var(--accent);
}
.switch.left {
border-left: solid 0.1rem var(--accent);
}
.switch.right {
border-right: solid 0.1rem var(--accent);
}
.switch.space-right {
margin-right: 1rem
}
.switch[data-enabled="true"] {
color: var(--background);
background: var(--accent);
cursor: default;
}
.switches {
display: flex;
width: auto;
flex-direction: row;
flex-wrap: nowrap;
}
.text-to-copy {
user-select: text;
border: var(--border-15);
padding: 1rem;
overflow: auto;
}
/* adapt the page according to screen size */
@media screen and (min-width: 2300px) {
html {
zoom: 130%;
}
}
@media screen and (min-width: 3840px) {
html {
zoom: 180%;
}
}
@media screen and (min-width: 5000px) {
html {
zoom: 300%;
}
}
@media screen and (max-width: 1440px) {
#cobalt-main-box {
width: 65%;
}
.popup {
width: 40%;
}
}
@media screen and (max-width: 1024px) {
#cobalt-main-box {
width: 75%;
}
.popup {
width: 60%;
}
}
@media screen and (max-height: 850px) {
.popup {
height: 80%
}
}
/* mobile page */
@media screen and (max-width: 949px) {
#logo-area {
padding-right: 0;
padding-top: 0;
position: fixed;
line-height: 0;
margin-top: -2rem;
width: 100%;
text-align: center;
}
#cobalt-main-box {
width: 80%;
display: flex;
border: none;
padding: 0;
}
.popup, .popup.scrollable {
border: none;
width: 90%;
height: 90%;
max-height: 100%;
}
}
@media screen and (max-width: 524px) {
#logo-area {
padding-right: 0;
padding-top: 0;
position: fixed;
line-height: 0;
margin-top: -2rem;
width: 100%;
text-align: center;
}
#cobalt-main-box {
width: 90%;
display: flex;
border: none;
padding: 0;
}
.popup, .popup.scrollable {
border: none;
width: 90%;
height: 90%;
max-height: 100%;
}
}

197
src/static/cobalt.js Normal file
View File

@@ -0,0 +1,197 @@
let isIOS = navigator.userAgent.toLowerCase().match("iphone os");
let switchers = {
"theme": ["auto", "light", "dark"],
"youtubeFormat": ["mp4", "webm", "audio"],
"quality": ["max", "hig", "mid", "low"]
}
function eid(id) {
return document.getElementById(id)
}
function enable(id) {
eid(id).dataset.enabled = "true";
}
function disable(id) {
eid(id).dataset.enabled = "false";
}
function vis(state) {
return (state === 1) ? "visible" : "hidden";
}
function changeDownloadButton(action, text) {
switch (action) {
case 0:
eid("download-button").disabled = true
if (localStorage.getItem("alwaysVisibleButton") == "true") {
eid("download-button").value = text
eid("download-button").style.padding = '0 1rem'
} else {
eid("download-button").value = ''
eid("download-button").style.padding = '0'
}
break;
case 1:
eid("download-button").disabled = false
eid("download-button").value = text
eid("download-button").style.padding = '0 1rem'
break;
case 2:
eid("download-button").disabled = true
eid("download-button").value = text
eid("download-button").style.padding = '0 1rem'
break;
}
}
document.addEventListener("keydown", function(event) {
if (event.key == "Tab") {
eid("download-button").value = '>>'
eid("download-button").style.padding = '0 1rem'
}
})
function button() {
let regex = /https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/.test(eid("url-input-area").value);
regex ? changeDownloadButton(1, '>>') : changeDownloadButton(0, '>>');
}
function copy(id, data) {
let e = document.getElementById(id);
e.classList.add("text-backdrop");
data ? navigator.clipboard.writeText(data) : navigator.clipboard.writeText(e.innerText);
setTimeout(() => { e.classList.remove("text-backdrop") }, 600);
}
function detectColorScheme() {
let theme = "auto";
let localTheme = localStorage.getItem("theme");
if (localTheme) {
theme = localTheme;
} else if (!window.matchMedia) {
theme = "dark"
}
document.documentElement.setAttribute("data-theme", theme);
}
function popup(type, action, text) {
eid("popup-backdrop").style.visibility = vis(action);
switch (type) {
case "about":
eid("popup-about").style.visibility = vis(action);
if (!localStorage.getItem("seenAbout")) localStorage.setItem("seenAbout", "true");
break;
case "error":
eid("desc-error").innerHTML = text;
eid("popup-error").style.visibility = vis(action);
break;
case "download":
if (action == 1) {
eid("pd-download").href = text;
eid("pd-copy").setAttribute("onClick", `copy('pd-copy', '${text}')` );
}
eid("popup-download").style.visibility = vis(action);
break;
default:
eid(`popup-${type}`).style.visibility = vis(action);
break;
}
}
function changeSwitcher(li, b, u) {
if (u) localStorage.setItem(li, b);
if (b) {
for (i in switchers[li]) {
(switchers[li][i] == b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`)
}
if (li == "theme") detectColorScheme();
} else {
localStorage.setItem(li, switchers[li][0]);
for (i in switchers[li]) {
(switchers[li][i] == switchers[li][0]) ? enable(`${li}-${switchers[li][0]}`) : disable(`${li}-${switchers[li][i]}`)
}
}
}
function internetError() {
eid("url-input-area").disabled = false
changeDownloadButton(2, '!!')
popup("error", 1, loc.noInternet);
}
function checkbox(action) {
if (eid(action).checked) {
localStorage.setItem(action, "true");
if (action == "alwaysVisibleButton") button();
} else {
localStorage.setItem(action, "false");
if (action == "alwaysVisibleButton") button();
}
}
function loadSettings() {
if (localStorage.getItem("alwaysVisibleButton") == "true") {
eid("alwaysVisibleButton").checked = true;
eid("download-button").value = '>>'
eid("download-button").style.padding = '0 1rem';
}
if (localStorage.getItem("downloadPopup") == "true" && !isIOS) {
eid("downloadPopup").checked = true;
}
changeSwitcher("theme", localStorage.getItem("theme"))
changeSwitcher("youtubeFormat", localStorage.getItem("youtubeFormat"))
changeSwitcher("quality", localStorage.getItem("quality"))
}
async function download(url) {
changeDownloadButton(2, '...');
eid("url-input-area").disabled = true;
let format = '';
if (url.includes("youtube.com/") || url.includes("/youtu.be/")) {
format = `&format=${localStorage.getItem("youtubeFormat")}`
}
fetch(`/api/json?quality=${localStorage.getItem("quality")}${format}&url=${encodeURIComponent(url)}`).then(async (response) => {
let j = await response.json();
if (j.status != "error" && j.status != "rate-limit") {
switch (j.status) {
case "redirect":
changeDownloadButton(2, '>>>')
setTimeout(() => {
changeDownloadButton(1, '>>')
eid("url-input-area").disabled = false
}, 3000)
if (localStorage.getItem("downloadPopup") == "true") {
popup('download', 1, j.url)
} else {
window.open(j.url, '_blank');
}
break;
case "stream":
changeDownloadButton(2, '?..')
fetch(`${j.url}&p=1&origin=front`).then(async (response) => {
let jp = await response.json();
if (jp.status == "continue") {
changeDownloadButton(2, '>>>')
window.location.href = j.url
setTimeout(() => {
changeDownloadButton(1, '>>')
eid("url-input-area").disabled = false
}, 5000)
} else {
eid("url-input-area").disabled = false
changeDownloadButton(2, '!!')
popup("error", 1, jp.text);
}
}).catch((error) => internetError());
break;
}
} else {
eid("url-input-area").disabled = false
changeDownloadButton(2, '!!')
popup("error", 1, j.text);
}
}).catch((error) => internetError());
}
window.onload = function () {
loadSettings();
detectColorScheme();
changeDownloadButton(0, '>>');
eid("cobalt-main-box").style.visibility = 'visible';
eid("footer").style.visibility = 'visible';
eid("url-input-area").value = "";
if (!localStorage.getItem("seenAbout")) popup('about', 1);
if (isIOS) localStorage.setItem("downloadPopup", "true");
}
eid("url-input-area").addEventListener("keyup", (event) => {
if (event.key === 'Enter') {
eid("download-button").click();
}
})

View File

@@ -0,0 +1,64 @@
{
"name": "cobalt",
"short_name": "cobalt",
"start_url": "/",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}, {
"src": "/icons/generic.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}, {
"src": "/icons/maskable/x48.png",
"sizes": "48x48",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}, {
"src": "/icons/maskable/x1280.png",
"sizes": "1280x1280",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

View File

@@ -0,0 +1 @@
@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_DdVXQQ.woff2') format('woff2');unicode-range:U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_ndVXQQ.woff2') format('woff2');unicode-range:U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_HdVXQQ.woff2') format('woff2');unicode-range:U+1F00-1FFF}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_7dVXQQ.woff2') format('woff2');unicode-range:U+0370-03FF}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_LdVXQQ.woff2') format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_PdVXQQ.woff2') format('woff2');unicode-range:U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF}@font-face{font-family:'Noto Sans Mono';font-style:normal;font-weight:500;font-stretch:100%;font-display:swap;src:url('notosansmono_3dVQ.woff2') format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
src/static/icons/wide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

2
src/static/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow: /icons/ /fonts/ *.js *.css