Merge branch 'current' into crowdin

This commit is contained in:
wukko 2023-01-18 16:38:21 +06:00 committed by GitHub
commit 1ce0c98175
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 418 additions and 285 deletions

View file

@ -13,19 +13,22 @@ cobalt is social media downloader with zero bullshit. It's friendly, accessible,
It preserves original media quality so you get best downloads possible (unless you change that in settings).
## Supported services
| Service | Video + Audio | Only audio | Additional features |
| -------- | :---: | :---: | :----- |
| Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. |
| Twitter Spaces | ❌️ | ✅ | Audio metadata. |
| YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. |
| YouTube Music | ❌ | ✅ | Audio metadata. |
| Reddit | ✅ | ✅ | |
| TikTok & douyin | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermarks. |
| SoundCloud | ❌ | ✅ | Audio metadata. |
| bilibili.com | ✅ | ✅ | |
| Tumblr | ✅ | ✅ | |
| Vimeo | ✅ | ❌️ | |
| VK Videos & Clips | ✅ | ❌️ | |
| Service | Video + Audio | Only audio | Additional features |
| -------- | :---: | :---: | :----- |
| Twitter | ✅ | ✅ | Ability to save multiple videos/GIFs from a single tweet. |
| Twitter Spaces | ❌️ | ✅ | Audio metadata. |
| YouTube & Shorts | ✅ | ✅ | Support for 8K, 4K, HDR, and high FPS videos. |
| YouTube Music | ❌ | ✅ | Audio metadata. |
| Reddit | ✅ | ✅ | |
| TikTok & douyin | ✅ | ✅ | Video downloads with or without watermark; image slideshow downloads without watermarks. |
| SoundCloud | ❌ | ✅ | Audio metadata, downloads from private links |
| bilibili.com | ✅ | ✅ | |
| Tumblr | ✅ | ✅ | |
| Vimeo | ✅ | ❌️ | |
| VK Videos & Clips | ✅ | ❌️ | |
## cobalt API
cobalt has an open API that you can use for free. It's pretty straightforward in use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/API.md) and see for yourself.
## How to contribute translations
You can translate cobalt to any language you want on [cobalt's crowdin](https://crowdin-co.wukko.me/). Feel free to ignore QA errors if you think you know better. If you don't see a language you want to translate cobalt to, open an issue, and I'll add it to crowdin.

View file

@ -21,6 +21,7 @@ Response Body Type: ``application/json``
| isAudioOnly | boolean | ``true / false`` | ``false`` | |
| isNoTTWatermark | boolean | ``true / false`` | ``false`` | Changes whether downloaded TikTok & Douyin videos have watermarks. |
| isTTFullAudio | boolean | ``true / false`` | ``false`` | Enables download of original sound used in a TikTok video. |
| isAudioMuted | boolean | ``true / false`` | ``false`` | Disables audio track in video downloads. |
### Response Body Variables
| key | type | variables |
@ -44,12 +45,12 @@ Item type: ``object``
Content live render streaming endpoint.<br>
### Request Query Variables
| key | variables | description |
|:----|:-----------------|:------------------------------------------------------------------------------------------------------------------------------|
| p | ``1`` | Used for checking the rate limit. |
| t | Stream UUID | Unique stream identificator by which cobalt finds stored stream info data. |
| h | HMAC | Hashed combination of: (hashed) ip address, stream uuid, expiry timestamp, and service name. Used for verification of stream. |
| e | Expiry timestamp | |
| key | variables | description |
|:----|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------|
| p | ``1`` | Used for checking the rate limit. |
| t | Stream token | Unique stream identificator which is used for retrieving cached stream info data. |
| h | HMAC | Hashed combination of: (hashed) ip address, stream token, expiry timestamp, and service name. Used for verification of stream. |
| e | Expiry timestamp | |
## GET: ``/api/onDemand``
On-demand website element loading. Currently used only for older changelogs.<br>

View file

@ -1,7 +1,7 @@
{
"name": "cobalt",
"description": "save what you love",
"version": "4.5",
"version": "4.7.3",
"author": "wukko",
"exports": "./src/cobalt.js",
"type": "module",

View file

@ -10,12 +10,12 @@ import { appName, genericUserAgent, version, internetExplorerRedirect } from "./
import { getJSON } from "./modules/api.js";
import renderPage from "./modules/pageRender/page.js";
import { apiJSON, checkJSONPost, languageCode } from "./modules/sub/utils.js";
import { Bright, Cyan } from "./modules/sub/consoleText.js";
import { Bright, Cyan, Green, Red } from "./modules/sub/consoleText.js";
import stream from "./modules/stream/stream.js";
import loc from "./localization/manager.js";
import { buildFront } from "./modules/build.js";
import { changelogHistory } from "./modules/pageRender/onDemand.js";
import { encrypt } from "./modules/sub/crypto.js";
import { sha256 } from "./modules/sub/crypto.js";
const commitHash = shortCommit();
const app = express();
@ -71,7 +71,7 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
}));
app.post('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), async (req, res) => {
try {
let ip = encrypt(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt);
let ip = sha256(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt);
switch (req.params.type) {
case 'json':
try {
@ -101,31 +101,13 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') })
}
});
app.get('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), async (req, res) => {
app.get('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), (req, res) => {
try {
let ip = encrypt(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt);
let ip = sha256(req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip.replace('::ffff:', ''), process.env.streamSalt);
switch (req.params.type) {
// **
// json GET method will be deprecated by 4.5! make sure to move your shortcuts to POST method.
// **
case 'json':
try {
if (req.query.url && req.query.url.length < 150) {
let chck = checkJSONPost({});
chck["ip"] = ip;
let j = await getJSON(req.query.url.trim(), languageCode(req), chck)
res.status(j.status).json(j.body);
} else {
let j = apiJSON(3, { t: loc(languageCode(req), 'ErrorNoLink', process.env.selfURL) })
res.status(j.status).json(j.body);
}
} catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') })
}
res.status(405).json({ 'status': 'error', 'text': 'GET method for this request has been deprecated. see https://github.com/wukko/cobalt/blob/current/docs/API.md for up-to-date API documentation.' });
break;
// **
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ will be removed soon ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// **
case 'stream':
if (req.query.p) {
res.status(200).json({ "status": "continue" });
@ -190,8 +172,9 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
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`)
let startTime = new Date();
console.log(`\n${Cyan(appName)} ${Bright(`v.${version}-${commitHash}`)}\nStart time: ${Bright(`${startTime.toUTCString()} (${Math.floor(new Date().getTime())})`)}\n\nURL: ${Cyan(`${process.env.selfURL}`)}\nPort: ${process.env.port}\n`)
});
} else {
console.log(`you can't run the server without generating a .env file. please run the setup script first: npm run setup`)
console.log(Red(`cobalt hasn't been configured yet or configuration is invalid.\n`) + Bright(`please run the setup script to fix this: `) + Green(`npm run setup`))
}

View file

@ -1,5 +1,5 @@
{
"streamLifespan": 3600000,
"streamLifespan": 120000,
"maxVideoDuration": 7500000,
"maxAudioDuration": 7500000,
"genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36",
@ -41,6 +41,9 @@
"10-31": "🎃",
"11-01": "🕯️",
"11-02": "🕯️",
"12-20": "🎄",
"12-21": "🎄",
"12-22": "🎄",
"12-23": "🎄",
"12-24": "🎄",
"12-25": "🎄",
@ -56,7 +59,7 @@
"webm": ["-c:v", "copy", "-c:a", "copy"],
"mp4": ["-c:v", "copy", "-c:a", "copy", "-movflags", "faststart+frag_keyframe+empty_moov"],
"copy": ["-c:a", "copy"],
"audio": ["-ar", "48000", "-ac", "2", "-b:a", "320k"],
"audio": ["-vn", "-ar", "48000", "-ac", "2", "-b:a", "320k"],
"m4a": ["-movflags", "frag_keyframe+empty_moov"]
}
}

View file

@ -72,13 +72,13 @@ a {
:focus-visible {
outline: var(--border-15);
}
[type=checkbox] {
margin-right: 0.8rem;
}
[type="checkbox"] {
-webkit-appearance: none;
margin-right: 0.8rem;
appearance: none;
margin-right: 1rem;
z-index: 0;
border: 0;
height: 15px;
}
[type="checkbox"]::before {
content: "";
@ -91,9 +91,12 @@ a {
position: relative;
}
[type="checkbox"]:checked::before {
box-shadow: inset 0 0 0 0.2rem var(--accent-button-bg);
box-shadow: inset 0 0 0 0.14rem var(--accent-button-bg);
background-color: var(--accent);
}
.checkbox span {
margin-top: 0.21rem;
}
button {
background: none;
border: none;
@ -278,9 +281,9 @@ input[type="checkbox"] {
height: auto;
width: 32%;
z-index: 999;
padding: 3rem 2rem 2rem 2rem;
padding: 2rem;
font-size: 0.9rem;
max-height: 80%;
max-height: 85%;
}
.popup.small {
width: 20%
@ -296,7 +299,7 @@ input[type="checkbox"] {
z-index: 998;
}
.popup.scrollable {
height: 80%;
height: 85%;
}
.scrollable .bottom-link {
padding-bottom: 2rem;
@ -378,6 +381,7 @@ input[type="checkbox"] {
position: relative;
background: var(--background);
z-index: 999;
padding-top: 0.8rem;
}
#popup-content.with-footer {
margin-bottom: 3rem;
@ -421,7 +425,7 @@ input[type="checkbox"] {
flex-direction: row;
flex-wrap: nowrap;
align-content: center;
padding: 0.6rem 1rem 0.6rem 0.6rem;
padding: 0.55rem 1rem 0.8rem 0.7rem;
width: auto;
margin: 0 0.5rem 0.5rem 0;
background: var(--accent-button-bg);
@ -454,7 +458,7 @@ input[type="checkbox"] {
color: var(--accent-unhover-2);
}
.switch {
padding: 0.8rem;
padding: 0.7rem;
width: 100%;
text-align: center;
color: var(--accent);
@ -479,6 +483,7 @@ input[type="checkbox"] {
flex-direction: row;
flex-wrap: nowrap;
overflow-x: scroll;
scrollbar-width: none;
}
.autowidth {
width: auto;
@ -493,7 +498,7 @@ input[type="checkbox"] {
overflow: auto;
}
#close-bottom {
max-width: 3rem;
max-width: 2.8rem;
margin-left: 1rem;
background: var(--background);
border: var(--border-15);
@ -509,6 +514,9 @@ input[type="checkbox"] {
position: relative;
width: 100%;
}
.popup-tabs {
margin-top: 0.8rem;
}
.emoji {
margin-right: 0.4rem;
}
@ -573,9 +581,6 @@ input[type="checkbox"] {
margin-top: 0!important;
margin-bottom: 1rem;
}
.popup-tabs {
padding-top: 0.5rem;
}
/* adapt the page according to screen size */
@media screen and (min-width: 2300px) {
html {
@ -717,7 +722,7 @@ input[type="checkbox"] {
width: 100%!important;
}
#footer {
bottom: 2rem;
bottom: 1.7rem;
transform: translate(-50%, 0%);
}
#footer-buttons {
@ -727,6 +732,12 @@ input[type="checkbox"] {
.footer-pair .footer-button {
width: 100%!important;
}
#popup-header {
padding-top: 1.2rem;
}
#popup-settings #popup-header {
padding-top: 0.8rem;
}
.picker-image-container {
height: 7rem;
width: 7rem;
@ -754,12 +765,15 @@ input[type="checkbox"] {
.popup, .popup.scrollable, .popup.small {
border: none;
width: 90%;
height: 90%;
height: 95%;
max-height: 100%;
}
.bottom-link {
padding-bottom: 2rem;
}
.popup-tabs {
margin-top: .3rem;
}
}
@media screen and (max-width: 400px) {
.popup-title {

View file

@ -1,7 +1,7 @@
let ua = navigator.userAgent.toLowerCase();
let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os");
let version = 19;
let version = 21;
let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/);
let notification = `<div class="notification-dot"></div>`
@ -13,7 +13,7 @@ let switchers = {
"vQuality": ["hig", "max", "mid", "low"],
"aFormat": ["mp3", "best", "ogg", "wav", "opus"]
}
let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio"];
let checkboxes = ["disableTikTokWatermark", "fullTikTokAudio", "muteAudio"];
let exceptions = { // used for mobile devices
"vQuality": "mid"
}
@ -339,6 +339,7 @@ async function download(url) {
if (sGet("fullTikTokAudio") === "true") req["isTTFullAudio"] = true; // audio tiktok full
} else {
req["vQuality"] = sGet("vQuality").slice(0, 4);
if (sGet("muteAudio") === "true") req["isAudioMuted"] = true;
if (url.includes("youtube.com/") || url.includes("/youtu.be/")) req["vFormat"] = sGet("vFormat").slice(0, 4);
if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") req["isNoTTWatermark"] = true;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View file

@ -1,7 +1,7 @@
{
"name": "english",
"substrings": {
"ContactLink": "<a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">contact the maintainer</a>"
"ContactLink": "<a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">file an issue on github</a>"
},
"strings": {
"LinkInput": "paste the link here",
@ -38,7 +38,6 @@
"SettingsAppearanceSubtitle": "appearance",
"SettingsThemeSubtitle": "theme",
"SettingsFormatSubtitle": "download format",
"SettingsDownloadsSubtitle": "downloads",
"SettingsQualitySubtitle": "quality",
"SettingsThemeAuto": "auto",
"SettingsThemeLight": "light",
@ -53,11 +52,11 @@
"SettingsEnableDownloadPopup": "ask for a way to save",
"AccessibilityEnableDownloadPopup": "ask what to do with downloads",
"SettingsFormatDescription": "select webm if you want max quality available. webm videos are usually higher bitrate, but ios devices can't play them natively.",
"SettingsQualityDescription": "if selected quality isn't available, closest one gets picked instead.\nif you want to post a youtube video on social media, then select a combination of mp4 and 720p. those videos are usually not in av1 codec, so they should play just fine basically everywhere.",
"SettingsQualityDescription": "if selected quality isn't available, closest one gets picked instead.\nif you want to post a youtube video on social media, select a combination of mp4 and 720p.",
"LinkGitHubIssues": "&gt;&gt; report issues and check out the source code on github",
"LinkGitHubChanges": "&gt;&gt; see previous commits and contribute on github",
"NoScriptMessage": "{appName} uses javascript for api requests and interactive interface. you have to allow javascript to use this site. i don't have any ads or trackers, pinky promise.",
"DownloadPopupDescriptionIOS": "on ios devices, you have to press and hold the download button, hide the video preview, and then select \"download linked file\" in appeared popup to save the video. this will be required for as long as apple forces safari webview upon all browser developers on ios.",
"DownloadPopupDescriptionIOS": "press and hold the download button, hide the video preview, and then select \"download linked file\" to save.",
"DownloadPopupDescription": "download button opens a new tab with requested file. you can disable this popup in settings.",
"DownloadPopupWayToSave": "pick a way to save",
"ClickToCopy": "press to copy",
@ -73,7 +72,7 @@
"AccessibilityModeToggle": "toggle download mode",
"DonateLinksDescription": "donation links open in a new tab. this is the best way to donate if you want me to receive your donation directly.",
"SettingsAudioFormatBest": "best",
"SettingsAudioFormatDescription": "when best format is selected, you get audio in best quality available, because audio is kept in its original format. if you select anything other than that, you'll get a slightly compressed file.",
"SettingsAudioFormatDescription": "when best format is selected, you get audio in best quality available, because it's not re-encoded. everything else will be re-encoded.",
"Keyphrase": "save what you love",
"SettingsRemoveWatermark": "disable watermark",
"ErrorPopupCloseButton": "got it",
@ -102,12 +101,15 @@
"MediaPickerExplanationPhoneIOS": "press and hold, hide the preview, and then select \"download linked file\" to save.",
"TwitterSpaceWasntRecorded": "this twitter space wasn't recorded, so there's nothing to download. try another one!",
"ErrorCantProcess": "i couldn't process your request :(\nyou can try again, but if issue persists, please {ContactLink}.",
"ChangelogPressToHide": "press to hide",
"ChangelogPressToHide": "press to collapse",
"Donate": "donate",
"DonateSub": "help me keep it up",
"DonateExplanation": "{appName} does not (and will never) serve ads or sell your data, therefore it's <span class=\"text-backdrop\">completely free to use</span>. but hey! turns out keeping up a web service used by hundreds of thousands of people is somewhat costly.\n\nif you ever found {appName} useful and want to keep it online, or simply want to thank the developer, consider chipping in! each and every cent helps and is VERY appreciated.",
"DonateVia": "donate via",
"DonateHireMe": "or, as an alternative, you can <a class=\"text-backdrop\" href=\"{s}\" target=\"_blank\">hire me</a>.",
"DiscordServer": "join the live conversation about {appName} on the <a class=\"text-backdrop\" href=\"{discord}\" target=\"_blank\">official discord server</a>"
"SettingsVideoMute": "mute audio",
"SettingsVideoMuteExplanation": "disables audio in downloaded video when possible. ignored when audio mode is on or service only supports audio.",
"SettingsVideoGeneral": "general",
"ErrorSoundCloudNoClientId": "couldn't find client_id that is required to fetch audio data from soundcloud. try again, and if issue persists, {ContactLink}."
}
}

View file

@ -25,7 +25,7 @@
"ErrorUnsupported": "с твоей ссылкой что-то не так, или же этот сервис ещё не поддерживается. может быть, ты вставил не ту ссылку?",
"ErrorBrokenLink": "{s} поддерживается, но с твоей ссылкой что-то не так. может быть, ты её не полностью скопировал?",
"ErrorNoLink": "я не гадалка и не могу угадывать, что ты хочешь скачать. попробуй в следующий раз вставить ссылку.",
"ErrorPageRenderFail": "что-то пошло не так, поэтому у меня не получилось срендерить страницу. если это повторится ещё раз, пожалуйста, {ContactLink}. также приложи хэш текущего коммита ({s}) с действиями для повторения этой ошибки. можно на русском языке. спасибо :)",
"ErrorPageRenderFail": "что-то пошло не так и у меня не получилось срендерить страницу. если это повторится ещё раз, пожалуйста, {ContactLink}. также приложи хэш текущего коммита ({s}) с действиями для повторения этой ошибки. можно на русском языке. спасибо :)",
"ErrorRateLimit": "ты делаешь слишком много запросов. успокойся и попробуй ещё раз через несколько минут.",
"ErrorCouldntFetch": "мне не удалось получить инфу о твоей ссылке. проверь её и попробуй ещё раз.",
"ErrorLengthLimit": "твоё видео длиннее чем {s} минут(ы). это превышает текущий лимит. скачай что-нибудь покороче, а не экранизацию \"войны и мира\".",
@ -53,8 +53,8 @@
"AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками",
"SettingsFormatDescription": "выбирай webm, если хочешь максимальное качество. у webm видео битрейт обычно выше, но устройства на ios не могут проигрывать их без сторонних приложений.",
"SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.\nесли ты хочешь опубликовать видео с youtube где-то в соц. сетях, то выбирай комбинацию из mp4 и 720p.",
"LinkGitHubIssues": "&gt;&gt; сообщай о проблемах и смотри исходный код на гитхабе",
"LinkGitHubChanges": "&gt;&gt; смотри предыдущие изменения на гитхабе",
"LinkGitHubIssues": "&gt;&gt; сообщай о проблемах и смотри исходный код на github",
"LinkGitHubChanges": "&gt;&gt; смотри предыдущие изменения на github",
"NoScriptMessage": "{appName} использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких трекеров или рекламы, обещаю.",
"DownloadPopupDescriptionIOS": "зажми кнопку \"скачать\", затем скрой превью видео и выбери \"загрузить файл по ссылке\" в появившемся окне.",
"DownloadPopupDescription": "кнопка скачивания открывает новое окно с файлом. ты можешь отключить выбор метода сохранения файла в настройках.",

View file

@ -15,7 +15,7 @@ export function loadLoc() {
}
loadLoc();
export function replaceBase(s) {
return s.replace(/\n/g, '<br/>').replace(/{appName}/g, appName).replace(/{repo}/g, repo)// .replace(/{discord}/g, socials.discord)
return s.replace(/\n/g, '<br/>').replace(/{appName}/g, appName).replace(/{repo}/g, repo).replace(/{bS}/g, '<div class=\"bullpadding\">').replace(/{bE}/g, '</div>').replace(/\*;/g, "&bull;");
}
export function replaceAll(lang, str, string, replacement) {
let s = replaceBase(str[string])

View file

@ -1,11 +1,21 @@
{
"current": {
"version": "4.7",
"title": "we're better together! thank you for bug reports.",
"banner": "bettertogether.webp",
"content": "this update includes a bunch of improvements, many of which were made thanks to the community :D\n\nservice-related improvements:\n*; private soundcloud links are now supported (#68);\n*; tiktok usernames with dots in them no longer confuse cobalt (#71);\n*; .ogg files no longer wrongfully include a video channel (#67);\n*; fixed an issue that caused cobalt to freak out when user attempted to download an audio from audio-only service with \"mute video\" option enabled.\n\nui improvements:\n*; popup padding has been evened out. popups are now able to fit in more information on scroll, especially on mobile;\n*; all buttons are now of even size and are displayed without any padding issues across all modern browsers and devices;\n*; checkbox is no longer crippled on ios;\n*; many explanation texts have been simplified to get rid of unnecessary bloat (no bullshit, remember?);\n*; moved tiktok section in video settings higher due to higher priority.\n\nstability improvements:\n*; ffmpeg process now should end upon finishing the render;\n*; ffmpeg should also quit when download is abruptly cut off;\n*; fixed a memory leak that was caused by misconfigured stream information caching (#63).\n\ninternal improvements:\n*; requested streams are now stored in cache for 2 minutes instead of 1000 hours (yes, 1000 hours, i fucked up);\n*; cached data is now reused if user requests same content within 2 minutes;\n*; page render module is now even cleaner than before;\n*; proper support for bullet-points in loc strings.\n\nyou can suggest features or report bugs on <a class=\"text-backdrop\" href=\"{repo}\" target=\"_blank\">github</a> or <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">twitter</a>. both work just fine, use whichever you're more comfortable with.\n\nthank you for using cobalt, and thank you for reading this changelog.\n\nyou're amazing, keep it up :)"
},
"history": [{
"version": "4.6",
"title": "mute videos and proper soundcloud support",
"banner": "shutup.png",
"content": "i've been longing to implement both of these things, and here they finally are.\n\nservice-related improvements:\n{bS}*; you now can download videos with no audio! simply enable the \"mute audio\" option in settings &gt; audio.\n*; soundcloud module has been updated, and downloads should no longer break after some time.{bE}\nvisual improvements:\n{bS}*; moved some things around in settings popup, and added separators where separation is needed.\n*; updated some texts in english and russian.\n*; version and commit hash have been joined together, now they're a single unit.{bE}\ninternal improvements:\n{bS}*; updated api documentation to include isAudioMuted.\n*; simplified the startup message.\n*; created render elements for separator and explanation due to high duplication of them in the page.\n*; fully deprecated GET method for API requests.\n*; fixed some code quirks.{bE}\nhere's how soundcloud downloads got fixed:\n\npreviously, client_id was (stupidly) hardcoded. that means cobalt wasn't able to fetch song data if soundcloud web app got updated.\nnow, cobalt tries to find the up-to-date client_id, caches it in memory, and checks if web app version has changed to update the id accordingly. you can see this change for yourself on github."
}, {
"version": "4.5",
"title": "better, faster, stronger, stable",
"banner": "meowthstrong.webp",
"content": "your favorite social media downloader just got even better! this update includes a ton of imporvements and fixes.\n\nin fact, there are so many changes, i had to split them in sections.\n\nservice-related improvements:\n<div class=\"bullpadding\">&bull; vimeo module has been revamped, all sorts of videos should now be supported.\n&bull; vimeo audio downloads! you now can download audios from more recent videos.\n&bull; {appName} now supports all sorts of tumblr links. (even those scary ones from the mobile app)\n&bull; vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.\n&bull; youtube videos with community warnings should now be possible to download.</div>\nuser interface improvements:\n<div class=\"bullpadding\">&bull; list of supported services is now MUCH easier to read.\n&bull; banners in changelog history should no longer overlap each other.\n&bull; bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.</div>\ninternal improvements:\n<div class=\"bullpadding\">&bull; cobalt will now match the link to regex when using ?u= query for autopasting it into input area.\n&bull; better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.\n&bull; moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on <a class=\"text-backdrop\" href=\"https://github.com/wukko/better-ytdl-core\" target=\"_blank\">github</a> or <a class=\"text-backdrop\" href=\"https://www.npmjs.com/package/better-ytdl-core\" target=\"_blank\">npm</a>!\n&bull; ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.\n&bull; \"got\" package has been (mostly) replaced by native fetch api. this should greately reduce ram usage.\n&bull; all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?\n&bull; other code optimizations. there's less clutter overall.</div>\nhuge update, right? seems like everything's fixed now?\n\nnope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt/issues/62\" target=\"_blank\">please feel free to do it on github!</a>\n\nthank you for reading this, and thank you for sticking with cobalt and me."
},
"history": [{
"content": "your favorite social media downloader just got even better! this update includes a ton of imporvements and fixes.\n\nin fact, there are so many changes, i had to split them in sections.\n\nservice-related improvements:\n{bS}*; vimeo module has been revamped, all sorts of videos should now be supported.\n*; vimeo audio downloads! you now can download audios from more recent videos.\n*; {appName} now supports all sorts of tumblr links. (even those scary ones from the mobile app)\n*; vk clips support has been fixed. they rolled back the separation of videos and clips, so i had to do the same.\n*; youtube videos with community warnings should now be possible to download.{bE}\nuser interface improvements:\n{bS}*; list of supported services is now MUCH easier to read.\n*; banners in changelog history should no longer overlap each other.\n*; bullet points! they have a bit of extra padding, so it makes them stand out of the rest of text.{bE}\ninternal improvements:\n{bS}*; cobalt will now match the link to regex when using ?u= query for autopasting it into input area.\n*; better rate limiting: limiting now is done per minute, not per 20 minutes. this ensures less waiting and less attack area for request spammers.\n*; moved to my own fork of ytdl-core, cause main project seems to have been abandoned. go check it out on <a class=\"text-backdrop\" href=\"https://github.com/wukko/better-ytdl-core\" target=\"_blank\">github</a> or <a class=\"text-backdrop\" href=\"https://www.npmjs.com/package/better-ytdl-core\" target=\"_blank\">npm</a>!\n*; ALL user inputs are now properly sanitized on the server. that includes variables for POST api method, too.\n*; \"got\" package has been (mostly) replaced by native fetch api. this should greately reduce ram usage.\n*; all unnecessary duplications of module imports have been gotten rid of. no more error passing strings from inside of service modules. you don't make mistakes only if you don't do anything, right?\n*; other code optimizations. there's less clutter overall.{bE}\nhuge update, right? seems like everything's fixed now?\n\nnope, one issue still persists: sometimes youtube server drops packets for an audio file while cobalt's rendering the video for you. this results in abrupt cuts of audio. if you want to help solving this issue, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt/issues/62\" target=\"_blank\">please feel free to do it on github!</a>\n\nthank you for reading this, and thank you for sticking with cobalt and me."
}, {
"version": "4.4",
"title": "over 1 million monthly requests. thank you.",
"banner": "onemillionr.webp",

View file

@ -39,7 +39,15 @@ export function checkbox(action, text, aria, paddingType) {
<span>${text}</span>
</label>`
}
export function sep(paddingType) {
let paddingClass = ``
switch(paddingType) {
case 0:
paddingClass += ` top-margin`;
break;
}
return `<div class="separator${paddingClass}"></div>`
}
export function popup(obj) {
let classes = obj.classes ? obj.classes : []
let body = obj.body;
@ -143,7 +151,9 @@ export function footerButtons(obj) {
return `
<div id="footer-buttons">${items}</div>`
}
export function explanation(text) {
return `<div class="explanation">${text}</div>`
}
export function celebrationsEmoji() {
let n = new Date().toISOString().split('T')[0].split('-');
let dm = `${n[1]}-${n[2]}`;

View file

@ -1,4 +1,4 @@
import { backdropLink, celebrationsEmoji, checkbox, footerButtons, multiPagePopup, popup, popupWithBottomButtons, settingsCategory, switcher } from "./elements.js";
import { backdropLink, celebrationsEmoji, checkbox, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher } from "./elements.js";
import { services as s, appName, authorInfo, version, quality, repo, donations, supportedAudio } from "../config.js";
import { getCommitInfo } from "../sub/currentCommit.js";
import loc from "../../localization/manager.js";
@ -27,11 +27,14 @@ for (let i in donations["crypto"]) {
donate += `<div class="subtitle${extr}">${i} (REPLACEME)</div><div id="don-${i}" class="text-to-copy" onClick="copy('don-${i}')">${donations["crypto"][i]}</div>`
extr = ' top-margin'
}
export default function(obj) {
audioFormats[0]["text"] = loc(obj.lang, 'SettingsAudioFormatBest');
const t = (str, replace) => { return loc(obj.lang, str, replace) };
let ua = obj.useragent.toLowerCase();
let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os");
audioFormats[0]["text"] = t('SettingsAudioFormatBest');
try {
return `<!DOCTYPE html>
<html lang="en">
@ -43,10 +46,10 @@ export default function(obj) {
<meta property="og:url" content="${process.env.selfURL}" />
<meta property="og:title" content="${appName}" />
<meta property="og:description" content="${loc(obj.lang, 'EmbedBriefDescription')}" />
<meta property="og:description" content="${t('EmbedBriefDescription')}" />
<meta property="og:image" content="${process.env.selfURL}icons/generic.png" />
<meta name="title" content="${appName}" />
<meta name="description" content="${loc(obj.lang, 'AboutSummary')}" />
<meta name="description" content="${t('AboutSummary')}" />
<meta name="theme-color" content="#000000" />
<meta name="twitter:card" content="summary" />
@ -59,51 +62,51 @@ export default function(obj) {
<link rel="stylesheet" href="cobalt.css" />
<link rel="stylesheet" href="fonts/notosansmono.css" />
<noscript><div style="margin: 2rem;">${loc(obj.lang, 'NoScriptMessage')}</div></noscript>
<noscript><div style="margin: 2rem;">${t('NoScriptMessage')}</div></noscript>
</head>
<body id="cobalt-body" data-nosnippet>
${multiPagePopup({
name: "about",
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
closeAria: t('AccessibilityClosePopup'),
tabs: [{
name: "about",
title: `${emoji("🐲")} ${loc(obj.lang, 'AboutTab')}`,
title: `${emoji("🐲")} ${t('AboutTab')}`,
content: popup({
name: "about",
header: {
aboveTitle: {
text: loc(obj.lang, 'MadeWithLove'),
text: t('MadeWithLove'),
url: authorInfo.link
},
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
title: loc(obj.lang, 'TitlePopupAbout')
closeAria: t('AccessibilityClosePopup'),
title: t('TitlePopupAbout')
},
body: [{
text: loc(obj.lang, 'AboutSummary')
text: t('AboutSummary')
}, {
text: `${loc(obj.lang, 'AboutSupportedServices')}`,
text: `${t('AboutSupportedServices')}`,
nopadding: true
}, {
text: `<div class="bullpadding">${enabledServices}.</div>`
}, {
text: obj.lang !== "ru" ? loc(obj.lang, 'FollowTwitter') : "",
text: obj.lang !== "ru" ? t('FollowTwitter') : "",
classes: ["desc-padding"]
}, {
text: backdropLink(repo, loc(obj.lang, 'LinkGitHubIssues')),
text: backdropLink(repo, t('LinkGitHubIssues')),
classes: ["bottom-link"]
}]
})
}, {
name: "changelog",
title: `${emoji("🎉")} ${loc(obj.lang, 'ChangelogTab')}`,
title: `${emoji("🎉")} ${t('ChangelogTab')}`,
content: popup({
name: "changelog",
header: {
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
title: `${emoji("🪄", 30)} ${loc(obj.lang, 'TitlePopupChangelog')}`
closeAria: t('AccessibilityClosePopup'),
title: `${emoji("🪄", 30)} ${t('TitlePopupChangelog')}`
},
body: [{
text: `<div class="category-title">${loc(obj.lang, 'ChangelogLastMajor')}</div>`,
text: `<div class="category-title">${t('ChangelogLastMajor')}</div>`,
raw: true
}, {
text: changelogManager("banner") ? `<div class="changelog-banner"><img class="changelog-img" src="${changelogManager("banner")}" onerror="this.style.display='none'"></img></div>`: '',
@ -115,54 +118,56 @@ export default function(obj) {
}, {
text: changelogManager("content")
}, {
text: `<div class="separator"></div><span class="text-backdrop">${obj.hash}:</span> ${com[0]}`,
text: `${sep()}<span class="text-backdrop">${obj.hash}:</span> ${com[0]}`,
classes: ["changelog-subtitle"],
nopadding: true
}, {
text: com[1]
}, {
text: backdropLink(`${repo}/commits`, loc(obj.lang, 'LinkGitHubChanges')),
text: backdropLink(`${repo}/commits`, t('LinkGitHubChanges')),
classes: ["bottom-link"]
}, {
text: `<div class="category-title">${loc(obj.lang, 'ChangelogOlder')}</div>`,
text: `<div class="category-title">${t('ChangelogOlder')}</div>`,
raw: true
}, {
text: `<div id="changelog-history"><button class="switch bottom-margin" onclick="loadOnDemand('changelog-history', '0')">${loc(obj.lang, "ChangelogPressToExpand")}</button></div>`,
text: `<div id="changelog-history"><button class="switch bottom-margin" onclick="loadOnDemand('changelog-history', '0')">${t("ChangelogPressToExpand")}</button></div>`,
raw: true
}]
})
}, {
name: "donate",
title: `${emoji("💰")} ${loc(obj.lang, 'DonationsTab')}`,
title: `${emoji("💰")} ${t('DonationsTab')}`,
content: popup({
name: "donate",
header: {
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
title: emoji("💸", 30) + loc(obj.lang, 'TitlePopupDonate'),
subtitle: loc(obj.lang, 'DonateSub')
closeAria: t('AccessibilityClosePopup'),
title: emoji("💸", 30) + t('TitlePopupDonate')
},
body: [{
text: `<div class="category-title">${t('DonateSub')}</div>`,
raw: true
}, {
text: `<div class="changelog-banner"><img class="changelog-img" src="updateBanners/catsleep.webp" onerror="this.style.display='none'"></img></div>`,
raw: true
},{
text: loc(obj.lang, 'DonateExplanation')
}, {
text: donateLinks.replace(/REPLACEME/g, loc(obj.lang, 'DonateVia')),
text: t('DonateExplanation')
}, {
text: donateLinks.replace(/REPLACEME/g, t('DonateVia')),
raw: true
}, {
text: loc(obj.lang, 'DonateLinksDescription'),
text: t('DonateLinksDescription'),
classes: ["explanation"]
}, {
text: `<div class="separator"></div>`,
text: sep(),
raw: true
}, {
text: donate.replace(/REPLACEME/g, loc(obj.lang, 'ClickToCopy')),
text: donate.replace(/REPLACEME/g, t('ClickToCopy')),
classes: ["desc-padding"]
}, {
text: `<div class="separator"></div>`,
text: sep(),
raw: true
}, {
text: loc(obj.lang, 'DonateHireMe', authorInfo.link),
text: t('DonateHireMe', authorInfo.link),
classes: ["desc-padding"]
}]
})
@ -170,100 +175,100 @@ export default function(obj) {
})}
${multiPagePopup({
name: "settings",
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
closeAria: t('AccessibilityClosePopup'),
header: {
aboveTitle: {
text: `v.${version} ~ ${obj.hash}`,
text: `v.${version}-${obj.hash}`,
url: `${repo}/commit/${obj.hash}`
},
title: `${emoji("⚙️", 30)} ${loc(obj.lang, 'TitlePopupSettings')}`
title: `${emoji("⚙️", 30)} ${t('TitlePopupSettings')}`
},
tabs: [{
name: "video",
title: `${emoji("🎬")} ${loc(obj.lang, 'SettingsVideoTab')}`,
title: `${emoji("🎬")} ${t('SettingsVideoTab')}`,
content: settingsCategory({
name: "downloads",
title: loc(obj.lang, 'SettingsDownloadsSubtitle'),
title: t('SettingsVideoGeneral'),
body: switcher({
name: "vQuality",
subtitle: loc(obj.lang, 'SettingsQualitySubtitle'),
explanation: loc(obj.lang, 'SettingsQualityDescription'),
subtitle: t('SettingsQualitySubtitle'),
explanation: t('SettingsQualityDescription'),
items: [{
"action": "max",
"text": `${loc(obj.lang, 'SettingsQualitySwitchMax')}<br/>(2160p+)`
"text": `${t('SettingsQualitySwitchMax')}<br/>(2160p+)`
}, {
"action": "hig",
"text": `${loc(obj.lang, 'SettingsQualitySwitchHigh')}<br/>(${quality.hig}p)`
"text": `${t('SettingsQualitySwitchHigh')}<br/>(${quality.hig}p)`
}, {
"action": "mid",
"text": `${loc(obj.lang, 'SettingsQualitySwitchMedium')}<br/>(${quality.mid}p)`
"text": `${t('SettingsQualitySwitchMedium')}<br/>(${quality.mid}p)`
}, {
"action": "low",
"text": `${loc(obj.lang, 'SettingsQualitySwitchLow')}<br/>(${quality.low}p)`
"text": `${t('SettingsQualitySwitchLow')}<br/>(${quality.low}p)`
}]
})
}) + `${!isIOS ? checkbox("downloadPopup", loc(obj.lang, 'SettingsEnableDownloadPopup'), loc(obj.lang, 'AccessibilityEnableDownloadPopup'), 1) : ''}`
+ settingsCategory({
name: "youtube",
body: switcher({
name: "vFormat",
subtitle: loc(obj.lang, 'SettingsFormatSubtitle'),
explanation: loc(obj.lang, 'SettingsFormatDescription'),
items: [{
"action": "mp4",
"text": "mp4 (av1)"
}, {
"action": "webm",
"text": "webm (vp9)"
}]
})
})
+ settingsCategory({
name: "tiktok",
title: "tiktok & douyin",
body: checkbox("disableTikTokWatermark", loc(obj.lang, 'SettingsRemoveWatermark'))
})
+ settingsCategory({
name: "tiktok",
title: "tiktok & douyin",
body: checkbox("disableTikTokWatermark", t('SettingsRemoveWatermark'))
})
+ settingsCategory({
name: "youtube",
body: switcher({
name: "vFormat",
subtitle: t('SettingsFormatSubtitle'),
explanation: t('SettingsFormatDescription'),
items: [{
"action": "mp4",
"text": "mp4 (av1)"
}, {
"action": "webm",
"text": "webm (vp9)"
}]
})
})
}, {
name: "audio",
title: `${emoji("🎶")} ${loc(obj.lang, 'SettingsAudioTab')}`,
title: `${emoji("🎶")} ${t('SettingsAudioTab')}`,
content: settingsCategory({
name: "general",
title: loc(obj.lang, 'SettingsAudioTab'),
title: t('SettingsAudioTab'),
body: switcher({
name: "aFormat",
subtitle: loc(obj.lang, 'SettingsFormatSubtitle'),
explanation: loc(obj.lang, 'SettingsAudioFormatDescription'),
subtitle: t('SettingsFormatSubtitle'),
explanation: t('SettingsAudioFormatDescription'),
items: audioFormats
})
}) + sep(0) + checkbox("muteAudio", t('SettingsVideoMute'), t('SettingsVideoMute'), 3) + explanation(t('SettingsVideoMuteExplanation'))
}) + settingsCategory({
name: "tiktok",
title: "tiktok & douyin",
body: checkbox("fullTikTokAudio", loc(obj.lang, 'SettingsAudioFullTikTok'), loc(obj.lang, 'SettingsAudioFullTikTok'), 3) + `<div class="explanation">${loc(obj.lang, 'SettingsAudioFullTikTokDescription')}</div>`
body: checkbox("fullTikTokAudio", t('SettingsAudioFullTikTok'), t('SettingsAudioFullTikTok'), 3) + `<div class="explanation">${t('SettingsAudioFullTikTokDescription')}</div>`
})
}, {
name: "other",
title: `${emoji("🪅")} ${loc(obj.lang, 'SettingsOtherTab')}`,
title: `${emoji("🪅")} ${t('SettingsOtherTab')}`,
content: settingsCategory({
name: "appearance",
title: loc(obj.lang, 'SettingsAppearanceSubtitle'),
title: t('SettingsAppearanceSubtitle'),
body: switcher({
name: "theme",
subtitle: loc(obj.lang, 'SettingsThemeSubtitle'),
subtitle: t('SettingsThemeSubtitle'),
items: [{
"action": "auto",
"text": loc(obj.lang, 'SettingsThemeAuto')
"text": t('SettingsThemeAuto')
}, {
"action": "dark",
"text": loc(obj.lang, 'SettingsThemeDark')
"text": t('SettingsThemeDark')
}, {
"action": "light",
"text": loc(obj.lang, 'SettingsThemeLight')
"text": t('SettingsThemeLight')
}]
}) + checkbox("alwaysVisibleButton", loc(obj.lang, 'SettingsKeepDownloadButton'), loc(obj.lang, 'AccessibilityKeepDownloadButton'), 2)
}) + checkbox("alwaysVisibleButton", t('SettingsKeepDownloadButton'), t('AccessibilityKeepDownloadButton'), 2)
}) + settingsCategory({
name: "miscellaneous",
title: loc(obj.lang, 'Miscellaneous'),
body: checkbox("disableChangelog", loc(obj.lang, 'SettingsDisableNotifications'))
title: t('Miscellaneous'),
body: checkbox("disableChangelog", t('SettingsDisableNotifications')) + `${!isIOS ? checkbox("downloadPopup", t('SettingsEnableDownloadPopup'), t('AccessibilityEnableDownloadPopup'), 1) : ''}`
})
}],
})}
@ -271,25 +276,25 @@ export default function(obj) {
name: "download",
standalone: true,
header: {
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
subtitle: loc(obj.lang, 'TitlePopupDownload')
closeAria: t('AccessibilityClosePopup'),
subtitle: t('TitlePopupDownload')
},
body: switcher({
name: "download",
subtitle: loc(obj.lang, 'DownloadPopupWayToSave'),
explanation: `${!isIOS ? loc(obj.lang, 'DownloadPopupDescription') : loc(obj.lang, 'DownloadPopupDescriptionIOS')}`,
items: `<a id="pd-download" class="switch full space-right" target="_blank" href="/">${loc(obj.lang, 'Download')}</a>
<div id="pd-copy" class="switch full">${loc(obj.lang, 'CopyURL')}</div>`
subtitle: t('DownloadPopupWayToSave'),
explanation: `${!isIOS ? t('DownloadPopupDescription') : t('DownloadPopupDescriptionIOS')}`,
items: `<a id="pd-download" class="switch full space-right" target="_blank" href="/">${t('Download')}</a>
<div id="pd-copy" class="switch full">${t('CopyURL')}</div>`
})
})}
${popupWithBottomButtons({
name: "picker",
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
closeAria: t('AccessibilityClosePopup'),
header: {
title: `<div id="picker-title"></div>`,
explanation: `<div id="picker-subtitle"></div>`,
},
buttons: [`<a id="picker-download" class="switch" target="_blank" href="/">${loc(obj.lang, 'ImagePickerDownloadAudio')}</a>`],
buttons: [`<a id="picker-download" class="switch" target="_blank" href="/">${t('ImagePickerDownloadAudio')}</a>`],
content: '<div id="picker-holder"></div>'
})}
${popup({
@ -298,10 +303,10 @@ export default function(obj) {
buttonOnly: true,
emoji: emoji("☹️", 48, 1),
classes: ["small"],
buttonText: loc(obj.lang, 'ErrorPopupCloseButton'),
buttonText: t('ErrorPopupCloseButton'),
header: {
closeAria: loc(obj.lang, 'AccessibilityClosePopup'),
title: loc(obj.lang, 'TitlePopupError')
closeAria: t('AccessibilityClosePopup'),
title: t('TitlePopupError')
},
body: `<div id="desc-error" class="desc-padding subtext"></div>`
})}
@ -310,13 +315,13 @@ export default function(obj) {
<div id="logo-area">${appName}</div>
<div id="download-area" class="mobile-center">
<div id="top">
<input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="128" autocapitalize="off" placeholder="${loc(obj.lang, 'LinkInput')}" aria-label="${loc(obj.lang, 'AccessibilityInputArea')}" oninput="button()"></input>
<input id="url-input-area" class="mono" type="text" autocorrect="off" maxlength="128" autocapitalize="off" placeholder="${t('LinkInput')}" aria-label="${t('AccessibilityInputArea')}" oninput="button()"></input>
<button id="url-clear" onclick="clearInput()" style="display:none;">x</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, 'AccessibilityDownloadButton')}">
<input id="download-button" class="mono dontRead" onclick="download(document.getElementById('url-input-area').value)" type="submit" value="" disabled=true aria-label="${t('AccessibilityDownloadButton')}">
</div>
<div id="bottom">
<button id="pasteFromClipboard" class="switch" onclick="pasteClipboard()" aria-label="${loc(obj.lang, 'PasteFromClipboard')}">${emoji("📋", 22)} ${loc(obj.lang, 'PasteFromClipboard')}</button>
<button id="audioMode" class="switch" onclick="toggle('audioMode')" aria-label="${loc(obj.lang, 'AccessibilityModeToggle')}">${emoji("✨", 22, 1)}</button>
<button id="pasteFromClipboard" class="switch" onclick="pasteClipboard()" aria-label="${t('PasteFromClipboard')}">${emoji("📋", 22)} ${t('PasteFromClipboard')}</button>
<button id="audioMode" class="switch" onclick="toggle('audioMode')" aria-label="${t('AccessibilityModeToggle')}">${emoji("✨", 22, 1)}</button>
</div>
</div>
</div>
@ -325,38 +330,37 @@ export default function(obj) {
footerButtons([{
name: "about",
type: "popup",
text: `${emoji(celebrationsEmoji() , 22)} ${loc(obj.lang, 'AboutTab')}`,
aria: loc(obj.lang, 'AccessibilityOpenAbout')
text: `${emoji(celebrationsEmoji() , 22)} ${t('AboutTab')}`,
aria: t('AccessibilityOpenAbout')
}, {
name: "about",
type: "popup",
context: "donate",
text: `${emoji("💰", 22)} ${loc(obj.lang, 'Donate')}`,
aria: loc(obj.lang, 'AccessibilityOpenDonate')
text: `${emoji("💰", 22)} ${t('Donate')}`,
aria: t('AccessibilityOpenDonate')
}, {
name: "settings",
type: "popup",
text: `${emoji("⚙️", 22)} ${loc(obj.lang, 'TitlePopupSettings')}`,
aria: loc(obj.lang, 'AccessibilityOpenSettings')
}]
)}
text: `${emoji("⚙️", 22)} ${t('TitlePopupSettings')}`,
aria: t('AccessibilityOpenSettings')
}])}
</footer>
</body>
<script type="text/javascript">const loc = {
noInternet: ` + "`" + loc(obj.lang, 'ErrorNoInternet') + "`" + `,
noURLReturned: ` + "`" + loc(obj.lang, 'ErrorNoUrlReturned') + "`" + `,
unknownStatus: ` + "`" + loc(obj.lang, 'ErrorUnknownStatus') + "`" + `,
collapseHistory: ` + "`" + loc(obj.lang, 'ChangelogPressToHide') + "`" + `,
toggleDefault: '${emoji("✨")} ${loc(obj.lang, "ModeToggleAuto")}',
toggleAudio: '${emoji("🎶")} ${loc(obj.lang, "ModeToggleAudio")}',
pickerDefault: ` + "`" + loc(obj.lang, 'MediaPickerTitle') + "`" + `,
pickerImages: ` + "`" + loc(obj.lang, 'ImagePickerTitle') + "`" + `,
pickerImagesExpl: ` + "`" + loc(obj.lang, `ImagePickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `,
pickerDefaultExpl: ` + "`" + loc(obj.lang, `MediaPickerExplanation${isMobile ? `Phone${isIOS ? "IOS" : ""}` : "PC"}`) + "`" + `,
noInternet: ` + "`" + t('ErrorNoInternet') + "`" + `,
noURLReturned: ` + "`" + t('ErrorNoUrlReturned') + "`" + `,
unknownStatus: ` + "`" + t('ErrorUnknownStatus') + "`" + `,
collapseHistory: ` + "`" + t('ChangelogPressToHide') + "`" + `,
toggleDefault: '${emoji("✨")} ${t("ModeToggleAuto")}',
toggleAudio: '${emoji("🎶")} ${t("ModeToggleAudio")}',
pickerDefault: ` + "`" + t('MediaPickerTitle') + "`" + `,
pickerImages: ` + "`" + t('ImagePickerTitle') + "`" + `,
pickerImagesExpl: ` + "`" + t(`ImagePickerExplanation${isMobile ? "Phone" : "PC"}`) + "`" + `,
pickerDefaultExpl: ` + "`" + t(`MediaPickerExplanation${isMobile ? `Phone${isIOS ? "IOS" : ""}` : "PC"}`) + "`" + `,
};</script>
<script type="text/javascript" src="cobalt.js"></script>
</html>`;
} catch (err) {
return `${loc(obj.lang, 'ErrorPageRenderFail', obj.hash)}`;
return `${t('ErrorPageRenderFail', obj.hash)}`;
}
}

View file

@ -81,7 +81,7 @@ export default async function (host, patternMatch, url, lang, obj) {
noWatermark: obj.isNoTTWatermark, fullAudio: obj.isTTFullAudio,
isAudioOnly: obj.isAudioOnly
});
if (r.isAudioOnly) obj.isAudioOnly = true
if (r.isAudioOnly) obj.isAudioOnly = true;
break;
case "tumblr":
r = await tumblr({
@ -100,6 +100,7 @@ export default async function (host, patternMatch, url, lang, obj) {
r = await soundcloud({
author: patternMatch["author"], song: patternMatch["song"], url: url,
shortLink: patternMatch["shortLink"] ? patternMatch["shortLink"] : false,
accessKey: patternMatch["accessKey"] ? patternMatch["accessKey"] : false,
format: obj.aFormat,
lang: lang
});
@ -107,7 +108,7 @@ export default async function (host, patternMatch, url, lang, obj) {
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}
return !r.error ? matchActionDecider(r, host, obj.ip, obj.aFormat, obj.isAudioOnly, lang) : apiJSON(0, {
return !r.error ? matchActionDecider(r, host, obj.ip, obj.aFormat, obj.isAudioOnly, lang, obj.isAudioMuted) : apiJSON(0, {
t: Array.isArray(r.error) ? loc(lang, r.error[0], r.error[1]) : loc(lang, r.error)
});
} catch (e) {

View file

@ -2,8 +2,8 @@ import { audioIgnore, services, supportedAudio } from "../config.js"
import { apiJSON } from "../sub/utils.js"
import loc from "../../localization/manager.js";
export default function(r, host, ip, audioFormat, isAudioOnly, lang) {
if (!isAudioOnly && !r.picker) {
export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMuted) {
if (!isAudioOnly && !r.picker && !isAudioMuted) {
switch (host) {
case "twitter":
return apiJSON(1, { u: r.urls });
@ -42,7 +42,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang) {
case "tumblr":
return apiJSON(1, { u: r.urls });
case "vimeo":
if (r.filename) {
if (Array.isArray(r.urls)) {
return apiJSON(2, {
type: "render", u: r.urls, service: host, ip: ip,
filename: r.filename
@ -51,6 +51,16 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang) {
return apiJSON(1, { u: r.urls });
}
}
} else if (isAudioMuted && !isAudioOnly) {
let isSplit = Array.isArray(r.urls);
return apiJSON(2, {
type: isSplit ? "bridge" : "mute",
u: isSplit ? r.urls[0] : r.urls,
service: host,
ip: ip,
filename: r.filename,
mute: true,
});
} else if (r.picker) {
switch (host) {
case "douyin":
@ -71,13 +81,13 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang) {
picker: r.picker, service: host
})
}
} else {
} else if (isAudioOnly) {
if ((host === "reddit" && r.typeId === 1) || (host === "vimeo" && !r.filename) || audioIgnore.includes(host)) return apiJSON(0, { t: loc(lang, 'ErrorEmptyDownload') });
let type = "render";
let copy = false;
if (!supportedAudio.includes(audioFormat)) audioFormat = "best";
if ((host == "tiktok" || host == "douyin") && isAudioOnly && services.tiktok.audioFormats.includes(audioFormat)) {
if ((host == "tiktok" || host == "douyin") && services.tiktok.audioFormats.includes(audioFormat)) {
if (r.isMp3) {
if (audioFormat === "mp3" || audioFormat === "best") {
audioFormat = "mp3"
@ -105,5 +115,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang) {
filename: r.audioFilename, isAudioOnly: true,
audioFormat: audioFormat, copy: copy, fileMetadata: r.fileMetadata ? r.fileMetadata : false
})
} else {
return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') });
}
}

View file

@ -84,9 +84,8 @@
"enabled": true
},
"soundcloud": {
"patterns": [":author/:song", ":shortLink"],
"patterns": [":author/:song/s-:accessKey", ":author/:song", ":shortLink"],
"bestAudio": "none",
"clientid": "YeTcsotswIIc4sse5WZsXszVxMtP6eLc",
"enabled": true
}
}

View file

@ -4,7 +4,7 @@ export default async function(obj) {
try {
let html = await fetch(`https://bilibili.com/video/${obj.id}`, {
headers: {"user-agent": genericUserAgent}
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"')) {

View file

@ -2,7 +2,7 @@ import { maxVideoDuration } from "../config.js";
export default async function(obj) {
try {
let data = await fetch(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`).then(async (r) => {return await r.json()}).catch(() => {return false});
let data = await fetch(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}/${obj.name}.json`).then((r) => {return r.json()}).catch(() => {return false});
if (!data) return { error: 'ErrorCouldntFetch' };
data = data[0]["data"]["children"][0]["data"];

View file

@ -1,4 +1,39 @@
import { genericUserAgent, maxAudioDuration, services } from "../config.js";
import { genericUserAgent, maxAudioDuration } from "../config.js";
let cachedID = {}
async function findClientID() {
try {
let sc = await fetch('https://soundcloud.com/').then((r) => {return r.text()}).catch(() => {return false});
let sc_version = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
if (cachedID.version == sc_version) {
return cachedID.id
} else {
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
let clientid;
for (let script of scripts) {
let url = script[1];
if (url && !url.startsWith('https://a-v2.sndcdn.com')) return;
let scrf = await fetch(url).then((r) => {return r.text()}).catch(() => {return false});
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
if (id && typeof id[0] === 'string') {
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
break;
}
}
cachedID.version = sc_version;
cachedID.id = clientid;
return clientid;
}
} catch (e) {
return false;
}
}
export default async function(obj) {
try {
@ -6,32 +41,36 @@ export default async function(obj) {
if (!obj.author && !obj.song && obj.shortLink) {
html = await fetch(`https://soundcloud.app.goo.gl/${obj.shortLink}/`, {
headers: {"user-agent": genericUserAgent}
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
}
if (obj.author && obj.song) {
html = await fetch(`https://soundcloud.com/${obj.author}/${obj.song}`, {
html = await fetch(`https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`, {
headers: {"user-agent": genericUserAgent}
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
}
if (!html) return { error: 'ErrorCouldntFetch'};
if (html.includes('<script>window.__sc_hydration = ') && html.includes('"format":{"protocol":"progressive","mime_type":"audio/mpeg"},') && html.includes('{"hydratable":"sound","data":')) {
let json = JSON.parse(html.split('{"hydratable":"sound","data":')[1].split('}];</script>')[0])
if (json["media"]["transcodings"]) {
let fileUrl = `${json.media.transcodings[0]["url"].replace("/hls", "/progressive")}?client_id=${services["soundcloud"]["clientid"]}&track_authorization=${json.track_authorization}`;
if (fileUrl.substring(0, 54) === "https://api-v2.soundcloud.com/media/soundcloud:tracks:") {
if (json.duration < maxAudioDuration) {
let file = await fetch(fileUrl).then(async (r) => {return (await r.json()).url}).catch(() => {return false});
if (!file) return { error: 'ErrorCouldntFetch' };
return {
urls: file,
audioFilename: `soundcloud_${json.id}`,
fileMetadata: {
title: json.title,
artist: json.user.username,
let clientId = await findClientID();
if (clientId) {
let fileUrlBase = json.media.transcodings[0]["url"].replace("/hls", "/progressive")
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
if (fileUrl.substring(0, 54) === "https://api-v2.soundcloud.com/media/soundcloud:tracks:") {
if (json.duration < maxAudioDuration) {
let file = await fetch(fileUrl).then(async (r) => {return (await r.json()).url}).catch(() => {return false});
if (!file) return { error: 'ErrorCouldntFetch' };
return {
urls: file,
audioFilename: `soundcloud_${json.id}`,
fileMetadata: {
title: json.title,
artist: json.user.username,
}
}
}
} else return { error: ['ErrorLengthAudioConvert', maxAudioDuration / 60000] }
}
} else return { error: ['ErrorLengthAudioConvert', maxAudioDuration / 60000] }
}
} else return { error: 'ErrorSoundCloudNoClientId' }
} else return { error: 'ErrorEmptyDownload' }
} else return { error: ['ErrorBrokenLink', 'soundcloud'] }
} catch (e) {

View file

@ -32,7 +32,7 @@ export default async function(obj) {
let html = await fetch(`${config[obj.host]["short"]}${obj.id}`, {
redirect: "manual",
headers: { "user-agent": userAgent }
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.slice(0, 17) === '<a href="https://' && html.includes('/video/')) {
@ -46,7 +46,7 @@ export default async function(obj) {
let detail;
detail = await fetch(config[obj.host]["api"].replace("{postId}", obj.postId), {
headers: {"user-agent": "TikTok 26.2.0 rv:262018 (iPhone; iOS 14.4.2; en_US) Cronet"}
}).then(async (r) => {return await r.json()}).catch(() => {return false});
}).then((r) => {return r.json()}).catch(() => {return false});
detail = selector(detail, obj.host, obj.postId);

View file

@ -5,10 +5,10 @@ export default async function(obj) {
let user = obj.user ? obj.user : obj.url.split('.')[0].replace('https://', '');
let html = await fetch(`https://${user}.tumblr.com/post/${obj.id}`, {
headers: {"user-agent": genericUserAgent}
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes('property="og:video" content="https://va.media.tumblr.com/')) {
return { urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"/>')[0]}`, audioFilename: `tumblr_${obj.id}_audio` }
return { urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`, audioFilename: `tumblr_${obj.id}_audio` }
} else return { error: 'ErrorEmptyDownload' }
} catch (e) {
return { error: 'ErrorBadFetch' };

View file

@ -15,13 +15,13 @@ export default async function(obj) {
let req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then(async (r) => { return r.status == 200 ? await r.json() : false;}).catch(() => {return false});
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
let showURL = `${apiURL}/statuses/show/${obj.id}.json?tweet_mode=extended&include_user_entities=0&trim_user=1&include_entities=0&cards_platform=Web-12&include_cards=1`
if (!obj.spaceId) {
let req_status = await fetch(showURL, { headers: _headers }).then(async (r) => { return r.status == 200 ? await r.json() : false;}).catch((e) => { return false});
let req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status == 200 ? r.json() : false;}).catch((e) => { return false});
if (!req_status) {
_headers.authorization = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw";
delete _headers["x-guest-token"]
@ -29,11 +29,11 @@ export default async function(obj) {
req_act = await fetch(`${apiURL}/guest/activate.json`, {
method: "POST",
headers: _headers
}).then(async (r) => { return r.status == 200 ? await r.json() : false;}).catch(() => {return false});
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
req_status = await fetch(showURL, { headers: _headers }).then(async (r) => { return r.status == 200 ? await r.json() : false;}).catch(() => {return false});
req_status = await fetch(showURL, { headers: _headers }).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
}
if (!req_status) return { error: 'ErrorCouldntFetch' }
if (req_status["extended_entities"] && req_status["extended_entities"]["media"]) {
@ -47,7 +47,7 @@ export default async function(obj) {
return { error: 'ErrorNoVideosInTweet' }
}
if (single) {
return { urls: single, audioFilename: `twitter_${obj.id}_audio` }
return { urls: single, filename: `twitter_${obj.id}.mp4`, audioFilename: `twitter_${obj.id}_audio` }
} else if (multiple) {
return { picker: multiple }
} else {
@ -63,13 +63,13 @@ export default async function(obj) {
variables: {"id": obj.spaceId,"isMetatagsQuery":true,"withSuperFollowsUserFields":true,"withDownvotePerspective":false,"withReactionsMetadata":false,"withReactionsPerspective":false,"withSuperFollowsTweetFields":true,"withReplays":true}, features: {"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"verified_phone_label_enabled":false,"tweetypie_unmention_optimization_enabled":true,"responsive_web_uc_gql_enabled":true,"vibe_api_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":false,"interactive_text_enabled":true,"responsive_web_text_conversations_enabled":false,"responsive_web_enhance_cards_enabled":true}
}
let AudioSpaceById = await fetch(`https://twitter.com/i/api/graphql/wJ5g4zf7v8qPHSQbaozYuw/AudioSpaceById?variables=${new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1)}&features=${new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1)}`, { headers: _headers }).then(async (r) => {
return r.status == 200 ? await r.json() : false;
let AudioSpaceById = await fetch(`https://twitter.com/i/api/graphql/wJ5g4zf7v8qPHSQbaozYuw/AudioSpaceById?variables=${new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1)}&features=${new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1)}`, { headers: _headers }).then((r) => {
return r.status == 200 ? r.json() : false;
}).catch((e) => {return false});
if (AudioSpaceById) {
if (AudioSpaceById.data.audioSpace.metadata.is_space_available_for_replay === true) {
let streamStatus = await fetch(`https://twitter.com/i/api/1.1/live_video_stream/status/${AudioSpaceById.data.audioSpace.metadata.media_key}`, { headers: _headers }).then(async (r) => {return r.status == 200 ? await r.json() : false;}).catch(() => {return false;});
let streamStatus = await fetch(`https://twitter.com/i/api/1.1/live_video_stream/status/${AudioSpaceById.data.audioSpace.metadata.media_key}`, { headers: _headers }).then((r) => {return r.status == 200 ? r.json() : false;}).catch(() => {return false;});
if (!streamStatus) return { error: 'ErrorCouldntFetch' };
let participants = AudioSpaceById.data.audioSpace.participants.speakers

View file

@ -2,7 +2,7 @@ import { quality, services } from "../config.js";
export default async function(obj) {
try {
let api = await fetch(`https://player.vimeo.com/video/${obj.id}/config`).then(async (r) => {return await r.json()}).catch(() => {return false});
let api = await fetch(`https://player.vimeo.com/video/${obj.id}/config`).then((r) => {return r.json()}).catch(() => {return false});
if (!api) return { error: 'ErrorCouldntFetch' };
let downloadType = "";
@ -29,10 +29,10 @@ export default async function(obj) {
} catch (e) {
best = all[0]
}
return { urls: best["url"] };
return { urls: best["url"], filename: `tumblr_${obj.id}.mp4` };
case "dash":
let masterJSONURL = api["request"]["files"]["dash"]["cdns"]["akfire_interconnect_quic"]["url"];
let masterJSON = await fetch(masterJSONURL).then(async (r) => {return await r.json()}).catch(() => {return false});
let masterJSON = await fetch(masterJSONURL).then((r) => {return r.json()}).catch(() => {return false});
if (!masterJSON) return { error: 'ErrorCouldntFetch' };
if (masterJSON.video) {
let type = "";

View file

@ -7,7 +7,7 @@ export default async function(obj) {
let html;
html = await fetch(`https://vk.com/video-${obj.userId}_${obj.videoId}`, {
headers: {"user-agent": genericUserAgent}
}).then(async (r) => {return await r.text()}).catch(() => {return false});
}).then((r) => {return r.text()}).catch(() => {return false});
if (!html) return { error: 'ErrorCouldntFetch' };
if (html.includes(`{"lang":`)) {
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);

View file

@ -1,39 +1,50 @@
import NodeCache from "node-cache";
import { UUID, encrypt } from "../sub/crypto.js";
import { sha256 } from "../sub/crypto.js";
import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({ stdTTL: streamLifespan, checkperiod: 120 });
const streamCache = new NodeCache({ stdTTL: streamLifespan/1000, checkperiod: 10, deleteOnExpire: true });
const salt = process.env.streamSalt;
export function createStream(obj) {
let streamUUID = UUID(),
exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = encrypt(`${streamUUID},${obj.service},${obj.ip},${exp}`, salt)
streamCache.on("expired", (key) => {
streamCache.del(key);
});
streamCache.set(streamUUID, {
id: streamUUID,
service: obj.service,
type: obj.type,
urls: obj.u,
filename: obj.filename,
hmac: ghmac,
ip: obj.ip,
exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat,
time: obj.time,
copy: obj.copy,
metadata: obj.fileMetadata ? obj.fileMetadata : false
});
return `${process.env.selfURL}api/stream?t=${streamUUID}&e=${exp}&h=${ghmac}`;
export function createStream(obj) {
let streamID = sha256(`${obj.ip},${obj.service},${obj.filename},${obj.audioFormat},${obj.mute}`, salt),
exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = sha256(`${streamID},${obj.service},${obj.ip},${exp}`, salt);
if (!streamCache.has(streamID)) {
streamCache.set(streamID, {
id: streamID,
service: obj.service,
type: obj.type,
urls: obj.u,
filename: obj.filename,
hmac: ghmac,
ip: obj.ip,
exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat,
time: obj.time ? obj.time : false,
copy: !!obj.copy,
mute: !!obj.mute,
metadata: obj.fileMetadata ? obj.fileMetadata : false
});
} else {
let streamInfo = streamCache.get(streamID);
exp = streamInfo.exp;
ghmac = streamInfo.hmac;
}
return `${process.env.selfURL}api/stream?t=${streamID}&e=${exp}&h=${ghmac}`;
}
export function verifyStream(ip, id, hmac, exp) {
try {
let streamInfo = streamCache.get(id);
if (streamInfo) {
let ghmac = encrypt(`${id},${streamInfo.service},${ip},${exp}`, salt);
let ghmac = sha256(`${id},${streamInfo.service},${ip},${exp}`, salt);
if (hmac == ghmac && ip == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) {
return streamInfo;
} else {

View file

@ -1,6 +1,6 @@
import { apiJSON } from "../sub/utils.js";
import { verifyStream } from "./manage.js";
import { streamAudioOnly, streamDefault, streamLiveRender } from "./types.js";
import { streamAudioOnly, streamDefault, streamLiveRender, streamVideoOnly } from "./types.js";
export default function(res, ip, id, hmac, exp) {
try {
@ -13,6 +13,9 @@ export default function(res, ip, id, hmac, exp) {
case "render":
streamLiveRender(streamInfo, res);
break;
case "mute":
streamVideoOnly(streamInfo, res);
break;
default:
streamDefault(streamInfo, res);
break;

View file

@ -6,7 +6,9 @@ import { metadataManager, msToTime } from "../sub/utils.js";
export function streamDefault(streamInfo, res) {
try {
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.isAudioOnly ? `${streamInfo.filename}.${streamInfo.audioFormat}` : streamInfo.filename}"`);
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1]
let regFilename = !streamInfo.mute ? streamInfo.filename : `${streamInfo.filename.split('.')[0]}_mute.${format}`
res.setHeader('Content-disposition', `attachment; filename="${streamInfo.isAudioOnly ? `${streamInfo.filename}.${streamInfo.audioFormat}` : regFilename}"`);
const stream = got.get(streamInfo.urls, {
headers: {
"user-agent": genericUserAgent
@ -46,7 +48,9 @@ export function streamLiveRender(streamInfo, res) {
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}"`);
ffmpegProcess.stdio[3].pipe(res);
ffmpegProcess.on('end', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => {
ffmpegProcess.kill();
res.end();
@ -87,7 +91,39 @@ export function streamAudioOnly(streamInfo, res) {
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename}.${streamInfo.audioFormat}"`);
ffmpegProcess.stdio[3].pipe(res);
ffmpegProcess.on('end', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => {
ffmpegProcess.kill();
res.end();
});
} catch (e) {
res.end();
}
}
export function streamVideoOnly(streamInfo, res) {
try {
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1], args = [
'-loglevel', '-8',
'-i', streamInfo.urls,
'-c', 'copy', '-an'
]
if (format == "mp4") args.push('-movflags', 'faststart+frag_keyframe+empty_moov')
args.push('-f', format, 'pipe:3');
const ffmpegProcess = spawn(ffmpeg, args, {
windowsHide: true,
stdio: [
'inherit', 'inherit', 'inherit',
'pipe'
],
});
res.setHeader('Connection', 'keep-alive');
res.setHeader('Content-Disposition', `attachment; filename="${streamInfo.filename.split('.')[0]}_mute.${format}"`);
ffmpegProcess.stdio[3].pipe(res);
ffmpegProcess.on('end', () => ffmpegProcess.kill());
res.on('finish', () => ffmpegProcess.kill());
res.on('close', () => ffmpegProcess.kill());
ffmpegProcess.on('error', (err) => {
ffmpegProcess.kill();
res.end();

View file

@ -1,8 +1,5 @@
import { createHmac, randomUUID } from "crypto";
import { createHmac } from "crypto";
export function encrypt(str, salt) {
export function sha256(str, salt) {
return createHmac("sha256", salt).update(str).digest("hex");
}
export function UUID() {
return randomUUID();
}

View file

@ -6,7 +6,7 @@ let apiVar = {
vQuality: ["max", "hig", "mid", "low", "los"],
aFormat: ["best", "mp3", "ogg", "wav", "opus"]
},
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio"]
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted"]
}
export function apiJSON(type, obj) {
@ -62,7 +62,18 @@ export function msToTime(d) {
return r;
}
export function cleanURL(url, host) {
let forbiddenChars = ['}', '{', '(', ')', '\\', '@', '%', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'"]
let forbiddenChars = ['}', '{', '(', ')', '\\', '%', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@"]
switch(host) {
case "youtube":
url = url.split('&')[0];
break;
case "tiktok":
url = url.replace(/@([a-zA-Z]+(\.[a-zA-Z]+)+)/, "@a")
default:
url = url.split('?')[0];
if (url.substring(url.length - 1) === "/") url = url.substring(0, url.length - 1);
break;
}
for (let i in forbiddenChars) {
url = url.replaceAll(forbiddenChars[i], '')
}
@ -70,14 +81,6 @@ export function cleanURL(url, host) {
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.slice(0, 128)
}
export function languageCode(req) {
@ -95,7 +98,8 @@ export function checkJSONPost(obj) {
aFormat: "mp3",
isAudioOnly: false,
isNoTTWatermark: false,
isTTFullAudio: false
isTTFullAudio: false,
isAudioMuted: false,
}
try {
let objKeys = Object.keys(obj);
@ -106,7 +110,7 @@ export function checkJSONPost(obj) {
if (apiVar.booleanOnly.includes(objKeys[i])) {
def[objKeys[i]] = obj[objKeys[i]] ? true : false;
} else {
if (apiVar.allowed[objKeys[i]].includes(obj[objKeys[i]])) def[objKeys[i]] = String(obj[objKeys[i]])
if (apiVar.allowed[objKeys[i]] && apiVar.allowed[objKeys[i]].includes(obj[objKeys[i]])) def[objKeys[i]] = String(obj[objKeys[i]])
}
}
}