diff --git a/README.md b/README.md index 328651f1..f7210717 100644 --- a/README.md +++ b/README.md @@ -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/). \ No newline at end of file +- [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/). diff --git a/docs/api.md b/docs/api.md index 6d8cc697..6b3b9aaa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/examples/cookies.example.json b/docs/examples/cookies.example.json index faaeb569..5ebdb635 100644 --- a/docs/examples/cookies.example.json +++ b/docs/examples/cookies.example.json @@ -1,5 +1,8 @@ { "instagram": [ - "mid=replace; ig_did=this; csrftoken=cookie" + "mid=; ig_did=; csrftoken=; ds_user_id=; sessionid=" + ], + "reddit": [ + "client_id=; client_secret=; refresh_token=" ] } diff --git a/docs/examples/docker-compose.example.yml b/docs/examples/docker-compose.example.yml index b5ce8a30..b6f4a90b 100644 --- a/docs/examples/docker-compose.example.yml +++ b/docs/examples/docker-compose.example.yml @@ -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 diff --git a/docs/run-an-instance.md b/docs/run-an-instance.md index 801895dc..5a181cc8 100644 --- a/docs/run-an-instance.md +++ b/docs/run-an-instance.md @@ -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.
***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.
`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.
***REQUIRED TO RUN WEB***. | +| `SHOW_SPONSORS` | `0` | `1` | toggles sponsor list in about popup.
`0`: disabled. `1`: enabled. | +| `IS_BETA` | `0` | `1` | toggles beta tag next to cobalt logo.
`0`: disabled. `1`: enabled. | diff --git a/package.json b/package.json index 84a250e8..4378bcff 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/cobalt.js b/src/cobalt.js index 2d90e07e..050aec46 100644 --- a/src/cobalt.js +++ b/src/cobalt.js @@ -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'); diff --git a/src/config.json b/src/config.json index ae0c16fc..f1aa4a2a 100644 --- a/src/config.json +++ b/src/config.json @@ -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" }, diff --git a/src/core/api.js b/src/core/api.js index 5f910315..eda3c014 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -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` ) }); } diff --git a/src/core/web.js b/src/core/web.js index 08a6ffed..7c0cbf33 100644 --- a/src/core/web.js +++ b/src/core/web.js @@ -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` ) }) } diff --git a/src/front/assets/meowbalt/error.png b/src/front/assets/meowbalt/error.png new file mode 100644 index 00000000..533858a7 Binary files /dev/null and b/src/front/assets/meowbalt/error.png differ diff --git a/src/front/assets/meowbalt/question.png b/src/front/assets/meowbalt/question.png new file mode 100644 index 00000000..330cd69b Binary files /dev/null and b/src/front/assets/meowbalt/question.png differ diff --git a/src/front/cobalt.css b/src/front/cobalt.css index 585fb4e8..267092cb 100644 --- a/src/front/cobalt.css +++ b/src/front/cobalt.css @@ -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; diff --git a/src/front/cobalt.js b/src/front/cobalt.js index cdf143bc..6d24247e 100644 --- a/src/front/cobalt.js +++ b/src/front/cobalt.js @@ -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 = `
`; +const notification = ``; const switchers = { "theme": ["auto", "light", "dark"], @@ -600,15 +600,11 @@ window.onload = () => { if (setUn !== null) { if (setUn) { sSet("migrated", "true") - eid("desc-migration").innerHTML += `

${loc.DataTransferSuccess}` - } else { - eid("desc-migration").innerHTML += `

