diff --git a/LICENSE b/LICENSE index f288702..ab7cd99 100644 --- a/LICENSE +++ b/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 the "copyright" line and a pointer to where the full notice is found. - - Copyright (C) + 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: - Copyright (C) + 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 -. +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dc1e6d --- /dev/null +++ b/README.md @@ -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. diff --git a/cobalt.js b/cobalt.js new file mode 100644 index 0000000..f7d3b0e --- /dev/null +++ b/cobalt.js @@ -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.') +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..b969349 --- /dev/null +++ b/config.json @@ -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" + } +} \ No newline at end of file diff --git a/files/cobalt.css b/files/cobalt.css new file mode 100644 index 0000000..1ae1398 --- /dev/null +++ b/files/cobalt.css @@ -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%; + } +} \ No newline at end of file diff --git a/files/cobalt.js b/files/cobalt.js new file mode 100644 index 0000000..f47c5c4 --- /dev/null +++ b/files/cobalt.js @@ -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(); + } +}) \ No newline at end of file diff --git a/files/cobalt.webmanifest b/files/cobalt.webmanifest new file mode 100644 index 0000000..c26bac4 --- /dev/null +++ b/files/cobalt.webmanifest @@ -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" +} \ No newline at end of file diff --git a/files/fonts/notosansmono/notosansmono.css b/files/fonts/notosansmono/notosansmono.css new file mode 100644 index 0000000..f87ca7f --- /dev/null +++ b/files/fonts/notosansmono/notosansmono.css @@ -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} \ No newline at end of file diff --git a/files/fonts/notosansmono/notosansmono_3dVQ.woff2 b/files/fonts/notosansmono/notosansmono_3dVQ.woff2 new file mode 100644 index 0000000..1174c36 Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_3dVQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_7dVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_7dVXQQ.woff2 new file mode 100644 index 0000000..e9fcb07 Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_7dVXQQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_DdVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_DdVXQQ.woff2 new file mode 100644 index 0000000..28bca6f Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_DdVXQQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_HdVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_HdVXQQ.woff2 new file mode 100644 index 0000000..95050fe Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_HdVXQQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_LdVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_LdVXQQ.woff2 new file mode 100644 index 0000000..137ea72 Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_LdVXQQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_PdVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_PdVXQQ.woff2 new file mode 100644 index 0000000..e3267a5 Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_PdVXQQ.woff2 differ diff --git a/files/fonts/notosansmono/notosansmono_ndVXQQ.woff2 b/files/fonts/notosansmono/notosansmono_ndVXQQ.woff2 new file mode 100644 index 0000000..bb374b0 Binary files /dev/null and b/files/fonts/notosansmono/notosansmono_ndVXQQ.woff2 differ diff --git a/files/icons/android-chrome-192x192.png b/files/icons/android-chrome-192x192.png new file mode 100644 index 0000000..0d3ac83 Binary files /dev/null and b/files/icons/android-chrome-192x192.png differ diff --git a/files/icons/android-chrome-512x512.png b/files/icons/android-chrome-512x512.png new file mode 100644 index 0000000..d1777d4 Binary files /dev/null and b/files/icons/android-chrome-512x512.png differ diff --git a/files/icons/apple-touch-icon.png b/files/icons/apple-touch-icon.png new file mode 100644 index 0000000..b2abfa6 Binary files /dev/null and b/files/icons/apple-touch-icon.png differ diff --git a/files/icons/favicon-16x16.png b/files/icons/favicon-16x16.png new file mode 100644 index 0000000..94f9c1a Binary files /dev/null and b/files/icons/favicon-16x16.png differ diff --git a/files/icons/favicon-32x32.png b/files/icons/favicon-32x32.png new file mode 100644 index 0000000..b4e9203 Binary files /dev/null and b/files/icons/favicon-32x32.png differ diff --git a/files/icons/favicon.ico b/files/icons/favicon.ico new file mode 100644 index 0000000..1b08e4b Binary files /dev/null and b/files/icons/favicon.ico differ diff --git a/files/icons/generic.png b/files/icons/generic.png new file mode 100644 index 0000000..d1777d4 Binary files /dev/null and b/files/icons/generic.png differ diff --git a/files/icons/maskable/x128.png b/files/icons/maskable/x128.png new file mode 100644 index 0000000..49a3370 Binary files /dev/null and b/files/icons/maskable/x128.png differ diff --git a/files/icons/maskable/x1280.png b/files/icons/maskable/x1280.png new file mode 100644 index 0000000..7aff5da Binary files /dev/null and b/files/icons/maskable/x1280.png differ diff --git a/files/icons/maskable/x192.png b/files/icons/maskable/x192.png new file mode 100644 index 0000000..0d981b6 Binary files /dev/null and b/files/icons/maskable/x192.png differ diff --git a/files/icons/maskable/x384.png b/files/icons/maskable/x384.png new file mode 100644 index 0000000..e63b50d Binary files /dev/null and b/files/icons/maskable/x384.png differ diff --git a/files/icons/maskable/x48.png b/files/icons/maskable/x48.png new file mode 100644 index 0000000..357a13c Binary files /dev/null and b/files/icons/maskable/x48.png differ diff --git a/files/icons/maskable/x512.png b/files/icons/maskable/x512.png new file mode 100644 index 0000000..a58bb23 Binary files /dev/null and b/files/icons/maskable/x512.png differ diff --git a/files/icons/maskable/x72.png b/files/icons/maskable/x72.png new file mode 100644 index 0000000..b3d7045 Binary files /dev/null and b/files/icons/maskable/x72.png differ diff --git a/files/icons/maskable/x96.png b/files/icons/maskable/x96.png new file mode 100644 index 0000000..2972d60 Binary files /dev/null and b/files/icons/maskable/x96.png differ diff --git a/files/icons/wide.png b/files/icons/wide.png new file mode 100644 index 0000000..012c1f8 Binary files /dev/null and b/files/icons/wide.png differ diff --git a/files/robots.txt b/files/robots.txt new file mode 100644 index 0000000..43eb659 --- /dev/null +++ b/files/robots.txt @@ -0,0 +1,2 @@ +User-Agent: * +Disallow: /icons/ /fonts/ *.js *.css \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..0864127 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2020", + "strictNullChecks": true, + "strictFunctionTypes": true + }, + "exclude": [ + "node_modules", + "**/node_modules/*" + ] +} \ No newline at end of file diff --git a/modules/api.js b/modules/api.js new file mode 100644 index 0000000..69484ab --- /dev/null +++ b/modules/api.js @@ -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') }); + } +} \ No newline at end of file diff --git a/modules/config.js b/modules/config.js new file mode 100644 index 0000000..792df68 --- /dev/null +++ b/modules/config.js @@ -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} \ No newline at end of file diff --git a/modules/page-renderer.js b/modules/page-renderer.js new file mode 100644 index 0000000..83a2fb0 --- /dev/null +++ b/modules/page-renderer.js @@ -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 += `
${i} (${loc("en", 'desc', 'clicktocopy').trim()})
${donations[i]}
` +} +export default function(obj) { + let isIOS = obj.useragent.toLowerCase().match("iphone os") + try { + return ` + + + + + + ${appName} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +`; + } catch (err) { + return `${loc('en', 'apiError', 'fatal', obj.hash)}`; + } +} \ No newline at end of file diff --git a/modules/services/all.json b/modules/services/all.json new file mode 100644 index 0000000..94a89e9 --- /dev/null +++ b/modules/services/all.json @@ -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 + } +} \ No newline at end of file diff --git a/modules/services/bilibili.js b/modules/services/bilibili.js new file mode 100644 index 0000000..7282dad --- /dev/null +++ b/modules/services/bilibili.js @@ -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('')[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') }; + } +} + diff --git a/modules/services/reddit.js b/modules/services/reddit.js new file mode 100644 index 0000000..38e0771 --- /dev/null +++ b/modules/services/reddit.js @@ -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") }; + } +} \ No newline at end of file diff --git a/modules/services/twitter.js b/modules/services/twitter.js new file mode 100644 index 0000000..494e48c --- /dev/null +++ b/modules/services/twitter.js @@ -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") }; + } +} \ No newline at end of file diff --git a/modules/services/vk.js b/modules/services/vk.js new file mode 100644 index 0000000..aa81e56 --- /dev/null +++ b/modules/services/vk.js @@ -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') }; + } +} \ No newline at end of file diff --git a/modules/services/youtube.js b/modules/services/youtube.js new file mode 100644 index 0000000..6f2f4c6 --- /dev/null +++ b/modules/services/youtube.js @@ -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') }; + } +} + diff --git a/modules/setup.js b/modules/setup.js new file mode 100644 index 0000000..d230c19 --- /dev/null +++ b/modules/setup.js @@ -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() +} \ No newline at end of file diff --git a/modules/stream/manage.js b/modules/stream/manage.js new file mode 100644 index 0000000..8a43bd8 --- /dev/null +++ b/modules/stream/manage.js @@ -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" } }; + } +} \ No newline at end of file diff --git a/modules/stream/select-quality.js b/modules/stream/select-quality.js new file mode 100644 index 0000000..2bc2228 --- /dev/null +++ b/modules/stream/select-quality.js @@ -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) + } + } +} \ No newline at end of file diff --git a/modules/stream/stream.js b/modules/stream/stream.js new file mode 100644 index 0000000..b24badb --- /dev/null +++ b/modules/stream/stream.js @@ -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) + } +} diff --git a/modules/stream/types.js b/modules/stream/types.js new file mode 100644 index 0000000..66d20dd --- /dev/null +++ b/modules/stream/types.js @@ -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); + } +} \ No newline at end of file diff --git a/modules/sub/api-helper.js b/modules/sub/api-helper.js new file mode 100644 index 0000000..86c1ab9 --- /dev/null +++ b/modules/sub/api-helper.js @@ -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 +} \ No newline at end of file diff --git a/modules/sub/console-text.js b/modules/sub/console-text.js new file mode 100644 index 0000000..3a4001c --- /dev/null +++ b/modules/sub/console-text.js @@ -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) +} diff --git a/modules/sub/crypto.js b/modules/sub/crypto.js new file mode 100644 index 0000000..4946bf7 --- /dev/null +++ b/modules/sub/crypto.js @@ -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(); +} \ No newline at end of file diff --git a/modules/sub/current-commit.js b/modules/sub/current-commit.js new file mode 100644 index 0000000..b9c37cf --- /dev/null +++ b/modules/sub/current-commit.js @@ -0,0 +1,5 @@ +import { execSync } from "child_process"; + +export default function() { + return execSync('git rev-parse --short HEAD').toString().trim() +} \ No newline at end of file diff --git a/modules/sub/errors.js b/modules/sub/errors.js new file mode 100644 index 0000000..a56cb0e --- /dev/null +++ b/modules/sub/errors.js @@ -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'); +} \ No newline at end of file diff --git a/modules/sub/load-json.js b/modules/sub/load-json.js new file mode 100644 index 0000000..c431fc5 --- /dev/null +++ b/modules/sub/load-json.js @@ -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 + } +} \ No newline at end of file diff --git a/modules/sub/loc.js b/modules/sub/loc.js new file mode 100644 index 0000000..a178746 --- /dev/null +++ b/modules/sub/loc.js @@ -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, '
').replace(/{appName}/g, appName) + if (replacement) { + s = s.replace(/{s}/g, replacement) + } + return s + ' ' + } else { + return string + } + } catch (e) { + return string + } +} \ No newline at end of file diff --git a/modules/sub/match.js b/modules/sub/match.js new file mode 100644 index 0000000..c1b747e --- /dev/null +++ b/modules/sub/match.js @@ -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) }) + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..53e7fd1 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/strings/en/accessibility.json b/strings/en/accessibility.json new file mode 100644 index 0000000..3f2714e --- /dev/null +++ b/strings/en/accessibility.json @@ -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" +} \ No newline at end of file diff --git a/strings/en/apiError.json b/strings/en/apiError.json new file mode 100644 index 0000000..788bee0 --- /dev/null +++ b/strings/en/apiError.json @@ -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 let me know.", + "fatal": "something went wrong and page couldn't be rendered. if you want me to fix this, please contact me. 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." +} \ No newline at end of file diff --git a/strings/en/changelog.json b/strings/en/changelog.json new file mode 100644 index 0000000..6b8c35c --- /dev/null +++ b/strings/en/changelog.json @@ -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" +} \ No newline at end of file diff --git a/strings/en/desc.json b/strings/en/desc.json new file mode 100644 index 0000000..d2d1d19 --- /dev/null +++ b/strings/en/desc.json @@ -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" +} \ No newline at end of file diff --git a/strings/en/settings.json b/strings/en/settings.json new file mode 100644 index 0000000..2cbbc9a --- /dev/null +++ b/strings/en/settings.json @@ -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." +} \ No newline at end of file diff --git a/strings/en/title.json b/strings/en/title.json new file mode 100644 index 0000000..228b055 --- /dev/null +++ b/strings/en/title.json @@ -0,0 +1,7 @@ +{ + "about": "owo what's this?", + "settings": "settings", + "error": "uh-oh...", + "changelog": "what's new?", + "donate": "support {appName}" +} \ No newline at end of file