moved to new repo
This commit is contained in:
35
modules/api.js
Normal file
35
modules/api.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import UrlPattern from "url-pattern";
|
||||
|
||||
import { services as patterns } from "./config.js";
|
||||
|
||||
import { cleanURL, apiJSON } from "./sub/api-helper.js";
|
||||
import { errorUnsupported } from "./sub/errors.js";
|
||||
import loc from "./sub/loc.js";
|
||||
import match from "./sub/match.js";
|
||||
|
||||
export async function getJSON(originalURL, ip, lang, format, quality) {
|
||||
try {
|
||||
let url = decodeURI(originalURL);
|
||||
if (!url.includes('http://')) {
|
||||
let hostname = url.replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."),
|
||||
host = hostname[hostname.length - 2],
|
||||
patternMatch;
|
||||
if (host == "youtu") {
|
||||
host = "youtube";
|
||||
url = `https://youtube.com/watch?v=${url.replace("youtu.be/", "").replace("https://", "")}`;
|
||||
}
|
||||
if (host && host.length < 20 && host in patterns && patterns[host]["enabled"]) {
|
||||
for (let i in patterns[host]["patterns"]) {
|
||||
patternMatch = new UrlPattern(patterns[host]["patterns"][i]).match(cleanURL(url, host).split(".com/")[1]);
|
||||
}
|
||||
if (patternMatch) {
|
||||
return await match(host, patternMatch, url, ip, lang, format, quality);
|
||||
} else throw Error()
|
||||
} else throw Error()
|
||||
} else {
|
||||
return apiJSON(0, { t: errorUnsupported(lang) } )
|
||||
}
|
||||
} catch (e) {
|
||||
return apiJSON(0, { t: loc(lang, 'apiError', 'generic') + loc(lang, 'apiError', 'letMeKnow') });
|
||||
}
|
||||
}
|
||||
18
modules/config.js
Normal file
18
modules/config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import loadJson from "./sub/load-json.js";
|
||||
|
||||
let config = loadJson("./config.json");
|
||||
let services = loadJson("./modules/services/all.json");
|
||||
|
||||
let appName = config.appName
|
||||
let version = config.version
|
||||
let streamLifespan = config.streamLifespan
|
||||
let maxVideoDuration = config.maxVideoDuration
|
||||
let genericUserAgent = config.genericUserAgent
|
||||
let repo = config.repo
|
||||
let authorInfo = config.authorInfo
|
||||
let supportedLanguages = config.supportedLanguages
|
||||
let quality = config.quality
|
||||
let internetExplorerRedirect = config.internetExplorerRedirect
|
||||
let donations = config.donations
|
||||
|
||||
export {appName, version, streamLifespan, maxVideoDuration, genericUserAgent, repo, authorInfo, services, supportedLanguages, quality, internetExplorerRedirect, donations}
|
||||
171
modules/page-renderer.js
Normal file
171
modules/page-renderer.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { services, appName, authorInfo, version, quality, repo, donations } from "./config.js";
|
||||
import loc from "./sub/loc.js";
|
||||
|
||||
let s = services
|
||||
let enabledServices = Object.keys(s).filter((p) => {
|
||||
if (s[p].enabled) {
|
||||
return true
|
||||
}
|
||||
}).sort().map((p) => {
|
||||
if (s[p].alias) {
|
||||
return s[p].alias
|
||||
} else {
|
||||
return p
|
||||
}
|
||||
}).join(', ')
|
||||
let donate = ``
|
||||
for (let i in donations) {
|
||||
donate += `<div class="subtitle">${i} (${loc("en", 'desc', 'clicktocopy').trim()})</div><div id="don-${i}" class="text-to-copy" onClick="copy('don-${i}')">${donations[i]}</div>`
|
||||
}
|
||||
export default function(obj) {
|
||||
let isIOS = obj.useragent.toLowerCase().match("iphone os")
|
||||
try {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5" />
|
||||
|
||||
<title>${appName}</title>
|
||||
|
||||
<meta property="og:url" content="${process.env.selfURL}" />
|
||||
<meta property="og:title" content="${appName}" />
|
||||
<meta property="og:description" content="${loc(obj.lang, 'desc', 'embed')}" />
|
||||
<meta property="og:image" content="${process.env.selfURL}icons/generic.png" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="icons/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png" />
|
||||
|
||||
<link rel="manifest" href="cobalt.webmanifest" />
|
||||
<link rel="stylesheet" href="cobalt.css" />
|
||||
<link rel="stylesheet" href="fonts/notosansmono/notosansmono.css" />
|
||||
|
||||
<noscript><div style="margin: 2rem;">${loc(obj.lang, 'desc', 'noScript')}</div></noscript>
|
||||
</head>
|
||||
<body id="cobalt-body">
|
||||
<div id="popup-about" class="popup-narrow center box" style="visibility: hidden;">
|
||||
<div id="popup-header" class="popup-header">
|
||||
<button id="close" class="button mono" onclick="popup('about', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
|
||||
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'about')}</div>
|
||||
</div>
|
||||
<div id="content" class="popup-content with-footer">
|
||||
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'about')}</div>
|
||||
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'support_1')} ${enabledServices}.</div>
|
||||
${isIOS ? `<div id="desc" class="popup-subtitle popup-desc"><span class="text-backdrop">${loc(obj.lang, 'desc', 'iosTitle')}</span></div><div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'desc', 'ios')}</div>`: ``}
|
||||
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${repo}">${loc(obj.lang, 'desc', 'sourcecode')}</a></div>
|
||||
</div>
|
||||
<div id="popup-footer" class="popup-footer">
|
||||
<a id="popup-bottom" class="popup-footer-content" href="${authorInfo.link}">${loc(obj.lang, 'desc', 'popupBottom')}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="popup-changelog" class="popup-narrow center box" style="visibility: hidden;">
|
||||
<div id="popup-header" class="popup-header">
|
||||
<button id="close" class="button mono" onclick="popup('changelog', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
|
||||
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'changelog')}</div>
|
||||
<div id="desc" class="popup-subtitle">${loc(obj.lang, 'changelog', 'subtitle')}</div>
|
||||
</div>
|
||||
<div id="content" class="popup-content">
|
||||
<div id="desc" class="popup-desc about-padding">${loc(obj.lang, 'changelog', 'text')}</div>
|
||||
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${repo}">${loc(obj.lang, 'changelog', 'github')}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="popup-donate" class="popup-narrow center box" style="visibility: hidden;">
|
||||
<div id="popup-header" class="popup-header">
|
||||
<button id="close" class="button mono" onclick="popup('donate', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
|
||||
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'donate')}</div>
|
||||
<div id="desc" class="little-subtitle">${loc(obj.lang, 'desc', 'donationsSub')}</div>
|
||||
</div>
|
||||
<div id="content" class="popup-content">
|
||||
${donate}
|
||||
<div id="desc" class="explanation about-padding">${loc(obj.lang, 'desc', 'donations')}</div>
|
||||
<div id="desc" class="popup-desc"><a class="text-backdrop" href="${authorInfo.contact}">${loc(obj.lang, 'desc', 'donateDm')}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="popup-settings" class="popup-narrow scrollable center box" style="visibility: hidden;">
|
||||
<div id="popup-header" class="popup-header">
|
||||
<button id="close" class="button mono" onclick="popup('settings', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
|
||||
<div id="version" class="popup-above-title">v.${version} ~ ${obj.hash}</div>
|
||||
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'settings')}</div>
|
||||
</div>
|
||||
<div id="content" class="popup-content">
|
||||
<div id="settings-appearance" class="settings-category">
|
||||
<div class="title">${loc(obj.lang, 'settings', 'category-appearance')}</div>
|
||||
<div class="settings-category-content">
|
||||
<div id="theme-switcher" class="switch-container">
|
||||
<div class="subtitle">${loc(obj.lang, 'settings', 'theme')}</div>
|
||||
<div class="switches">
|
||||
<div id="theme-auto" class="switch full" onclick="changeSwitcher('theme', 'auto', 1)">${loc(obj.lang, 'settings', 'theme-auto')}</div>
|
||||
<div id="theme-dark" class="switch" onclick="changeSwitcher('theme', 'dark', 1)">${loc(obj.lang, 'settings', 'theme-dark')}</div>
|
||||
<div id="theme-light" class="switch full" onclick="changeSwitcher('theme', 'light', 1)">${loc(obj.lang, 'settings', 'theme-light')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtitle">${loc(obj.lang, 'settings', 'misc')}</div>
|
||||
<label class="checkbox">
|
||||
<input id="always-visible-button" type="checkbox" aria-label="${loc(obj.lang, 'accessibility', 'alwaysVisibleButton')}" onclick="checkbox('alwaysVisibleButton', 'always-visible-button')">${loc(obj.lang, 'settings', 'always-visible')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-quality" class="settings-category">
|
||||
<div class="title">${loc(obj.lang, 'settings', 'general')}</div>
|
||||
<div class="settings-category-content">
|
||||
<div id="quality-switcher" class="switch-container">
|
||||
<div class="subtitle">${loc(obj.lang, 'settings', 'quality')}</div>
|
||||
<div class="switches">
|
||||
<div id="quality-max" class="switch full" onclick="changeSwitcher('quality', 'max', 1)">${loc(obj.lang, 'settings', 'q-max')}</div>
|
||||
<div id="quality-hig" class="switch" onclick="changeSwitcher('quality', 'hig', 1)">${loc(obj.lang, 'settings', 'q-hig')}(${quality.hig}p)</div>
|
||||
<div id="quality-mid" class="switch full" onclick="changeSwitcher('quality', 'mid', 1)">${loc(obj.lang, 'settings', 'q-mid')}(${quality.mid}p)</div>
|
||||
<div id="quality-low" class="switch right" onclick="changeSwitcher('quality', 'low', 1)">${loc(obj.lang, 'settings', 'q-low')}(${quality.low}p)</div>
|
||||
</div>
|
||||
<div class="explanation">${loc(obj.lang, 'settings', 'q-desc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settings-youtube" class="settings-category">
|
||||
<div class="title">youtube</div>
|
||||
<div class="settings-category-content">
|
||||
<div id="youtube-switcher" class="switch-container">
|
||||
<div class="subtitle">${loc(obj.lang, 'settings', 'format')}</div>
|
||||
<div class="switches">
|
||||
<div id="youtubeFormat-mp4" class="switch full" onclick="changeSwitcher('youtubeFormat', 'mp4', 1)">mp4</div>
|
||||
<div id="youtubeFormat-webm" class="switch" onclick="changeSwitcher('youtubeFormat', 'webm', 1)">webm</div>
|
||||
<div id="youtubeFormat-audio" class="switch full" onclick="changeSwitcher('youtubeFormat', 'audio', 1)">audio only</div>
|
||||
</div>
|
||||
<div class="explanation">${loc(obj.lang, 'settings', 'format-info')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="popup-error" class="popup center box" style="visibility: hidden;">
|
||||
<button id="close" class="button mono" onclick="popup('error', 0)" aria-label="${loc(obj.lang, 'accessibility', 'close')}">x</button>
|
||||
<div id="title" class="popup-title">${loc(obj.lang, 'title', 'error')}</div>
|
||||
<div id="desc-error" class="popup-desc"></div>
|
||||
</div>
|
||||
<div id="popup-backdrop" style="visibility: hidden;"></div>
|
||||
<div id="cobalt-main-box" class="center box" style="visibility: hidden;">
|
||||
<div id="logo-area">${appName}</div>
|
||||
<div id="download-area" class="mobile-center">
|
||||
<input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="110" autocapitalize="off" placeholder="${loc(obj.lang, 'desc', 'input')}" aria-label="${loc(obj.lang, 'accessibility', 'input')}" oninput="button()">
|
||||
<input id="download-button" class="mono dontRead" onclick="download(document.getElementById('url-input-area').value)" type="submit" value="" disabled=true aria-label="${loc(obj.lang, 'accessibility', 'download')}">
|
||||
</div>
|
||||
</div>
|
||||
<footer id="footer" style="visibility: hidden;">
|
||||
<div id="footer-buttons">
|
||||
<button id="about-open" class="button footer-button" onclick="popup('about', 1)" aria-label="${loc(obj.lang, 'accessibility', 'about')}">?</button>
|
||||
<button id="changelog-open" class="button footer-button" onclick="popup('changelog', 1)" aria-label="${loc(obj.lang, 'accessibility', 'changelog')}">!</button>
|
||||
<button id="donate-open" class="button footer-button" onclick="popup('donate', 1)" aria-label="${loc(obj.lang, 'accessibility', 'donate')}">$</button>
|
||||
<button id="settings-open" class="button footer-button" onclick="popup('settings', 1)" aria-label="${loc(obj.lang, 'accessibility', 'settings')}">+</button>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<script type="text/javascript">const loc = {noInternet:"${loc(obj.lang, 'apiError', 'noInternet')}"}</script>
|
||||
<script type="text/javascript" src="cobalt.js"></script>
|
||||
</html>`;
|
||||
} catch (err) {
|
||||
return `${loc('en', 'apiError', 'fatal', obj.hash)}`;
|
||||
}
|
||||
}
|
||||
72
modules/services/all.json
Normal file
72
modules/services/all.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"bilibili": {
|
||||
"alias": "bilibili.com",
|
||||
"patterns": ["video/:id"],
|
||||
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
|
||||
"enabled": true
|
||||
},
|
||||
"reddit": {
|
||||
"patterns": ["r/:sub/comments/:id/:title"],
|
||||
"enabled": true
|
||||
},
|
||||
"twitter": {
|
||||
"patterns": [":user/status/:id"],
|
||||
"quality_match": ["1080", "720", "480", "360", "240", "144"],
|
||||
"enabled": true,
|
||||
"api": "api.twitter.com",
|
||||
"token": "AAAAAAAAAAAAAAAAAAAAAIK1zgAAAAAA2tUWuhGZ2JceoId5GwYWU5GspY4%3DUq7gzFoCZs1QfwGoVdvSac3IniczZEYXIcDyumCauIXpcAPorE",
|
||||
"apiURLs": {
|
||||
"activate": "1.1/guest/activate.json",
|
||||
"status_show": "1.1/statuses/show.json"
|
||||
}
|
||||
},
|
||||
"vk": {
|
||||
"patterns": ["video-:userId_:videoId"],
|
||||
"quality_match": {
|
||||
"2160": 7,
|
||||
"1440": 6,
|
||||
"1080": 5,
|
||||
"720": 3,
|
||||
"480": 2,
|
||||
"360": 1,
|
||||
"240": 0,
|
||||
"144": 4
|
||||
},
|
||||
"quality": {
|
||||
"1080": "hig",
|
||||
"720": "mid",
|
||||
"480": "low"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"youtube": {
|
||||
"patterns": ["watch?v=:id"],
|
||||
"quality_match": ["2160", "1440", "1080", "720", "480", "360", "240", "144"],
|
||||
"quality": {
|
||||
"1080": "hig",
|
||||
"720": "mid",
|
||||
"480": "low"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"youtube music": {
|
||||
"patterns": ["watch?v=:id"],
|
||||
"enabled": true
|
||||
},
|
||||
"tumblr": {
|
||||
"patterns": ["post/:id"],
|
||||
"enabled": false
|
||||
},
|
||||
"facebook": {
|
||||
"patterns": [":pageid/:type/:postid"],
|
||||
"enabled": false
|
||||
},
|
||||
"instagram": {
|
||||
"patterns": [":type/:id"],
|
||||
"enabled": false
|
||||
},
|
||||
"tiktok": {
|
||||
"patterns": [":pageid/:type/:postid", ":id"],
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
38
modules/services/bilibili.js
Normal file
38
modules/services/bilibili.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import got from "got";
|
||||
import loc from "../sub/loc.js";
|
||||
import { genericUserAgent, maxVideoDuration } from "../config.js";
|
||||
|
||||
export default async function(obj) {
|
||||
try {
|
||||
let html = await got.get(`https://bilibili.com/video/${obj.id}`, {
|
||||
headers: { "user-agent": genericUserAgent }
|
||||
});
|
||||
html.on('error', (err) => {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
});
|
||||
html = html.body;
|
||||
if (html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"')) {
|
||||
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
|
||||
if (streamData.data.timelength <= maxVideoDuration) {
|
||||
let video = streamData["data"]["dash"]["video"].filter((v) => {
|
||||
if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/") && v["height"] != 4320) {
|
||||
return true;
|
||||
}
|
||||
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
|
||||
let audio = streamData["data"]["dash"]["audio"].filter((a) => {
|
||||
if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) {
|
||||
return true;
|
||||
}
|
||||
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
|
||||
return { urls: [video[0]["baseUrl"], audio[0]["baseUrl"]], time: streamData.data.timelength, filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4` };
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
}
|
||||
}
|
||||
|
||||
17
modules/services/reddit.js
Normal file
17
modules/services/reddit.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import got from "got";
|
||||
import loc from "../sub/loc.js";
|
||||
import { genericUserAgent, maxVideoDuration } from "../config.js";
|
||||
|
||||
export default async function(obj) {
|
||||
try {
|
||||
let req = await got.get(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`, { headers: { "user-agent": genericUserAgent } });
|
||||
let data = (JSON.parse(req.body))[0]["data"]["children"][0]["data"];
|
||||
if ("reddit_video" in data["secure_media"] && data["secure_media"]["reddit_video"]["duration"] * 1000 < maxVideoDuration) {
|
||||
return { urls: [data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0], `${data["secure_media"]["reddit_video"]["fallback_url"].split('_')[0]}_audio.mp4`], filename: `reddit_${data["secure_media"]["reddit_video"]["fallback_url"].split('/')[3]}.mp4` };
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'nothingToDownload') };
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: loc("en", "apiError", "nothingToDownload") };
|
||||
}
|
||||
}
|
||||
57
modules/services/twitter.js
Normal file
57
modules/services/twitter.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import got from "got";
|
||||
import loc from "../sub/loc.js";
|
||||
import { services } from "../config.js";
|
||||
|
||||
const configSt = services.twitter;
|
||||
|
||||
async function fetchTweetInfo(obj) {
|
||||
let cantConnect = { error: loc('en', 'apiError', 'cantConnectToAPI', 'twitter') }
|
||||
try {
|
||||
let _headers = {
|
||||
"Authorization": `Bearer ${configSt.token}`,
|
||||
"Host": configSt.api,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": 0
|
||||
};
|
||||
let req_act = await got.post(`https://${configSt.api}/${configSt.apiURLs.activate}`, {
|
||||
headers: _headers
|
||||
});
|
||||
req_act.on('error', (err) => {
|
||||
return cantConnect
|
||||
})
|
||||
_headers["x-guest-token"] = req_act.body["guest_token"];
|
||||
let req_status = await got.get(`https://${configSt.api}/${configSt.apiURLs.status_show}?id=${obj.id}&tweet_mode=extended`, {
|
||||
headers: _headers
|
||||
});
|
||||
req_status.on('error', (err) => {
|
||||
return cantConnect
|
||||
})
|
||||
return JSON.parse(req_status.body);
|
||||
} catch (err) {
|
||||
return { error: cantConnect };
|
||||
}
|
||||
}
|
||||
export default async function (obj) {
|
||||
let nothing = { error: loc('en', 'apiError', 'nothingToDownload') }
|
||||
try {
|
||||
let parsbod = await fetchTweetInfo(obj);
|
||||
if (!parsbod.error) {
|
||||
if (parsbod.hasOwnProperty("extended_entities") && parsbod["extended_entities"].hasOwnProperty("media")) {
|
||||
if (parsbod["extended_entities"]["media"][0]["type"] === "video" || parsbod["extended_entities"]["media"][0]["type"] === "animated_gif") {
|
||||
let variants = parsbod["extended_entities"]["media"][0]["video_info"]["variants"]
|
||||
return variants.filter((v) => {
|
||||
if (v["content_type"] == "video/mp4") {
|
||||
return true
|
||||
}
|
||||
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"]
|
||||
} else {
|
||||
return nothing
|
||||
}
|
||||
} else {
|
||||
return nothing
|
||||
}
|
||||
} else return parsbod;
|
||||
} catch (err) {
|
||||
return { error: loc("en", "apiError", "youtubeBroke") };
|
||||
}
|
||||
}
|
||||
47
modules/services/vk.js
Normal file
47
modules/services/vk.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import got from "got";
|
||||
import { xml2json } from "xml-js";
|
||||
import loc from "../sub/loc.js";
|
||||
import { genericUserAgent, maxVideoDuration, services } from "../config.js";
|
||||
import selectQuality from "../stream/select-quality.js";
|
||||
|
||||
export default async function(obj) {
|
||||
try {
|
||||
let html = await got.get(`https://vk.com/video-${obj.userId}_${obj.videoId}`, { headers: { "user-agent": genericUserAgent } });
|
||||
html.on('error', (err) => {
|
||||
return false;
|
||||
});
|
||||
html = html.body;
|
||||
if (html.includes(`{"lang":`)) {
|
||||
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
|
||||
if (js["mvData"]["is_active_live"] == '0') {
|
||||
if (js["mvData"]["duration"] <= maxVideoDuration / 1000) {
|
||||
let mpd = JSON.parse(xml2json(js["player"]["params"][0]["manifest"], { compact: true, spaces: 4 }));
|
||||
|
||||
let repr = mpd["MPD"]["Period"]["AdaptationSet"]["Representation"];
|
||||
if (!mpd["MPD"]["Period"]["AdaptationSet"]["Representation"]) {
|
||||
repr = mpd["MPD"]["Period"]["AdaptationSet"][0]["Representation"];
|
||||
}
|
||||
let attr = repr[repr.length - 1]["_attributes"];
|
||||
let selectedQuality = `url${attr["height"]}`;
|
||||
|
||||
let maxQuality = js["player"]["params"][0][selectedQuality].split('type=')[1].slice(0, 1)
|
||||
let userQuality = selectQuality('vk', obj.quality, Object.entries(services.vk.quality_match).reduce((r, [k, v]) => { r[v] = k; return r;})[maxQuality])
|
||||
|
||||
if (selectedQuality in js["player"]["params"][0]) {
|
||||
return { url: js["player"]["params"][0][selectedQuality].replace(`type=${maxQuality}`, `type=${services.vk.quality_match[userQuality]}`), filename: `vk_${js["player"]["params"][0][selectedQuality].split("id=")[1]}_${attr['width']}x${attr['height']}.mp4` };
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'nothingToDownload') };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'liveVideo') };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'nothingToDownload') };
|
||||
}
|
||||
} catch (err) {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
}
|
||||
}
|
||||
74
modules/services/youtube.js
Normal file
74
modules/services/youtube.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import ytdl from "ytdl-core";
|
||||
import loc from "../sub/loc.js";
|
||||
import { maxVideoDuration, quality as mq } from "../config.js";
|
||||
import selectQuality from "../stream/select-quality.js";
|
||||
|
||||
export default async function (obj) {
|
||||
try {
|
||||
let info = await ytdl.getInfo(obj.id);
|
||||
if (info) {
|
||||
info = info.formats;
|
||||
if (!info[0]["isLive"]) {
|
||||
if (obj.isAudioOnly) {
|
||||
obj.format = "webm"
|
||||
obj.quality = "max"
|
||||
}
|
||||
let selectedVideo, videoMatch = [], video = [], audio = info.filter((a) => {
|
||||
if (!a["isLive"] && !a["isHLS"] && !a["isDashMPD"] && a["hasAudio"] && !a["hasVideo"] && a["container"] == obj.format) {
|
||||
return true;
|
||||
}
|
||||
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
||||
if (!obj.isAudioOnly) {
|
||||
video = info.filter((a) => {
|
||||
if (!a["isLive"] && !a["isHLS"] && !a["isDashMPD"] && !a["hasAudio"] && a["hasVideo"] && a["container"] == obj.format && a["height"] != 4320) {
|
||||
if (obj.quality != "max" && mq[obj.quality] == a["height"]) {
|
||||
videoMatch.push(a)
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
|
||||
selectedVideo = video[0]
|
||||
if (obj.quality != "max") {
|
||||
if (videoMatch.length > 0) {
|
||||
selectedVideo = videoMatch[0]
|
||||
} else {
|
||||
let ss = selectQuality("youtube", obj.quality, video[0]["height"])
|
||||
selectedVideo = video.filter((a) => {
|
||||
if (a["height"] == ss) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
selectedVideo = selectedVideo[0]
|
||||
}
|
||||
}
|
||||
if (obj.quality == "los") {
|
||||
selectedVideo = video[video.length - 1]
|
||||
}
|
||||
}
|
||||
if (audio[0]["approxDurationMs"] <= maxVideoDuration) {
|
||||
if (!obj.isAudioOnly && video.length > 0) {
|
||||
let filename = `youtube_${obj.id}_${selectedVideo["width"]}x${selectedVideo["height"]}.${obj.format}`;
|
||||
if (video.length > 0 && audio.length > 0) {
|
||||
return { type: "render", urls: [selectedVideo["url"], audio[0]["url"]], time: video[0]["approxDurationMs"], filename: filename };
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeBroke') };
|
||||
}
|
||||
} else if (audio.length > 0) {
|
||||
return { type: "render", isAudioOnly: true, urls: [audio[0]["url"]], filename: `youtube_${obj.id}_${audio[0]["audioBitrate"]}kbps.opus` };
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeBroke') };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeLimit', maxVideoDuration / 60000) };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'liveVideo') };
|
||||
}
|
||||
} else {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: loc('en', 'apiError', 'youtubeFetch') };
|
||||
}
|
||||
}
|
||||
|
||||
54
modules/setup.js
Normal file
54
modules/setup.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { existsSync, unlinkSync, appendFileSync } from "fs";
|
||||
import { createInterface } from "readline";
|
||||
import { Cyan, Bright, Green } from "./sub/console-text.js";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
let envPath = './.env';
|
||||
let q = `${Cyan('?')} \x1b[1m`;
|
||||
let ob = { streamSalt: randomBytes(64).toString('hex') }
|
||||
let rl = createInterface({ input: process.stdin,output: process.stdout });
|
||||
|
||||
console.log(
|
||||
`${Cyan("Welcome to cobalt!")}\n${Bright("We'll get you up and running in no time.\nLet's start by creating a ")}${Cyan(".env")}${Bright(" file. You can always change it later.")}`
|
||||
)
|
||||
console.log(
|
||||
Bright("\nWhat's the selfURL we'll be running on? (localhost)")
|
||||
)
|
||||
|
||||
rl.question(q, r1 => {
|
||||
if (r1) {
|
||||
ob['selfURL'] = `https://${r1}/`
|
||||
} else {
|
||||
ob['selfURL'] = `http://localhost`
|
||||
}
|
||||
console.log(Bright("\nGreat! Now, what's the port we'll be running on? (9000)"))
|
||||
rl.question(q, r2 => {
|
||||
if (!r1 && !r2) {
|
||||
ob['selfURL'] = `http://localhost:9000/`
|
||||
ob['port'] = 9000
|
||||
} else if (!r1 && r2) {
|
||||
ob['selfURL'] = `http://localhost:${r2}/`
|
||||
ob['port'] = r2
|
||||
} else {
|
||||
ob['port'] = r2
|
||||
}
|
||||
final()
|
||||
});
|
||||
})
|
||||
|
||||
let final = () => {
|
||||
if (existsSync(envPath)) {
|
||||
unlinkSync(envPath)
|
||||
}
|
||||
for (let i in ob) {
|
||||
appendFileSync(envPath, `${i}=${ob[i]}\n`)
|
||||
}
|
||||
console.log(Bright("\nI've created a .env file with selfURL, port, and stream salt."))
|
||||
console.log(`${Bright("Now I'll run")} ${Cyan("npm install")} ${Bright("to install all dependencies. It shouldn't take long.\n\n")}`)
|
||||
execSync('npm install',{stdio:[0,1,2]});
|
||||
console.log(`\n\n${Green("All done!\n")}`)
|
||||
console.log("You can re-run this script any time to update the configuration.")
|
||||
console.log("\nYou're now ready to start the main project.\nHave fun!")
|
||||
rl.close()
|
||||
}
|
||||
45
modules/stream/manage.js
Normal file
45
modules/stream/manage.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import NodeCache from "node-cache";
|
||||
|
||||
import { UUID, encrypt } from "../sub/crypto.js";
|
||||
import { streamLifespan } from "../config.js";
|
||||
|
||||
const streamCache = new NodeCache({ stdTTL: streamLifespan, checkperiod: 120 });
|
||||
|
||||
export function createStream(obj) {
|
||||
let streamUUID = UUID(),
|
||||
exp = Math.floor(new Date().getTime()) + streamLifespan,
|
||||
ghmac = encrypt(`${streamUUID},${obj.url},${obj.ip},${exp}`, obj.salt),
|
||||
iphmac = encrypt(`${obj.ip}`, obj.salt);
|
||||
|
||||
streamCache.set(streamUUID, {
|
||||
id: streamUUID,
|
||||
service: obj.service,
|
||||
type: obj.type,
|
||||
urls: obj.urls,
|
||||
filename: obj.filename,
|
||||
hmac: ghmac,
|
||||
ip: iphmac,
|
||||
exp: exp,
|
||||
isAudioOnly: obj.isAudioOnly ? true : false,
|
||||
time: obj.time
|
||||
});
|
||||
return `${process.env.selfURL}api/stream?t=${streamUUID}&e=${exp}&h=${ghmac}`;
|
||||
}
|
||||
|
||||
export function verifyStream(ip, id, hmac, exp, salt) {
|
||||
try {
|
||||
let streamInfo = streamCache.get(id);
|
||||
if (streamInfo) {
|
||||
let ghmac = encrypt(`${id},${streamInfo.url},${ip},${exp}`, salt);
|
||||
if (hmac == ghmac && encrypt(`${ip}`, salt) == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) {
|
||||
return streamInfo;
|
||||
} else {
|
||||
return { error: 'Unauthorized', status: 401 };
|
||||
}
|
||||
} else {
|
||||
return { error: 'this stream token does not exist', status: 400 };
|
||||
}
|
||||
} catch (e) {
|
||||
return { status: 500, body: { status: "error", text: "Internal Server Error" } };
|
||||
}
|
||||
}
|
||||
32
modules/stream/select-quality.js
Normal file
32
modules/stream/select-quality.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { services, quality as mq } from "../config.js";
|
||||
|
||||
function closest(goal, array) {
|
||||
return array.sort().reduce(function(prev, curr) {
|
||||
return (Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev);
|
||||
});
|
||||
}
|
||||
|
||||
export default function(service, quality, maxQuality) {
|
||||
if (quality == "max") {
|
||||
return maxQuality
|
||||
}
|
||||
|
||||
quality = parseInt(mq[quality])
|
||||
maxQuality = parseInt(maxQuality)
|
||||
|
||||
if (quality >= maxQuality || quality == maxQuality) {
|
||||
return maxQuality
|
||||
}
|
||||
if (quality < maxQuality) {
|
||||
if (services[service]["quality"][quality]) {
|
||||
return quality
|
||||
} else {
|
||||
let s = Object.keys(services[service]["quality_match"]).filter((q) => {
|
||||
if (q <= quality) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
return closest(quality, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
modules/stream/stream.js
Normal file
27
modules/stream/stream.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { apiJSON } from "../sub/api-helper.js";
|
||||
import { verifyStream } from "./manage.js";
|
||||
import { streamAudioOnly, streamDefault, streamLiveRender } from "./types.js";
|
||||
|
||||
export default function(res, ip, id, hmac, exp) {
|
||||
try {
|
||||
let streamInfo = verifyStream(ip, id, hmac, exp, process.env.streamSalt);
|
||||
if (!streamInfo.error) {
|
||||
if (streamInfo.isAudioOnly) {
|
||||
streamAudioOnly(streamInfo, res);
|
||||
} else {
|
||||
switch (streamInfo.type) {
|
||||
case "render":
|
||||
streamLiveRender(streamInfo, res);
|
||||
break;
|
||||
default:
|
||||
streamDefault(streamInfo, res);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
|
||||
}
|
||||
} catch (e) {
|
||||
internalError(res)
|
||||
}
|
||||
}
|
||||
131
modules/stream/types.js
Normal file
131
modules/stream/types.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { spawn } from "child_process";
|
||||
import ffmpeg from "ffmpeg-static";
|
||||
import got from "got";
|
||||
import { genericUserAgent } from "../config.js";
|
||||
import { msToTime } from "../sub/api-helper.js";
|
||||
import { internalError } from "../sub/errors.js";
|
||||
import loc from "../sub/loc.js";
|
||||
|
||||
export async function streamDefault(streamInfo, res) {
|
||||
try {
|
||||
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.filename}"`);
|
||||
const stream = got.get(streamInfo.urls, {
|
||||
headers: {
|
||||
"user-agent": genericUserAgent
|
||||
},
|
||||
isStream: true
|
||||
});
|
||||
stream.pipe(res).on('error', (err) => {
|
||||
internalError(res);
|
||||
throw Error("File stream pipe error.");
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
internalError(res);
|
||||
throw Error("File stream error.")
|
||||
});
|
||||
} catch (e) {
|
||||
internalError(res);
|
||||
}
|
||||
}
|
||||
export async function streamLiveRender(streamInfo, res) {
|
||||
try {
|
||||
if (streamInfo.urls.length == 2) {
|
||||
let headers = {};
|
||||
if (streamInfo.service == "bilibili") {
|
||||
headers = { "user-agent": genericUserAgent };
|
||||
}
|
||||
const audio = got.get(streamInfo.urls[1], { isStream: true, headers: headers });
|
||||
const video = got.get(streamInfo.urls[0], { isStream: true, headers: headers });
|
||||
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [
|
||||
'-loglevel', '-8',
|
||||
'-i', 'pipe:3',
|
||||
'-i', 'pipe:4',
|
||||
'-map', '0:a',
|
||||
'-map', '1:v',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'copy',
|
||||
];
|
||||
if (format == 'mp4') {
|
||||
args.push('-movflags', 'frag_keyframe+empty_moov');
|
||||
if (streamInfo.service == "youtube") {
|
||||
args.push('-t', msToTime(streamInfo.time));
|
||||
}
|
||||
} else if (format == 'webm') {
|
||||
args.push('-t', msToTime(streamInfo.time));
|
||||
}
|
||||
args.push('-f', format, 'pipe:5');
|
||||
const ffmpegProcess = spawn(ffmpeg, args, {
|
||||
windowsHide: true,
|
||||
stdio: [
|
||||
'inherit', 'inherit', 'inherit',
|
||||
'pipe', 'pipe', 'pipe'
|
||||
],
|
||||
});
|
||||
ffmpegProcess.on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
audio.on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
video.on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
|
||||
ffmpegProcess.stdio[5].pipe(res);
|
||||
audio.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
video.pipe(ffmpegProcess.stdio[4]).on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ status: "error", text: loc('en', 'apiError', 'corruptedVideo') });
|
||||
}
|
||||
} catch (e) {
|
||||
internalError(res);
|
||||
}
|
||||
}
|
||||
export async function streamAudioOnly(streamInfo, res) {
|
||||
try {
|
||||
let headers = {};
|
||||
if (streamInfo.service == "bilibili") {
|
||||
headers = { "user-agent": genericUserAgent };
|
||||
}
|
||||
const audio = got.get(streamInfo.urls[0], { isStream: true, headers: headers });
|
||||
const ffmpegProcess = spawn(ffmpeg, [
|
||||
'-loglevel', '-8',
|
||||
'-i', 'pipe:3',
|
||||
'-vn',
|
||||
'-c:a', 'copy',
|
||||
'-f', `${streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]}`,
|
||||
'pipe:4',
|
||||
], {
|
||||
windowsHide: true,
|
||||
stdio: [
|
||||
'inherit', 'inherit', 'inherit',
|
||||
'pipe', 'pipe'
|
||||
],
|
||||
});
|
||||
ffmpegProcess.on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
audio.on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
|
||||
ffmpegProcess.stdio[4].pipe(res);
|
||||
audio.pipe(ffmpegProcess.stdio[3]).on('error', (err) => {
|
||||
ffmpegProcess.kill();
|
||||
internalError(res);
|
||||
});
|
||||
} catch (e) {
|
||||
internalError(res);
|
||||
}
|
||||
}
|
||||
51
modules/sub/api-helper.js
Normal file
51
modules/sub/api-helper.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createStream } from "../stream/manage.js";
|
||||
|
||||
export function apiJSON(type, obj) {
|
||||
try {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return { status: 400, body: { status: "error", text: obj.t } };
|
||||
case 1:
|
||||
return { status: 200, body: { status: "redirect", url: obj.u } };
|
||||
case 2:
|
||||
return { status: 200, body: { status: "stream", url: createStream(obj) } };
|
||||
case 3:
|
||||
return { status: 200, body: { status: "success", text: obj.t } };
|
||||
case 4:
|
||||
return { status: 429, body: { status: "rate-limit", text: obj.t } };
|
||||
default:
|
||||
return { status: 400, body: { status: "error", text: "Bad Request" } };
|
||||
}
|
||||
} catch (e) {
|
||||
return { status: 500, body: { status: "error", text: "Internal Server Error" } };
|
||||
}
|
||||
}
|
||||
export function msToTime(d) {
|
||||
let milliseconds = parseInt((d % 1000) / 100),
|
||||
seconds = parseInt((d / 1000) % 60),
|
||||
minutes = parseInt((d / (1000 * 60)) % 60),
|
||||
hours = parseInt((d / (1000 * 60 * 60)) % 24),
|
||||
r;
|
||||
|
||||
hours = (hours < 10) ? "0" + hours : hours;
|
||||
minutes = (minutes < 10) ? "0" + minutes : minutes;
|
||||
seconds = (seconds < 10) ? "0" + seconds : seconds;
|
||||
r = hours + ":" + minutes + ":" + seconds;
|
||||
milliseconds ? r += "." + milliseconds : r += "";
|
||||
return r;
|
||||
}
|
||||
export function cleanURL(url, host) {
|
||||
url = url.replace('}', '').replace('{', '').replace(')', '').replace('(', '').replace(' ', '');
|
||||
if (url.includes('youtube.com/shorts/')) {
|
||||
url = url.split('?')[0].replace('shorts/', 'watch?v=');
|
||||
}
|
||||
if (host == "youtube") {
|
||||
url = url.split('&')[0];
|
||||
} else {
|
||||
url = url.split('?')[0];
|
||||
if (url.substring(url.length - 1) == "/") {
|
||||
url = url.substring(0, url.length - 1);
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
49
modules/sub/console-text.js
Normal file
49
modules/sub/console-text.js
Normal file
@@ -0,0 +1,49 @@
|
||||
export function t(color, tt) {
|
||||
return color + tt + "\x1b[0m"
|
||||
}
|
||||
export function Reset(tt) {
|
||||
return "\x1b[0m" + tt
|
||||
}
|
||||
export function Bright(tt) {
|
||||
return t("\x1b[1m", tt)
|
||||
}
|
||||
export function Dim(tt) {
|
||||
return t("\x1b[2m", tt)
|
||||
}
|
||||
export function Underscore(tt) {
|
||||
return t("\x1b[4m", tt)
|
||||
}
|
||||
export function Blink(tt) {
|
||||
return t("\x1b[5m", tt)
|
||||
}
|
||||
export function Reverse(tt) {
|
||||
return t("\x1b[7m", tt)
|
||||
}
|
||||
export function Hidden(tt) {
|
||||
return t("\x1b[8m", tt)
|
||||
}
|
||||
|
||||
export function Black(tt) {
|
||||
return t("\x1b[30m", tt)
|
||||
}
|
||||
export function Red(tt) {
|
||||
return t("\x1b[31m", tt)
|
||||
}
|
||||
export function Green(tt) {
|
||||
return t("\x1b[32m", tt)
|
||||
}
|
||||
export function Yellow(tt) {
|
||||
return t("\x1b[33m", tt)
|
||||
}
|
||||
export function Blue(tt) {
|
||||
return t("\x1b[34m", tt)
|
||||
}
|
||||
export function Magenta(tt) {
|
||||
return t("\x1b[35m", tt)
|
||||
}
|
||||
export function Cyan(tt) {
|
||||
return t("\x1b[36m", tt)
|
||||
}
|
||||
export function White(tt) {
|
||||
return t("\x1b[37m", tt)
|
||||
}
|
||||
11
modules/sub/crypto.js
Normal file
11
modules/sub/crypto.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createHmac, createHash, randomUUID } from "crypto";
|
||||
|
||||
export function encrypt(str, salt) {
|
||||
return createHmac("sha256", salt).update(str).digest("hex");
|
||||
}
|
||||
export function md5(string) {
|
||||
return createHash('md5').update(string).digest('hex');
|
||||
}
|
||||
export function UUID() {
|
||||
return randomUUID();
|
||||
}
|
||||
5
modules/sub/current-commit.js
Normal file
5
modules/sub/current-commit.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { execSync } from "child_process";
|
||||
|
||||
export default function() {
|
||||
return execSync('git rev-parse --short HEAD').toString().trim()
|
||||
}
|
||||
11
modules/sub/errors.js
Normal file
11
modules/sub/errors.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import loc from "./loc.js";
|
||||
|
||||
export function internalError(res) {
|
||||
res.status(501).json({ status: "error", text: "Internal Server Error" });
|
||||
}
|
||||
export function errorUnsupported(lang) {
|
||||
return loc(lang, 'apiError', 'notSupported') + loc(lang, 'apiError', 'letMeKnow');
|
||||
}
|
||||
export function genericError(lang, host) {
|
||||
return loc(lang, 'apiError', 'brokenLink', host) + loc(lang, 'apiError', 'letMeKnow');
|
||||
}
|
||||
9
modules/sub/load-json.js
Normal file
9
modules/sub/load-json.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
export default function(path) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path, 'utf-8'))
|
||||
} catch(e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
22
modules/sub/loc.js
Normal file
22
modules/sub/loc.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { supportedLanguages, appName } from "../config.js";
|
||||
import loadJson from "./load-json.js";
|
||||
|
||||
export default function(lang, cat, string, replacement) {
|
||||
if (!lang in supportedLanguages) {
|
||||
lang = 'en'
|
||||
}
|
||||
try {
|
||||
let str = loadJson(`./strings/${lang}/${cat}.json`);
|
||||
if (str && str[string]) {
|
||||
let s = str[string].replace(/\n/g, '<br/>').replace(/{appName}/g, appName)
|
||||
if (replacement) {
|
||||
s = s.replace(/{s}/g, replacement)
|
||||
}
|
||||
return s + ' '
|
||||
} else {
|
||||
return string
|
||||
}
|
||||
} catch (e) {
|
||||
return string
|
||||
}
|
||||
}
|
||||
90
modules/sub/match.js
Normal file
90
modules/sub/match.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { apiJSON } from "./api-helper.js";
|
||||
import { errorUnsupported, genericError } from "./errors.js";
|
||||
|
||||
import bilibili from "../services/bilibili.js";
|
||||
import reddit from "../services/reddit.js";
|
||||
import twitter from "../services/twitter.js";
|
||||
import youtube from "../services/youtube.js";
|
||||
import vk from "../services/vk.js";
|
||||
|
||||
export default async function (host, patternMatch, url, ip, lang, format, quality) {
|
||||
try {
|
||||
switch (host) {
|
||||
case "twitter":
|
||||
if (patternMatch["id"] && patternMatch["id"].length < 20) {
|
||||
let r = await twitter({
|
||||
id: patternMatch["id"],
|
||||
lang: lang
|
||||
});
|
||||
return (!r.error) ? apiJSON(1, { u: r.split('?')[0] }) : apiJSON(0, { t: r.error })
|
||||
} else throw Error()
|
||||
case "vk":
|
||||
if (patternMatch["userId"] && patternMatch["videoId"] && patternMatch["userId"].length <= 10 && patternMatch["videoId"].length == 9) {
|
||||
let r = await vk({
|
||||
userId: patternMatch["userId"],
|
||||
videoId: patternMatch["videoId"],
|
||||
lang: lang, quality: quality
|
||||
});
|
||||
return (!r.error) ? apiJSON(2, { type: "bridge", urls: r.url, filename: r.filename, service: host, ip: ip, salt: process.env.streamSalt }) : apiJSON(0, { t: r.error });
|
||||
} else throw Error()
|
||||
case "bilibili":
|
||||
if (patternMatch["id"] && patternMatch["id"].length >= 12) {
|
||||
let r = await bilibili({
|
||||
id: patternMatch["id"].slice(0, 12),
|
||||
lang: lang
|
||||
});
|
||||
return (!r.error) ? apiJSON(2, {
|
||||
type: "render", urls: r.urls,
|
||||
service: host, ip: ip,
|
||||
filename: r.filename,
|
||||
salt: process.env.streamSalt, time: r.time
|
||||
}) : apiJSON(0, { t: r.error });
|
||||
} else throw Error()
|
||||
case "youtube":
|
||||
if (patternMatch["id"]) {
|
||||
let fetchInfo = {
|
||||
id: patternMatch["id"].slice(0, 11),
|
||||
lang: lang, quality: quality,
|
||||
format: "mp4"
|
||||
};
|
||||
switch (format) {
|
||||
case "webm":
|
||||
fetchInfo["format"] = "webm";
|
||||
break;
|
||||
case "audio":
|
||||
fetchInfo["format"] = "webm";
|
||||
fetchInfo["isAudioOnly"] = true;
|
||||
fetchInfo["quality"] = "max";
|
||||
break;
|
||||
}
|
||||
if (url.match('music.youtube.com')) {
|
||||
fetchInfo["isAudioOnly"] = true;
|
||||
}
|
||||
let r = await youtube(fetchInfo);
|
||||
return (!r.error) ? apiJSON(2, {
|
||||
type: r.type, urls: r.urls, service: host, ip: ip,
|
||||
filename: r.filename, salt: process.env.streamSalt,
|
||||
isAudioOnly: fetchInfo["isAudioOnly"] ? fetchInfo["isAudioOnly"] : false,
|
||||
time: r.time,
|
||||
}) : apiJSON(0, { t: r.error });
|
||||
} else throw Error()
|
||||
case "reddit":
|
||||
if (patternMatch["sub"] && patternMatch["id"] && patternMatch["title"] && patternMatch["sub"].length <= 22 && patternMatch["id"].length <= 10 && patternMatch["title"].length <= 96) {
|
||||
let r = await reddit({
|
||||
sub: patternMatch["sub"],
|
||||
id: patternMatch["id"],
|
||||
title: patternMatch["title"]
|
||||
});
|
||||
return (!r.error) ? apiJSON(2, {
|
||||
type: "render", urls: r.urls,
|
||||
service: host, ip: ip,
|
||||
filename: r.filename, salt: process.env.streamSalt
|
||||
}) : apiJSON(0, { t: r.error });
|
||||
} else throw Error()
|
||||
default:
|
||||
return apiJSON(0, { t: errorUnsupported(lang) })
|
||||
}
|
||||
} catch (e) {
|
||||
return apiJSON(0, { t: genericError(lang, host) })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user