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
62 changed files with 2187 additions and 4 deletions

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) })
}
}