4.3: open api + post method for main endpoint

This commit is contained in:
wukko 2022-11-12 22:40:11 +06:00
parent 4d06554256
commit 316e6423f4
18 changed files with 268 additions and 86 deletions

66
docs/API.md Normal file
View file

@ -0,0 +1,66 @@
# cobalt API Documentation
This document provides info about methods and acceptable variables for all cobalt API requests.<br>
## POST: ``/api/json``
Main processing endpoint.<br>
```
⚠️ GET method for this endpoint is deprecated and will be removed entirely soon.
Make sure to update your shortcuts and scripts.
Only url query can be used with this method.
```
Request Body Type: ``application/json``<br>
Response Body Type: ``application/json``
### Request Body Variables
| key | type | variables | default | description |
|:----------------|:--------|:----------------------------------|:-----------|:----------------------------------------------------------------------|
| url | string | Sharable URL encoded as URI | ``null`` | **Must** be included in every request. |
| vFormat | string | ``mp4 / webm`` | ``mp4`` | Applies only to YouTube downloads. ``mp4`` is recommended for phones. |
| vQuality | string | ``los / low / mid / hig / max`` | ``hig`` | ``mid`` quality is recommended for phones. |
| aFormat | string | ``best / mp3 / ogg / wav / opus`` | ``mp3`` | |
| 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. |
### Response Body Variables
| key | type | variables |
|:-----------|:-------|:--------------------------------------------------------------|
| status | string | ``error / redirect / stream / success / rate-limit / picker`` |
| text | string | Text |
| url | string | Direct link to a file / link to cobalt's stream |
| pickerType | string | ``various / images`` |
| picker | array | Array of picker items |
| audio | string | Direct link to a file / link to cobalt's stream |
### Picker Item Variables
Item type: ``object``
| key | type | variables | description |
|:-----------|:-------|:------------------------------------------------|:--------------------------------------------|
| type | string | ``video`` | Used only if ``pickerType`` is ``various``. |
| url | string | Direct link to a file / link to cobalt's stream | |
| thumb | string | Item thumbnail that's displayed in the picker | Used only for ``video`` type. |
## GET: ``/api/stream``
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 | |
## GET: ``/api/onDemand``
On-demand website element loading. Currently used only for older changelogs.<br>
### Request Query Variables
| key | variables | description |
|:--------|:----------|:---------------------------------------|
| blockId | ``0`` | Block ID to be rendered on the server. |
### Response Body Variables
| key | type | variables |
|:-----------|:-------|:-----------------------------|
| status | string | ``error / success`` |
| text | string | Error text or rendered block |

View file

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

View file

