7.11: cache encryption, meowbalt, dailymotion, bilibili, and much more! (#381)

This commit is contained in:
wukko 2024-03-07 00:43:43 +06:00 committed by GitHub
commit 0a457adf62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 727 additions and 261 deletions

View file

@ -13,7 +13,8 @@ this list is not final and keeps expanding over time. if support for a service y
| service | video + audio | only audio | only video | metadata | rich file names |
| :-------- | :-----------: | :--------: | :--------: | :------: | :-------------: |
| bilibili.com | ✅ | ✅ | ✅ | | |
| bilibili.com & bilibili.tv | ✅ | ✅ | ✅ | | |
| dailymotion | ✅ | ✅ | ✅ | ✅ | ✅ |
| instagram posts & stories | ✅ | ✅ | ✅ | | |
| instagram reels | ✅ | ✅ | ✅ | | |
| ok video | ✅ | ❌ | ❌ | ✅ | ✅ |
@ -68,14 +69,23 @@ cobalt is ***NOT*** a piracy tool and cannot be used as such. it can only downlo
cobalt is my passion project, update schedule depends solely on my free time, motivation, and mood. don't expect any consistency in update releases.
## cobalt licenses
## cobalt license
cobalt code is licensed under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE).
update banners and various assets of cobalt branding included within the repo are *not* covered by the AGPL-3.0 license and cannot be used using same terms.
cobalt branding, mascots, and other related assets included in the repo are ***copyrighted*** and not covered by the AGPL-3.0 license. you ***cannot*** use them under same terms.
you are allowed to host an ***unmodified*** instance of cobalt with branding, but this ***does not*** give you permission to use it anywhere else, or make derivatives of it in any way.
### notes:
- mascots and other assets are a part of the branding.
- when making an alternative version of the project, please replace or remove all branding (including the name).
- you **must** link the original repo when using any parts of code (such as using separate processing modules in your project) or forking the project.
- if you make a modified version of cobalt, the codebase **must** be published under the same license (according to AGPL-3.0).
## 3rd party licenses
[Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
[Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
many update banners were taken from [tenor.com](https://tenor.com/).
- [Fluent Emoji by Microsoft](https://github.com/microsoft/fluentui-emoji) (used in cobalt) is under [MIT](https://github.com/microsoft/fluentui-emoji/blob/main/LICENSE) license.
- [Noto Sans Mono](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/) fonts (used in cobalt) are licensed under the [OFL](https://fonts.google.com/noto/specimen/Noto+Sans+Mono/about) license.
- many update banners were taken from [tenor.com](https://tenor.com/).

View file

@ -54,15 +54,12 @@ item type: `object`
| `thumb` | `string` | item thumbnail that's displayed in the picker | used only for `video` type. |
## GET: `/api/stream`
cobalt's live render (or stream) endpoint. used for sending various media content over to the user.
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
from a successful call to `/api/json`. however, the parameters passed to it are **opaque**
and **unmodifiable** from your (the api client's) perspective, and can change between versions.
### request query variables
| key | variables | description |
|:-----|:-----------------|:-------------------------------------------------------------------------------------------------------------------------------|
| `p` | `1` | used for probing whether user is rate limited. |
| `t` | stream token | unique stream id. 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 | |
therefore you don't need to worry about what they mean - but if you really want to know, you can
[read the source code](../src/modules/stream/manage.js).
## GET: `/api/serverInfo`
returns current basic server info.

View file

@ -1,5 +1,8 @@
{
"instagram": [
"mid=replace; ig_did=this; csrftoken=cookie"
"mid=<replace>; ig_did=<with>; csrftoken=<your>; ds_user_id=<own>; sessionid=<cookies>"
],
"reddit": [
"client_id=<replace_this>; client_secret=<replace_this>; refresh_token=<replace_this>"
]
}

View file

@ -13,17 +13,17 @@ services:
ports:
- 9000:9000/tcp
# if you're using a reverse proxy, uncomment the next line:
# if you're using a reverse proxy, uncomment the next line and remove the one above (9000:9000/tcp):
#- 127.0.0.1:9000:9000
environment:
# replace apiURL with your instance's target url in same format
- apiURL=https://co.wuk.sh/
# replace apiName with your instance's distinctive name
- apiName=eu-nl
# replace https://co.wuk.sh/ with your instance's target url in same format
- API_URL=https://co.wuk.sh/
# replace eu-nl with your instance's distinctive name
- API_NAME=eu-nl
# if you want to use cookies when fetching data from services, uncomment the next line
#- cookiePath=/cookies.json
# see cookies_example.json for example file.
#- COOKIE_PATH=/cookies.json
# see cookies.example.json for example file.
labels:
- com.centurylinklabs.watchtower.scope=cobalt
@ -43,14 +43,14 @@ services:
ports:
- 9001:9001/tcp
# if you're using a reverse proxy, uncomment the next line:
# if you're using a reverse proxy, uncomment the next line and remove the one above (9001:9001/tcp):
#- 127.0.0.1:9001:9001
environment:
# replace webURL with your instance's target url in same format
- webURL=https://cobalt.tools/
# replace apiURL with preferred api instance url
- apiURL=https://co.wuk.sh/
# replace https://cobalt.tools/ with your instance's target url in same format
- WEB_URL=https://cobalt.tools/
# replace https://co.wuk.sh/ with preferred api instance url
- API_URL=https://co.wuk.sh/
labels:
- com.centurylinklabs.watchtower.scope=cobalt

View file

@ -47,3 +47,25 @@ setup script installs all needed `npm` dependencies, but you have to install `no
sudo apt install nscd
sudo service nscd start
```
## list of all environment variables
### variables for api
| variable name | default | example | description |
|:----------------------|:----------|:------------------------|:------------|
| `API_PORT` | `9000` | `9000` | changes port from which api server is accessible. |
| `API_URL` | | `https://co.wuk.sh/` | changes url from which api server is accessible. <br> ***REQUIRED TO RUN API***. |
| `API_NAME` | `unknown` | `ams-1` | api server name that is shown in `/api/serverInfo`. |
| `CORS_WILDCARD` | `1` | `0` | toggles cross-origin resource sharing. <br> `0`: disabled. `1`: enabled. |
| `CORS_URL` | not used | `https://cobalt.tools/` | cross-origin resource sharing url. api will be available only from this url if `CORS_WILDCARD` is set to `0`. |
| `COOKIE_PATH` | not used | `/cookies.json` | path for cookie file relative to main folder. |
| `PROCESSING_PRIORITY` | not used | `10` | changes `nice` value* for ffmpeg subprocess. available only on unix systems. |
\* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)).
### variables for web
| variable name | default | example | description |
|:--------------- |:--------|:------------------------|:--------------------------------------------------------------------------------------|
| `WEB_PORT` | `9001` | `9001` | changes port from which frontend server is accessible. |
| `WEB_URL` | | `https://cobalt.tools/` | changes url from which frontend server is accessible. <br> ***REQUIRED TO RUN WEB***. |
| `SHOW_SPONSORS` | `0` | `1` | toggles sponsor list in about popup. <br> `0`: disabled. `1`: enabled. |
| `IS_BETA` | `0` | `1` | toggles beta tag next to cobalt logo. <br> `0`: disabled. `1`: enabled. |

View file

@ -1,7 +1,7 @@
{
"name": "cobalt",
"description": "save what you love",
"version": "7.10.4",
"version": "7.11",
"author": "wukko",
"exports": "./src/cobalt.js",
"type": "module",
@ -37,9 +37,9 @@
"ipaddr.js": "2.1.0",
"nanoid": "^4.0.2",
"node-cache": "^5.1.2",
"psl": "1.9.0",
"psl": "https://github.com/lupomontero/psl#5eadae91361d8289d582700f90582b0d0cb73155",
"set-cookie-parser": "2.6.0",
"undici": "^5.19.1",
"undici": "^6.7.0",
"url-pattern": "1.0.3",
"youtubei.js": "^9.1.0"
}

View file

@ -1,4 +1,5 @@
import "dotenv/config";
import "./modules/sub/alias-envs.js";
import express from "express";
@ -21,8 +22,8 @@ app.disable('x-powered-by');
await loadLoc();
const apiMode = process.env.apiURL && !process.env.webURL;
const webMode = process.env.webURL && process.env.apiURL;
const apiMode = process.env.API_URL && !process.env.WEB_URL;
const webMode = process.env.WEB_URL && process.env.API_URL;
if (apiMode) {
const { runAPI } = await import('./core/api.js');

View file

@ -54,7 +54,8 @@
}
},
"links": {
"saveToGalleryShortcut": "https://www.icloud.com/shortcuts/b401917928fd407daf1db0fd07eb7e78",
"saveToGalleryShortcut": "https://www.icloud.com/shortcuts/14e9aebf04b24156acc34ceccf7e6fcd",
"saveToFilesShortcut": "https://www.icloud.com/shortcuts/2134cd9d4d6b41448b2201f933542b2e",
"statusPage": "https://status.cobalt.tools/",
"troubleshootingGuide": "https://github.com/wukko/cobalt/blob/current/docs/troubleshooting.md"
},

View file

@ -10,11 +10,11 @@ import { apiJSON, checkJSONPost, getIP, languageCode } from "../modules/sub/util
import { Bright, Cyan } from "../modules/sub/consoleText.js";
import stream from "../modules/stream/stream.js";
import loc from "../localization/manager.js";
import { sha256 } from "../modules/sub/crypto.js";
import { generateHmac } from "../modules/sub/crypto.js";
import { verifyStream } from "../modules/stream/manage.js";
export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
const corsConfig = process.env.cors === '0' ? {
const corsConfig = process.env.CORS_WILDCARD === '0' ? {
origin: process.env.CORS_URL,
optionsSuccessStatus: 200
} : {};
@ -24,7 +24,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 20,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt),
keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => {
return res.status(429).json({
"status": "rate-limit",
@ -37,7 +37,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
max: 25,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: req => sha256(getIP(req), ipSalt),
keyGenerator: req => generateHmac(getIP(req), ipSalt),
handler: (req, res, next, opt) => {
return res.status(429).json({
"status": "rate-limit",
@ -47,11 +47,15 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
});
const startTime = new Date();
const startTimestamp = Math.floor(startTime.getTime());
const startTimestamp = startTime.getTime();
app.set('trust proxy', ['loopback', 'uniquelocal']);
app.use('/api/:type', cors(corsConfig));
app.use('/api/:type', cors({
methods: ['GET', 'POST'],
...corsConfig
}));
app.use('/api/json', apiLimiter);
app.use('/api/stream', apiLimiterStream);
app.use('/api/onDemand', apiLimiter);
@ -60,6 +64,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try { decodeURIComponent(req.path) } catch (e) { return res.redirect('/') }
next();
});
app.use('/api/json', express.json({
verify: (req, res, buf) => {
let acceptCon = String(req.header('Accept')) === "application/json";
@ -71,6 +76,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
}
}
}));
// handle express.json errors properly (https://github.com/expressjs/express/issues/4065)
app.use('/api/json', (err, req, res, next) => {
let errorText = "invalid json body";
@ -86,6 +92,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
next();
}
});
app.post('/api/json', async (req, res) => {
try {
let lang = languageCode(req);
@ -118,13 +125,17 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
try {
switch (req.params.type) {
case 'stream':
if (req.query.t && req.query.h && req.query.e && req.query.t.toString().length === 21
&& req.query.h.toString().length === 64 && req.query.e.toString().length === 13) {
let streamInfo = verifyStream(req.query.t, req.query.h, req.query.e);
const q = req.query;
const checkQueries = q.t && q.e && q.h && q.s && q.i;
const checkBaseLength = q.t.length === 21 && q.e.length === 13;
const checkSafeLength = q.h.length === 43 && q.s.length === 43 && q.i.length === 22;
if (checkQueries && checkBaseLength && checkSafeLength) {
let streamInfo = verifyStream(q.t, q.h, q.e, q.s, q.i);
if (streamInfo.error) {
return res.status(streamInfo.status).json(apiJSON(0, { t: streamInfo.error }).body);
}
if (req.query.p) {
if (q.p) {
return res.status(200).json({
status: "continue"
});
@ -132,7 +143,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
return stream(res, streamInfo);
} else {
let j = apiJSON(0, {
t: "stream token, hmac, or expiry timestamp is missing"
t: "bad request. stream link may be incomplete or corrupted."
})
return res.status(j.status).json(j.body);
}
@ -141,9 +152,9 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
version: version,
commit: gitCommit,
branch: gitBranch,
name: process.env.apiName || "unknown",
url: process.env.apiURL,
cors: process.env?.cors === "0" ? 0 : 1,
name: process.env.API_NAME || "unknown",
url: process.env.API_URL,
cors: process.env?.CORS_WILDCARD === "0" ? 0 : 1,
startTime: `${startTimestamp}`
});
default:
@ -159,22 +170,25 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) {
});
}
});
app.get('/api/status', (req, res) => {
res.status(200).end()
});
app.get('/favicon.ico', (req, res) => {
res.sendFile(`${__dirname}/src/front/icons/favicon.ico`)
});
app.get('/*', (req, res) => {
res.redirect('/api/json')
});
app.listen(process.env.apiPort || 9000, () => {
app.listen(process.env.API_PORT || 9000, () => {
console.log(`\n` +
`${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
`Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` +
`URL: ${Cyan(`${process.env.apiURL}`)}\n` +
`Port: ${process.env.apiPort || 9000}\n`
`URL: ${Cyan(`${process.env.API_URL}`)}\n` +
`Port: ${process.env.API_PORT || 9000}\n`
)
});
}

View file

@ -76,12 +76,12 @@ export async function runWeb(express, app, gitCommit, gitBranch, __dirname) {
return res.redirect('/')
});
app.listen(process.env.webPort || 9001, () => {
app.listen(process.env.WEB_PORT || 9001, () => {
console.log(`\n` +
`${Cyan("cobalt")} WEB ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` +
`Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` +
`URL: ${Cyan(`${process.env.webURL}`)}\n` +
`Port: ${process.env.webPort || 9001}\n`
`URL: ${Cyan(`${process.env.WEB_URL}`)}\n` +
`Port: ${process.env.WEB_PORT || 9001}\n`
)
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -192,7 +192,7 @@ input[type="text"],
z-index: -1;
position: absolute;
border: var(--accent-highlight) solid 0.15rem;
border-radius: 8px/9px;
border-radius: 22px;
}
.desktop button:hover,
.desktop .switch:hover,
@ -441,11 +441,17 @@ button:active,
-webkit-backdrop-filter: blur(7px);
}
.popup.small {
width: 20%;
width: 21rem;
box-shadow: 0px 0px 60px 0px var(--accent-hover);
padding: 1.7rem;
padding: 18px;
transform: translate(-50%,-50%)scale(.95);
pointer-events: all;
border-radius: 22px;
}
.popup.small .popup-content-inner {
display: flex;
flex-direction: column;
gap: 18px;
}
.popup.small.visible {
transform: translate(-50%, -50%);
@ -462,12 +468,30 @@ button:active,
.popup.small .popup-title {
margin-bottom: 0.6rem;
}
.popup.small .explanation {
margin-bottom: 0.9rem;
}
.popup.small .close-error.switch {
background: var(--accent)!important;
color: var(--background);
height: 2.5rem;
}
#popup-error,
#popup-download {
display: flex;
flex-direction: column;
padding-top: 4rem;
}
#popup-error {
justify-content: center;
align-items: center;
}
.popout-meowbalt {
position: absolute;
top: -7rem;
user-select: none;
-webkit-user-select: none;
pointer-events: none;
height: 180px;
width: 180px;
aspect-ratio: 1/1;
}
.popup.scrollable {
height: 95%;
@ -531,7 +555,8 @@ button:active,
-webkit-user-select: text;
}
.desc-error {
padding-bottom: 1.5rem;
padding-bottom: 0rem;
text-align: center;
}
.popup-title {
font-size: 1.5rem;
@ -957,44 +982,43 @@ button:active,
.changelog-img,
.changelog-banner,
.close-error,
.changelog-tag-version,
#download-switcher .switch,
#popup-about .switch,
.popup-tabs .switch,
.text-to-copy,
.text-to-copy.text-backdrop,
#filename-preview {
border-radius: 6px / 7px;
border-radius: 8px / 9px;
}
[type=checkbox] {
border-radius: 3px / 4px;
}
.popup,
.scrollable .popup-content {
border-radius: 8px;
border-radius: 12px;
}
.popup-header .glass-bkg {
border-top-left-radius: 8px 9px;
border-top-right-radius: 8px 9px;
border-top-left-radius: 11px 12px;
border-top-right-radius: 11px 12px;
border-bottom: var(--accent-highlight) solid 0.1rem;
top: -1px;
}
.popup-tabs .glass-bkg {
border-bottom-left-radius: 8px 9px;
border-bottom-right-radius: 8px 9px;
border-bottom-left-radius: 11px 12px;
border-bottom-right-radius: 11px 12px;
border-top: var(--accent-highlight) solid 0.1rem;
bottom: -1px;
}
.switches :first-child {
border-top-left-radius: 6px 7px;
border-bottom-left-radius: 6px 7px;
.switches .switch:first-child {
border-top-left-radius: 8px 9px;
border-bottom-left-radius: 8px 9px;
}
.switches :last-child {
border-top-right-radius: 6px 7px;
border-bottom-right-radius: 6px 7px;
.switches .switch:last-child {
border-top-right-radius: 8px 9px;
border-bottom-right-radius: 8px 9px;
}
.text-backdrop {
border-radius: 3px / 4px;
border-radius: 4px / 5px;
}
.collapse-list:first-child,
.collapse-list:first-child .collapse-header {
@ -1017,17 +1041,11 @@ button:active,
}
/* adapt the page according to screen size */
@media screen and (max-width: 1550px) {
.popup.small {
width: 25%
}
.popup {
width: 40%;
}
}
@media screen and (max-width: 1440px) {
.popup.small {
width: 30%
}
.popup {
width: 45%;
}
@ -1038,17 +1056,11 @@ button:active,
}
}
@media screen and (max-width: 1200px) {
.popup.small {
width: 35%
}
.popup {
width: 55%;
}
}
@media screen and (max-width: 1025px) {
.popup.small {
width: 40%
}
.popup {
width: 60%;
}
@ -1058,6 +1070,16 @@ button:active,
width: 75%;
}
}
@media screen and (max-width: 680px) {
.popup {
width: 90%;
}
}
@media screen and (max-width: 660px) {
#cobalt-main-box {
width: calc(100% - (0.7rem * 2));
}
}
/* mobile page */
@media screen and (max-width: 499px) {
.tab {
@ -1070,10 +1092,7 @@ button:active,
width: calc(100% - 1.3rem);
}
}
@media screen and (max-width: 660px) {
#cobalt-main-box {
width: calc(100% - (0.7rem * 2));
}
@media screen and (max-width: 535px) {
#cobalt-main-box #bottom {
flex-direction: row-reverse;
}
@ -1128,7 +1147,7 @@ button:active,
transform: unset;
}
.popup.small {
width: calc(100% - 1.7rem * 2);
width: calc(100% - 18px * 2);
height: auto;
top: unset;
bottom: 0;
@ -1143,8 +1162,8 @@ button:active,
border-top: var(--accent-highlight) solid 0.15rem;
}
.popup.small.visible {
transform: none;
transition: transform 210ms cubic-bezier(0.062, 0.82, 0.165, 1), opacity 130ms ease-in-out;
transform: translateY(0rem);
transition: transform 250ms cubic-bezier(0.075, 0.82, 0.165, 1), opacity 130ms ease-in-out;
}
.popup.small .popup-header {
background: none;

View file

@ -1,4 +1,4 @@
const version = 41;
const version = 42;
const ua = navigator.userAgent.toLowerCase();
const isIOS = ua.match("iphone os");
@ -8,7 +8,7 @@ const isFirefox = ua.match("firefox/");
const isOldFirefox = ua.match("firefox/") && ua.split("firefox/")[1].split('.')[0] < 103;
const regex = new RegExp(/https:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()!@:%_\+.~#?&\/\/=]*)/);
const notification = `<div class="notification-dot"></div>`;
const notification = `<span class="notification-dot"></span>`;
const switchers = {
"theme": ["auto", "light", "dark"],
@ -600,15 +600,11 @@ window.onload = () => {
if (setUn !== null) {
if (setUn) {
sSet("migrated", "true")
eid("desc-migration").innerHTML += `<br><br>${loc.DataTransferSuccess}`
} else {
eid("desc-migration").innerHTML += `<br><br>${loc.DataTransferError}`
}
}
}
loadSettings();
detectColorScheme();
popup("migration", 1);
}
window.history.replaceState(null, '', window.location.pathname);

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View file

@ -16,7 +16,6 @@
"AccessibilityOpenDonate": "open donation popup",
"TitlePopupAbout": "what's cobalt?",
"TitlePopupSettings": "settings",
"TitlePopupError": "uh-oh...",
"TitlePopupChangelog": "what's new?",
"TitlePopupDonate": "support cobalt",
"TitlePopupDownload": "how to save?",
@ -46,7 +45,7 @@
"AccessibilityEnableDownloadPopup": "ask what to do with downloads",
"SettingsQualityDescription": "if selected quality isn't available, closest one is used instead.",
"NoScriptMessage": "cobalt uses javascript for api requests and interactive interface. you have to allow javascript to use this site. there are no pesty scripts, pinky promise.",
"DownloadPopupDescriptionIOS": "easiest way to save videos on ios:\n1. add <a class=\"text-backdrop link\" href=\"{saveToGalleryShortcut}\" target=\"_blank\">this siri shortcut</a>.\n2. press \"share\" above and select \"save to photos\" in appeared share sheet.\nif asked, review the permission request, and press \"always allow\".\n\nalternative method:\npress and hold the download button, hide the video preview, and select \"download linked file\" to download.\nthen, open safari downloads, select the file you downloaded, open share menu, and finally press \"save video\".",
"DownloadPopupDescriptionIOS": "how to save to photos:\n1. add <a class=\"text-backdrop link\" href=\"{saveToGalleryShortcut}\" target=\"_blank\">save to photos shortcut</a>.\n2. press \"share\" button above this text.\n3. select \"save to photos\" in the share sheet.\n\nhow to save to files:\n1. add <a class=\"text-backdrop link\" href=\"{saveToFilesShortcut}\" target=\"_blank\">save to files shortcut</a>.\n2. press \"share\" button above this text.\n3. select \"save to files\" in the share sheet.\n4. select a folder to save the file to and press \"open\".\n\nboth shortcuts can only be used from the cobalt web app.",
"DownloadPopupDescription": "download button opens a new tab with requested file. you can disable this popup in settings.",
"ClickToCopy": "press to copy",
"Download": "download",
@ -91,7 +90,7 @@
"ChangelogPressToHide": "collapse",
"Donate": "donate",
"DonateSub": "help it stay online",
"DonateExplanation": "cobalt doesn't shove ads in your face and doesn't sell your personal data, and thus is <span class=\"text-backdrop\">completely free to use for everyone</span>. but development and maintenance of a media-heavy service used by over 350k people is quite costly. both in terms of time and money. as a student, it's rather difficult for me to handle such expenses on my own.\n\nif cobalt has helped you in the past and you want to keep it growing and evolving, you can do so by making a donation!\n\nby donating you're helping everyone who uses cobalt: teachers, students, musicians, content creators, artists, lecturers, and many, many more!\n\nin past few months donations have let me:\n*; increase stability and uptime to nearly 100%.\n*; speed up ALL downloads, especially heavier ones.\n*; open cobalt api for free public use.\n*; withstand several huge user influxes with 0 downtime.\n*; move to a reliable and trustworthy cloud infrastructure provider.\n*; separate frontend and api for resilience and future decentralization.\n\n<span class=\"text-backdrop\">every cent matters and is extremely appreciated</span>, you can truly make a difference!",
"DonateExplanation": "cobalt doesn't shove ads in your face and doesn't sell your personal data, meaning that it's <span class=\"text-backdrop\">completely free to use</span> for everyone. but development and maintenance of a media-heavy service used by over 750k people is quite costly. both in terms of time and money.\n\nif cobalt helped you in the past and you want to keep it growing and evolving, you can return the favor by making a donation!\n\nyour donation will help all cobalt users: educators, students, content creators, artists, musicians, and many, many more!\n\nin past, donations have let cobalt:\n*; increase stability and uptime to nearly 100%.\n*; speed up ALL downloads, especially heavier ones.\n*; open the api for free public use.\n*; withstand several huge user influxes with 0 downtime.\n*; add resource-intensive features (such as gif conversion).\n*; continue improving our infrastructure.\n*; keep developers happy.\n\n<span class=\"text-backdrop\">every cent matters and is extremely appreciated</span>, you can truly make a difference!\n\nif you can't donate, share cobalt with a friend! we don't get ads anywhere, so cobalt is spread by word of mouth.\nsharing is the easiest way to help achieve the goal of better internet for everyone.",
"DonateVia": "donate via",
"DonateHireMe": "...or you can <a class=\"text-backdrop link\" href=\"{s}\" target=\"_blank\">hire me</a> :)",
"SettingsVideoMute": "mute audio",
@ -103,7 +102,7 @@
"ServicesNote": "this list is not final and keeps expanding over time, make sure to check it once in a while!",
"FollowSupport": "keep in touch with cobalt for news, support, and more:",
"SourceCode": "explore source code, report issues, star or fork the repo:",
"PrivacyPolicy": "cobalt's privacy policy is simple: no data about you is ever collected or stored. zero, zilch, nada, nothing.\nwhat you download is solely your business, not mine or anyone else's.\n\nif your download requires live render, some non-backtraceable data is temporarily stored in server's RAM. it's necessary for this feature to function.\n\nin this case info about requested content is stored for <span class=\"text-backdrop\">90 seconds</span> and then permanently removed.\nno one (even me) has access to this data. official cobalt codebase doesn't provide a way to read it outside of processing functions.\n\nyou can check cobalt's <a class=\"text-backdrop link\" href=\"{repo}\" target=\"_blank\">source code</a> yourself and see that everything is as stated.",
"PrivacyPolicy": "cobalt's privacy policy is simple: no data about you is ever collected or stored. zero, zilch, nada, nothing.\nwhat you download is solely your business, not mine or anyone else's.\n\nif your download requires rendering, then data about requested content is encrypted and temporarily stored in server's RAM. it's necessary for this feature to function.\n\nencrypted data is stored for <span class=\"text-backdrop\">90 seconds</span> and then permanently removed.\n\nstored data is only possible to decrypt with unique encryption keys from your download link. furthermore, the official cobalt codebase doesn't provide a way to read temporarily stored data outside of processing functions.\n\nyou can check cobalt's <a class=\"text-backdrop link\" href=\"{repo}\" target=\"_blank\">source code</a> yourself and see that everything is as stated.",
"ErrorYTUnavailable": "this youtube video is unavailable, it could be region or age restricted. try another one!",
"ErrorYTTryOtherCodec": "i couldn't find anything to download with your settings. try another codec or quality!\n\nsometimes youtube api acts unexpectedly. try again or try another settings.",
"SettingsCodecSubtitle": "youtube codec",
@ -155,8 +154,8 @@
"DonateImageDescription": "cat sleeping on a laptop keyboard and typing letters repeatedly",
"SettingsTwitterGif": "convert gifs to .gif",
"SettingsTwitterGifDescription": "converting looping videos to .gif reduces quality and majorly increases file size. if you want best efficiency, keep this setting off.",
"UpdateTwitterGif": "twitter gifs and pinterest",
"ErrorTweetProtected": "this tweet is from a private account, so i can't see it. try another one!",
"ErrorTweetNSFW": "this tweet contains sensitive content, so i can't see it. try another one!"
"ErrorTweetNSFW": "this tweet contains sensitive content, so i can't see it. try another one!",
"UpdateEncryption": "encryption and new services"
}
}

View file

@ -16,7 +16,6 @@
"AccessibilityOpenDonate": "сделать пожертвование",
"TitlePopupAbout": "что за кобальт?",
"TitlePopupSettings": "настройки",
"TitlePopupError": "опаньки...",
"TitlePopupChangelog": "что нового?",
"TitlePopupDonate": "поддержи кобальт",
"TitlePopupDownload": "как сохранить?",
@ -46,7 +45,7 @@
"AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками",
"SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.",
"NoScriptMessage": "кобальт использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких зловредных скриптов, обещаю.",
"DownloadPopupDescriptionIOS": "наиболее простой метод скачивания видео на ios:\n1. добавь <a class=\"text-backdrop link\" href=\"{saveToGalleryShortcut}\" target=\"_blank\">этот сценарий siri</a>.\n2. нажми \"поделиться\" выше и выбери \"save to photos\" в открывшемся окне.\nесли появляется окно с запросом разрешения, то прочитай его, потом нажми \"всегда разрешать\".\n\nальтернативный метод:\nзажми кнопку \"скачать\", затем скрой превью и выбери \"загрузить файл по ссылке\" в появившемся окне.\nпотом открой загрузки в safari, выбери скачанный файл, нажми иконку \"поделиться\", и, наконец, нажми \"сохранить видео\".",
"DownloadPopupDescriptionIOS": "как сохранить в фото:\n1. добавь этот сценарий siri: <a class=\"text-backdrop link\" href=\"{saveToGalleryShortcut}\" target=\"_blank\">save to photos</a>.\n2. нажми \"поделиться\" выше этого текста.\n3. выбери \"save to photos\" в открывшемся окне.\n\nкак сохранить в файлы:\n1. добавь этот сценарий siri: <a class=\"text-backdrop link\" href=\"{saveToGalleryShortcut}\" target=\"_blank\">save to files</a>.\n2. нажми \"поделиться\" выше этого текста.\n3. выбери \"save to files\" в открывшемся окне.\n4. выбери папку для сохранения файла и нажми \"открыть\".\n\nоба сценария работают только вместе с веб-приложением кобальта.",
"DownloadPopupDescription": "кнопка скачивания открывает новое окно с файлом. ты можешь отключить выбор метода скачивания файла в настройках.",
"ClickToCopy": "нажми, чтобы скопировать",
"Download": "скачать",
@ -92,7 +91,7 @@
"ChangelogPressToHide": "скрыть",
"Donate": "донаты",
"DonateSub": "ты можешь помочь!",
"DonateExplanation": "кобальт не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает <span class=\"text-backdrop\">совершенно бесплатно для всех</span>. но разработка и поддержка медиа сервиса, которым пользуются более 350 тысяч людей, обходится довольно затратно. мне, как студенту, оплачивать такое в одиночку довольно трудно.\n\nесли кобальт тебе помог и ты хочешь, чтобы он продолжал работать и развиваться, то это можно сделать через донаты!\n\nделая донат ты помогаешь всем, кто пользуется кобальтом: преподавателям, студентам, музыкантам, художникам, контент-мейкерам и многим-многим другим!\n\nза последние несколько месяцев благодаря донатам я смог:\n*; повысить стабильность и аптайм почти до 100%.\n*; ускорить ВСЕ загрузки, особенно наиболее тяжёлые.\n*; открыть api кобальта для свободного публичного использования.\n*; выдержать несколько огромных наплывов пользователей без перебоев.\n*; перейти к надёжному поставщику облачной инфры.\n*; разделить фронтенд и api для обеспечения отказоустойчивости и децентрализации в будущем.\n\n<span class=\"text-backdrop\">каждый донат невероятно ценится</span> и помогает кобальту развиваться!",
"DonateExplanation": "кобальт не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает <span class=\"text-backdrop\">совершенно бесплатно</span> для всех. но разработка и поддержка медиа сервиса, которым пользуются более 750 тысяч людей, обходится довольно затратно.\n\nесли кобальт тебе помог и ты хочешь, чтобы он продолжал расти и развиваться, то это можно сделать через донаты!\n\nтвой донат поможет всем, кто пользуется кобальтом: преподавателям, студентам, музыкантам, художникам, контент-мейкерам и многим-многим другим!\n\nв прошлом донаты помогли кобальту:\n*; повысить стабильность и аптайм почти до 100%.\n*; ускорить ВСЕ загрузки, особенно наиболее тяжёлые.\n*; открыть api для бесплатного использования.\n*; выдержать несколько огромных наплывов пользователей без перебоев.\n*; добавить ресурсоемкие фичи (например конвертацию в gif).\n*; продолжать улучшать нашу инфраструктуру.\n*; радовать разработчиков.\n\n<span class=\"text-backdrop\">каждый донат невероятно ценится</span> и помогает кобальту развиваться!\n\nесли ты не можешь отправить донат, то поделись кобальтом с другом! мы нигде не размещаем рекламу, поэтому кобальт распространяется из уст в уста.\nподелиться - самый простой способ помочь достичь цели лучшего интернета для всех.",
"DonateVia": "открыть",
"DonateHireMe": "...или же ты можешь <a class=\"text-backdrop link\" href=\"{s}\" target=\"_blank\">пригласить меня на работу</a> :)",
"SettingsVideoMute": "убрать аудио",
@ -104,7 +103,7 @@
"ServicesNote": "этот список далеко не финальный и постоянно пополняется, заглядывай сюда почаще!",
"FollowSupport": "подписывайся на соц.сети кобальта для новостей и поддержки:",
"SourceCode": "шарься в исходнике, пиши о проблемах, или же форкай репозиторий:",
"PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется лайв рендер, то некоторые неотслеживаемые данные временно держатся в ОЗУ сервера. это необходимо для работы данной функции.\n\nв этом случае данные о запрошенном контенте хранятся в течение <span class=\"text-backdrop\">90 секунд</span>. по истечении этого времени всё стирается. ни у кого (даже у меня) нет доступа к временно хранящимся данным, так как официальная кодовая база кобальта не предусматривает возможности их чтения вне функций обработки.\n\nты всегда можешь посмотреть <a class=\"text-backdrop link\" href=\"{repo}\" target=\"_blank\">исходный код кобальта</a> и убедиться, что всё так, как заявлено.",
"PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется рендер, то зашифрованные данные о ней временно хранятся в ОЗУ сервера. это необходимо для работы данной функции.\n\nзашифрованные данные хранятся в течение <span class=\"text-backdrop\">90 секунд</span> и затем безвозвратно удаляются.\n\ncохранённые данные можно расшифровать только с помощью уникальных ключей шифрования из твоей ссылки на скачивание. кроме того, официальная кодовая база кобальта не предусматривает возможности чтения эти данные вне функций обработки.\n\nты всегда можешь посмотреть <a class=\"text-backdrop link\" href=\"{repo}\" target=\"_blank\">исходный код кобальта</a> и убедиться, что всё так, как заявлено.",
"ErrorYTUnavailable": "это видео недоступно, возможно оно ограничено по региону или доступу. попробуй другое!",
"ErrorYTTryOtherCodec": "я не нашёл того, что мог бы скачать с твоими настройками. попробуй другой кодек или качество!",
"SettingsCodecSubtitle": "кодек для видео с youtube",
@ -157,8 +156,8 @@
"DonateImageDescription": "кошка спит на клавиатуре ноутбука и многократно печатает буквы",
"SettingsTwitterGif": "конвертировать гифки в .gif",
"SettingsTwitterGifDescription": "конвертирование зацикленного видео в .gif снижает качество и значительно увеличивает размер файла. если важна максимальная эффективность, то не используй эту функцию.",
"UpdateTwitterGif": "гифки с твиттера и одноклассники",
"ErrorTweetProtected": "этот твит из закрытого аккаунта, поэтому я не могу его увидеть. попробуй другой!",
"ErrorTweetNSFW": "этот твит содержит деликатный контент, поэтому я не могу его увидеть. попробуй другой!"
"ErrorTweetNSFW": "этот твит содержит деликатный контент, поэтому я не могу его увидеть. попробуй другой!",
"UpdateEncryption": "шифрование и новые сервисы"
}
}

View file

@ -19,6 +19,7 @@ export function replaceBase(s) {
return s
.replace(/\n/g, '<br>')
.replace(/{saveToGalleryShortcut}/g, links.saveToGalleryShortcut)
.replace(/{saveToFilesShortcut}/g, links.saveToFilesShortcut)
.replace(/{repo}/g, repo)
.replace(/{statusPage}/g, links.statusPage)
.replace(/\*;/g, "&bull;");

View file

@ -1,5 +1,17 @@
{
"current": {
"version": "7.11",
"date": "March 6, 2024",
"title": "cache encryption, meowbalt, dailymotion, bilibili, and much more!",
"banner": {
"file": "meowth7eleven.webp",
"alt": "meowth plush in front of 7-eleven store",
"width": 850,
"height": 640
},
"content": "cobalt may not have as many groceries as 7-eleven, but it sure does have lots of big changes in this update!\n\n*; all cached stream info is now encrypted and can only be decrypted with a link you get from cobalt.\n*; new popup style featuring meowbalt, cobalt's speedy mascot. you will see him more often from now on!\n*; added support for dailymotion (including short links).\n*; added support for bilibili.tv, fixed support for bilibili.com, and added support for all related short links.\n*; added support for private vimeo links.\n*; added support for tumblr audio and revamped the entire module.\n*; added support for embed ok.ru links.\n\nwe also updated the privacy policy to reflect the addition of data encryption, go check it out.\n\nfor people with iphones:\n*; clearer ios saving tutorial.\n*; added \"save to files\" ios shortcut.\n*; updated save to photos shortcut.\n\nmake sure to save both shortcuts and read the updated tutorial!\n\nfor people who host a cobalt instance:\n*; updated all environment variables TO_BE_LIKE_THIS. time to update your configs! for now cobalt is backwards compatible with old variable names, but it won't last forever.\n*; added a list of all environment variables and their descriptions to <a class=\"text-backdrop link\" href=\"{repo}/blob/current/docs/run-an-instance.md#list-of-all-environment-variables\" target=\"_blank\">run-an-instance doc</a>.\n*; updated <a class=\"text-backdrop link\" href=\"{repo}/blob/current/docs/examples/cookies.example.json\" target=\"_blank\">cookie file example</a> with more services and improved examples.\n*; updated <a class=\"text-backdrop link\" href=\"{repo}/blob/current/docs/examples/docker-compose.example.yml\" target=\"_blank\">docker compose example</a> with better explanations and up-to-date env variable samples.\n*; updated some packages to get rid of all unnecessary messages in console.\n\nwant to host an instance? <a class=\"text-backdrop link\" href=\"{repo}/blob/current/docs/run-an-instance.md\" target=\"_blank\">learn how to do it here</a>.\n\nfrontend changes:\n*; removed migration popup.\n*; corners across ui are even more round now.\n*; bottom glass bkg in popups is no longer rounded on top right.\n*; small popup no longer stretches like gum, it's fixed in size on desktop.\n*; small popup animation no longer lags on mobile.\n*; better ui scaling across resolutions.\n*; updated donation text.\n\nthank you for using cobalt, all 750k of you. hope you like this update as much as we enjoyed making it :D"
},
"history": [{
"version": "7.9",
"date": "January 17, 2024",
"title": "twitter gifs, pinterest, ok.ru, and more!",
@ -10,8 +22,7 @@
"height": 350
},
"content": "yes, you read that right. cobalt now lets you convert any twitter gif to an actual .gif file! (finally)\njust go to settings and enable this feature :)\n\nservice improvements:\n*; added an option to <a class=\"text-backdrop link\" href=\"{repo}/issues/250\" target=\"_blank\">convert gifs from twitter</a> into actual .gif format. files will be bigger and lower quality, but maybe you want that.\n*; pinterest support has been completely redone, now all videos (<a class=\"text-backdrop link\" href=\"{repo}/issues/160\" target=\"_blank\">and even pin.it links</a>) are supported.\n*; added <a class=\"text-backdrop link\" href=\"{repo}/issues/322\" target=\"_blank\">support for ok.ru</a> in case you're a russian grandma.\n*; now processing <a class=\"text-backdrop link\" href=\"{repo}/issues/318\" target=\"_blank\">all reddit links</a> (including old.reddit.com).\n*; <a class=\"text-backdrop link\" href=\"{repo}/issues/316\" target=\"_blank\">instagram live vods</a> are now supported.\n*; fixed a <a class=\"text-backdrop link\" href=\"{repo}/issues/289\" target=\"_blank\">rare vimeo bug</a> related to 1440p videos.\n\nother improvements:\n*; ui fade in animation is no longer present if you've disabled animations.\n*; all images now have alt descriptions.\n*; cobalt html is now <a class=\"text-backdrop link\" href=\"{repo}/issues/317\" target=\"_blank\">biblically correct</a> and follows the html spec.\n*; lots of cleaning up.\n\npatches since 7.8:\n*; shift+key <a class=\"text-backdrop link\" href=\"{repo}/issues/288\" target=\"_blank\">shortcuts are now ignored</a> if url bar is focused.\n*; longer soundcloud links are now supported, also catching more tiktok-related errors.\n*; removed mastodon from support links as that account is no longer active.\n*; added ability to download a specific video from multi media tweets and support for /mediaViewer links.\n*; fixed <a class=\"text-backdrop link\" href=\"{repo}/issues/309\" target=\"_blank\">modal blurriness</a> in chromium.\n*; minor html changes (road to biblically correct one).\n\nlots of long-awaited updates (especially twitter gifs), hope you enjoy them and have a great day :D"
},
"history": [{
}, {
"version": "7.8",
"date": "December 25, 2023",
"title": "new years clean up! bug fixes and fresh look for the home page",

View file

@ -69,15 +69,17 @@ export function popup(obj) {
}
return `
${obj.standalone ? `<div id="popup-${obj.name}" class="popup center${!obj.buttonOnly ? " box" : ''}${classes.length > 0 ? ' ' + classes.join(' ') : ''}">` : ''}
<div class="popup-header">
<div class="popup-header-contents">
${obj.buttonOnly ? obj.header.emoji : ``}
${obj.header.aboveTitle ? `<a class="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div class="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
</div>
${!obj.buttonOnly ? `<div class="glass-bkg alone"></div>` : ''}
</div>
${obj.buttonOnly ? obj.header.emoji : ``}
${obj.name === "error" ? `` :
`<div class="popup-header">
<div class="popup-header-contents">
${obj.header.aboveTitle ? `<a class="popup-above-title" target="_blank" href="${obj.header.aboveTitle.url}">${obj.header.aboveTitle.text}</a>` : ''}
${obj.header.title ? `<div class="popup-title">${obj.header.title}</div>` : ''}
${obj.header.subtitle ? `<div id="popup-subtitle">${obj.header.subtitle}</div>` : ''}
</div>
${!obj.buttonOnly ? `<div class="glass-bkg alone"></div>` : ''}
</div>`
}
<div class="popup-content popup-content-inner">
${body}${obj.buttonOnly ? `<button class="close-error switch" onclick="popup('${obj.name}', 0)">${obj.buttonText}</button>` : ''}
</div>
@ -264,5 +266,5 @@ export function sponsoredList() {
}
export function betaTag() {
return process.env.isBeta ? '<span class="logo-sub">β</span>' : ''
return process.env.IS_BETA ? '<span class="logo-sub">β</span>' : ''
}

View file

@ -48,10 +48,10 @@ export default function(obj) {
<title>${t("AppTitleCobalt")}</title>
<meta property="og:url" content="${process.env.webURL}">
<meta property="og:url" content="${process.env.WEB_URL}">
<meta property="og:title" content="${t("AppTitleCobalt")}">
<meta property="og:description" content="${t('EmbedBriefDescription')}">
<meta property="og:image" content="${process.env.webURL}icons/generic.png">
<meta property="og:image" content="${process.env.WEB_URL}icons/generic.png">
<meta name="title" content="${t("AppTitleCobalt")}">
<meta name="description" content="${t('AboutSummary')}">
<meta name="theme-color" content="#000000">
@ -68,10 +68,12 @@ export default function(obj) {
<link rel="apple-touch-icon" sizes="180x180" href="icons/apple-touch-icon.png">
<link rel="manifest" href="manifest.webmanifest">
<link rel="preload" href="fonts/notosansmono.css" as="style">
<link rel="stylesheet" href="fonts/notosansmono.css">
<link rel="stylesheet" href="cobalt.css">
<link rel="preload" href="fonts/notosansmono.css" as="style">
<link rel="preload" href="assets/meowbalt/error.png" as="image">
<link rel="preload" href="assets/meowbalt/question.png" as="image">
</head>
<body id="cobalt-body" ${platform === "d" ? 'class="desktop"' : ''}>
<noscript>
@ -165,7 +167,7 @@ export default function(obj) {
body: t("FairUse")
}])
},
...(process.env.showSponsors ?
...(process.env.SHOW_SPONSORS ?
[{
text: t("SponsoredBy"),
classes: ["sponsored-by-text"],
@ -531,7 +533,9 @@ export default function(obj) {
classes: ["small"],
header: {
closeAria: t('AccessibilityGoBack'),
emoji: emoji("🐱", 78, 1, 1),
emoji: `<img class="popout-meowbalt" `
+ `draggable="false" loading="lazy" `
+ `alt="😿" src="assets/meowbalt/question.png">`,
title: t('TitlePopupDownload')
},
body: switcher({
@ -551,33 +555,19 @@ export default function(obj) {
buttonOnly: true,
classes: ["small"],
header: {
title: t('TitlePopupError'),
emoji: emoji("😿", 78, 1, 1),
emoji: `<img class="popout-meowbalt" `
+ `draggable="false" loading="lazy" `
+ `alt="😿" src="assets/meowbalt/error.png">`,
},
body: `<div id="desc-error" class="desc-padding subtext desc-error"></div>`,
buttonText: t('ErrorPopupCloseButton')
})}
</div>
<div id="popup-migration-container" class="popup-from-bottom">
${popup({
name: "migration",
standalone: true,
buttonOnly: true,
classes: ["small"],
header: {
title: t('NewDomainWelcomeTitle'),
emoji: emoji("😸", 78, 1, 1),
},
body: `<div id="desc-migration" class="desc-padding subtext desc-error">${t('NewDomainWelcome')}</div>`,
buttonText: t('ErrorPopupCloseButton')
})}
<div id="popup-backdrop-message" onclick="popup('message', 0)"></div>
</div>
<div id="popup-backdrop" onclick="hideAllPopups()"></div>
<div id="home" style="visibility:hidden">
${urgentNotice({
emoji: "🎬",
text: t("UpdateTwitterGif"),
emoji: "🔒",
text: t("UpdateEncryption"),
visible: true,
action: "popup('about', 1, 'changelog')"
})}
@ -627,7 +617,7 @@ export default function(obj) {
</footer>
</div>
<script>
let defaultApiUrl = '${process.env.apiURL ? process.env.apiURL : ''}';
let defaultApiUrl = '${process.env.API_URL || ''}';
const loc = ${webLoc(t,
[
'ErrorNoInternet',

View file

@ -3,7 +3,7 @@ import { readFile, writeFile } from 'fs/promises';
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
const WRITE_INTERVAL = 60000,
cookiePath = process.env.cookiePath,
cookiePath = process.env.COOKIE_PATH,
COUNTER = Symbol('counter');
let cookies = {}, dirty = false, intervalId;

View file

@ -24,6 +24,7 @@ import pinterest from "./services/pinterest.js";
import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
import rutube from "./services/rutube.js";
import dailymotion from "./services/dailymotion.js";
export default async function(host, patternMatch, url, lang, obj) {
assert(url instanceof URL);
@ -56,9 +57,7 @@ export default async function(host, patternMatch, url, lang, obj) {
});
break;
case "bilibili":
r = await bilibili({
id: patternMatch.id.slice(0, 12)
});
r = await bilibili(patternMatch);
break;
case "youtube":
let fetchInfo = {
@ -105,6 +104,7 @@ export default async function(host, patternMatch, url, lang, obj) {
case "vimeo":
r = await vimeo({
id: patternMatch.id.slice(0, 11),
password: patternMatch.password,
quality: obj.vQuality,
isAudioOnly: isAudioOnly,
forceDash: isAudioOnly ? true : obj.vimeoDash
@ -158,6 +158,9 @@ export default async function(host, patternMatch, url, lang, obj) {
isAudioOnly: isAudioOnly
});
break;
case "dailymotion":
r = await dailymotion(patternMatch);
break;
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}

View file

@ -143,7 +143,7 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
const isBestHostAudio = services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"]);
const isTikTok = host === "tiktok" || host === "douyin";
const isTumblr = host === "tumblr" && !r.filename;
const isTumblrAudio = host === "tumblr" && !r.filename;
const isSoundCloud = host === "soundcloud";
if (isTikTok && services.tiktok.audioFormats.includes(audioFormat)) {
@ -168,11 +168,6 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
}
}
if (isTumblr && isBestOrMp3) {
audioFormat = "mp3";
processType = "bridge"
}
if (isBestAudioDefined || isBestHostAudio) {
audioFormat = services[host]["bestAudio"];
processType = "bridge";
@ -181,6 +176,11 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di
copy = true
}
if (isTumblrAudio && isBestOrMp3) {
audioFormat = "mp3";
processType = "bridge"
}
if (r.isM3U8 || host === "vimeo") {
copy = false;
processType = "render"

View file

@ -1,27 +1,105 @@
import { genericUserAgent, maxVideoDuration } from "../../config.js";
// TO-DO: quality picking, bilibili.tv support, and higher quality downloads (currently requires an account)
export default async function(obj) {
let html = await fetch(`https://bilibili.com/video/${obj.id}`, {
// TO-DO: higher quality downloads (currently requires an account)
function com_resolveShortlink(shortId) {
return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
.then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
.then(url => {
if (!url) return;
const path = new URL(url).pathname;
if (path.startsWith('/video/'))
return path.split('/')[2];
})
.catch(() => {})
}
function getBest(content) {
return content?.filter(v => v.baseUrl || v.url)
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
}
function extractBestQuality(dashData) {
const bestVideo = getBest(dashData.video),
bestAudio = getBest(dashData.audio);
if (!bestVideo || !bestAudio) return [];
return [ bestVideo, bestAudio ];
}
async function com_download(id) {
let html = await fetch(`https://bilibili.com/video/${id}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) return { error: 'ErrorEmptyDownload' };
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
return { error: 'ErrorEmptyDownload' };
}
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
if (streamData.data.timelength > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
if (streamData.data.timelength > maxVideoDuration) {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
let video = streamData["data"]["dash"]["video"].filter(v =>
!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")
).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter(a =>
!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")
).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
const [ video, audio ] = extractBestQuality(streamData.data.dash);
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
}
return {
urls: [video[0]["baseUrl"], audio[0]["baseUrl"]],
audioFilename: `bilibili_${obj.id}_audio`,
filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4`
urls: [video.baseUrl, audio.baseUrl],
audioFilename: `bilibili_${id}_audio`,
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
};
}
async function tv_download(id) {
const url = new URL(
'https://api.bilibili.tv/intl/gateway/web/playurl'
+ '?s_locale=en_US&platform=web&qn=64&type=0&device=wap'
+ '&tf=0&spm_id=bstar-web.ugc-video-detail.0.0&from_spm_id='
);
url.searchParams.set('aid', id);
const { data } = await fetch(url).then(a => a.json());
if (!data?.playurl?.video) {
return { error: 'ErrorEmptyDownload' };
}
const [ video, audio ] = extractBestQuality({
video: data.playurl.video.map(s => s.video_resource)
.filter(s => s.codecs.includes('avc1')),
audio: data.playurl.audio_resource
});
if (!video || !audio) {
return { error: 'ErrorEmptyDownload' };
}
if (video.duration > maxVideoDuration) {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
return {
urls: [video.url, audio.url],
audioFilename: `bilibili_tv_${id}_audio`,
filename: `bilibili_tv_${id}.mp4`
};
}
export default async function({ comId, tvId, comShortLink }) {
if (comShortLink) {
comId = await com_resolveShortlink(comShortLink);
}
if (comId) {
return com_download(comId);
} else if (tvId) {
return tv_download(tvId);
}
return { error: 'ErrorCouldntFetch' };
}

View file

@ -0,0 +1,107 @@
import HLSParser from 'hls-parser';
import { maxVideoDuration } from '../../config.js';
let _token;
function getExp(token) {
return JSON.parse(
Buffer.from(token.split('.')[1], 'base64')
).exp * 1000;
}
const getToken = async () => {
if (_token && getExp(_token) > new Date().getTime()) {
return _token;
}
const req = await fetch('https://graphql.api.dailymotion.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
'Authorization': 'Basic MGQyZDgyNjQwOWFmOWU3MmRiNWQ6ODcxNmJmYTVjYmEwMmUwMGJkYTVmYTg1NTliNDIwMzQ3NzIyYWMzYQ=='
},
body: 'traffic_segment=&grant_type=client_credentials'
}).then(r => r.json()).catch(() => {});
if (req.access_token) {
return _token = req.access_token;
}
}
export default async function({ id }) {
const token = await getToken();
if (!token) return { error: 'ErrorSomethingWentWrong' };
const req = await fetch('https://graphql.api.dailymotion.com/',
{
method: 'POST',
headers: {
'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-DM-AppInfo-Version': '7.16.0_240213162706',
'X-DM-AppInfo-Type': 'iosapp',
'X-DM-AppInfo-Id': 'com.dailymotion.dailymotion'
},
body: JSON.stringify({
operationName: "Media",
query: `
query Media($xid: String!, $password: String) {
media(xid: $xid, password: $password) {
__typename
... on Video {
xid
hlsURL
duration
title
channel {
displayName
}
}
}
}
`,
variables: { xid: id }
})
}
).then(r => r.status === 200 && r.json()).catch(() => {});
const media = req?.data?.media;
if (media?.__typename !== 'Video' || !media.hlsURL) {
return { error: 'ErrorEmptyDownload' }
}
if (media.duration * 1000 > maxVideoDuration) {
return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
}
const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
if (!manifest) return { error: 'ErrorSomethingWentWrong' };
const bestQuality = HLSParser.parse(manifest).variants
.filter(v => v.codecs.includes('avc1'))
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
if (!bestQuality) return { error: 'ErrorEmptyDownload' }
const fileMetadata = {
title: media.title,
artist: media.channel.displayName
}
return {
urls: bestQuality.uri,
isM3U8: true,
filenameAttributes: {
service: 'dailymotion',
id: media.xid,
title: fileMetadata.title,
author: fileMetadata.artist,
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
qualityLabel: `${bestQuality.resolution.height}p`,
extension: 'mp4'
},
fileMetadata
}
}

View file

@ -1,8 +1,26 @@
import psl from "psl";
import { genericUserAgent } from "../../config.js";
export default async function(obj) {
let { subdomain } = psl.parse(obj.url.hostname);
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
const API_BASE = 'https://api-http2.tumblr.com';
function request(domain, id) {
const url = new URL(`/v2/blog/${domain}/posts/${id}/permalink`, API_BASE);
url.searchParams.set('api_key', API_KEY);
url.searchParams.set('fields[blogs]', 'uuid,name,avatar,?description,?can_message,?can_be_followed,?is_adult,?reply_conditions,'
+ '?theme,?title,?url,?is_blocked_from_primary,?placement_id,?primary,?updated,?followed,'
+ '?ask,?can_subscribe,?paywall_access,?subscription_plan,?is_blogless_advertiser,?tumblrmart_accessories');
return fetch(url, {
headers: {
'User-Agent': 'Tumblr/iPhone/33.3/333010/17.3.1/tumblr',
'X-Version': 'iPhone/33.3/333010/17.3.1/tumblr'
}
}).then(a => a.json()).catch(() => {});
}
export default async function(input) {
let { subdomain } = psl.parse(input.url.hostname);
if (subdomain?.includes('.')) {
return { error: ['ErrorBrokenLink', 'tumblr'] }
@ -10,26 +28,44 @@ export default async function(obj) {
subdomain = undefined
}
let html = await fetch(`https://${subdomain ?? obj.user}.tumblr.com/post/${obj.id}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
const domain = `${subdomain ?? input.user}.tumblr.com`;
const data = await request(domain, input.id);
if (!html) return { error: 'ErrorCouldntFetch' };
const element = data?.response?.timeline?.elements?.[0];
if (!element) return { error: 'ErrorEmptyDownload' };
let r;
if (html.includes('property="og:video" content="https://va.media.tumblr.com/')) {
r = {
urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`,
filename: `tumblr_${obj.id}.mp4`,
audioFilename: `tumblr_${obj.id}_audio`
}
} else if (html.includes('property="og:audio" content="https://a.tumblr.com/')) {
r = {
urls: `https://a.tumblr.com/${html.split('property="og:audio" content="https://a.tumblr.com/')[1].split('"')[0]}`,
audioFilename: `tumblr_${obj.id}`,
const contents = [
...element.content,
...element?.trail?.map(t => t.content).flat()
]
const audio = contents.find(c => c.type === 'audio');
if (audio && audio.provider === 'tumblr') {
const fileMetadata = {
title: audio?.title,
artist: audio?.artist
};
return {
urls: audio.media.url,
filenameAttributes: {
service: 'tumblr',
id: input.id,
title: fileMetadata.title,
author: fileMetadata.artist
},
isAudioOnly: true
}
} else r = { error: 'ErrorEmptyDownload' };
}
return r
const video = contents.find(c => c.type === 'video');
if (video && video.provider === 'tumblr') {
return {
urls: video.media.url,
filename: `tumblr_${input.id}.mp4`,
audioFilename: `tumblr_${input.id}_audio`
}
}
return { error: 'ErrorEmptyDownload' }
}

View file

@ -104,7 +104,7 @@ export default async function({ id, index, toGif }) {
const baseTweet = tweet.data.tweetResult.result.legacy,
repostedTweet = baseTweet.retweeted_status_result?.result.legacy.extended_entities;
let media = (repostedTweet?.media || baseTweet.extended_entities.media);
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
media = media?.filter(m => m.video_info?.variants?.length);
// check if there's a video at given index (/video/<index>)

View file

@ -28,7 +28,14 @@ export default async function(obj) {
let quality = obj.quality === "max" ? "9000" : obj.quality;
if (!quality || obj.isAudioOnly) quality = "9000";
let api = await fetch(`https://player.vimeo.com/video/${obj.id}/config`).then((r) => { return r.json() }).catch(() => { return false });
const url = new URL(`https://player.vimeo.com/video/${obj.id}/config`);
if (obj.password) {
url.searchParams.set('h', obj.password);
}
let api = await fetch(url)
.then(r => r.json())
.catch(() => {});
if (!api) return { error: 'ErrorCouldntFetch' };
let downloadType = "dash";
@ -71,6 +78,7 @@ export default async function(obj) {
}
let masterM3U8 = `${masterJSONURL.split("/sep/")[0]}/sep/video/${bestVideo.id}/master.m3u8`;
const fallbackResolution = bestVideo.height > bestVideo.width ? bestVideo.width : bestVideo.height;
return {
urls: masterM3U8,
@ -81,8 +89,8 @@ export default async function(obj) {
id: obj.id,
title: fileMetadata.title,
author: fileMetadata.artist,
resolution: `${bestVideo["width"]}x${bestVideo["height"]}`,
qualityLabel: `${resolutionMatch[bestVideo["width"]]}p`,
resolution: `${bestVideo.width}x${bestVideo.height}`,
qualityLabel: `${resolutionMatch[bestVideo.width] || fallbackResolution}p`,
extension: "mp4"
}
}

View file

@ -2,8 +2,11 @@
"audioIgnore": ["vk", "ok"],
"config": {
"bilibili": {
"alias": "bilibili.com videos",
"patterns": ["video/:id"],
"alias": "bilibili.com & bilibili.tv",
"patterns": [
"video/:comId", "_shortLink/:comShortLink",
"_tv/:lang/video/:tvId", "_tv/video/:tvId"
],
"enabled": true
},
"reddit": {
@ -32,7 +35,7 @@
"ok": {
"alias": "ok video",
"tld": "ru",
"patterns": ["video/:id"],
"patterns": ["video/:id", "videoembed/:id"],
"enabled": true
},
"youtube": {
@ -43,6 +46,7 @@
"enabled": true
},
"tumblr": {
"alias": "tumblr video & audio",
"patterns": ["post/:id", "blog/view/:user/:id", ":user/:id", ":user/:id/:trackingId"],
"subdomains": "*",
"enabled": true
@ -61,7 +65,7 @@
"enabled": false
},
"vimeo": {
"patterns": [":id", "video/:id"],
"patterns": [":id", "video/:id", ":id/:password"],
"enabled": true,
"bestAudio": "mp3"
},
@ -106,6 +110,11 @@
"tld": "ru",
"patterns": ["video/:id", "play/embed/:id"],
"enabled": true
},
"dailymotion": {
"alias": "dailymotion videos",
"patterns": ["video/:id"],
"enabled": true
}
}
}

View file

@ -1,6 +1,9 @@
export const testers = {
"bilibili": (patternMatch) =>
patternMatch.id?.length <= 12,
"bilibili": (patternMatch) =>
patternMatch.comId?.length <= 12 || patternMatch.comShortLink?.length <= 16
|| patternMatch.tvId?.length <= 24,
"dailymotion": (patternMatch) => patternMatch.id?.length <= 32,
"instagram": (patternMatch) =>
patternMatch.postId?.length <= 12
@ -39,7 +42,8 @@ export const testers = {
patternMatch.id?.length < 20,
"vimeo": (patternMatch) =>
patternMatch.id?.length <= 11,
patternMatch.id?.length <= 11
&& (!patternMatch.password || patternMatch.password.length < 16),
"vine": (patternMatch) =>
patternMatch.id?.length <= 12,

View file

@ -16,6 +16,7 @@ export function aliasURL(url) {
url.search = `?v=${encodeURIComponent(parts[2])}`
}
break;
case "youtu":
if (url.hostname === 'youtu.be' && parts.length >= 2) {
/* youtu.be urls can be weird, e.g. https://youtu.be/<id>//asdasd// still works
@ -25,6 +26,7 @@ export function aliasURL(url) {
}`)
}
break;
case "pin":
if (url.hostname === 'pin.it' && parts.length === 2) {
url = new URL(`https://pinterest.com/url_shortener/${
@ -46,6 +48,22 @@ export function aliasURL(url) {
url = new URL(`https://twitch.tv/_/clip/${parts[1]}`);
}
break;
case "bilibili":
if (host.tld === 'tv') {
url = new URL(`https://bilibili.com/_tv${url.pathname}`);
}
break;
case "b23":
if (url.hostname === 'b23.tv' && parts.length === 2) {
url = new URL(`https://bilibili.com/_shortLink/${parts[1]}`)
}
break;
case "dai":
if (url.hostname === 'dai.ly' && parts.length === 2) {
url = new URL(`https://dailymotion.com/video/${parts[1]}`)
}
}
return url

View file

@ -39,27 +39,27 @@ function setup() {
console.log(Bright("\nCool! What's the domain this API instance will be running on? (localhost)\nExample: co.wuk.sh"));
rl.question(q, apiURL => {
ob['apiURL'] = `http://localhost:9000/`;
ob['apiPort'] = 9000;
if (apiURL && apiURL !== "localhost") ob['apiURL'] = `https://${apiURL.toLowerCase()}/`;
ob.API_URL = `http://localhost:9000/`;
ob.API_PORT = 9000;
if (apiURL && apiURL !== "localhost") ob.API_URL = `https://${apiURL.toLowerCase()}/`;
console.log(Bright("\nGreat! Now, what port will it be running on? (9000)"));
rl.question(q, apiPort => {
if (apiPort) ob['apiPort'] = apiPort;
if (apiPort && (apiURL === "localhost" || !apiURL)) ob['apiURL'] = `http://localhost:${apiPort}/`;
if (apiPort) ob.API_PORT = apiPort;
if (apiPort && (apiURL === "localhost" || !apiURL)) ob.API_URL = `http://localhost:${apiPort}/`;
console.log(Bright("\nWhat will your instance's name be? Usually it's something like eu-nl aka region-country. (local)"));
rl.question(q, apiName => {
ob['apiName'] = apiName.toLowerCase();
if (!apiName || apiName === "local") ob['apiName'] = "local";
ob.API_NAME = apiName.toLowerCase();
if (!apiName || apiName === "local") ob.API_NAME = "local";
console.log(Bright("\nOne last thing: would you like to enable CORS? It allows other websites and extensions to use your instance's API.\ny/n (n)"));
rl.question(q, apiCors => {
let answCors = apiCors.toLowerCase().trim();
if (answCors !== "y" && answCors !== "yes") ob['cors'] = '0'
if (answCors !== "y" && answCors !== "yes") ob.CORS_WILDCARD = '0'
final()
})
})
@ -71,25 +71,25 @@ function setup() {
console.log(Bright("\nAwesome! What's the domain this web app instance will be running on? (localhost)\nExample: cobalt.tools"));
rl.question(q, webURL => {
ob['webURL'] = `http://localhost:9001/`;
ob['webPort'] = 9001;
if (webURL && webURL !== "localhost") ob['webURL'] = `https://${webURL.toLowerCase()}/`;
ob.WEB_URL = `http://localhost:9001/`;
ob.WEB_PORT = 9001;
if (webURL && webURL !== "localhost") ob.WEB_URL = `https://${webURL.toLowerCase()}/`;
console.log(
Bright("\nGreat! Now, what port will it be running on? (9001)")
)
rl.question(q, webPort => {
if (webPort) ob['webPort'] = webPort;
if (webPort && (webURL === "localhost" || !webURL)) ob['webURL'] = `http://localhost:${webPort}/`;
if (webPort) ob.WEB_PORT = webPort;
if (webPort && (webURL === "localhost" || !webURL)) ob.WEB_URL = `http://localhost:${webPort}/`;
console.log(
Bright("\nOne last thing: what default API domain should be used? (co.wuk.sh)\nIf it's hosted locally, make sure to include the port:") + Cyan(" localhost:9000")
);
rl.question(q, apiURL => {
ob['apiURL'] = `https://${apiURL.toLowerCase()}/`;
if (apiURL.includes(':')) ob['apiURL'] = `http://${apiURL.toLowerCase()}/`;
if (!apiURL) ob['apiURL'] = "https://co.wuk.sh/";
ob.API_URL = `https://${apiURL.toLowerCase()}/`;
if (apiURL.includes(':')) ob.API_URL = `http://${apiURL.toLowerCase()}/`;
if (!apiURL) ob.API_URL = "https://co.wuk.sh/";
final()
})
});

View file

@ -2,7 +2,7 @@ import NodeCache from "node-cache";
import { randomBytes } from "crypto";
import { nanoid } from 'nanoid';
import { sha256 } from "../sub/crypto.js";
import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js";
import { streamLifespan } from "../config.js";
const streamCache = new NodeCache({
@ -15,48 +15,68 @@ streamCache.on("expired", (key) => {
streamCache.del(key);
})
const streamSalt = randomBytes(64).toString('hex');
const hmacSalt = randomBytes(64).toString('hex');
export function createStream(obj) {
let streamID = nanoid(),
exp = Math.floor(new Date().getTime()) + streamLifespan,
ghmac = sha256(`${streamID},${obj.service},${exp}`, streamSalt);
if (!streamCache.has(streamID)) {
streamCache.set(streamID, {
id: streamID,
service: obj.service,
const streamID = nanoid(),
iv = randomBytes(16).toString('base64url'),
secret = randomBytes(32).toString('base64url'),
exp = new Date().getTime() + streamLifespan,
hmac = generateHmac(`${streamID},${exp},${iv},${secret}`, hmacSalt),
streamData = {
exp: exp,
type: obj.type,
urls: obj.u,
service: obj.service,
filename: obj.filename,
hmac: ghmac,
exp: exp,
isAudioOnly: !!obj.isAudioOnly,
audioFormat: obj.audioFormat,
time: obj.time ? obj.time : false,
isAudioOnly: !!obj.isAudioOnly,
copy: !!obj.copy,
mute: !!obj.mute,
metadata: obj.fileMetadata ? obj.fileMetadata : false
});
} else {
let streamInfo = streamCache.get(streamID);
exp = streamInfo.exp;
ghmac = streamInfo.hmac;
metadata: obj.fileMetadata || false
};
streamCache.set(
streamID,
encryptStream(streamData, iv, secret)
)
let streamLink = new URL('/api/stream', process.env.API_URL);
const params = {
't': streamID,
'e': exp,
'h': hmac,
's': secret,
'i': iv
}
return `${process.env.apiURL}api/stream?t=${streamID}&e=${exp}&h=${ghmac}`;
for (const [key, value] of Object.entries(params)) {
streamLink.searchParams.append(key, value);
}
return streamLink.toString();
}
export function verifyStream(id, hmac, exp) {
export function verifyStream(id, hmac, exp, secret, iv) {
try {
let streamInfo = streamCache.get(id.toString());
const ghmac = generateHmac(`${id},${exp},${iv},${secret}`, hmacSalt);
if (ghmac !== String(hmac)) {
return {
error: "i couldn't verify if you have access to this stream. go back and try again!",
status: 401
}
}
const streamInfo = JSON.parse(decryptStream(streamCache.get(id.toString()), iv, secret));
if (!streamInfo) return {
error: "this download link has expired or doesn't exist. go back and try again!",
status: 400
}
let ghmac = sha256(`${id},${streamInfo.service},${exp}`, streamSalt);
if (String(hmac) === ghmac && String(exp) === String(streamInfo.exp) && ghmac === String(streamInfo.hmac)
&& Number(exp) > Math.floor(new Date().getTime())) {
if (String(exp) === String(streamInfo.exp) && Number(exp) > new Date().getTime()) {
return streamInfo;
}
return {
@ -64,6 +84,6 @@ export function verifyStream(id, hmac, exp) {
status: 401
}
} catch (e) {
return { status: 500, body: { status: "error", text: "Internal Server Error" } };
return { status: 500, body: { status: "error", text: "couldn't verify this stream. request a new one!" } };
}
}

View file

@ -80,17 +80,33 @@ export async function streamLiveRender(streamInfo, res) {
if (streamInfo.urls.length !== 2) return shutdown();
const { body: audio } = await request(streamInfo.urls[1], {
maxRedirections: 16, signal: abortController.signal
maxRedirections: 16, signal: abortController.signal,
headers: {
'user-agent': genericUserAgent,
referer: streamInfo.service === 'bilibili'
? 'https://www.bilibili.com/'
: undefined,
}
});
let format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1],
args = [
const format = streamInfo.filename.split('.')[streamInfo.filename.split('.').length - 1];
let args = [
'-loglevel', '-8',
'-user_agent', genericUserAgent
];
if (streamInfo.service === 'bilibili') {
args.push(
'-headers', 'Referer: https://www.bilibili.com/\r\n',
)
}
args.push(
'-i', streamInfo.urls[0],
'-i', 'pipe:3',
'-map', '0:v',
'-map', '1:a',
];
);
args = args.concat(ffmpegArgs[format]);
if (streamInfo.metadata) {
@ -129,11 +145,16 @@ export function streamAudioOnly(streamInfo, res) {
try {
let args = [
'-loglevel', '-8'
]
'-loglevel', '-8',
'-user_agent', genericUserAgent
];
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
args.push('-seekable', '0');
} else if (streamInfo.service === 'bilibili') {
args.push('-headers', 'Referer: https://www.bilibili.com/\r\n');
}
args.push(
'-i', streamInfo.urls,
'-vn'
@ -178,17 +199,23 @@ export function streamVideoOnly(streamInfo, res) {
let args = [
'-loglevel', '-8'
]
if (streamInfo.service === "twitter") {
args.push('-seekable', '0')
} else if (streamInfo.service === 'bilibili') {
args.push('-headers', 'Referer: https://www.bilibili.com/\r\n')
}
args.push(
'-i', streamInfo.urls,
'-c', 'copy'
)
if (streamInfo.mute) {
args.push('-an')
}
if (streamInfo.service === "vimeo" || streamInfo.service === "rutube") {
if (["vimeo", "rutube", "dailymotion"].includes(streamInfo.service)) {
args.push('-bsf:a', 'aac_adtstoasc')
}

View file

@ -0,0 +1,23 @@
import { Red } from "./consoleText.js";
const mapping = {
apiPort: 'API_PORT',
apiURL: 'API_URL',
apiName: 'API_NAME',
cors: 'CORS_WILDCARD',
cookiePath: 'COOKIE_PATH',
webPort: 'WEB_PORT',
webURL: 'WEB_URL',
showSponsors: 'SHOW_SPONSORS',
isBeta: 'IS_BETA'
}
for (const [ oldEnv, newEnv ] of Object.entries(mapping)) {
if (process.env[oldEnv] && !process.env[newEnv]) {
process.env[newEnv] = process.env[oldEnv];
console.error(`${Red('[!]')} ${oldEnv} is deprecated and will be removed in a future version.`);
console.error(` You should use ${newEnv} instead.`);
console.error();
delete process.env[oldEnv];
}
}

View file

@ -1,5 +1,23 @@
import { createHmac } from "crypto";
import { createHmac, createCipheriv, createDecipheriv, scryptSync } from "crypto";
export function sha256(str, salt) {
return createHmac("sha256", salt).update(str).digest("hex");
const algorithm = "aes256"
export function generateHmac(str, salt) {
return createHmac("sha256", salt).update(str).digest("base64url");
}
export function encryptStream(plaintext, iv, secret) {
const buff = Buffer.from(JSON.stringify(plaintext));
const key = Buffer.from(secret, "base64url");
const cipher = createCipheriv(algorithm, key, Buffer.from(iv, "base64url"));
return Buffer.concat([ cipher.update(buff), cipher.final() ])
}
export function decryptStream(ciphertext, iv, secret) {
const buff = Buffer.from(ciphertext);
const key = Buffer.from(secret, "base64url");
const decipher = createDecipheriv(algorithm, key, Buffer.from(iv, "base64url"));
return Buffer.concat([ decipher.update(buff), decipher.final() ])
}

View file

@ -11,7 +11,6 @@ const apiVar = {
},
booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"]
}
const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '=='];
const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '='];
export function apiJSON(type, obj) {

View file

@ -1,4 +1,5 @@
import "dotenv/config";
import "../modules/sub/alias-envs.js";
import { getJSON } from "../modules/api.js";
import { services } from "../modules/config.js";

View file

@ -746,6 +746,23 @@
"code": 200,
"status": "stream"
}
}, {
"name": "b23.tv shortlink",
"url": "https://b23.tv/lbMyOI9",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
},
{
"name": "bilibili.tv link",
"url": "https://www.bilibili.tv/en/video/4789599404426256",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}],
"tumblr": [{
"name": "at.tumblr link",
@ -773,7 +790,7 @@
}
}, {
"name": "tumblr audio",
"url": "https://rf9weu8hjf789234hf9.tumblr.com/post/172006661342/everyone-thats-made-a-video-out-of-this-without",
"url": "https://www.tumblr.com/zedneon/737815079301562368/zedneon-ft-mr-sauceman-tech-n9ne-speed-of?source=share",
"params": {},
"expected": {
"code": 200,
@ -830,6 +847,14 @@
"code": 200,
"status": "stream"
}
}, {
"name": "private video",
"url": "https://vimeo.com/903115595/f14d06da38",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}],
"reddit": [{
"name": "video with audio",
@ -1180,5 +1205,30 @@
"code": 200,
"status": "stream"
}
}],
"dailymotion": [{
"name": "regular video",
"url": "https://www.dailymotion.com/video/x8t1eho",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "private video",
"url": "https://www.dailymotion.com/video/k41fZWpx2TaAORA2nok",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "dai.ly shortened link",
"url": "https://dai.ly/k41fZWpx2TaAORA2nok",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}]
}