${loc.DataTransferError}` } } } loadSettings(); detectColorScheme(); - popup("migration", 1); } window.history.replaceState(null, '', window.location.pathname); diff --git a/src/front/updateBanners/meowth7eleven.webp b/src/front/updateBanners/meowth7eleven.webp new file mode 100644 index 00000000..f44722d8 Binary files /dev/null and b/src/front/updateBanners/meowth7eleven.webp differ diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json index 7bc69001..587b7ca7 100644 --- a/src/localization/languages/en.json +++ b/src/localization/languages/en.json @@ -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 this siri shortcut.\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 save to photos shortcut.\n2. press \"share\" button above this text.\n3. select \"save to photos\" in the share sheet.\n\nhow to save to files:\n1. add save to files shortcut.\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 completely free to use for everyone. 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\nevery cent matters and is extremely appreciated, 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 completely free to use 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\nevery cent matters and is extremely appreciated, 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 hire me :)", "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 90 seconds 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 source code 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 90 seconds 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 source code 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" } } diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json index 7af3413b..57f07f04 100644 --- a/src/localization/languages/ru.json +++ b/src/localization/languages/ru.json @@ -16,7 +16,6 @@ "AccessibilityOpenDonate": "сделать пожертвование", "TitlePopupAbout": "что за кобальт?", "TitlePopupSettings": "настройки", - "TitlePopupError": "опаньки...", "TitlePopupChangelog": "что нового?", "TitlePopupDonate": "поддержи кобальт", "TitlePopupDownload": "как сохранить?", @@ -46,7 +45,7 @@ "AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками", "SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.", "NoScriptMessage": "кобальт использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких зловредных скриптов, обещаю.", - "DownloadPopupDescriptionIOS": "наиболее простой метод скачивания видео на ios:\n1. добавь этот сценарий siri.\n2. нажми \"поделиться\" выше и выбери \"save to photos\" в открывшемся окне.\nесли появляется окно с запросом разрешения, то прочитай его, потом нажми \"всегда разрешать\".\n\nальтернативный метод:\nзажми кнопку \"скачать\", затем скрой превью и выбери \"загрузить файл по ссылке\" в появившемся окне.\nпотом открой загрузки в safari, выбери скачанный файл, нажми иконку \"поделиться\", и, наконец, нажми \"сохранить видео\".", + "DownloadPopupDescriptionIOS": "как сохранить в фото:\n1. добавь этот сценарий siri: save to photos.\n2. нажми \"поделиться\" выше этого текста.\n3. выбери \"save to photos\" в открывшемся окне.\n\nкак сохранить в файлы:\n1. добавь этот сценарий siri: save to files.\n2. нажми \"поделиться\" выше этого текста.\n3. выбери \"save to files\" в открывшемся окне.\n4. выбери папку для сохранения файла и нажми \"открыть\".\n\nоба сценария работают только вместе с веб-приложением кобальта.", "DownloadPopupDescription": "кнопка скачивания открывает новое окно с файлом. ты можешь отключить выбор метода скачивания файла в настройках.", "ClickToCopy": "нажми, чтобы скопировать", "Download": "скачать", @@ -92,7 +91,7 @@ "ChangelogPressToHide": "скрыть", "Donate": "донаты", "DonateSub": "ты можешь помочь!", - "DonateExplanation": "кобальт не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает совершенно бесплатно для всех. но разработка и поддержка медиа сервиса, которым пользуются более 350 тысяч людей, обходится довольно затратно. мне, как студенту, оплачивать такое в одиночку довольно трудно.\n\nесли кобальт тебе помог и ты хочешь, чтобы он продолжал работать и развиваться, то это можно сделать через донаты!\n\nделая донат ты помогаешь всем, кто пользуется кобальтом: преподавателям, студентам, музыкантам, художникам, контент-мейкерам и многим-многим другим!\n\nза последние несколько месяцев благодаря донатам я смог:\n*; повысить стабильность и аптайм почти до 100%.\n*; ускорить ВСЕ загрузки, особенно наиболее тяжёлые.\n*; открыть api кобальта для свободного публичного использования.\n*; выдержать несколько огромных наплывов пользователей без перебоев.\n*; перейти к надёжному поставщику облачной инфры.\n*; разделить фронтенд и api для обеспечения отказоустойчивости и децентрализации в будущем.\n\nкаждый донат невероятно ценится и помогает кобальту развиваться!", + "DonateExplanation": "кобальт не пихает рекламу тебе в лицо и не продаёт твои личные данные, а значит работает совершенно бесплатно для всех. но разработка и поддержка медиа сервиса, которым пользуются более 750 тысяч людей, обходится довольно затратно.\n\nесли кобальт тебе помог и ты хочешь, чтобы он продолжал расти и развиваться, то это можно сделать через донаты!\n\nтвой донат поможет всем, кто пользуется кобальтом: преподавателям, студентам, музыкантам, художникам, контент-мейкерам и многим-многим другим!\n\nв прошлом донаты помогли кобальту:\n*; повысить стабильность и аптайм почти до 100%.\n*; ускорить ВСЕ загрузки, особенно наиболее тяжёлые.\n*; открыть api для бесплатного использования.\n*; выдержать несколько огромных наплывов пользователей без перебоев.\n*; добавить ресурсоемкие фичи (например конвертацию в gif).\n*; продолжать улучшать нашу инфраструктуру.\n*; радовать разработчиков.\n\nкаждый донат невероятно ценится и помогает кобальту развиваться!\n\nесли ты не можешь отправить донат, то поделись кобальтом с другом! мы нигде не размещаем рекламу, поэтому кобальт распространяется из уст в уста.\nподелиться - самый простой способ помочь достичь цели лучшего интернета для всех.", "DonateVia": "открыть", "DonateHireMe": "...или же ты можешь пригласить меня на работу :)", "SettingsVideoMute": "убрать аудио", @@ -104,7 +103,7 @@ "ServicesNote": "этот список далеко не финальный и постоянно пополняется, заглядывай сюда почаще!", "FollowSupport": "подписывайся на соц.сети кобальта для новостей и поддержки:", "SourceCode": "шарься в исходнике, пиши о проблемах, или же форкай репозиторий:", - "PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется лайв рендер, то некоторые неотслеживаемые данные временно держатся в ОЗУ сервера. это необходимо для работы данной функции.\n\nв этом случае данные о запрошенном контенте хранятся в течение 90 секунд. по истечении этого времени всё стирается. ни у кого (даже у меня) нет доступа к временно хранящимся данным, так как официальная кодовая база кобальта не предусматривает возможности их чтения вне функций обработки.\n\nты всегда можешь посмотреть исходный код кобальта и убедиться, что всё так, как заявлено.", + "PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется рендер, то зашифрованные данные о ней временно хранятся в ОЗУ сервера. это необходимо для работы данной функции.\n\nзашифрованные данные хранятся в течение 90 секунд и затем безвозвратно удаляются.\n\ncохранённые данные можно расшифровать только с помощью уникальных ключей шифрования из твоей ссылки на скачивание. кроме того, официальная кодовая база кобальта не предусматривает возможности чтения эти данные вне функций обработки.\n\nты всегда можешь посмотреть исходный код кобальта и убедиться, что всё так, как заявлено.", "ErrorYTUnavailable": "это видео недоступно, возможно оно ограничено по региону или доступу. попробуй другое!", "ErrorYTTryOtherCodec": "я не нашёл того, что мог бы скачать с твоими настройками. попробуй другой кодек или качество!", "SettingsCodecSubtitle": "кодек для видео с youtube", @@ -157,8 +156,8 @@ "DonateImageDescription": "кошка спит на клавиатуре ноутбука и многократно печатает буквы", "SettingsTwitterGif": "конвертировать гифки в .gif", "SettingsTwitterGifDescription": "конвертирование зацикленного видео в .gif снижает качество и значительно увеличивает размер файла. если важна максимальная эффективность, то не используй эту функцию.", - "UpdateTwitterGif": "гифки с твиттера и одноклассники", "ErrorTweetProtected": "этот твит из закрытого аккаунта, поэтому я не могу его увидеть. попробуй другой!", - "ErrorTweetNSFW": "этот твит содержит деликатный контент, поэтому я не могу его увидеть. попробуй другой!" + "ErrorTweetNSFW": "этот твит содержит деликатный контент, поэтому я не могу его увидеть. попробуй другой!", + "UpdateEncryption": "шифрование и новые сервисы" } } diff --git a/src/localization/manager.js b/src/localization/manager.js index 2b251fe3..c22f9500 100644 --- a/src/localization/manager.js +++ b/src/localization/manager.js @@ -19,6 +19,7 @@ export function replaceBase(s) { return s .replace(/\n/g, '
') .replace(/{saveToGalleryShortcut}/g, links.saveToGalleryShortcut) + .replace(/{saveToFilesShortcut}/g, links.saveToFilesShortcut) .replace(/{repo}/g, repo) .replace(/{statusPage}/g, links.statusPage) .replace(/\*;/g, "•"); diff --git a/src/modules/changelog/changelog.json b/src/modules/changelog/changelog.json index c37e796f..9633cae1 100644 --- a/src/modules/changelog/changelog.json +++ b/src/modules/changelog/changelog.json @@ -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 run-an-instance doc.\n*; updated cookie file example with more services and improved examples.\n*; updated docker compose example 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? learn how to do it here.\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 convert gifs from twitter 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 (and even pin.it links) are supported.\n*; added support for ok.ru in case you're a russian grandma.\n*; now processing all reddit links (including old.reddit.com).\n*; instagram live vods are now supported.\n*; fixed a rare vimeo bug 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 biblically correct and follows the html spec.\n*; lots of cleaning up.\n\npatches since 7.8:\n*; shift+key shortcuts are now ignored 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 modal blurriness 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", diff --git a/src/modules/pageRender/elements.js b/src/modules/pageRender/elements.js index a677d2bc..79129398 100644 --- a/src/modules/pageRender/elements.js +++ b/src/modules/pageRender/elements.js @@ -69,15 +69,17 @@ export function popup(obj) { } return ` ${obj.standalone ? ` - ')[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' }; +} diff --git a/src/modules/processing/services/dailymotion.js b/src/modules/processing/services/dailymotion.js new file mode 100644 index 00000000..1993ecad --- /dev/null +++ b/src/modules/processing/services/dailymotion.js @@ -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 + } +} \ No newline at end of file diff --git a/src/modules/processing/services/tumblr.js b/src/modules/processing/services/tumblr.js index 75d354e7..05c7fd84 100644 --- a/src/modules/processing/services/tumblr.js +++ b/src/modules/processing/services/tumblr.js @@ -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' } } diff --git a/src/modules/processing/services/twitter.js b/src/modules/processing/services/twitter.js index 7e01fab4..94b55eb1 100644 --- a/src/modules/processing/services/twitter.js +++ b/src/modules/processing/services/twitter.js @@ -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/) diff --git a/src/modules/processing/services/vimeo.js b/src/modules/processing/services/vimeo.js index db623bdb..f51f7220 100644 --- a/src/modules/processing/services/vimeo.js +++ b/src/modules/processing/services/vimeo.js @@ -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" } } diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index 804f5978..bdadb660 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -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 } } } diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index 970e8f40..f4dee15b 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -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, diff --git a/src/modules/processing/url.js b/src/modules/processing/url.js index 9c87889d..b272ff80 100644 --- a/src/modules/processing/url.js +++ b/src/modules/processing/url.js @@ -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///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 diff --git a/src/modules/setup.js b/src/modules/setup.js index f81a0053..80032fcd 100644 --- a/src/modules/setup.js +++ b/src/modules/setup.js @@ -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() }) }); diff --git a/src/modules/stream/manage.js b/src/modules/stream/manage.js index 459e5a6b..2e4cb0fe 100644 --- a/src/modules/stream/manage.js +++ b/src/modules/stream/manage.js @@ -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!" } }; } } diff --git a/src/modules/stream/types.js b/src/modules/stream/types.js index cdfb4a05..6a58058d 100644 --- a/src/modules/stream/types.js +++ b/src/modules/stream/types.js @@ -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') } diff --git a/src/modules/sub/alias-envs.js b/src/modules/sub/alias-envs.js new file mode 100644 index 00000000..24f6a856 --- /dev/null +++ b/src/modules/sub/alias-envs.js @@ -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]; + } +} diff --git a/src/modules/sub/crypto.js b/src/modules/sub/crypto.js index e8bf2f94..b3a0539b 100644 --- a/src/modules/sub/crypto.js +++ b/src/modules/sub/crypto.js @@ -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() ]) } diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 8f8678d3..bb21bbb4 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -11,7 +11,6 @@ const apiVar = { }, booleanOnly: ["isAudioOnly", "isNoTTWatermark", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] } -const forbiddenChars = ['}', '{', '(', ')', '\\', '>', '<', '^', '*', '!', '~', ';', ':', ',', '`', '[', ']', '#', '$', '"', "'", "@", '==']; const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; export function apiJSON(type, obj) { diff --git a/src/test/test.js b/src/test/test.js index 15b28cca..d8373ff7 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -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"; diff --git a/src/test/tests.json b/src/test/tests.json index a298d152..e404859f 100644 --- a/src/test/tests.json +++ b/src/test/tests.json @@ -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" + } }] }