@ -9,12 +9,13 @@ import { shortCommit } from "./modules/sub/currentCommit.js";
import { appName, genericUserAgent, version, internetExplorerRedirect } from "./modules/config.js"; import { appName, genericUserAgent, version, internetExplorerRedirect } from "./modules/config.js";
import { getJSON } from "./modules/api.js"; import { getJSON } from "./modules/api.js";
import renderPage from "./modules/pageRender/page.js"; import renderPage from "./modules/pageRender/page.js";
import { apiJSON, languageCode } from "./modules/sub/utils.js"; import { apiJSON, checkJSONPost, languageCode } from "./modules/sub/utils.js";
import { Bright, Cyan } from "./modules/sub/consoleText.js"; import { Bright, Cyan } from "./modules/sub/consoleText.js";
import stream from "./modules/stream/stream.js"; import stream from "./modules/stream/stream.js";
import loc from "./localization/manager.js"; import loc from "./localization/manager.js";
import { buildFront } from "./modules/build.js"; import { buildFront } from "./modules/build.js";
import { changelogHistory } from "./modules/pageRender/onDemand.js"; import { changelogHistory } from "./modules/pageRender/onDemand.js";
import { encrypt } from "./modules/sub/crypto.js";
const commitHash = shortCommit(); const commitHash = shortCommit();
const app = express(); const app = express();
@ -24,7 +25,7 @@ app.disable('x-powered-by');
if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt && process.env.port) { if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt && process.env.port) {
const apiLimiter = rateLimit({ const apiLimiter = rateLimit({
windowMs: 20 * 60 * 1000, windowMs: 20 * 60 * 1000,
max: 100, max: 800,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
@ -33,7 +34,7 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
}) })
const apiLimiterStream = rateLimit({ const apiLimiterStream = rateLimit({
windowMs: 6 * 60 * 1000, windowMs: 6 * 60 * 1000,
max: 24, max: 600,
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
handler: (req, res, next, opt) => { handler: (req, res, next, opt) => {
@ -56,36 +57,81 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
} }
next(); next();
}); });
app.use('/api/json', express.json({
app.get('/api/:type', cors({ origin: process.env.selfURL, optionsSuccessStatus: 200 }), async (req, res) => { verify: (req, res, buf) => {
try { try {
JSON.parse(buf);
if (buf.length > 720) throw new Error();
if (req.header('Content-Type') != "application/json") res.status(500).json({ 'status': 'error', 'text': 'invalid content type header' })
if (req.header('Accept') != "application/json") res.status(500).json({ 'status': 'error', 'text': 'invalid accept header' })
} catch(e) {
res.status(500).json({ 'status': 'error', 'text': 'invalid json body.' })
}
}
}));
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);
switch (req.params.type) { switch (req.params.type) {
case 'json': case 'json':
if (req.query.url && req.query.url.length < 150) { try {
let j = await getJSON(req.query.url.trim(), languageCode(req), { let request = req.body;
ip: req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip, let chck = checkJSONPost(request);
format: req.query.format ? req.query.format.slice(0, 5) : "mp4", if (request.url && chck) {
quality: req.query.quality ? req.query.quality.slice(0, 3) : "mid", chck["ip"] = ip;
audioFormat: req.query.audioFormat ? req.query.audioFormat.slice(0, 4) : "mp3", let j = await getJSON(request.url.trim(), languageCode(req), chck)
isAudioOnly: !!req.query.audio,
noWatermark: !!req.query.nw,
fullAudio: !!req.query.ttfull,
})
res.status(j.status).json(j.body); res.status(j.status).json(j.body);
} else { } else {
try {
let j = apiJSON(3, { t: loc(languageCode(req), 'ErrorNoLink', process.env.selfURL) }) let j = apiJSON(3, { t: loc(languageCode(req), 'ErrorNoLink', process.env.selfURL) })
if (!typeof j === "undefined" && j.status && j.body) {
res.status(j.status).json(j.body); res.status(j.status).json(j.body);
} else { }
catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorUnknownStatus') }) res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorUnknownStatus') })
} }
} }
} catch (e) {
res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') })
}
break; break;
default:
let j = apiJSON(0, { t: "unknown response type" })
res.status(j.status).json(j.body);
break;
}
} catch (e) {
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) => {
try {
let ip = encrypt(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') })
}
break;
// **
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ will be removed soon ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// **
case 'stream': case 'stream':
if (req.query.p) { if (req.query.p) {
res.status(200).json({ "status": "continue" }); res.status(200).json({ "status": "continue" });
} else if (req.query.t) { } else if (req.query.t && req.query.h && req.query.e) {
let ip = req.header('x-forwarded-for') ? req.header('x-forwarded-for') : req.ip
stream(res, ip, req.query.t, req.query.h, req.query.e); stream(res, ip, req.query.t, req.query.h, req.query.e);
} else { } else {
let j = apiJSON(0, { t: "no stream id" }) let j = apiJSON(0, { t: "no stream id" })
@ -117,7 +163,7 @@ if (fs.existsSync('./.env') && process.env.selfURL && process.env.streamSalt &&
break; break;
} }
} catch (e) { } catch (e) {
res.status(500).json({ 'status': 'error', 'text': 'something went wrong.' }) res.status(500).json({ 'status': 'error', 'text': loc(languageCode(req), 'ErrorCantProcess') })
} }
}); });
app.get("/api", (req, res) => { app.get("/api", (req, res) => {

View file

@ -305,6 +305,18 @@ input[type="checkbox"] {
font-size: 1.1rem; font-size: 1.1rem;
padding-bottom: 0.7rem; padding-bottom: 0.7rem;
} }
.changelog-banner {
width: 100%;
background-color: var(--accent-button-bg);
max-height: 300px;
margin-bottom: 2rem;
}
.changelog-img {
object-fit: cover;
width: inherit;
height: inherit;
max-height: inherit;
}
.nowrap { .nowrap {
white-space: nowrap; white-space: nowrap;
} }

View file

@ -1,7 +1,7 @@
let ua = navigator.userAgent.toLowerCase(); let ua = navigator.userAgent.toLowerCase();
let isIOS = ua.match("iphone os"); let isIOS = ua.match("iphone os");
let isMobile = ua.match("android") || ua.match("iphone os"); let isMobile = ua.match("android") || ua.match("iphone os");
let version = 15; let version = 16;
let regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/); 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>` let notification = `<div class="notification-dot"></div>`
@ -288,6 +288,7 @@ function loadSettings() {
for (let i in switchers) { for (let i in switchers) {
changeSwitcher(i, sGet(i)) changeSwitcher(i, sGet(i))
} }
updateMP4Text();
} }
function changeButton(type, text) { function changeButton(type, text) {
switch (type) { switch (type) {
@ -302,6 +303,12 @@ function changeButton(type, text) {
eid("url-clear").style.display = "block"; eid("url-clear").style.display = "block";
eid("url-input-area").disabled = false eid("url-input-area").disabled = false
break; break;
case 2: //enable back + information popup
popup("error", 1, text);
changeDownloadButton(1, '>>');
eid("url-clear").style.display = "block";
eid("url-input-area").disabled = false
break;
} }
} }
function resetSettings() { function resetSettings() {
@ -319,23 +326,23 @@ async function download(url) {
changeDownloadButton(2, '...'); changeDownloadButton(2, '...');
eid("url-clear").style.display = "none"; eid("url-clear").style.display = "none";
eid("url-input-area").disabled = true; eid("url-input-area").disabled = true;
let audioMode = sGet("audioMode"); let req = {
let format = ``; url: encodeURIComponent(url.split("&")[0].split('%')[0]),
if (audioMode === "false") { aFormat: sGet("aFormat").slice(0, 4),
if (url.includes("youtube.com/") || url.includes("/youtu.be/")) {
format = `&format=${sGet("vFormat")}`
} else if ((url.includes("tiktok.com/") || url.includes("douyin.com/")) && sGet("disableTikTokWatermark") === "true") {
format = `&nw=true`
} }
if (sGet("audioMode") === "true") {
req["isAudioOnly"] = true;
req["isNoTTWatermark"] = true; // video tiktok no watermark
if (sGet("fullTikTokAudio") === "true") req["isTTFullAudio"] = true; // audio tiktok full
} else { } else {
format = `&nw=true` req["vQuality"] = sGet("vQuality").slice(0, 4);
if (sGet("fullTikTokAudio") === "true") format += `&ttfull=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;
} }
let mode = (sGet("audioMode") === "true") ? `audio=true` : `quality=${sGet("vQuality")}` await fetch('/api/json', { method: "POST", body: JSON.stringify(req), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }).then(async (r) => {
await fetch(`/api/json?audioFormat=${sGet("aFormat")}&${mode}${format}&url=${encodeURIComponent(url)}`).then(async (r) => {
let j = await r.json(); let j = await r.json();
if (j.status !== "error" && j.status !== "rate-limit") { if (j.status !== "error" && j.status !== "rate-limit") {
if (j.url) { if (j.url || j.picker) {
switch (j.status) { switch (j.status) {
case "redirect": case "redirect":
changeDownloadButton(2, '>>>'); changeDownloadButton(2, '>>>');
@ -343,21 +350,21 @@ async function download(url) {
sGet("downloadPopup") === "true" ? popup('download', 1, j.url) : window.open(j.url, '_blank'); sGet("downloadPopup") === "true" ? popup('download', 1, j.url) : window.open(j.url, '_blank');
break; break;
case "picker": case "picker":
if (j.audio && j.url) { if (j.audio && j.picker) {
changeDownloadButton(2, '?..') changeDownloadButton(2, '?..')
fetch(`${j.audio}&p=1`).then(async (res) => { fetch(`${j.audio}&p=1`).then(async (res) => {
let jp = await res.json(); let jp = await res.json();
if (jp.status === "continue") { if (jp.status === "continue") {
changeDownloadButton(2, '>>>'); changeDownloadButton(2, '>>>');
popup('picker', 1, { audio: j.audio, arr: j.url, type: j.pickerType }); popup('picker', 1, { audio: j.audio, arr: j.picker, type: j.pickerType });
setTimeout(() => { changeButton(1) }, 5000); setTimeout(() => { changeButton(1) }, 5000);
} else { } else {
changeButton(0, jp.text); changeButton(0, jp.text);
} }
}).catch((error) => internetError()); }).catch((error) => internetError());
} else if (j.url) { } else if (j.picker) {
changeDownloadButton(2, '>>>'); changeDownloadButton(2, '>>>');
popup('picker', 1, { arr: j.url, type: j.pickerType }); popup('picker', 1, { arr: j.picker, type: j.pickerType });
setTimeout(() => { changeButton(1) }, 5000); setTimeout(() => { changeButton(1) }, 5000);
} else { } else {
changeButton(0, loc.noURLReturned); changeButton(0, loc.noURLReturned);
@ -375,12 +382,17 @@ async function download(url) {
} }
}).catch((error) => internetError()); }).catch((error) => internetError());
break; break;
case "success":
changeButton(2, j.text);
break;
default: default:
changeButton(0, loc.unknownStatus); changeButton(0, loc.unknownStatus);
break; break;
} }
} else { } else {
changeButton(0, loc.noURLReturned); if (j.status === "success") {
changeButton(2, j.text)
} else changeButton(0, loc.noURLReturned);
} }
} else { } else {
changeButton(0, j.text); changeButton(0, j.text);

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

View file

@ -105,6 +105,7 @@
"MediaPickerExplanationPC": "click or right click to download what you want.", "MediaPickerExplanationPC": "click or right click to download what you want.",
"MediaPickerExplanationPhone": "press or press and hold to download what you want.", "MediaPickerExplanationPhone": "press or press and hold to download what you want.",
"MediaPickerExplanationPhoneIOS": "press and hold, hide the preview, and then select \"download linked file\" to save.", "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!" "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}."
} }
} }

