moved to new repo
8
LICENSE
|
@ -631,8 +631,8 @@ to attach them to the start of each source file to most effectively
|
||||||
state the exclusion of warranty; and each file should have at least
|
state the exclusion of warranty; and each file should have at least
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
cobalt is possibly the nicest social media downloader out there.
|
||||||
Copyright (C) <year> <name of author>
|
Copyright (C) 2022 wukko
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
|
||||||
If the program does terminal interaction, make it output a short
|
If the program does terminal interaction, make it output a short
|
||||||
notice like this when it starts in an interactive mode:
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
cobalt Copyright (C) 2022 wukko
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
This is free software, and you are welcome to redistribute it
|
This is free software, and you are welcome to redistribute it
|
||||||
under certain conditions; type `show c' for details.
|
under certain conditions; type `show c' for details.
|
||||||
|
@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you
|
||||||
may consider it more useful to permit linking proprietary applications with
|
may consider it more useful to permit linking proprietary applications with
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
Public License instead of this License. But first, please read
|
Public License instead of this License. But first, please read
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
64
README.md
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
# cobalt
|
||||||
|
Sleek and easy to use social media downloader built on JavaScript. Try it out live: [co.wukko.me](https://co.wukko.me/)!
|
||||||
|
|
||||||
|
![cobalt logo](https://raw.githubusercontent.com/wukko/cobalt/master/files/icons/wide.png "cobalt logo")
|
||||||
|
|
||||||
|
## What is cobalt?
|
||||||
|
Everyone is annoyed by the mess video downloaders are on the web, and cobalt aims to be the ultimate social media downloader, that is sleek, easy to use, and doesn't bother you with ads or privacy invasion agreement popups.
|
||||||
|
|
||||||
|
cobalt doesn't remux any videos, so videos you get are max quality available (unless you change that in settings).
|
||||||
|
|
||||||
|
## What's supported?
|
||||||
|
- Twitter
|
||||||
|
- YouTube and YouTube Music
|
||||||
|
- bilibili.com
|
||||||
|
- Reddit
|
||||||
|
- VK
|
||||||
|
|
||||||
|
## Stuff that has to be done
|
||||||
|
- [ ] Quality switching for bilibili and Twitter
|
||||||
|
- [ ] Clean up the mess that localisation is right now
|
||||||
|
- [ ] Sort contents of .json files
|
||||||
|
- [ ] Rename each entry key to be less linked to specific service (entries like youtubeBroke are awful, I'm sorry)
|
||||||
|
- [ ] Add support for more languages when localisation clean up is done
|
||||||
|
- [ ] Clean up css
|
||||||
|
- [ ] Use esmbuild to minify frontend css and js
|
||||||
|
- [ ] Make switch buttons in settings selectable with keyboard
|
||||||
|
- [ ] Do something about changelog because the way it is right now is not really great
|
||||||
|
- [ ] Remake page rendering module to be more versatile
|
||||||
|
- [ ] Clean up code to be more consistent across modules
|
||||||
|
- [ ] Matching could be redone, I'll see what I can do
|
||||||
|
- [ ] Facebook and Instagram support
|
||||||
|
- [ ] TikTok support (?)
|
||||||
|
- [ ] Support for bilibili.tv (?)
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
This is my passion project, so update scheduele depends on my motivation. Don't expect any consistency in that.
|
||||||
|
|
||||||
|
## Make your own homegrown cobalt
|
||||||
|
Code might be a little messy, but I promise to improve it over time.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Node.js 14.16 or newer
|
||||||
|
- git
|
||||||
|
|
||||||
|
### npm modules
|
||||||
|
- express
|
||||||
|
- got
|
||||||
|
- url-pattern
|
||||||
|
- xml-js
|
||||||
|
- dotenv
|
||||||
|
- express-rate-limit
|
||||||
|
- ffmpeg-static
|
||||||
|
- node-cache
|
||||||
|
- ytdl-core
|
||||||
|
|
||||||
|
Setup script installs all needed **npm** dependencies, but you have to install Node.js and git yourself, if you don't have those already.
|
||||||
|
|
||||||
|
1. Clone the repo: `git clone https://github.com/wukko/cobalt`
|
||||||
|
2. Run setup script and follow instructions: `npm run setup`
|
||||||
|
3. Run cobalt via `npm start` or `node cobalt`
|
||||||
|
4. Done.
|
||||||
|
|
||||||
|
## License
|
||||||
|
cobalt is under [GPL-3.0 license](https://github.com/wukko/cobalt/LICENSE), keep that in mind when doing something with it.
|
126
cobalt.js
Normal file
|
@ -0,0 +1,126 @@
|
||||||
|
import "dotenv/config"
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
|
||||||
|
import currentCommit 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/loc.js";
|
||||||
|
import { Bright, Cyan } from "./modules/sub/console-text.js";
|
||||||
|
import stream from "./modules/stream/stream.js";
|
||||||
|
|
||||||
|
const commitHash = currentCommit();
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
if (fs.existsSync('./.env') && fs.existsSync('./config.json')) {
|
||||||
|
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.set('etag', 'strong');
|
||||||
|
app.use('/api/', apiLimiter);
|
||||||
|
app.use('/api/stream', apiLimiterStream);
|
||||||
|
app.use('/', express.static('files'));
|
||||||
|
|
||||||
|
// avoid the %% URIError
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
try {
|
||||||
|
decodeURIComponent(req.path)
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
return res.redirect(process.env.selfURL);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/:type', 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) : "webm",
|
||||||
|
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.')
|
||||||
|
}
|
31
config.json
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"appName": "cobalt",
|
||||||
|
"version": "2.1",
|
||||||
|
"streamLifespan": 1800000,
|
||||||
|
"maxVideoDuration": 1920000,
|
||||||
|
"genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.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",
|
||||||
|
"twitter": "uwukko"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"hig": "1080",
|
||||||
|
"mid": "720",
|
||||||
|
"low": "480"
|
||||||
|
}
|
||||||
|
}
|
488
files/cobalt.css
Normal file
|
@ -0,0 +1,488 @@
|
||||||
|
: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 {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:active,
|
||||||
|
.switch:active,
|
||||||
|
.checkbox: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: 0.5rem;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
#footer-buttons {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.footer-button {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent-unhover-2);
|
||||||
|
border: 0.15rem var(--accent-unhover-2) solid;
|
||||||
|
padding: 0.4rem 0.8rem 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;
|
||||||
|
width: 55%;
|
||||||
|
height: auto;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-height: 80%;
|
||||||
|
}
|
||||||
|
#popup-backdrop {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: var(--background);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 998;
|
||||||
|
}
|
||||||
|
.popup-narrow {
|
||||||
|
visibility: hidden;
|
||||||
|
position: fixed;
|
||||||
|
width: 30%;
|
||||||
|
height: auto;
|
||||||
|
z-index: 999;
|
||||||
|
padding: 3rem 2rem 2rem 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
max-height: 80%;
|
||||||
|
}
|
||||||
|
.popup-narrow.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-narrow .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: 3rem;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
.popup-narrow #close {
|
||||||
|
right: 0rem;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.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[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,
|
||||||
|
.popup {
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
.popup-narrow {
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 1024px) {
|
||||||
|
#cobalt-main-box,
|
||||||
|
.popup {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
.popup-narrow {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (max-height: 850px) {
|
||||||
|
.popup-narrow {
|
||||||
|
height: 80%
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* mobile page */
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
#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-narrow,
|
||||||
|
.popup-narrow.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-narrow,
|
||||||
|
.popup-narrow.scrollable {
|
||||||
|
border: none;
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
214
files/cobalt.js
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
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) {
|
||||||
|
let e = document.getElementById(id);
|
||||||
|
e.classList.add("text-backdrop");
|
||||||
|
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 "settings":
|
||||||
|
eid("popup-settings").style.visibility = vis(action);
|
||||||
|
break;
|
||||||
|
case "changelog":
|
||||||
|
eid("popup-changelog").style.visibility = vis(action);
|
||||||
|
break;
|
||||||
|
case "donate":
|
||||||
|
eid("popup-donate").style.visibility = vis(action);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function changeSwitcher(li, b, u) {
|
||||||
|
if (u) {
|
||||||
|
localStorage.setItem(li, b);
|
||||||
|
}
|
||||||
|
let l = {
|
||||||
|
"theme": ["auto", "light", "dark"],
|
||||||
|
"youtubeFormat": ["mp4", "webm", "audio"],
|
||||||
|
"quality": ["max", "hig", "mid", "low"]
|
||||||
|
}
|
||||||
|
if (b) {
|
||||||
|
for (i in l[li]) {
|
||||||
|
if (l[li][i] == b) {
|
||||||
|
enable(`${li}-${b}`)
|
||||||
|
} else {
|
||||||
|
disable(`${li}-${l[li][i]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (li == "theme") {
|
||||||
|
detectColorScheme();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(li, l[li][0]);
|
||||||
|
for (i in l[li]) {
|
||||||
|
if (l[li][i] == l[li][0]) {
|
||||||
|
enable(`${li}-${l[li][0]}`)
|
||||||
|
} else {
|
||||||
|
disable(`${li}-${l[li][i]}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function internetError() {
|
||||||
|
eid("url-input-area").disabled = false
|
||||||
|
changeDownloadButton(2, '!!')
|
||||||
|
popup("error", 1, loc.noInternet);
|
||||||
|
}
|
||||||
|
function checkbox(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'alwaysVisibleButton':
|
||||||
|
if (eid("always-visible-button").checked) {
|
||||||
|
localStorage.setItem("alwaysVisibleButton", "true");
|
||||||
|
button();
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("alwaysVisibleButton", "false");
|
||||||
|
button();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function loadSettings() {
|
||||||
|
if (localStorage.getItem("alwaysVisibleButton") == "true") {
|
||||||
|
eid("always-visible-button").checked = true;
|
||||||
|
eid("download-button").value = '>>'
|
||||||
|
eid("download-button").style.padding = '0 1rem';
|
||||||
|
}
|
||||||
|
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 (navigator.userAgent.toLowerCase().match("iphone os")) {
|
||||||
|
window.location.href = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eid("url-input-area").addEventListener("keyup", (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
eid("download-button").click();
|
||||||
|
}
|
||||||
|
})
|
64
files/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
files/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
files/fonts/notosansmono/notosansmono_3dVQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_7dVXQQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_DdVXQQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_HdVXQQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_LdVXQQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_PdVXQQ.woff2
Normal file
BIN
files/fonts/notosansmono/notosansmono_ndVXQQ.woff2
Normal file
BIN
files/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
files/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
files/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
files/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 215 B |
BIN
files/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 365 B |
BIN
files/icons/favicon.ico
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
files/icons/generic.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
files/icons/maskable/x128.png
Normal file
After Width: | Height: | Size: 2.6 KiB |
BIN
files/icons/maskable/x1280.png
Normal file
After Width: | Height: | Size: 39 KiB |
BIN
files/icons/maskable/x192.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
files/icons/maskable/x384.png
Normal file
After Width: | Height: | Size: 8.5 KiB |
BIN
files/icons/maskable/x48.png
Normal file
After Width: | Height: | Size: 854 B |
BIN
files/icons/maskable/x512.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
files/icons/maskable/x72.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
files/icons/maskable/x96.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
files/icons/wide.png
Normal file
After Width: | Height: | Size: 13 KiB |
2
files/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow: /icons/ /fonts/ *.js *.css
|
13
jsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2020",
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"**/node_modules/*"
|
||||||
|
]
|
||||||
|
}
|
35
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/loc.js";
|
||||||
|
import match from "./sub/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') });
|
||||||
|
}
|
||||||
|
}
|
18
modules/config.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import loadJson from "./sub/load-json.js";
|
||||||
|
|
||||||
|
let config = loadJson("./config.json");
|
||||||
|
let services = loadJson("./modules/services/all.json");
|
||||||
|
|
||||||
|
let appName = config.appName
|
||||||
|
let version = config.version
|
||||||
|
let streamLifespan = config.streamLifespan
|
||||||
|
let maxVideoDuration = config.maxVideoDuration
|
||||||
|
let genericUserAgent = config.genericUserAgent
|
||||||
|
let repo = config.repo
|
||||||
|
let authorInfo = config.authorInfo
|
||||||
|
let supportedLanguages = config.supportedLanguages
|
||||||
|
let quality = config.quality
|
||||||
|
let internetExplorerRedirect = config.internetExplorerRedirect
|
||||||
|
let donations = config.donations
|
||||||
|
|
||||||
|
export {appName, version, streamLifespan, maxVideoDuration, genericUserAgent, repo, authorInfo, services, supportedLanguages, quality, internetExplorerRedirect, donations}
|
171
modules/page-renderer.js
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import { services, appName, authorInfo, version, quality, repo, donations } from "./config.js";
|
||||||
|
import loc from "./sub/loc.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>`
|
||||||
|
}
|
||||||
|
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=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="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-about" class="popup-narrow 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', 'about')}</div>
|
||||||
|
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'support_1')} ${enabledServices}.</div>
|
||||||
|
${isIOS ? `<div id="desc" class="popup-subtitle popup-desc"><span class="text-backdrop">${loc(obj.lang, 'desc', 'iosTitle')}</span></div><div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'ios')}</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-narrow 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">${loc(obj.lang, 'changelog', 'subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
<div id="content" class="popup-content">
|
||||||
|
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'changelog', 'text')}</div>
|
||||||
|
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${repo}">${loc(obj.lang, 'changelog', 'github')}</a></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="popup-donate" class="popup-narrow 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-narrow 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', 'category-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', 'theme-auto')}</div>
|
||||||
|
<div id="theme-dark" class="switch" onclick="changeSwitcher('theme', 'dark', 1)">${loc(obj.lang, 'settings', 'theme-dark')}</div>
|
||||||
|
<div id="theme-light" class="switch full" onclick="changeSwitcher('theme', 'light', 1)">${loc(obj.lang, 'settings', 'theme-light')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="subtitle">${loc(obj.lang, 'settings', 'misc')}</div>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input id="always-visible-button" type="checkbox" aria-label="${loc(obj.lang, 'accessibility', 'alwaysVisibleButton')}" onclick="checkbox('alwaysVisibleButton', 'always-visible-button')">${loc(obj.lang, 'settings', 'always-visible')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="settings-quality" 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', 'q-max')}</div>
|
||||||
|
<div id="quality-hig" class="switch" onclick="changeSwitcher('quality', 'hig', 1)">${loc(obj.lang, 'settings', 'q-hig')}(${quality.hig}p)</div>
|
||||||
|
<div id="quality-mid" class="switch full" onclick="changeSwitcher('quality', 'mid', 1)">${loc(obj.lang, 'settings', 'q-mid')}(${quality.mid}p)</div>
|
||||||
|
<div id="quality-low" class="switch right" onclick="changeSwitcher('quality', 'low', 1)">${loc(obj.lang, 'settings', 'q-low')}(${quality.low}p)</div>
|
||||||
|
</div>
|
||||||
|
<div class="explanation">${loc(obj.lang, 'settings', 'q-desc')}</div>
|
||||||
|
</div>
|
||||||
|
</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', 'format-info')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="popup-error" class="popup center box" style="visibility: hidden;">
|
||||||
|
<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 id="desc-error" class="popup-desc"></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
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
|
||||||
|
}
|
||||||
|
}
|
38
modules/services/bilibili.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import got from "got";
|
||||||
|
import loc from "../sub/loc.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
modules/services/reddit.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import got from "got";
|
||||||
|
import loc from "../sub/loc.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
modules/services/twitter.js
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import got from "got";
|
||||||
|
import loc from "../sub/loc.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
modules/services/vk.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import got from "got";
|
||||||
|
import { xml2json } from "xml-js";
|
||||||
|
import loc from "../sub/loc.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
modules/services/youtube.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import ytdl from "ytdl-core";
|
||||||
|
import loc from "../sub/loc.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"]) {
|
||||||
|
if (obj.isAudioOnly) {
|
||||||
|
obj.format = "webm"
|
||||||
|
obj.quality = "max"
|
||||||
|
}
|
||||||
|
let selectedVideo, videoMatch = [], video = [], audio = info.filter((a) => {
|
||||||
|
if (!a["isLive"] && !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["isLive"] && !a["isHLS"] && !a["isDashMPD"] && !a["hasAudio"] && a["hasVideo"] && a["container"] == obj.format && a["height"] != 4320) {
|
||||||
|
if (obj.quality != "max" && mq[obj.quality] == a["height"]) {
|
||||||
|
videoMatch.push(a)
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
||||||
|
selectedVideo = video[0]
|
||||||
|
if (obj.quality != "max") {
|
||||||
|
if (videoMatch.length > 0) {
|
||||||
|
selectedVideo = videoMatch[0]
|
||||||
|
} else {
|
||||||
|
let ss = selectQuality("youtube", obj.quality, video[0]["height"])
|
||||||
|
selectedVideo = video.filter((a) => {
|
||||||
|
if (a["height"] == ss) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
selectedVideo = selectedVideo[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (obj.quality == "los") {
|
||||||
|
selectedVideo = video[video.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audio[0]["approxDurationMs"] <= maxVideoDuration) {
|
||||||
|
if (!obj.isAudioOnly && video.length > 0) {
|
||||||
|
let filename = `youtube_${obj.id}_${selectedVideo["width"]}x${selectedVideo["height"]}.${obj.format}`;
|
||||||
|
if (video.length > 0 && audio.length > 0) {
|
||||||
|
return { type: "render", urls: [selectedVideo["url"], audio[0]["url"]], time: video[0]["approxDurationMs"], filename: filename };
|
||||||
|
} else {
|
||||||
|
return { error: loc('en', 'apiError', 'youtubeBroke') };
|
||||||
|
}
|
||||||
|
} 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
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
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
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
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)
|
||||||
|
}
|
||||||
|
}
|
131
modules/stream/types.js
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import ffmpeg from "ffmpeg-static";
|
||||||
|
import got from "got";
|
||||||
|
import { genericUserAgent } from "../config.js";
|
||||||
|
import { msToTime } from "../sub/api-helper.js";
|
||||||
|
import { internalError } from "../sub/errors.js";
|
||||||
|
import loc from "../sub/loc.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) => {
|
||||||
|
internalError(res);
|
||||||
|
throw Error("File stream pipe error.");
|
||||||
|
});
|
||||||
|
stream.on('error', (err) => {
|
||||||
|
internalError(res);
|
||||||
|
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:a',
|
||||||
|
'-map', '1:v',
|
||||||
|
'-c:v', 'copy',
|
||||||
|
'-c:a', 'copy',
|
||||||
|
];
|
||||||
|
if (format == 'mp4') {
|
||||||
|
args.push('-movflags', 'frag_keyframe+empty_moov');
|
||||||
|
if (streamInfo.service == "youtube") {
|
||||||
|
args.push('-t', msToTime(streamInfo.time));
|
||||||
|
}
|
||||||
|
} else if (format == 'webm') {
|
||||||
|
args.push('-t', msToTime(streamInfo.time));
|
||||||
|
}
|
||||||
|
args.push('-f', format, 'pipe:5');
|
||||||
|
const ffmpegProcess = spawn(ffmpeg, args, {
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: [
|
||||||
|
'inherit', 'inherit', 'inherit',
|
||||||
|
'pipe', 'pipe', 'pipe'
|
||||||
|
],
|
||||||
|
});
|
||||||
|
ffmpegProcess.on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
audio.on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
video.on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
|
||||||
|
ffmpegProcess.stdio[5].pipe(res);
|
||||||
|
audio.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
video.pipe(ffmpegProcess.stdio[4]).on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
} 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();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
audio.on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
|
||||||
|
ffmpegProcess.stdio[4].pipe(res);
|
||||||
|
audio.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
|
||||||
|
ffmpegProcess.kill();
|
||||||
|
internalError(res);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
internalError(res);
|
||||||
|
}
|
||||||
|
}
|
51
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
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
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();
|
||||||
|
}
|
5
modules/sub/current-commit.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
|
export default function() {
|
||||||
|
return execSync('git rev-parse --short HEAD').toString().trim()
|
||||||
|
}
|
11
modules/sub/errors.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import loc from "./loc.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');
|
||||||
|
}
|
9
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
|
||||||
|
}
|
||||||
|
}
|
22
modules/sub/loc.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { supportedLanguages, appName } from "../config.js";
|
||||||
|
import loadJson from "./load-json.js";
|
||||||
|
|
||||||
|
export default function(lang, cat, string, replacement) {
|
||||||
|
if (!lang in supportedLanguages) {
|
||||||
|
lang = 'en'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let str = loadJson(`./strings/${lang}/${cat}.json`);
|
||||||
|
if (str && str[string]) {
|
||||||
|
let s = str[string].replace(/\n/g, '<br/>').replace(/{appName}/g, appName)
|
||||||
|
if (replacement) {
|
||||||
|
s = s.replace(/{s}/g, replacement)
|
||||||
|
}
|
||||||
|
return s + ' '
|
||||||
|
} else {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
}
|
90
modules/sub/match.js
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { apiJSON } from "./api-helper.js";
|
||||||
|
import { errorUnsupported, genericError } from "./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"]) {
|
||||||
|
let fetchInfo = {
|
||||||
|
id: patternMatch["id"].slice(0, 11),
|
||||||
|
lang: lang, quality: quality,
|
||||||
|
format: "mp4"
|
||||||
|
};
|
||||||
|
switch (format) {
|
||||||
|
case "webm":
|
||||||
|
fetchInfo["format"] = "webm";
|
||||||
|
break;
|
||||||
|
case "audio":
|
||||||
|
fetchInfo["format"] = "webm";
|
||||||
|
fetchInfo["isAudioOnly"] = true;
|
||||||
|
fetchInfo["quality"] = "max";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (url.match('music.youtube.com')) {
|
||||||
|
fetchInfo["isAudioOnly"] = true;
|
||||||
|
}
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
}
|
35
package.json
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
"name": "cobalt",
|
||||||
|
"description": "easy to use social media downloader",
|
||||||
|
"version": "2.1",
|
||||||
|
"author": "wukko",
|
||||||
|
"exports": "./cobalt.js",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node cobalt",
|
||||||
|
"setup": "node modules/setup.js"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/wukko/cobalt-web.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/wukko/cobalt-web/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/wukko/cobalt-web#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.0.1",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"express-rate-limit": "^6.3.0",
|
||||||
|
"ffmpeg-static": "^5.0.0",
|
||||||
|
"got": "^12.1.0",
|
||||||
|
"node-cache": "^5.1.2",
|
||||||
|
"url-pattern": "^1.0.3",
|
||||||
|
"xml-js": "^1.6.11",
|
||||||
|
"ytdl-core": "4.11.0"
|
||||||
|
}
|
||||||
|
}
|
10
strings/en/accessibility.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"about": "About",
|
||||||
|
"settings": "Settings",
|
||||||
|
"input": "URL input area",
|
||||||
|
"download": "Download button",
|
||||||
|
"changelog": "Changelog",
|
||||||
|
"close": "Close button",
|
||||||
|
"alwaysVisibleButton": "Keep the download button always visible",
|
||||||
|
"donate": "Support {appName} by donating"
|
||||||
|
}
|
22
strings/en/apiError.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"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": "something is wrong with your link. it's probably missing. i can't guess what you want to download!",
|
||||||
|
"badResolution": "your screen resolution is not supported. try a different device.",
|
||||||
|
"tryAgain": "\ncheck the link and try again.",
|
||||||
|
"letMeKnow": "but if issue persists, please <a class=\"text-backdrop nowrap\" href=\"https://wukko.me/contacts\">let me know</a>.",
|
||||||
|
"fatal": "something went wrong and page couldn't be rendered. 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 {s} minutes.",
|
||||||
|
"youtubeBroke": "something went wrong with info fetching. you can try a different format or just try again later.",
|
||||||
|
"corruptedVideo": "oddly enough the requested video is corrupted on its origin server. youtube does this sometimes because it's fucking stupid.",
|
||||||
|
"corruptedAudio": "oddly enough the requested audio is corrupted on its origin server. youtube does this sometimes because it's fucking stupid.",
|
||||||
|
"noInternet": "it seems like either {appName} api is down or there's no internet. 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 server ip got blocked. try again later.",
|
||||||
|
"noStreamID": "there's no such stream id. you can't fool me!",
|
||||||
|
"noType": "there's no such expected response type."
|
||||||
|
}
|
5
strings/en/changelog.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"subtitle": "everything is new! (v.2.1)",
|
||||||
|
"text": "- added support for: bilibili.com, youtube, youtube music, reddit, vk;\n- remade the way downloads are handled;\n- added proper website branding;\n- added settings, donations, and changelog menu;\n- added manual theme picker;\n- added format picker for youtube;\n- added quality picker for youtube and vk downloads (bilibili and twitter later);\n- improved usability;\n- upgraded the download button to be adaptive depending on current status;\n- popups are now adaptive, too;\n- better scalability;\n- took out trash;\n- moved from commonjs to ems;\n- overall revamp of backend and frontend;\n- fixed various issues that were present in older version.",
|
||||||
|
"github": ">> see previous changes and contribute on github"
|
||||||
|
}
|
15
strings/en/desc.json
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"input": "paste the link here",
|
||||||
|
"about": "{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",
|
||||||
|
"support_1": "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 ads or any trackers, pinky promise.",
|
||||||
|
"ios": "it's impossible to download videos directly from browser on ios. you'll have to open share sheet and select \"save to files\" in order to save videos from twitter. if you don't see that option, you have to install the official \"files\" app from app store first.",
|
||||||
|
"iosTitle": "notice for ios users",
|
||||||
|
"donationsSub": "it's quite complicated to pay for hosting right now",
|
||||||
|
"donations": "you can currently donate only in crypto, because i live under dictatorship and can't use debit cards anywhere outside of country. i don't like crypto the way it is right now, but it's the only way for me to pay for anything (including hosting) online. everything else like paypal is no longer available.",
|
||||||
|
"donateDm": ">> let me know if currency you want to donate isn't listed",
|
||||||
|
"clicktocopy": "click to copy"
|
||||||
|
}
|
20
strings/en/settings.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"category-appearance": "appearance",
|
||||||
|
"always-visible": "keep >> visible",
|
||||||
|
"picker": "always download max quality",
|
||||||
|
"format": "download format",
|
||||||
|
"format-info": "webm videos are usually higher quality but not all devices can play them. select webm only if you need max quality available. audio only downloads will always be max quality.",
|
||||||
|
"theme": "theme",
|
||||||
|
"theme-auto": "auto",
|
||||||
|
"theme-light": "light",
|
||||||
|
"theme-dark": "dark",
|
||||||
|
"misc": "miscellaneous",
|
||||||
|
"general": "downloads",
|
||||||
|
"quality": "quality",
|
||||||
|
"q-max": "max",
|
||||||
|
"q-hig": "high\n",
|
||||||
|
"q-mid": "medium\n",
|
||||||
|
"q-low": "low\n",
|
||||||
|
"q-los": "lowest",
|
||||||
|
"q-desc": "all resolutions listed here are max values. if there's no video of preferred quality closest one gets picked instead."
|
||||||
|
}
|
7
strings/en/title.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"about": "owo what's this?",
|
||||||
|
"settings": "settings",
|
||||||
|
"error": "uh-oh...",
|
||||||
|
"changelog": "what's new?",
|
||||||
|
"donate": "support {appName}"
|
||||||
|
}
|