moved to new repo

This commit is contained in:
wukko 2022-07-09 00:17:56 +06:00 committed by GitHub
parent 1decab4daf
commit 94acf10e9e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
62 changed files with 2187 additions and 4 deletions

View file

@ -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
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.>
Copyright (C) <year> <name of author>
cobalt is possibly the nicest social media downloader out there.
Copyright (C) 2022 wukko
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
@ -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
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 is free software, and you are welcome to redistribute it
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
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
<https://www.gnu.org/licenses/why-not-lgpl.html>.

64
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View file

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

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 B

BIN
files/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
files/icons/generic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
files/icons/wide.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

2
files/robots.txt Normal file
View file

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

13
jsconfig.json Normal file
View 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
View file

@ -0,0 +1,35 @@
import UrlPattern from "url-pattern";
import { services as patterns } from "./config.js";
import { cleanURL, apiJSON } from "./sub/api-helper.js";
import { errorUnsupported } from "./sub/errors.js";
import loc from "./sub/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
View 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
View 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
View file

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

View file

@ -0,0 +1,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') };
}
}

View 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") };
}
}

View 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
View 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') };
}
}

View 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
View file

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

45
modules/stream/manage.js Normal file
View file

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

View file

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

27
modules/stream/stream.js Normal file
View file

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

131
modules/stream/types.js Normal file
View 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
View file

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

View file

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

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

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

View file

@ -0,0 +1,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
View 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
View file

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

22
modules/sub/loc.js Normal file
View 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
View 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
View 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"
}
}

View 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
View 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."
}

View 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": "&gt;&gt; see previous changes and contribute on github"
}

15
strings/en/desc.json Normal file
View 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": "&gt;&gt; report issues and check out the source code on github",
"popupBottom": "made with <3 by wukko",
"noScript": "{appName} uses javascript for api requests and interactive interface. you have to allow javascript to use this site. we don't have 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": "&gt;&gt; let me know if currency you want to donate isn't listed",
"clicktocopy": "click to copy"
}

20
strings/en/settings.json Normal file
View 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
View file

@ -0,0 +1,7 @@
{
"about": "owo what's this?",
"settings": "settings",
"error": "uh-oh...",
"changelog": "what's new?",
"donate": "support {appName}"
}