View file

@ -105,6 +105,7 @@
"MediaPickerExplanationPC": "кликни, чтобы скачать. также можно скачать через контекстное меню правой кнопки мыши.", "MediaPickerExplanationPC": "кликни, чтобы скачать. также можно скачать через контекстное меню правой кнопки мыши.",
"MediaPickerExplanationPhone": "нажми, или нажми и удерживай, чтобы скачать.", "MediaPickerExplanationPhone": "нажми, или нажми и удерживай, чтобы скачать.",
"MediaPickerExplanationPhoneIOS": "нажми и удерживай, затем скрой превью, и наконец выбери \"загрузить файл по ссылке\".", "MediaPickerExplanationPhoneIOS": "нажми и удерживай, затем скрой превью, и наконец выбери \"загрузить файл по ссылке\".",
"TwitterSpaceWasntRecorded": "этот twitter space не был записан, поэтому я не могу его скачать. попробуй другой!" "TwitterSpaceWasntRecorded": "этот twitter space не был записан, поэтому я не могу его скачать. попробуй другой!",
"ErrorCantProcess": "я не смог обработать твой запрос :(\nты можешь попробовать ещё раз, но если не поможет, то {ContactLink}."
} }
} }

View file

@ -9,7 +9,7 @@ import match from "./processing/match.js";
export async function getJSON(originalURL, lang, obj) { export async function getJSON(originalURL, lang, obj) {
try { try {
let url = decodeURI(originalURL); let url = decodeURIComponent(originalURL);
if (!url.includes('http://')) { if (!url.includes('http://')) {
let hostname = url.replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."), let hostname = url.replace("https://", "").replace(' ', '').split('&')[0].split("/")[0].split("."),
host = hostname[hostname.length - 2], host = hostname[hostname.length - 2],
@ -33,11 +33,9 @@ export async function getJSON(originalURL, lang, obj) {
} }
if (patternMatch) { if (patternMatch) {
return await match(host, patternMatch, url, lang, obj); return await match(host, patternMatch, url, lang, obj);
} return apiJSON(0, { t: errorUnsupported(lang) }) } else return apiJSON(0, { t: errorUnsupported(lang) });
} return apiJSON(0, { t: errorUnsupported(lang) }) } else return apiJSON(0, { t: errorUnsupported(lang) });
} else { } else return apiJSON(0, { t: errorUnsupported(lang) });
return apiJSON(0, { t: errorUnsupported(lang) })
}
} catch (e) { } catch (e) {
return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') }); return apiJSON(0, { t: loc(lang, 'ErrorSomethingWentWrong') });
} }

