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
127
src/cobalt.js
Normal 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
@@ -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"]
|
||||
}
|
||||
}
|
||||
11
src/i18n/en/accessibility.json
Normal 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
@@ -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
@@ -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": ">> 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": ">> 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": ">> see previous changes and contribute on github"
|
||||
}
|
||||
21
src/i18n/en/settings.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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) })
|
||||
}
|
||||
}
|
||||
202
src/modules/page-renderer.js
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
72
src/modules/services/all.json
Normal 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
|
||||
}
|
||||
}
|
||||
34
src/modules/services/bilibili.js
Normal 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') };
|
||||
}
|
||||
}
|
||||
|
||||
17
src/modules/services/reddit.js
Normal 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") };
|
||||
}
|
||||
}
|
||||
57
src/modules/services/twitter.js
Normal 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") };
|
||||
}
|
||||
}
|
||||
47
src/modules/services/vk.js
Normal 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') };
|
||||
}
|
||||
}
|
||||
74
src/modules/services/youtube.js
Normal 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
@@ -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()
|
||||
}
|
||||
45
src/modules/stream/manage.js
Normal 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" } };
|
||||
}
|
||||
}
|
||||
32
src/modules/stream/select-quality.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/modules/stream/stream.js
Normal 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
@@ -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);
|
||||
}
|
||||
}
|
||||
51
src/modules/sub/api-helper.js
Normal 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
|
||||
}
|
||||
49
src/modules/sub/console-text.js
Normal 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
@@ -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();
|
||||
}
|
||||
10
src/modules/sub/current-commit.js
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
9
src/modules/sub/load-json.js
Normal 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
@@ -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
@@ -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();
|
||||
}
|
||||
})
|
||||
64
src/static/cobalt.webmanifest
Normal 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"
|
||||
}
|
||||
1
src/static/fonts/notosansmono/notosansmono.css
Normal 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}
|
||||
BIN
src/static/fonts/notosansmono/notosansmono_3dVQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_7dVXQQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_DdVXQQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_HdVXQQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_LdVXQQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_PdVXQQ.woff2
Normal file
BIN
src/static/fonts/notosansmono/notosansmono_ndVXQQ.woff2
Normal file
BIN
src/static/icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/static/icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/static/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src/static/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
src/static/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 365 B |
BIN
src/static/icons/favicon.ico
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
src/static/icons/generic.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src/static/icons/maskable/x128.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src/static/icons/maskable/x1280.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src/static/icons/maskable/x192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/static/icons/maskable/x384.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/static/icons/maskable/x48.png
Normal file
|
After Width: | Height: | Size: 854 B |
BIN
src/static/icons/maskable/x512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/static/icons/maskable/x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/static/icons/maskable/x96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/static/icons/wide.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
2
src/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow: /icons/ /fonts/ *.js *.css
|
||||