View file

@ -1,10 +1,15 @@
{ {
"current": { "current": {
"version": "4.3",
"title": "developers, developers, developers, developers",
"banner": "developersdevelopersdevelopers.webp",
"content": "this update features a TON of improvements.\n\n<a class=\"text-backdrop\" href=\"https://www.youtube.com/watch?v=SaVTHG-Ev4k\" target=\"_blank\">developers</a>, you now can rely on {appName} for getting content from social media. the api has been revamped and <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt/tree/current/docs/API.md\" target=\"_blank\">documentation</a> is now available. you can read more about API changes down below. go crazy, and have fun :D\n\nif you're not a developer, here's a list of changes that you probably care about:\n- rate limit is now approximately 8 times bigger. no more waiting, even if you want to download entirety of your tiktok \"for you\" page.\n- some updates will now have expressive banners, just like this one.\n- fixed what was causing an error when a youtube video had no description.\n- mp4 format button text should now be displayed properly, no matter if you touched the switcher or not.\n\nnext, the star of this update — improved api!\n- main endpoint now uses POST method instead of GET.\n- internal variables for preferences have been updated to be consistent and easier to understand.\n- ip address is now hashed right upon request, not somewhere deep inside the code.\n- global stream salt variable is no longer unnecessarily passed over a billion functions.\n- url and picker keys are now separate in the json response.\n- {appName} web app now correctly processes responses with \"success\" status.\n\nif you currently have a siri shortcut or some other script that uses the GET method, make sure to update it soon. this method is deprecated, limited, and will be removed entirely in coming updates.\n\nif you ever make something using {appName}'s api, make sure to mention <a class=\"text-backdrop\" href=\"https://twitter.com/justusecobalt\" target=\"_blank\">@justusecobalt</a> on twitter, i would absolutely love to see what you made."
},
"history": [{
"version": "4.2", "version": "4.2",
"title": "optimized quality picking and 8k video support", "title": "optimized quality picking and 8k video support",
"content": "- this update fixes quality picking that was accidentally broken in 4.0 update.\n- you now can download videos in 8k from youtube. why would you that? no idea. but i'm more than happy to give you this option.\n- default video quality for downloads from pc is now 1440p, and 720p for phones.\n- default video format is now mp4 for everyone.\n- default audio format is now mp3 for everyone.\n\nyou can always change new defaults back to whatever you prefer in settings.\n\nother changes:\n- added more clarity to quality picker description.\n- youtube video codecs are now right in the picker.\n- setup script is now easier to understand." "content": "- this update fixes quality picking that was accidentally broken in 4.0 update.\n- you now can download videos in 8k from youtube. why would you that? no idea. but i'm more than happy to give you this option.\n- default video quality for downloads from pc is now 1440p, and 720p for phones.\n- default video format is now mp4 for everyone.\n- default audio format is now mp3 for everyone.\n\nyou can always change new defaults back to whatever you prefer in settings.\n\nother changes:\n- added more clarity to quality picker description.\n- youtube video codecs are now right in the picker.\n- setup script is now easier to understand."
}, }, {
"history": [{
"version": "4.1", "version": "4.1",
"title": "better tiktok image downloads", "title": "better tiktok image downloads",
"content": "here's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- emoji in about button now changes on special occasions. be it halloween or christmas, {appName} will change just a tiny bit to fit in :D\n\nif you're not caught up with new stuff in {appName} 4.x yet, check out the previous changelog down below. there's a ton of stuff to like." "content": "here's what's up:\n- tiktok images are saved as .jpeg instead of .webp (finally, i know).\n- added support for image downloads from douyin.\n- fixed tiktok audio downloads from the image picker.\n- emoji in about button now changes on special occasions. be it halloween or christmas, {appName} will change just a tiny bit to fit in :D\n\nif you're not caught up with new stuff in {appName} 4.x yet, check out the previous changelog down below. there's a ton of stuff to like."
@ -12,7 +17,8 @@
"version": "4.0", "version": "4.0",
"title": "better and faster than ever", "title": "better and faster than ever",
"content": "this update has a ton of improvements and new features.\n\nchanges you probably care about:\n- {appName} now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when {appName} is able to get more info, it adds that metadata too.\n- tiktok downloads have been fixed, yet again, and if they ever break in the future, {appName} will fall back to downloading a less annoyingly watermarked video.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt\" target=\"_blank\">you can do it on github</a>.\n\nfun fact: average {appName} user is 10 times cooler than a regular person." "content": "this update has a ton of improvements and new features.\n\nchanges you probably care about:\n- {appName} now has support for recorded twitter spaces! download the previous conversation no matter how long it was.\n- download speeds from youtube are at least 10 times better now. you're welcome.\n- both video and audio length limits have been extended to 2 hours.\n- audio downloads from youtube, youtube music, twitter spaces, and soundcloud now have metadata! most often it's just title and artist, but when {appName} is able to get more info, it adds that metadata too.\n- tiktok downloads have been fixed, yet again, and if they ever break in the future, {appName} will fall back to downloading a less annoyingly watermarked video.\n- soundcloud downloads have been fixed, too.\n\nless notable changes:\n- currently experimenting with using mp3 as default audio format. if you set something other than mp3 before, it'll be set to mp3. you can always change it back in settings. let me know what you think about this.\n- \"download audio\" button from image picker no longer stays on the screen after popup was closed.\n- clipboard button now shows up depending on your browser's support for it.\n- you can no longer manually hide the clipboard button, 'cause it's unnecessary.\n- small internal improvements such as separation of changelog version and title.\n- fair bit of internal clean up.\n\nif you want to help me implement covers for downloaded audios, <a class=\"text-backdrop\" href=\"https://github.com/wukko/cobalt\" target=\"_blank\">you can do it on github</a>.\n\nfun fact: average {appName} user is 10 times cooler than a regular person."
}, { }],
"olderHistory": [{
"version": "3.7", "version": "3.7",
"title": "support for multi media tweets is here!", "title": "support for multi media tweets is here!",
"content": "{appName} now lets you save any of the videos or gifs in a tweet. even if there are many of them.\n\nsimply paste a link like you'd usually do and {appName} will ask what exactly you want to save.\n\nFIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for {appName}, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive.\n\nhowever, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome.\n\nother changes:\n- repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work.\n- {appName} is now properly viewable on phones with tiny screens, such as first gen iphone se.\n- scrollbars now should be visible only where they're needed.\n- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).\n- cleaned up some internal files, including main frontend js file.\n- reorganized some files in project directory, now you won't get lost when contributing or just looking through {appName}'s code." "content": "{appName} now lets you save any of the videos or gifs in a tweet. even if there are many of them.\n\nsimply paste a link like you'd usually do and {appName} will ask what exactly you want to save.\n\nFIREFOX USERS: if you have strict tracking protection on, you might wanna turn it off for {appName}, or else twitter video previews won't load. firefox filters out twitter image cdn as if it was a tracker, which it's not. it's a false-positive.\n\nhowever, you can leave it on if you're fine with blank squares and video numbers. i have thought of that in prior, you're welcome.\n\nother changes:\n- repurposed ex tiktok-only image picker to be dynamic and adapt depending on content to pick. that's exactly how twitter multi media downloads work.\n- {appName} is now properly viewable on phones with tiny screens, such as first gen iphone se.\n- scrollbars now should be visible only where they're needed.\n- brought back proper twitter api, because other one doesn't have multi media stuff (at least yet).\n- cleaned up some internal files, including main frontend js file.\n- reorganized some files in project directory, now you won't get lost when contributing or just looking through {appName}'s code."

View file

@ -8,6 +8,8 @@ export default function(string) {
switch (string) { switch (string) {
case "title": case "title":
return `${replaceBase(changelog["current"]["title"])} (${changelog["current"]["version"]})`; return `${replaceBase(changelog["current"]["title"])} (${changelog["current"]["version"]})`;
case "banner":
return changelog["current"]["banner"] ? `updateBanners/${changelog["current"]["banner"]}` : false;
case "content": case "content":
return replaceBase(changelog["current"]["content"]); return replaceBase(changelog["current"]["content"]);
case "history": case "history":

View file

@ -101,6 +101,9 @@ export default function(obj) {
body: [{ body: [{
text: `<div class="category-title">${loc(obj.lang, 'ChangelogLastMajor')}</div>`, text: `<div class="category-title">${loc(obj.lang, 'ChangelogLastMajor')}</div>`,
raw: true raw: true
}, {
text: changelogManager("banner") ? `<div class="changelog-banner"><img class="changelog-img" src="${changelogManager("banner")}" onerror="this.style.display='none'"></img></div>`: '',
raw: true
}, { }, {
text: changelogManager("title"), text: changelogManager("title"),
classes: ["changelog-subtitle"], classes: ["changelog-subtitle"],

View file

@ -34,7 +34,7 @@ export default async function (host, patternMatch, url, lang, obj) {
url: url, url: url,
userId: patternMatch["userId"], userId: patternMatch["userId"],
videoId: patternMatch["videoId"], videoId: patternMatch["videoId"],
lang: lang, quality: obj.quality lang: lang, quality: obj.vQuality
}); });
break; break;
case "bilibili": case "bilibili":
@ -46,11 +46,11 @@ export default async function (host, patternMatch, url, lang, obj) {
case "youtube": case "youtube":
let fetchInfo = { let fetchInfo = {
id: patternMatch["id"].slice(0, 11), id: patternMatch["id"].slice(0, 11),
lang: lang, quality: obj.quality, lang: lang, quality: obj.vQuality,
format: "webm" format: "webm"
}; };
if (url.match('music.youtube.com') || obj.isAudioOnly == true) obj.format = "audio"; if (url.match('music.youtube.com') || obj.isAudioOnly == true) obj.vFormat = "audio";
switch (obj.format) { switch (obj.vFormat) {
case "mp4": case "mp4":
fetchInfo["format"] = "mp4"; fetchInfo["format"] = "mp4";
break; break;
@ -76,7 +76,7 @@ export default async function (host, patternMatch, url, lang, obj) {
host: host, host: host,
postId: patternMatch["postId"], postId: patternMatch["postId"],
id: patternMatch["id"], lang: lang, id: patternMatch["id"], lang: lang,
noWatermark: obj.noWatermark, fullAudio: obj.fullAudio, noWatermark: obj.isNoTTWatermark, fullAudio: obj.isTTFullAudio,
isAudioOnly: obj.isAudioOnly isAudioOnly: obj.isAudioOnly
}); });
if (r.isAudioOnly) obj.isAudioOnly = true if (r.isAudioOnly) obj.isAudioOnly = true
@ -89,7 +89,7 @@ export default async function (host, patternMatch, url, lang, obj) {
break; break;
case "vimeo": case "vimeo":
r = await vimeo({ r = await vimeo({
id: patternMatch["id"].slice(0, 11), quality: obj.quality, id: patternMatch["id"].slice(0, 11), quality: obj.vQuality,
lang: lang lang: lang
}); });
break; break;
@ -98,14 +98,14 @@ export default async function (host, patternMatch, url, lang, obj) {
r = await soundcloud({ r = await soundcloud({
author: patternMatch["author"], song: patternMatch["song"], url: url, author: patternMatch["author"], song: patternMatch["song"], url: url,
shortLink: patternMatch["shortLink"] ? patternMatch["shortLink"] : false, shortLink: patternMatch["shortLink"] ? patternMatch["shortLink"] : false,
format: obj.audioFormat, format: obj.aFormat,
lang: lang lang: lang
}); });
break; break;
default: default:
return apiJSON(0, { t: errorUnsupported(lang) }); return apiJSON(0, { t: errorUnsupported(lang) });
} }
return matchActionDecider(r, host, obj.ip, obj.audioFormat, obj.isAudioOnly) return matchActionDecider(r, host, obj.ip, obj.aFormat, obj.isAudioOnly)
} catch (e) { } catch (e) {
return apiJSON(0, { t: genericError(lang, host) }) return apiJSON(0, { t: genericError(lang, host) })
} }

View file

@ -10,34 +10,34 @@ export default function(r, host, ip, audioFormat, isAudioOnly) {
case "vk": case "vk":
return apiJSON(2, { return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip, type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt filename: r.filename,
}); });
case "bilibili": case "bilibili":
return apiJSON(2, { return apiJSON(2, {
type: "render", u: r.urls, service: host, ip: ip, type: "render", u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt, filename: r.filename,
time: r.time time: r.time
}); });
case "youtube": case "youtube":
return apiJSON(2, { return apiJSON(2, {
type: r.type, u: r.urls, service: host, ip: ip, type: r.type, u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt, filename: r.filename,
time: r.time, time: r.time,
}); });
case "reddit": case "reddit":
return apiJSON(r.typeId, { return apiJSON(r.typeId, {
type: r.type, u: r.urls, service: host, ip: ip, type: r.type, u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt filename: r.filename,
}); });
case "tiktok": case "tiktok":
return apiJSON(2, { return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip, type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt filename: r.filename,
}); });
case "douyin": case "douyin":
return apiJSON(2, { return apiJSON(2, {
type: "bridge", u: r.urls, service: host, ip: ip, type: "bridge", u: r.urls, service: host, ip: ip,
filename: r.filename, salt: process.env.streamSalt filename: r.filename,
}); });
case "tumblr": case "tumblr":
return apiJSON(1, { u: r.urls }); return apiJSON(1, { u: r.urls });
@ -57,7 +57,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly) {
type: type, type: type,
picker: r.picker, picker: r.picker,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip, u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip,
filename: r.audioFilename, salt: process.env.streamSalt, isAudioOnly: true, audioFormat: audioFormat, copy: audioFormat === "best" ? true : false, filename: r.audioFilename, isAudioOnly: true, audioFormat: audioFormat, copy: audioFormat === "best" ? true : false,
}) })
case "twitter": case "twitter":
return apiJSON(5, { return apiJSON(5, {
@ -95,7 +95,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly) {
return apiJSON(2, { return apiJSON(2, {
type: type, type: type,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip, u: Array.isArray(r.urls) ? r.urls[1] : r.urls, service: host, ip: ip,
filename: r.audioFilename, salt: process.env.streamSalt, isAudioOnly: true, filename: r.audioFilename, isAudioOnly: true,
audioFormat: audioFormat, copy: copy, fileMetadata: r.fileMetadata ? r.fileMetadata : false audioFormat: audioFormat, copy: copy, fileMetadata: r.fileMetadata ? r.fileMetadata : false
}) })
} }

View file

@ -71,6 +71,7 @@ export default async function(obj) {
audioFilename: `youtube_${obj.id}_audio`, audioFilename: `youtube_${obj.id}_audio`,
fileMetadata: generalMeta fileMetadata: generalMeta
}; };
if (infoInitial.videoDetails.description) {
let isAutoGenAudio = infoInitial.videoDetails.description.startsWith("Provided to YouTube by"); let isAutoGenAudio = infoInitial.videoDetails.description.startsWith("Provided to YouTube by");
if (isAutoGenAudio) { if (isAutoGenAudio) {
let descItems = infoInitial.videoDetails.description.split("\n\n") let descItems = infoInitial.videoDetails.description.split("\n\n")
@ -78,6 +79,7 @@ export default async function(obj) {
r.fileMetadata.copyright = descItems[3] r.fileMetadata.copyright = descItems[3]
if (descItems[4].startsWith("Released on:")) r.fileMetadata.date = descItems[4].replace("Released on: ", '').trim(); if (descItems[4].startsWith("Released on:")) r.fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
} }
}
return r return r
} else { } else {
return { error: loc(obj.lang, 'ErrorBadFetch') }; return { error: loc(obj.lang, 'ErrorBadFetch') };

View file

@ -4,12 +4,13 @@ import { UUID, encrypt } from "../sub/crypto.js";
import { streamLifespan } from "../config.js"; import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({ stdTTL: streamLifespan, checkperiod: 120 }); const streamCache = new NodeCache({ stdTTL: streamLifespan, checkperiod: 120 });
const salt = process.env.streamSalt;
export function createStream(obj) { export function createStream(obj) {
let streamUUID = UUID(), let streamUUID = UUID(),
exp = Math.floor(new Date().getTime()) + streamLifespan, exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = encrypt(`${streamUUID},${obj.url},${obj.ip},${exp}`, obj.salt), ghmac = encrypt(`${streamUUID},${obj.service},${obj.ip},${exp}`, salt)
iphmac = encrypt(`${obj.ip}`, obj.salt)
streamCache.set(streamUUID, { streamCache.set(streamUUID, {
id: streamUUID, id: streamUUID,
service: obj.service, service: obj.service,
@ -17,7 +18,7 @@ export function createStream(obj) {
urls: obj.u, urls: obj.u,
filename: obj.filename, filename: obj.filename,
hmac: ghmac, hmac: ghmac,
ip: iphmac, ip: obj.ip,
exp: exp, exp: exp,
isAudioOnly: !!obj.isAudioOnly, isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat, audioFormat: obj.audioFormat,
@ -28,12 +29,12 @@ export function createStream(obj) {
return `${process.env.selfURL}api/stream?t=${streamUUID}&e=${exp}&h=${ghmac}`; return `${process.env.selfURL}api/stream?t=${streamUUID}&e=${exp}&h=${ghmac}`;
} }
export function verifyStream(ip, id, hmac, exp, salt) { export function verifyStream(ip, id, hmac, exp) {
try { try {
let streamInfo = streamCache.get(id); let streamInfo = streamCache.get(id);
if (streamInfo) { if (streamInfo) {
let ghmac = encrypt(`${id},${streamInfo.url},${ip},${exp}`, salt); let ghmac = encrypt(`${id},${streamInfo.service},${ip},${exp}`, salt);
if (hmac == ghmac && encrypt(`${ip}`, salt) == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) { if (hmac == ghmac && ip == streamInfo.ip && ghmac == streamInfo.hmac && exp > Math.floor(new Date().getTime()) && exp == streamInfo.exp) {
return streamInfo; return streamInfo;
} else { } else {
return { error: 'Unauthorized', status: 401 }; return { error: 'Unauthorized', status: 401 };

View file

@ -4,7 +4,7 @@ import { streamAudioOnly, streamDefault, streamLiveRender } from "./types.js";
export default function(res, ip, id, hmac, exp) { export default function(res, ip, id, hmac, exp) {
try { try {
let streamInfo = verifyStream(ip, id, hmac, exp, process.env.streamSalt); let streamInfo = verifyStream(ip, id, hmac, exp);
if (!streamInfo.error) { if (!streamInfo.error) {
if (streamInfo.isAudioOnly && streamInfo.type !== "bridge") { if (streamInfo.isAudioOnly && streamInfo.type !== "bridge") {
streamAudioOnly(streamInfo, res); streamAudioOnly(streamInfo, res);

View file

@ -22,7 +22,7 @@ export function apiJSON(type, obj) {
pickerType = "images" pickerType = "images"
break; break;
} }
return { status: 200, body: { status: "picker", pickerType: pickerType, url: obj.picker, audio: audio } }; return { status: 200, body: { status: "picker", pickerType: pickerType, picker: obj.picker, audio: audio } };
default: default:
return { status: 400, body: { status: "error", text: "Bad Request" } }; return { status: 400, body: { status: "error", text: "Bad Request" } };
} }
@ -75,3 +75,35 @@ export function unicodeDecode(str) {
return String.fromCharCode(parseInt(unicode.replace(/\\u/g, ""), 16)); return String.fromCharCode(parseInt(unicode.replace(/\\u/g, ""), 16));
}); });
} }
export function checkJSONPost(obj) {
let def = {
vFormat: "mp4",
vQuality: "hig",
aFormat: "mp3",
isAudioOnly: false,
isNoTTWatermark: false,
isTTFullAudio: false
}
let booleanOnly = ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio"]
try {
let objKeys = Object.keys(obj);
if (objKeys.length < 8) {
let objKeys = Object.keys(obj);
let defKeys = Object.keys(def);
for (let i in objKeys) {
if (defKeys.includes(objKeys[i])) {
if (booleanOnly.includes(objKeys[i])) {
def[objKeys[i]] = obj[objKeys[i]] ? true : false
} else {
def[objKeys[i]] = obj[objKeys[i]]
}
}
}
return def
} else {
return false
}
} catch (e) {
return false;
}
}