diff --git a/.deepsource.toml b/.deepsource.toml index d6a19e95..e5ec308c 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,11 +1,7 @@ version = 1 -test_patterns = [ - "src/test/test.js" -] - [[analyzers]] name = "javascript" -enabled = true - [analyzers.meta] - environment = ["nodejs"] + + [analyzers.meta] + environment = ["nodejs"] \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b3064281..c98785d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,13 @@ FROM node:18-bullseye-slim WORKDIR /app -RUN apt-get update -RUN apt-get install -y git -RUN rm -rf /var/lib/apt/lists/* - COPY package*.json ./ -RUN npm install -RUN git clone -n https://github.com/wukko/cobalt.git --depth 1 && mv cobalt/.git ./ && rm -rf cobalt +RUN apt-get update && \ + apt-get install -y git python3 build-essential && \ + npm install && \ + apt purge --autoremove -y python3 build-essential && \ + rm -rf ~/.cache/ /var/lib/apt/lists/* COPY . . EXPOSE 9000 diff --git a/README.md b/README.md index 4e513a21..8beeb3ea 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # cobalt best way to save what you love: [cobalt.tools](https://cobalt.tools/) -![cobalt logo with repeated logo (double arrow) pattern background](https://raw.githubusercontent.com/wukko/cobalt/current/src/front/icons/pattern.png "cobalt logo with repeated logo (double arrow) pattern background") +![cobalt logo with repeated logo (double arrow) pattern background](/src/front/icons/pattern.png "cobalt logo with repeated logo (double arrow) pattern background") ## what's cobalt? cobalt is a media downloader that doesn't piss you off. it's fast, friendly, and doesn't have any bullshit that modern web is filled with: ***no ads, trackers, or invasive analytics***. @@ -43,7 +43,7 @@ this list is not final and keeps expanding over time. if support for a service y | service | notes or features | | :-------- | :----- | | instagram | supports photos, videos, and stories. lets you pick what to save from multi-media posts. | -| pinterest | supports videos and stories. | +| pinterest | supports photos, gifs, videos and stories. | | reddit | supports gifs and videos. | | snapchat | supports spotlights and stories. lets you pick what to save from stories. | | soundcloud | supports private links. | @@ -53,12 +53,15 @@ this list is not final and keeps expanding over time. if support for a service y | youtube | supports videos, music, and shorts. 8K, 4K, HDR, VR, and high FPS videos. rich metadata & dubs. h264/av1/vp9 codecs. | ## cobalt api -cobalt has an open api that you can use in projects *for completely free~*. it's easy and straightforward to use, [check out the docs](https://github.com/wukko/cobalt/blob/current/docs/api.md) to learn how to use it. +cobalt has an open api that you can use in your projects *for free~*. it's easy and straightforward to use, [check out the docs](/docs/api.md) to learn how to use it. -you can use the main api instance ([co.wuk.sh](https://co.wuk.sh/)) in your projects. +✅ you can use the main api instance ([co.wuk.sh](https://co.wuk.sh/)) in your **personal** projects. +❌ you cannot use the free api commercially (anywhere that's gated behind paywalls or ads). host your own instance for this. + +we reserve the right to restrict abusive/excessive access to the main instance api. ## how to run your own instance -if you want to run your own instance for whatever purpose, [follow this guide](https://github.com/wukko/cobalt/blob/current/docs/run-an-instance.md). +if you want to run your own instance for whatever purpose, [follow this guide](/docs/run-an-instance.md). it's *highly* recommended to use a docker compose method unless you run for developing/debugging purposes. ## sponsors @@ -70,7 +73,7 @@ cobalt is a tool for easing content downloads from internet and takes ***zero li cobalt is ***NOT*** a piracy tool and cannot be used as such. it can only download free, publicly accessible content. such content can be easily downloaded through any browser's dev tools. pressing one button is easier, so i made a convenient, ad-less tool for such repeated actions. ## cobalt license -cobalt code is licensed under [AGPL-3.0](https://github.com/wukko/cobalt/blob/current/LICENSE). +cobalt code is licensed under [AGPL-3.0](/LICENSE). 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. diff --git a/docs/api.md b/docs/api.md index d67122bd..57509669 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,7 +32,7 @@ Content-Type: application/json | `dubLang` | `boolean` | `true / false` | `false` | backend uses Accept-Language header for youtube video audio tracks when `true`. | | `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. | | `twitterGif` | `boolean` | `true / false` | `false` | changes whether twitter gifs are converted to .gif | -| `vimeoDash` | `boolean` | `true / false` | `false` | changes whether streamed file type is preferred for vimeo videos. | +| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. | ### response body variables | key | type | variables | @@ -59,7 +59,7 @@ from a successful call to `/api/json`. however, the parameters passed to it are and **unmodifiable** from your (the api client's) perspective, and can change between versions. 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). +[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 5ebdb635..73f3378d 100644 --- a/docs/examples/cookies.example.json +++ b/docs/examples/cookies.example.json @@ -2,7 +2,13 @@ "instagram": [ "mid=; ig_did=; csrftoken=; ds_user_id=; sessionid=" ], + "instagram_bearer": [ + "token=", "token=IGT:2:" + ], "reddit": [ "client_id=; client_secret=; refresh_token=" + ], + "twitter": [ + "auth_token=; ct0=" ] } diff --git a/docs/examples/docker-compose.example.yml b/docs/examples/docker-compose.example.yml index 89c84642..24212ae4 100644 --- a/docs/examples/docker-compose.example.yml +++ b/docs/examples/docker-compose.example.yml @@ -2,7 +2,7 @@ version: '3.5' services: cobalt-api: - image: ghcr.io/wukko/cobalt:7 + image: ghcr.io/imputnet/cobalt:7 restart: unless-stopped container_name: cobalt-api @@ -21,6 +21,8 @@ services: 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 and the lines under volume + # COOKIE_PATH: "/cookies.json" # see docs/run-an-instance.md for more information labels: - com.centurylinklabs.watchtower.scope=cobalt @@ -30,7 +32,7 @@ services: #- ./cookies.json:/cookies.json cobalt-web: - image: ghcr.io/wukko/cobalt:7 + image: ghcr.io/imputnet/cobalt:7 restart: unless-stopped container_name: cobalt-web diff --git a/docs/images/troubleshooting/clipboard/config.png b/docs/images/troubleshooting/clipboard/config.png new file mode 100644 index 00000000..b0c0a048 Binary files /dev/null and b/docs/images/troubleshooting/clipboard/config.png differ diff --git a/docs/images/troubleshooting/clipboard/risk.png b/docs/images/troubleshooting/clipboard/risk.png new file mode 100644 index 00000000..1948f0eb Binary files /dev/null and b/docs/images/troubleshooting/clipboard/risk.png differ diff --git a/docs/images/troubleshooting/clipboard/search.png b/docs/images/troubleshooting/clipboard/search.png new file mode 100644 index 00000000..95684ff4 Binary files /dev/null and b/docs/images/troubleshooting/clipboard/search.png differ diff --git a/docs/images/troubleshooting/clipboard/toggle.png b/docs/images/troubleshooting/clipboard/toggle.png new file mode 100644 index 00000000..32060dc7 Binary files /dev/null and b/docs/images/troubleshooting/clipboard/toggle.png differ diff --git a/docs/images/troubleshooting/clipboard/toggled.png b/docs/images/troubleshooting/clipboard/toggled.png new file mode 100644 index 00000000..6afa0ace Binary files /dev/null and b/docs/images/troubleshooting/clipboard/toggled.png differ diff --git a/docs/run-an-instance.md b/docs/run-an-instance.md index 204cff09..25145224 100644 --- a/docs/run-an-instance.md +++ b/docs/run-an-instance.md @@ -18,7 +18,7 @@ if you need help with installing docker, follow *only the first step* of these t ``` i'm using `nano` in this example, it may not be available in your distro. you can use any other text editor. -3. copy and paste the [sample config from here](https://github.com/wukko/cobalt/blob/current/docs/examples/docker-compose.example.yml) for either web or api instance (or both, if you wish) and edit it to your needs. +3. copy and paste the [sample config from here](examples/docker-compose.example.yml) for either web or api instance (or both, if you wish) and edit it to your needs. make sure to replace default URLs with your own or cobalt won't work correctly. 4. finally, start the cobalt container (from cobalt directory): @@ -26,7 +26,7 @@ if you need help with installing docker, follow *only the first step* of these t docker compose up -d ``` -if you want your instance to support services that require authentication to view public content, create `cookies.json` file in the same directory as `docker-compose.yml`. example cookies file [can be found here](https://github.com/wukko/cobalt/blob/current/docs/examples/cookies.example.json). +if you want your instance to support services that require authentication to view public content, create `cookies.json` file in the same directory as `docker-compose.yml`. example cookies file [can be found here](examples/cookies.example.json). cobalt package will update automatically thanks to watchtower. @@ -53,13 +53,15 @@ sudo service nscd start | variable name | default | example | description | |:----------------------|:----------|:------------------------|:------------| | `API_PORT` | `9000` | `9000` | changes port from which api server is accessible. | +| `API_LISTEN_ADDRESS` | `0.0.0.0` | `127.0.0.1` | changes address from which api server is accessible. **if you are using docker, you usually don't need to configure this.** | | `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. | -| `TIKTOK_DEVICE_INFO` | ➖ | *see below* | device info (including `iid` and `device_id`) for tiktok functionality. required for tiktok to work. | +| `TIKTOK_DEVICE_INFO` | ➖ | *see below* | device info (including `iid` and `device_id`) for tiktok functionality. required for tiktok to work. | +| `FREEBIND_CIDR` | ➖ | `2001:db8::/32` | IPv6 prefix used for randomly assigning addresses to cobalt requests. only supported on linux systems. for more info, see below. | \* the higher the nice value, the lower the priority. [read more here](https://en.wikipedia.org/wiki/Nice_(Unix)). @@ -85,6 +87,12 @@ you can compress the json to save space. if you're using a `.env` file then the TIKTOK_DEVICE_INFO='{"iid":"","device_id":"","channel":"googleplay","app_name":"musical_ly","version_code":"310503","device_platform":"android","device_type":"Redmi+7","os_version":"13"}' ``` +#### FREEBIND_CIDR +setting a `FREEBIND_CIDR` allows cobalt to pick a random IP for every download and use it for all +requests it makes for that particular download. to use freebind in cobalt, you need to follow its [setup instructions](https://github.com/imputnet/freebind.js?tab=readme-ov-file#setup) first. if you configure this option while running cobalt +in a docker container, you also need to set the `API_LISTEN_ADDRESS` env to `127.0.0.1`, and set +`network_mode` for the container to `host`. + ### variables for web | variable name | default | example | description | |:---------------------|:---------------------|:------------------------|:--------------------------------------------------------------------------------------| diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8241ef98..4c97511f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5,29 +5,33 @@ if any issues occur while using cobalt, you can fix many of them yourself. this document aims to provide guides on how to fix most complicated of them. use wiki navigation on right to jump between solutions. -## how to fix clipboard pasting in firefox +## how to fix clipboard pasting in older versions of firefox +``` +🎉 firefox finally supports pasting by default starting from version 125. + +👍 you don't need to follow this tutorial if you're on the latest version of firefox. +``` you can fix this issue by changing a single preference in `about:config`. ### steps to enable clipboard functionality 1. go to `about:config`: - - ![screenshot showing about:config entered into address bar](https://github.com/wukko/cobalt/assets/71202418/9ad78612-a372-4949-aeac-99dfc41e273c) + ![screenshot showing about:config entered into address bar](images/troubleshooting/clipboard/config.png) 2. if asked, read what firefox has to say and press "accept the risk and continue". ⚠ tinkering with other preferences may break your browser. **do not** edit them unless you know what you're doing. - ![screenshot showing about:config security warning that reads: "proceed with caution. changing advanced configuration preferences can impact firefox performance or security." lower there's a pre-checked checkbox that says: "warn me when i attempt to access these preferences". lowest element is a blue button that says "accept the risk and continue"](https://github.com/wukko/cobalt/assets/71202418/02328729-dbfe-4ea4-b2ca-7bcf1998c2ca) + ![screenshot showing about:config security warning that reads: "proceed with caution. changing advanced configuration preferences can impact firefox performance or security." lower there's a pre-checked checkbox that says: "warn me when i attempt to access these preferences". lowest element is a blue button that says "accept the risk and continue"](images/troubleshooting/clipboard/risk.png) 3. search for `dom.events.asyncClipboard.readText` - ![screenshot showing "dom.events.asyncclipboard.readtext" entered into search on about:config page](https://github.com/wukko/cobalt/assets/71202418/7c7f7e3c-6a6a-40df-8436-277489e72e0b) + ![screenshot showing "dom.events.asyncclipboard.readtext" entered into search on about:config page](images/troubleshooting/clipboard/search.png) 4. press the toggle button on very right. - ![screenshot showing "dom.events.asyncclipboard.readtext" preference on about:config page with highlighted toggle button on very right](https://github.com/wukko/cobalt/assets/71202418/b45db18e-f4bf-4f1c-9a8c-f13a63a21335) + ![screenshot showing "dom.events.asyncclipboard.readtext" preference on about:config page with highlighted toggle button on very right](images/troubleshooting/clipboard/toggle.png) 5. "false" should change to "true". - ![screenshot showing "dom.events.asyncclipboard.readtext" preference on about:config page, this one with "true" text highlighted](https://github.com/wukko/cobalt/assets/71202418/4869b4ff-8385-4cd3-ae59-aa2e03a58b5f) + ![screenshot showing "dom.events.asyncclipboard.readtext" preference on about:config page, this one with "true" text highlighted](images/troubleshooting/clipboard/toggled.png) -6. go back to cobalt, reload the page, press `paste and download` button again. this time it works! enjoy simpler downloading experience :) +6. go back to cobalt, reload the page, press `paste` button again. this time it works! enjoy simpler downloading experience :) diff --git a/package.json b/package.json index 8885f1f8..2daa403c 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "cobalt", "description": "save what you love", - "version": "7.12.4", - "author": "wukko", + "version": "7.13.3", + "author": "imput", "exports": "./src/cobalt.js", "type": "module", "engines": { @@ -17,13 +17,13 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/wukko/cobalt.git" + "url": "git+https://github.com/imputnet/cobalt.git" }, "license": "AGPL-3.0", "bugs": { - "url": "https://github.com/wukko/cobalt/issues" + "url": "https://github.com/imputnet/cobalt/issues" }, - "homepage": "https://github.com/wukko/cobalt#readme", + "homepage": "https://github.com/imputnet/cobalt#readme", "dependencies": { "content-disposition-header": "0.6.0", "cors": "^2.8.5", @@ -38,8 +38,11 @@ "node-cache": "^5.1.2", "psl": "1.9.0", "set-cookie-parser": "2.6.0", - "undici": "^6.7.0", + "undici": "^5.19.1", "url-pattern": "1.0.3", - "youtubei.js": "^9.2.0" + "youtubei.js": "^9.3.0" + }, + "optionalDependencies": { + "freebind": "^0.2.2" } } diff --git a/src/cobalt.js b/src/cobalt.js index 050aec46..473c9b5b 100644 --- a/src/cobalt.js +++ b/src/cobalt.js @@ -6,6 +6,7 @@ import express from "express"; import { Bright, Green, Red } from "./modules/sub/consoleText.js"; import { getCurrentBranch, shortCommit } from "./modules/sub/currentCommit.js"; import { loadLoc } from "./localization/manager.js"; +import { mode } from "./modules/config.js" import path from 'path'; import { fileURLToPath } from 'url'; @@ -22,13 +23,10 @@ app.disable('x-powered-by'); await loadLoc(); -const apiMode = process.env.API_URL && !process.env.WEB_URL; -const webMode = process.env.WEB_URL && process.env.API_URL; - -if (apiMode) { +if (mode === 'API') { const { runAPI } = await import('./core/api.js'); runAPI(express, app, gitCommit, gitBranch, __dirname) -} else if (webMode) { +} else if (mode === 'WEB') { const { runWeb } = await import('./core/web.js'); await runWeb(express, app, gitCommit, gitBranch, __dirname) } else { diff --git a/src/config.json b/src/config.json index f1aa4a2a..0a32d220 100644 --- a/src/config.json +++ b/src/config.json @@ -3,9 +3,6 @@ "maxVideoDuration": 10800000, "genericUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", "authorInfo": { - "name": "wukko", - "link": "https://wukko.me/", - "contact": "https://wukko.me/contacts", "support": { "default": { "email": { diff --git a/src/core/api.js b/src/core/api.js index eda3c014..31ed7dd5 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -4,18 +4,18 @@ import { randomBytes } from "crypto"; const ipSalt = randomBytes(64).toString('hex'); -import { version } from "../modules/config.js"; +import { env, version } from "../modules/config.js"; import { getJSON } from "../modules/api.js"; import { apiJSON, checkJSONPost, getIP, languageCode } from "../modules/sub/utils.js"; import { Bright, Cyan } from "../modules/sub/consoleText.js"; import stream from "../modules/stream/stream.js"; import loc from "../localization/manager.js"; import { generateHmac } from "../modules/sub/crypto.js"; -import { verifyStream } from "../modules/stream/manage.js"; +import { verifyStream, getInternalStream } from "../modules/stream/manage.js"; export function runAPI(express, app, gitCommit, gitBranch, __dirname) { - const corsConfig = process.env.CORS_WILDCARD === '0' ? { - origin: process.env.CORS_URL, + const corsConfig = !env.corsWildcard ? { + origin: env.corsURL, optionsSuccessStatus: 200 } : {}; @@ -25,7 +25,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { standardHeaders: true, legacyHeaders: false, keyGenerator: req => generateHmac(getIP(req), ipSalt), - handler: (req, res, next, opt) => { + handler: (req, res) => { return res.status(429).json({ "status": "rate-limit", "text": loc(languageCode(req), 'ErrorRateLimit') @@ -38,7 +38,7 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { standardHeaders: true, legacyHeaders: false, keyGenerator: req => generateHmac(getIP(req), ipSalt), - handler: (req, res, next, opt) => { + handler: (req, res) => { return res.status(429).json({ "status": "rate-limit", "text": loc(languageCode(req), 'ErrorRateLimit') @@ -123,42 +123,53 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { app.get('/api/:type', (req, res) => { try { + let j; switch (req.params.type) { case 'stream': 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) { + if (q.p) { + return res.status(200).json({ + status: "continue" + }) + } 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 (q.p) { - return res.status(200).json({ - status: "continue" - }); - } return stream(res, streamInfo); - } else { - let j = apiJSON(0, { - t: "bad request. stream link may be incomplete or corrupted." - }) - return res.status(j.status).json(j.body); - } + } + + j = apiJSON(0, { + t: "bad request. stream link may be incomplete or corrupted." + }) + return res.status(j.status).json(j.body); + case 'istream': + if (!req.ip.endsWith('127.0.0.1')) + return res.sendStatus(403); + if (('' + req.query.t).length !== 21) + return res.sendStatus(400); + + let streamInfo = getInternalStream(req.query.t); + if (!streamInfo) return res.sendStatus(404); + streamInfo.headers = req.headers; + + return stream(res, { type: 'internal', ...streamInfo }); case 'serverInfo': return res.status(200).json({ version: version, commit: gitCommit, branch: gitBranch, - name: process.env.API_NAME || "unknown", - url: process.env.API_URL, - cors: process.env?.CORS_WILDCARD === "0" ? 0 : 1, + name: env.apiName, + url: env.apiURL, + cors: Number(env.corsWildcard), startTime: `${startTimestamp}` }); default: - let j = apiJSON(0, { + j = apiJSON(0, { t: "unknown response type" }) return res.status(j.status).json(j.body); @@ -183,12 +194,12 @@ export function runAPI(express, app, gitCommit, gitBranch, __dirname) { res.redirect('/api/json') }); - app.listen(process.env.API_PORT || 9000, () => { + app.listen(env.apiPort, env.listenAddress, () => { console.log(`\n` + `${Cyan("cobalt")} API ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` + `Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` + - `URL: ${Cyan(`${process.env.API_URL}`)}\n` + - `Port: ${process.env.API_PORT || 9000}\n` + `URL: ${Cyan(`${env.apiURL}`)}\n` + + `Port: ${env.apiPort}\n` ) }); } diff --git a/src/core/web.js b/src/core/web.js index 7c0cbf33..4c1b1999 100644 --- a/src/core/web.js +++ b/src/core/web.js @@ -1,4 +1,4 @@ -import { genericUserAgent, version } from "../modules/config.js"; +import { version, env } from "../modules/config.js"; import { apiJSON, languageCode } from "../modules/sub/utils.js"; import { Bright, Cyan } from "../modules/sub/consoleText.js"; @@ -67,7 +67,7 @@ export async function runWeb(express, app, gitCommit, gitBranch, __dirname) { return res.status(200).end() }); app.get("/", (req, res) => { - return res.sendFile(`${__dirname}/${findRendered(languageCode(req), req.header('user-agent') ? req.header('user-agent') : genericUserAgent)}`) + return res.sendFile(`${__dirname}/${findRendered(languageCode(req))}`) }); app.get("/favicon.ico", (req, res) => { return res.sendFile(`${__dirname}/src/front/icons/favicon.ico`) @@ -76,12 +76,12 @@ export async function runWeb(express, app, gitCommit, gitBranch, __dirname) { return res.redirect('/') }); - app.listen(process.env.WEB_PORT || 9001, () => { + app.listen(env.webPort, () => { console.log(`\n` + `${Cyan("cobalt")} WEB ${Bright(`v.${version}-${gitCommit} (${gitBranch})`)}\n` + `Start time: ${Bright(`${startTime.toUTCString()} (${startTimestamp})`)}\n\n` + - `URL: ${Cyan(`${process.env.WEB_URL}`)}\n` + - `Port: ${process.env.WEB_PORT || 9001}\n` + `URL: ${Cyan(`${env.webURL}`)}\n` + + `Port: ${env.webPort}\n` ) }) } diff --git a/src/front/cobalt.css b/src/front/cobalt.css index 7b59a1bf..668d0eae 100644 --- a/src/front/cobalt.css +++ b/src/front/cobalt.css @@ -6,47 +6,15 @@ --inset-focus: 0 0 0 0.1rem var(--accent) inset; --inset-focus-inv: 0 0 0 0.15rem var(--background) inset; --font-mono: 'Noto Sans Mono', 'Consolas', 'SF Mono', monospace; - --padding-1: 0.75rem; + --padding: 0.7rem; + --padding-small: 0.2rem; + --padding-dialog: 18px; --line-height: 1.65rem; --red: rgb(249, 47, 96); --blue: rgb(47, 138, 249); --gap: 0.5rem; --gap-no-icon: 0.6rem; } -@media (prefers-color-scheme: dark) { - :root { - --accent: rgb(225, 225, 225); - --accent-highlight: rgb(225, 225, 225, 4%); - --accent-subtext: rgb(110, 110, 110); - --accent-hover: rgb(30, 30, 30); - --accent-hover-elevated: rgb(48, 48, 48); - --accent-hover-transparent: rgba(48, 48, 48, 0.5); - --accent-button: rgb(25, 25, 25); - --accent-button-elevated: rgb(42, 42, 42); - --glass: rgba(25, 25, 25, 0.85); - --glass-lite: rgba(25, 25, 25, 0.98); - --subbackground: rgb(10, 10, 10); - --background: rgb(0, 0, 0); - --background-backdrop: rgba(0, 0, 0, 0.5); - } -} -@media (prefers-color-scheme: light) { - :root { - --accent: rgb(25, 25, 25); - --accent-highlight: rgb(25, 25, 25, 4%); - --accent-subtext: rgb(110, 110, 110); - --accent-hover: rgb(225, 225, 225); - --accent-hover-elevated: rgb(210, 210, 210); - --accent-hover-transparent: rgba(215, 215, 215, 0.5); - --accent-button: rgb(232, 232, 232); - --accent-button-elevated: rgb(215, 215, 215); - --glass: rgba(232, 232, 232, 0.85); - --glass-lite: rgba(232, 232, 232, 0.98); - --subbackground: rgb(240, 240, 240); - --background: rgb(255, 255, 255); - --background-backdrop: rgba(255, 255, 255, 0.5); - } -} [data-theme="dark"] { --accent: rgb(225, 225, 225); --accent-highlight: rgb(225, 225, 225, 4%); @@ -79,6 +47,7 @@ } html, body { + height: calc(100% + env(safe-area-inset-top) / 2); margin: 0; background: var(--background); color: var(--accent); @@ -89,7 +58,6 @@ body { overflow: hidden; -ms-overflow-style: none; scrollbar-width: none; - height: calc(100% + env(safe-area-inset-top)/2); } #home { position: fixed; @@ -118,10 +86,10 @@ a { align-items: center; flex-direction: row; flex-wrap: nowrap; - padding: calc(var(--gap) - 0.1rem) calc(var(--gap)*2 - 0.2rem) calc(var(--gap) - 0.1rem) var(--gap); + padding: calc(var(--gap) - 0.1rem) calc(var(--gap)*2 - var(--padding-small)) calc(var(--gap) - 0.1rem) var(--gap); width: auto; - margin-right: var(--padding-1); - margin-bottom: var(--padding-1); + margin-right: var(--padding); + margin-bottom: var(--padding); background: var(--accent-button); } .checkbox-label { @@ -133,7 +101,7 @@ a { width: 20px; height: 20px; z-index: 0; - margin-right: var(--padding-1); + margin-right: var(--padding); border: 0.15rem solid var(--accent); } [type="checkbox"]::before { @@ -241,7 +209,7 @@ button:active, background: none; border: var(--border-15); color: var(--accent); - padding: 0.3rem 0.75rem 0.5rem; + padding: 0.3rem var(--padding) 0.5rem; font-size: 1rem; } .mono { @@ -307,7 +275,7 @@ button:active, #url-clear { height: 100%; background: none; - padding: 0 1rem 0.2rem; + padding: 0 1rem var(--padding-small); transform: none; font-size: 1rem; box-shadow: none!important; @@ -320,8 +288,8 @@ button:active, display: flex; position: absolute; width: 20px; - padding-top: 0.2rem; - left: 0.7rem; + padding-top: var(--padding-small); + left: var(--padding); flex-wrap: nowrap; color: var(--accent-subtext); } @@ -350,7 +318,7 @@ button:active, position: absolute; display: flex; justify-content: center; - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 2rem); + padding-bottom: 2rem; font-size: 0.9rem; text-align: center; } @@ -412,7 +380,7 @@ button:active, max-height: 95%; opacity: 0; transform: translate(-50%,-48%)scale(.95); - box-shadow: 0 0 0 0.2rem var(--glass) inset, + box-shadow: 0 0 0 var(--padding-small) var(--glass) inset, 0 0 20px 0 var(--accent-hover-transparent); } .popup.visible { @@ -442,15 +410,15 @@ button:active, .popup.small { width: 21rem; box-shadow: 0px 0px 60px 0px var(--accent-hover); - padding: 18px; - transform: translate(-50%,-50%)scale(.95); + padding: var(--padding-dialog); + transform: translate(-50%, -50%)scale(.95); pointer-events: all; border-radius: 22px; } .popup.small .popup-content-inner { display: flex; flex-direction: column; - gap: 18px; + gap: var(--padding-dialog); width: 100%; } .popup.small.visible { @@ -507,7 +475,7 @@ button:active, width: 100%; max-height: 300px; min-height: 210px; - margin-bottom: 0.7rem; + margin-bottom: var(--padding); float: left; background: var(--accent-hover); display: flex; @@ -521,13 +489,13 @@ button:active, .changelog-tags { display: inline-flex; align-items: center; - gap: 0.7rem; - padding-bottom: 0.7rem; + gap: var(--padding); + padding-bottom: var(--padding); flex-wrap: wrap; } .changelog-tag-version { font-size: 1rem; - padding: 0.15rem 0.7rem; + padding: 0.15rem 0.5rem; } .changelog-tag-date { color: var(--accent-subtext); @@ -540,11 +508,11 @@ button:active, padding-top: 0!important; } .desc-padding { - padding-bottom: 0.7rem; + padding-bottom: var(--padding); } #popup-subtitle { font-size: 1.1rem; - padding-bottom: var(--padding-1); + padding-bottom: var(--padding); } .popup-desc, .desc-error, @@ -562,9 +530,9 @@ button:active, } .popup-title { font-size: 1.5rem; - line-height: 1.2em; display: flex; align-items: center; + line-height: 1em; margin-bottom: 0.4rem; margin-top: 0.4rem; } @@ -581,12 +549,12 @@ button:active, .popup-content-inner, .tab-content-settings, #picker-holder { - padding-top: calc(env(safe-area-inset-top)/2 + 4.7rem); - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 4.8rem); + padding-top: calc(var(--padding) + 4rem); + padding-bottom: 4.8rem; } .tab-content-settings, #tab-about-about .popup-content-inner { - padding-top: calc(env(safe-area-inset-top)/2 + 6rem);; + padding-top: 6rem; } .bullpadding { padding-left: 0.58rem; @@ -594,11 +562,11 @@ button:active, .popup-header { position: absolute; z-index: 999; - padding-top: calc(env(safe-area-inset-top)/2 + 1.7rem); + padding-top: calc(var(--padding) + 1rem); width: 100%; } .settings-category { - padding-bottom: 0.7rem; + padding-bottom: var(--padding); } .separator { float: left; @@ -616,13 +584,13 @@ button:active, line-height: var(--line-height); } .bottom-margin { - margin-bottom: var(--padding-1)!important; + margin-bottom: var(--padding)!important; } .top-margin { - margin-top: var(--padding-1)!important; + margin-top: var(--padding)!important; } .top-margin-only { - margin-top: var(--padding-1)!important; + margin-top: var(--padding)!important; margin-bottom: 0!important; } .no-margin { @@ -668,7 +636,7 @@ button:active, cursor: pointer; } .switch.space-right { - margin-right: var(--padding-1); + margin-right: var(--padding); } .switch:focus { box-shadow: var(--inset-focus) inset; @@ -738,12 +706,12 @@ button:active, bottom: 0; position: absolute; width: 100%; - padding-top: 0.2rem; - padding-bottom: 1.7rem; + padding-top: var(--padding-small); + padding-bottom: calc(var(--padding) + 1rem); } .popup-tabs-child { width: 100%; - padding: 0 0.2rem; + padding: 0 var(--padding-small); } .emoji, svg { user-select: none; @@ -773,10 +741,10 @@ button:active, justify-content: start; flex-wrap: wrap; align-content: space-around; - padding-top: calc(env(safe-area-inset-top)/2 + 7.6rem); - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 4.8rem); - padding-left: 0.2rem; - padding-right: 0.2rem; + padding-top: 7.6rem; + padding-bottom: 4.8rem; + padding-left: var(--padding-small); + padding-right: var(--padding-small); } .imageBlock { width: 100%; @@ -795,7 +763,7 @@ button:active, } #popup-picker .explanation { margin-top: 0!important; - margin-bottom: var(--padding-1); + margin-bottom: var(--padding); } #cobalt-main-box #bottom button { width: auto; @@ -807,7 +775,7 @@ button:active, -webkit-user-select: none; } .collapse-header { - padding: 0.5rem var(--padding-1); + padding: 0.5rem var(--padding); font-size: 0.95rem; display: flex; flex-direction: row; @@ -816,7 +784,7 @@ button:active, background: var(--accent-button); } .collapse-header .emoji { - margin-right: var(--padding-1); + margin-right: var(--padding); } .collapse-indicator { display: flex; @@ -836,7 +804,7 @@ button:active, } .collapse-body { display: none; - padding: var(--padding-1); + padding: var(--padding); padding-bottom: 1rem; user-select: text; -webkit-user-select: text; @@ -863,7 +831,7 @@ button:active, display: flex; justify-content: center; align-items: center; - padding-top: calc(env(safe-area-inset-top) + 1rem); + padding-top: 1rem; } .urgent-text { display: flex; @@ -943,7 +911,7 @@ button:active, display: flex; justify-content: center; flex-wrap: wrap; - gap: 0.2rem 1rem; + gap: var(--padding-small) 1rem; margin-bottom: 1rem; } .sponsored-logo svg { @@ -963,7 +931,7 @@ button:active, align-items: center; justify-content: flex-start; gap: 1rem; - padding: 0.5rem 0.7rem; + padding: 0.5rem var(--padding); } .filename-item.line { border-bottom: 0.1rem solid var(--accent-button-elevated); @@ -1079,7 +1047,7 @@ button:active, } @media screen and (max-width: 660px) { #cobalt-main-box { - width: calc(100% - (0.7rem * 2)); + width: calc(100% - (var(--padding) * 2)); } } /* mobile page */ @@ -1101,14 +1069,11 @@ button:active, #cobalt-main-box #bottom #audioMode button, #audioMode { width: 100%; } - #footer { - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 1.5rem); - } #footer-buttons { flex-direction: column; align-items: stretch; width: 100%; - padding: 0 0.7rem; + padding: 0 var(--padding); } .footer-pair .footer-button { width: 100%!important; @@ -1125,9 +1090,6 @@ button:active, flex-direction: column; gap: var(--gap); } - .urgent-notice { - padding-top: calc(env(safe-area-inset-bottom)/2 + 1rem); - } .popup, .popup-header .glass-bkg, .popup-tabs .glass-bkg, @@ -1149,14 +1111,13 @@ button:active, transform: unset; } .popup.small { - width: calc(100% - 18px * 2); + width: calc(100% - var(--padding-dialog) * 2); height: auto; top: unset; bottom: 0; left: 0; transform: none; position: absolute; - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 1.7rem); transform: translateY(30rem); } #popup-download .popout-meowbalt { @@ -1199,14 +1160,106 @@ button:active, max-height: 100%; box-shadow: none; } - .popup-tabs { - padding-bottom: calc(env(safe-area-inset-bottom)/2 + 1.5rem); - } .popup-content-inner, .tab-content-settings, .popup-tabs-child, .popup-header-contents { - padding-left: 0.7rem; - padding-right: 0.7rem; + padding-left: var(--padding); + padding-right: var(--padding); + } + .popup-content-inner, + .tab-content-settings, + #picker-holder { + padding-bottom: calc(var(--padding) + 3.5rem); + padding-top: calc(var(--padding) + 3rem - var(--padding-small)); + } + #footer, + .popup-tabs { + padding-bottom: var(--padding); + } + .popup.small { + padding-bottom: var(--padding-dialog) + } + .urgent-notice { + padding-top: 1rem; + } + .popup-title { + margin-top: var(--padding-small); + } + .popup-header { + padding-top: var(--padding); + } + .tab-content-settings, + #tab-about-about .popup-content-inner { + padding-top: calc(5rem - var(--padding-small)); + } +} +@media screen and (max-width: 535px) and (display-mode: standalone) { + .popup-header { + padding-top: max( + calc(env(safe-area-inset-top)), + var(--padding) + 1rem + ); + } + .urgent-notice { + padding-top: max( + calc(env(safe-area-inset-top) - var(--padding-small)), + var(--padding) + ); + } + #footer, + .popup-tabs { + padding-bottom: max( + calc(env(safe-area-inset-bottom) + var(--padding-small)), + var(--padding) + ); + } + .popup.small { + padding-bottom: max( + calc(env(safe-area-inset-bottom) + var(--padding-small)), + var(--padding-dialog) + ); + } + .popup-content-inner, + .tab-content-settings { + padding-top: max( + calc(env(safe-area-inset-top) + var(--padding) + var(--padding-small) + 2rem), + calc(var(--padding) + 4rem - var(--padding-small)) + ); + padding-bottom: max( + calc(env(safe-area-inset-bottom) + var(--padding) + 3rem), + calc(var(--padding) + var(--padding-small) * 2 + 3rem) + ); + } + .tab-content-settings, + #tab-about-about .popup-content-inner { + padding-top: max( + calc(env(safe-area-inset-top) + var(--padding) + var(--padding-small) * 2 + 3rem), + calc(var(--padding) + 5rem) + ); + } + #picker-holder { + padding-top: max( + calc(env(safe-area-inset-top) + var(--padding) + 5rem), + calc(var(--padding) * 2 + 6rem) + ); + padding-bottom: max( + calc(env(safe-area-inset-bottom) + var(--padding) + 2rem), + calc(4rem - var(--padding) + var(--padding-small)) + ); + } + + .android .popup-header { + padding-top: var(--padding); + } + .android .popup-content-inner, + .android .tab-content-settings, + .android #picker-holder { + padding-bottom: calc(var(--padding) + 3.5rem); + padding-top: calc(var(--padding) + 3rem - var(--padding-small)); + } + .android .tab-content-settings, + .android #tab-about-about .popup-content-inner { + padding-top: calc(5rem - var(--padding-small)); } } diff --git a/src/front/cobalt.js b/src/front/cobalt.js index ad2e9e59..ef5b448c 100644 --- a/src/front/cobalt.js +++ b/src/front/cobalt.js @@ -1,25 +1,19 @@ -const version = 42; - const ua = navigator.userAgent.toLowerCase(); -const isIOS = ua.match("iphone os"); -const isMobile = ua.match("android") || ua.match("iphone os"); -const isSafari = ua.match("safari/"); -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 isIOS = ua.includes("iphone os") || (ua.includes("mac os") && navigator.maxTouchPoints > 0); +const isAndroid = ua.includes("android"); +const isMobile = ua.includes("android") || isIOS; +const isSafari = ua.includes("safari/"); +const isFirefox = ua.includes("firefox/"); +const isOldFirefox = ua.includes("firefox/") && ua.split("firefox/")[1].split('.')[0] < 103; const switchers = { "theme": ["auto", "light", "dark"], "vCodec": ["h264", "av1", "vp9"], - "vQuality": ["1080", "max", "2160", "1440", "720", "480", "360"], + "vQuality": ["720", "max", "2160", "1440", "1080", "480", "360", "240", "144"], "aFormat": ["mp3", "best", "ogg", "wav", "opus"], - "dubLang": ["original", "auto"], - "vimeoDash": ["false", "true"], "audioMode": ["false", "true"], "filenamePattern": ["classic", "pretty", "basic", "nerdy"] -}; +} const checkboxes = [ "alwaysVisibleButton", "downloadPopup", @@ -29,111 +23,216 @@ const checkboxes = [ "disableAnimations", "disableMetadata", "twitterGif", - "plausible_ignore" -]; -const exceptions = { // used for mobile devices - "vQuality": "720" -}; -const bottomPopups = ["error", "download"]; - -const pageQuery = new URLSearchParams(window.location.search); + "plausible_ignore", + "ytDub", + "tiktokH265" +] +const bottomPopups = ["error", "download"] let store = {}; -function fixApiUrl(url) { +const validLink = (link) => { + try { + return /^https:/i.test(new URL(link).protocol); + } catch { + return false + } +} + +const fixApiUrl = (url) => { return url.endsWith('/') ? url.slice(0, -1) : url } let apiURL = fixApiUrl(defaultApiUrl); -function changeApi(url) { +const changeApi = (url) => { apiURL = fixApiUrl(url); return true } -function eid(id) { + +const eid = (id) => { return document.getElementById(id) } -function sGet(id) { + +const sGet = (id) =>{ return localStorage.getItem(id) } -function sSet(id, value) { +const sSet = (id, value) => { localStorage.setItem(id, value) } -function enable(id) { - eid(id).dataset.enabled = "true"; +const lazyGet = (key) => { + const value = sGet(key); + if (key in switchers) { + if (switchers[key][0] !== value) + return value; + } else if (checkboxes.includes(key)) { + if (value === 'true') + return true; + } } -function disable(id) { - eid(id).dataset.enabled = "false"; -} -function vis(state) { - return (state === 1) ? "visible" : "hidden"; -} -function opposite(state) { - return state === "true" ? "false" : "true"; -} -function changeDownloadButton(action, text) { + +const changeDownloadButton = (action, text) => { switch (action) { - case 0: + case "hidden": // hidden, but only visible when alwaysVisibleButton is true eid("download-button").disabled = true if (sGet("alwaysVisibleButton") === "true") { - eid("download-button").value = text + eid("download-button").value = '>>' eid("download-button").style.padding = '0 1rem' } else { eid("download-button").value = '' eid("download-button").style.padding = '0' } break; - case 1: - eid("download-button").disabled = false - eid("download-button").value = text - eid("download-button").style.padding = '0 1rem' - break; - case 2: + case "disabled": eid("download-button").disabled = true eid("download-button").value = text eid("download-button").style.padding = '0 1rem' break; + default: + eid("download-button").disabled = false + eid("download-button").value = '>>' + eid("download-button").style.padding = '0 1rem' + break; } } -document.addEventListener("keydown", (event) => { - if (event.key === "Tab") { - eid("download-button").value = '>>' - eid("download-button").style.padding = '0 1rem' - } -}) -function button() { - let regexTest = regex.test(eid("url-input-area").value); + +const button = () => { + let regexTest = validLink(eid("url-input-area").value); + + eid("url-clear").style.display = "none"; + if ((eid("url-input-area").value).length > 0) { eid("url-clear").style.display = "block"; - } else { - eid("url-clear").style.display = "none"; } - regexTest ? changeDownloadButton(1, '>>') : changeDownloadButton(0, '>>'); + + if (regexTest) { + changeDownloadButton() + } else { + changeDownloadButton("hidden") + } } -function clearInput() { + +const clearInput = () => { eid("url-input-area").value = ''; button(); } -function copy(id, data) { - let e = document.getElementById(id); - e.classList.add("text-backdrop"); - setTimeout(() => { e.classList.remove("text-backdrop") }, 600); - data ? navigator.clipboard.writeText(data) : navigator.clipboard.writeText(e.innerText); + +const copy = (id, data) => { + let target = document.getElementById(id); + target.classList.add("text-backdrop"); + + setTimeout(() => { + target.classList.remove("text-backdrop") + }, 600); + + if (data) { + navigator.clipboard.writeText(data) + } else { + navigator.clipboard.writeText(e.innerText) + } } -async function share(url) { - try { await navigator.share({url: url}) } catch (e) {} -} -function detectColorScheme() { + +const share = url => navigator?.share({ url }).catch(() => {}); + +const preferredColorScheme = () => { let theme = "auto"; let localTheme = sGet("theme"); + let isLightPreferred = false; + if (localTheme) { theme = localTheme; - } else if (!window.matchMedia) { - theme = "dark" } - document.documentElement.setAttribute("data-theme", theme); + if (window.matchMedia) { + isLightPreferred = window.matchMedia('(prefers-color-scheme: light)').matches; + } + if (theme === "auto") { + theme = isLightPreferred ? "light" : "dark" + } + + return theme } -function changeTab(evnt, tabId, tabClass) { + +const changeStatusBarColor = () => { + const theme = preferredColorScheme(); + const colors = { + "dark": "#000000", + "light": "#ffffff", + "dark-popup": "#151515", + "light-popup": "#ebebeb" + } + + let state = store.isPopupOpen ? "dark-popup" : "dark"; + + if (theme === "light") { + state = store.isPopupOpen ? "light-popup" : "light"; + } + + document.querySelector('meta[name="theme-color"]').setAttribute('content', colors[state]); +} +const detectColorScheme = () => { + document.documentElement.setAttribute("data-theme", preferredColorScheme()); + changeStatusBarColor(); +} + +if (window.matchMedia) { + window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { + changeStatusBarColor() + detectColorScheme() + }) +} + +const updateFilenamePreview = () => { + let videoFilePreview = ``; + let audioFilePreview = ``; + let resMatch = { + "max": "3840x2160", + "2160": "3840x2160", + "1440": "2560x1440", + "1080": "1920x1080", + "720": "1280x720", + "480": "854x480", + "360": "640x360", + } + + switch(sGet("filenamePattern")) { + case "classic": + videoFilePreview = `youtube_dQw4w9WgXcQ_${resMatch[sGet('vQuality')]}_${sGet('vCodec')}` + + `${sGet("muteAudio") === "true" ? "_mute" : ""}` + + `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; + audioFilePreview = `youtube_dQw4w9WgXcQ_audio` + + `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; + break; + case "basic": + videoFilePreview = `${loc.FilenamePreviewVideoTitle} ` + + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ` + + `${sGet('vCodec')}${sGet("muteAudio") === "true" ? ", mute" : ""})` + + `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; + audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor}` + + `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; + break; + case "pretty": + videoFilePreview = `${loc.FilenamePreviewVideoTitle} ` + + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, ` + + `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube)` + + `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; + audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} (soundcloud)` + + `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; + break; + case "nerdy": + videoFilePreview = `${loc.FilenamePreviewVideoTitle} ` + + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, ` + + `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube, dQw4w9WgXcQ)` + + `.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; + audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} ` + + `(soundcloud, 1242868615)` + + `.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; + break; + } + eid("video-filename-text").innerHTML = videoFilePreview + eid("audio-filename-text").innerHTML = audioFilePreview +} + +const changeTab = (evnt, tabId, tabClass) => { if (tabId === "tab-settings-other") updateFilenamePreview(); let tabcontent = document.getElementsByClassName(`tab-content-${tabClass}`); @@ -149,46 +248,15 @@ function changeTab(evnt, tabId, tabClass) { evnt.currentTarget.dataset.enabled = "true"; eid(tabId).dataset.enabled = "true"; eid(tabId).parentElement.scrollTop = 0; - - if (tabId === "tab-about-changelog" && sGet("changelogStatus") !== `${version}`) notificationCheck("changelog"); - if (tabId === "tab-about-about" && !sGet("seenAbout")) notificationCheck("about"); } -function expandCollapsible(evnt) { + +const expandCollapsible = (evnt) => { let classlist = evnt.currentTarget.parentNode.classList; let c = "expanded"; !classlist.contains(c) ? classlist.add(c) : classlist.remove(c); } -function notificationCheck(type) { - let changed = true; - switch (type) { - case "about": - sSet("seenAbout", "true"); - break; - case "changelog": - sSet("changelogStatus", version) - break; - default: - changed = false; - } - if (changed && sGet("changelogStatus") === `${version}`) { - setTimeout(() => { - eid("about-footer").innerHTML = eid("about-footer").innerHTML.replace(notification, ''); - eid("tab-button-about-changelog").innerHTML = eid("tab-button-about-changelog").innerHTML.replace(notification, '') - }, 900) - } - if (!sGet("seenAbout") && !eid("about-footer").innerHTML.includes(notification)) { - eid("about-footer").innerHTML = `${notification}${eid("about-footer").innerHTML}`; - } - if (sGet("changelogStatus") !== `${version}`) { - if (!eid("about-footer").innerHTML.includes(notification)) { - eid("about-footer").innerHTML = `${notification}${eid("about-footer").innerHTML}`; - } - if (!eid("tab-button-about-changelog").innerHTML.includes(notification)) { - eid("tab-button-about-changelog").innerHTML = `${notification}${eid("tab-button-about-changelog").innerHTML}`; - } - } -} -function hideAllPopups() { + +const hideAllPopups = () => { let filter = document.getElementsByClassName('popup'); for (let i = 0; i < filter.length; i++) { filter[i].classList.remove("visible"); @@ -201,13 +269,17 @@ function hideAllPopups() { eid("picker-download").href = '/'; eid("picker-download").classList.remove("visible"); } -function popup(type, action, text) { + +const popup = (type, action, text) => { if (action === 1) { hideAllPopups(); // hide the previous popup before showing a new one store.isPopupOpen = true; + + // if not a small popup, update status bar color to match the popup header + if (!bottomPopups.includes(type)) changeStatusBarColor(); switch (type) { case "about": - let tabId = sGet("changelogStatus") !== `${version}` ? "changelog" : "about"; + let tabId = "about"; if (text) tabId = text; eid(`tab-button-${type}-${tabId}`).click(); break; @@ -265,36 +337,54 @@ function popup(type, action, text) { } } else { store.isPopupOpen = false; + + // reset status bar to base color + changeStatusBarColor(); + if (type === "picker") { eid("picker-download").href = '/'; eid("picker-download").classList.remove("visible"); eid("picker-holder").innerHTML = '' } } - if (bottomPopups.includes(type)) eid(`popup-${type}-container`).classList.toggle("visible"); + if (bottomPopups.includes(type)) { + eid(`popup-${type}-container`).classList.toggle("visible"); + } eid("popup-backdrop").classList.toggle("visible"); eid(`popup-${type}`).classList.toggle("visible"); eid(`popup-${type}`).focus(); } -function changeSwitcher(li, b) { - if (b) { - if (!switchers[li].includes(b)) b = switchers[li][0]; - sSet(li, b); - for (let i in switchers[li]) { - (switchers[li][i] === b) ? enable(`${li}-${b}`) : disable(`${li}-${switchers[li][i]}`) + +const changeSwitcher = (switcher, state) => { + if (state) { + if (!switchers[switcher].includes(state)) { + state = switchers[switcher][0]; } - if (li === "theme") detectColorScheme(); - if (li === "filenamePattern") updateFilenamePreview(); + sSet(switcher, state); + + for (let i in switchers[switcher]) { + if (switchers[switcher][i] === state) { + eid(`${switcher}-${state}`).dataset.enabled = "true"; + } else { + eid(`${switcher}-${switchers[switcher][i]}`).dataset.enabled = "false"; + } + } + if (switcher === "theme") detectColorScheme(); + if (switcher === "filenamePattern") updateFilenamePreview(); } else { - let pref = switchers[li][0]; - if (isMobile && exceptions[li]) pref = exceptions[li]; - sSet(li, pref); - for (let i in switchers[li]) { - (switchers[li][i] === pref) ? enable(`${li}-${pref}`) : disable(`${li}-${switchers[li][i]}`) + let defaultValue = switchers[switcher][0]; + sSet(switcher, defaultValue); + for (let i in switchers[switcher]) { + if (switchers[switcher][i] === defaultValue) { + eid(`${switcher}-${defaultValue}`).dataset.enabled = "true"; + } else { + eid(`${switcher}-${switchers[switcher][i]}`).dataset.enabled = "false"; + } } } } -function checkbox(action) { + +const checkbox = (action) => { sSet(action, !!eid(action).checked); switch(action) { case "alwaysVisibleButton": button(); break; @@ -302,43 +392,158 @@ function checkbox(action) { case "disableAnimations": eid("cobalt-body").classList.toggle('no-animation'); break; } } -function changeButton(type, text) { + +const changeButton = (type, text) => { switch (type) { - case 0: //error + case "error": //error eid("url-input-area").disabled = false eid("url-clear").style.display = "block"; - changeDownloadButton(2, '!!'); + changeDownloadButton("disabled", '!!'); popup("error", 1, text); - setTimeout(() => { changeButton(1); }, 2500); + setTimeout(() => { changeButton("default") }, 2500); break; - case 1: //enable back - changeDownloadButton(1, '>>'); + case "default": //enable back + changeDownloadButton(); eid("url-clear").style.display = "block"; eid("url-input-area").disabled = false break; - case 2: //enable back + information popup + case "error-default": //enable back + information popup popup("error", 1, text); - changeDownloadButton(1, '>>'); + changeDownloadButton(); eid("url-clear").style.display = "block"; eid("url-input-area").disabled = false break; } } -function internetError() { + +const internetError = () => { eid("url-input-area").disabled = false - changeDownloadButton(2, '!!'); - setTimeout(() => { changeButton(1); }, 2500); + changeDownloadButton("disabled", '!!'); + setTimeout(() => { changeButton("default") }, 2500); popup("error", 1, loc.ErrorNoInternet); } -function resetSettings() { + +const resetSettings = () => { localStorage.clear(); window.location.reload(); } -async function pasteClipboard() { + +const download = async(url) => { + changeDownloadButton("disabled", '...'); + + eid("url-clear").style.display = "none"; + eid("url-input-area").disabled = true; + + let req = { + url, + vCodec: lazyGet("vCodec"), + vQuality: lazyGet("vQuality"), + aFormat: lazyGet("aFormat"), + filenamePattern: lazyGet("filenamePattern"), + isAudioOnly: lazyGet("audioMode"), + isTTFullAudio: lazyGet("fullTikTokAudio"), + isAudioMuted: lazyGet("muteAudio"), + disableMetadata: lazyGet("disableMetadata"), + dubLang: lazyGet("ytDub"), + twitterGif: lazyGet("twitterGif"), + tiktokH265: lazyGet("tiktokH265"), + } + + let j = await fetch(`${apiURL}/api/json`, { + method: "POST", + body: JSON.stringify(req), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + }).then(r => r.json()).catch(() => {}); + + if (!j) { + internetError(); + return; + } + + if ((j.status === "error" || j.status === "rate-limit") && j && j.text) { + changeButton("error", j.text); + return; + } + + if (j.text && (!j.url || !j.picker)) { + if (j.status === "success") { + changeButton("error-default", j.text) + } else { + changeButton("error", loc.ErrorNoUrlReturned); + } + } + switch (j.status) { + case "redirect": + changeDownloadButton("disabled", '>>>'); + setTimeout(() => { changeButton("default") }, 1500); + + if (sGet("downloadPopup") === "true") { + popup('download', 1, j.url) + } else { + window.open(j.url, '_blank') + } + break; + case "stream": + changeDownloadButton("disabled", '?..'); + + let probeStream = await fetch(`${j.url}&p=1`).then(r => r.json()).catch(() => {}); + if (!probeStream) return internetError(); + + if (probeStream.status !== "continue") { + changeButton("error", probeStream.text); + return; + } + + changeDownloadButton("disabled", '>>>'); + if (sGet("downloadPopup") === "true") { + popup('download', 1, j.url) + } else { + if (isMobile || isSafari) { + window.location.href = j.url; + } else { + window.open(j.url, '_blank'); + } + } + setTimeout(() => { changeButton("default") }, 2500); + break; + case "picker": + if (j.audio && j.picker) { + changeDownloadButton("disabled", '>>>'); + popup('picker', 1, { + audio: j.audio, + arr: j.picker, + type: j.pickerType + }); + setTimeout(() => { changeButton("default") }, 2500); + } else if (j.picker) { + changeDownloadButton("disabled", '>>>'); + popup('picker', 1, { + arr: j.picker, + type: j.pickerType + }); + setTimeout(() => { changeButton("default") }, 2500); + } else { + changeButton("error", loc.ErrorNoUrlReturned); + } + break; + case "success": + changeButton("error-default", j.text); + break; + default: + changeButton("error", loc.ErrorUnknownStatus); + break; + } +} + +const pasteClipboard = async() => { try { - let t = await navigator.clipboard.readText(); - if (regex.test(t)) { - eid("url-input-area").value = t; + let clipboard = await navigator.clipboard.readText(); + let onlyURL = clipboard.match(/https:\/\/[^\s]+/g) + if (onlyURL) { + eid("url-input-area").value = onlyURL; download(eid("url-input-area").value); } } catch (e) { @@ -353,204 +558,58 @@ async function pasteClipboard() { if (doError) popup("error", 1, errorMessage); } } -async function download(url) { - changeDownloadButton(2, '...'); - eid("url-clear").style.display = "none"; - eid("url-input-area").disabled = true; - let req = { - url, - aFormat: sGet("aFormat").slice(0, 4), - filenamePattern: sGet("filenamePattern"), - dubLang: false - } - if (sGet("dubLang") === "auto") { - req.dubLang = true - } else if (sGet("dubLang") === "custom") { - req.dubLang = true - } - if (sGet("vimeoDash") === "true") req.vimeoDash = true; - if (sGet("audioMode") === "true") { - req.isAudioOnly = true; - if (sGet("fullTikTokAudio") === "true") req.isTTFullAudio = true; // audio tiktok full - } else { - req.vQuality = sGet("vQuality").slice(0, 4); - if (sGet("muteAudio") === "true") req.isAudioMuted = true; - if (url.includes("youtube.com/") || url.includes("/youtu.be/")) req.vCodec = sGet("vCodec").slice(0, 4); - } - if (sGet("disableMetadata") === "true") req.disableMetadata = true; - if (sGet("twitterGif") === "true") req.twitterGif = true; - - let j = await fetch(`${apiURL}/api/json`, { - method: "POST", - body: JSON.stringify(req), - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } - }).then((r) => { return r.json() }).catch((e) => { return false }); - if (!j) { - internetError(); - return - } - - if (j && j.status !== "error" && j.status !== "rate-limit") { - if (j.text && (!j.url || !j.picker)) { - if (j.status === "success") { - changeButton(2, j.text) - } else changeButton(0, loc.ErrorNoUrlReturned); - } - switch (j.status) { - case "redirect": - changeDownloadButton(2, '>>>'); - setTimeout(() => { changeButton(1); }, 1500); - sGet("downloadPopup") === "true" ? popup('download', 1, j.url) : window.open(j.url, '_blank'); - break; - case "picker": - if (j.audio && j.picker) { - changeDownloadButton(2, '>>>'); - popup('picker', 1, { audio: j.audio, arr: j.picker, type: j.pickerType }); - setTimeout(() => { changeButton(1) }, 2500); - } else if (j.picker) { - changeDownloadButton(2, '>>>'); - popup('picker', 1, { arr: j.picker, type: j.pickerType }); - setTimeout(() => { changeButton(1) }, 2500); - } else { - changeButton(0, loc.ErrorNoUrlReturned); - } - break; - case "stream": - changeDownloadButton(2, '?..') - fetch(`${j.url}&p=1`).then(async (res) => { - let jp = await res.json(); - if (jp.status === "continue") { - changeDownloadButton(2, '>>>'); - if (sGet("downloadPopup") === "true") { - popup('download', 1, j.url) - } else { - if (isMobile || isSafari) { - window.location.href = j.url; - } else window.open(j.url, '_blank'); - } - setTimeout(() => { changeButton(1) }, 2500); - } else { - changeButton(0, jp.text); - } - }).catch((error) => internetError()); - break; - case "success": - changeButton(2, j.text); - break; - default: - changeButton(0, loc.ErrorUnknownStatus); - break; - } - } else if (j && j.text) { - changeButton(0, j.text); - } -} -async function loadCelebrationsEmoji() { - let bac = eid("about-footer").innerHTML; +const loadCelebrationsEmoji = async() => { + let aboutButtonBackup = eid("about-footer").innerHTML; try { - let j = await fetch(`/onDemand?blockId=1`).then((r) => { if (r.status === 200) { return r.json() } else { return false } }).catch(() => { return false }); + let j = await fetch(`/onDemand?blockId=1`).then(r => r.json()).catch(() => {}); + if (j && j.status === "success" && j.text) { - eid("about-footer").innerHTML = eid("about-footer").innerHTML.replace('🐲', j.text); + eid("about-footer").innerHTML = eid("about-footer").innerHTML.replace( + ``, + j.text + ) } - } catch (e) { - eid("about-footer").innerHTML = bac; + } catch { + eid("about-footer").innerHTML = aboutButtonBackup; } } -async function loadOnDemand(elementId, blockId) { - let j = {}; + +const loadOnDemand = async(elementId, blockId) => { store.historyButton = eid(elementId).innerHTML; eid(elementId).innerHTML = `
...
`; try { - if (store.historyContent) { - j = store.historyContent; - } else { - await fetch(`/onDemand?blockId=${blockId}`).then(async(r) => { - j = await r.json(); - if (j && j.status === "success") { - store.historyContent = j; - } else throw new Error(); - }).catch(() => { throw new Error() }); + if (!store.historyContent) { + let j = await fetch(`/onDemand?blockId=${blockId}`).then(r => r.json()).catch(() => {}); + if (!j) throw new Error(); + + if (j.status === "success") { + store.historyContent = j.text + } } - if (j.text) { - eid(elementId).innerHTML = `${j.text}`; - } else throw new Error() - } catch (e) { + eid(elementId).innerHTML = + ` + ${store.historyContent}`; + } catch { eid(elementId).innerHTML = store.historyButton; internetError() } } -function restoreUpdateHistory() { + +const restoreUpdateHistory = () => { eid("changelog-history").innerHTML = store.historyButton; } -function unpackSettings(b64) { - let changed = null; - try { - let settingsToImport = JSON.parse(atob(b64)); - let currentSettings = JSON.parse(JSON.stringify(localStorage)); - for (let s in settingsToImport) { - if (checkboxes.includes(s) && (settingsToImport[s] === "true" || settingsToImport[s] === "false") - && currentSettings[s] !== settingsToImport[s]) { - sSet(s, settingsToImport[s]); - changed = true - } - if (switchers[s] && switchers[s].includes(settingsToImport[s]) - && currentSettings[s] !== settingsToImport[s]) { - sSet(s, settingsToImport[s]); - changed = true - } - } - } catch (e) { - changed = false; - } - return changed -} -function updateFilenamePreview() { - let videoFilePreview = ``; - let audioFilePreview = ``; - let resMatch = { - "max": "3840x2160", - "2160": "3840x2160", - "1440": "2560x1440", - "1080": "1920x1080", - "720": "1280x720", - "480": "854x480", - "360": "640x360", - } - // "dubLang" - // sGet("muteAudio") === "true" - switch(sGet("filenamePattern")) { - case "classic": - videoFilePreview = `youtube_yPYZpwSpKmA_${resMatch[sGet('vQuality')]}_${sGet('vCodec')}` - + `${sGet("muteAudio") === "true" ? "_mute" : ""}.${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; - audioFilePreview = `youtube_yPYZpwSpKmA_audio.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; - break; - case "pretty": - videoFilePreview = - `${loc.FilenamePreviewVideoTitle} ` - + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, ` - + `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube).${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; - audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} (soundcloud).${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; - break; - case "basic": - videoFilePreview = - `${loc.FilenamePreviewVideoTitle} ` - + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}${sGet("muteAudio") === "true" ? " mute" : ""}).${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; - audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor}.${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; - break; - case "nerdy": - videoFilePreview = - `${loc.FilenamePreviewVideoTitle} ` - + `(${sGet('vQuality') === "max" ? "2160p" : `${sGet('vQuality')}p`}, ${sGet('vCodec')}, ` - + `${sGet("muteAudio") === "true" ? "mute, " : ""}youtube, yPYZpwSpKmA).${sGet('vCodec') === "vp9" ? 'webm' : 'mp4'}`; - audioFilePreview = `${loc.FilenamePreviewAudioTitle} - ${loc.FilenamePreviewAudioAuthor} (soundcloud, 1242868615).${sGet('aFormat') !== "best" ? sGet('aFormat') : 'opus'}`; - break; - } - eid("video-filename-text").innerHTML = videoFilePreview - eid("audio-filename-text").innerHTML = audioFilePreview -} -function loadSettings() { + +const loadSettings = () => { if (sGet("alwaysVisibleButton") === "true") { eid("alwaysVisibleButton").checked = true; eid("download-button").value = '>>' @@ -565,6 +624,17 @@ function loadSettings() { if (sGet("disableAnimations") === "true") { eid("cobalt-body").classList.add('no-animation'); } + if (!isMobile) { + eid("cobalt-body").classList.add('desktop'); + } + if (isAndroid) { + eid("cobalt-body").classList.add('android'); + } + if (isIOS) { + eid("download-switcher") + .querySelector(".explanation") + .innerHTML = loc.DownloadPopupDescriptionIOS; + } for (let i = 0; i < checkboxes.length; i++) { try { if (sGet(checkboxes[i]) === "true") eid(checkboxes[i]).checked = true; @@ -578,13 +648,14 @@ function loadSettings() { } updateFilenamePreview() } + window.onload = () => { loadCelebrationsEmoji(); loadSettings(); detectColorScheme(); - changeDownloadButton(0, '>>'); + changeDownloadButton("hidden"); eid("url-input-area").value = ""; if (isIOS) { @@ -595,37 +666,32 @@ window.onload = () => { eid("home").style.visibility = 'visible'; eid("home").classList.toggle("visible"); - if (pageQuery.has("u") && regex.test(pageQuery.get("u"))) { + const pageQuery = new URLSearchParams(window.location.search); + if (pageQuery.has("u") && validLink(pageQuery.get("u"))) { eid("url-input-area").value = pageQuery.get("u"); button() } - if (pageQuery.has("migration")) { - if (pageQuery.has("settingsData") && !sGet("migrated")) { - let setUn = unpackSettings(pageQuery.get("settingsData")); - if (setUn !== null) { - if (setUn) { - sSet("migrated", "true") - } - } - } - loadSettings(); - detectColorScheme(); - } window.history.replaceState(null, '', window.location.pathname); - notificationCheck(); - // fix for animations not working in Safari if (isIOS) { document.addEventListener('touchstart', () => {}, true); } } -eid("url-input-area").addEventListener("keydown", (e) => { + +eid("url-input-area").addEventListener("keydown", () => { button(); }) eid("url-input-area").addEventListener("keyup", (e) => { if (e.key === 'Enter') eid("download-button").click(); }) + +document.addEventListener("keydown", (event) => { + if (event.key === "Tab") { + eid("download-button").value = '>>' + eid("download-button").style.padding = '0 1rem' + } +}) document.onkeydown = (e) => { if (!store.isPopupOpen) { if (e.metaKey || e.ctrlKey || e.key === "/") eid("url-input-area").focus(); diff --git a/src/front/emoji/3d/cat_crying.svg b/src/front/emoji/3d/cat_crying.svg deleted file mode 100644 index afa45138..00000000 --- a/src/front/emoji/3d/cat_crying.svg +++ /dev/null @@ -1,382 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/emoji/3d/cat_flabbergasted.svg b/src/front/emoji/3d/cat_flabbergasted.svg deleted file mode 100644 index 84c9feda..00000000 --- a/src/front/emoji/3d/cat_flabbergasted.svg +++ /dev/null @@ -1,507 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/emoji/3d/cat_grin.svg b/src/front/emoji/3d/cat_grin.svg deleted file mode 100644 index be6e29d0..00000000 --- a/src/front/emoji/3d/cat_grin.svg +++ /dev/null @@ -1,345 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/emoji/3d/cat_smile.svg b/src/front/emoji/3d/cat_smile.svg deleted file mode 100644 index e50cbfa0..00000000 --- a/src/front/emoji/3d/cat_smile.svg +++ /dev/null @@ -1,345 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/emoji/3d/film_frames.png b/src/front/emoji/3d/film_frames.png new file mode 100644 index 00000000..6522c5f7 Binary files /dev/null and b/src/front/emoji/3d/film_frames.png differ diff --git a/src/front/emoji/3d/film_frames.svg b/src/front/emoji/3d/film_frames.svg deleted file mode 100644 index 4caf8736..00000000 --- a/src/front/emoji/3d/film_frames.svg +++ /dev/null @@ -1,338 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/emoji/3d/headphone.png b/src/front/emoji/3d/headphone.png new file mode 100644 index 00000000..b46173a2 Binary files /dev/null and b/src/front/emoji/3d/headphone.png differ diff --git a/src/front/emoji/3d/headphone.svg b/src/front/emoji/3d/headphone.svg deleted file mode 100644 index 90fc6cd6..00000000 --- a/src/front/emoji/3d/headphone.svg +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/front/icons/maskable/128.png b/src/front/icons/maskable/128.png new file mode 100644 index 00000000..e8213cfe Binary files /dev/null and b/src/front/icons/maskable/128.png differ diff --git a/src/front/icons/maskable/192.png b/src/front/icons/maskable/192.png new file mode 100644 index 00000000..8268d89a Binary files /dev/null and b/src/front/icons/maskable/192.png differ diff --git a/src/front/icons/maskable/384.png b/src/front/icons/maskable/384.png new file mode 100644 index 00000000..483e42ff Binary files /dev/null and b/src/front/icons/maskable/384.png differ diff --git a/src/front/icons/maskable/48.png b/src/front/icons/maskable/48.png new file mode 100644 index 00000000..02a5bca0 Binary files /dev/null and b/src/front/icons/maskable/48.png differ diff --git a/src/front/icons/maskable/512.png b/src/front/icons/maskable/512.png new file mode 100644 index 00000000..bb4af2f3 Binary files /dev/null and b/src/front/icons/maskable/512.png differ diff --git a/src/front/icons/maskable/72.png b/src/front/icons/maskable/72.png new file mode 100644 index 00000000..903f6bd5 Binary files /dev/null and b/src/front/icons/maskable/72.png differ diff --git a/src/front/icons/maskable/96.png b/src/front/icons/maskable/96.png new file mode 100644 index 00000000..c4b1ae60 Binary files /dev/null and b/src/front/icons/maskable/96.png differ diff --git a/src/front/icons/maskable/x128.png b/src/front/icons/maskable/x128.png deleted file mode 100644 index 49a3370e..00000000 Binary files a/src/front/icons/maskable/x128.png and /dev/null differ diff --git a/src/front/icons/maskable/x1280.png b/src/front/icons/maskable/x1280.png deleted file mode 100644 index 7aff5da7..00000000 Binary files a/src/front/icons/maskable/x1280.png and /dev/null differ diff --git a/src/front/icons/maskable/x192.png b/src/front/icons/maskable/x192.png deleted file mode 100644 index 0d981b6e..00000000 Binary files a/src/front/icons/maskable/x192.png and /dev/null differ diff --git a/src/front/icons/maskable/x384.png b/src/front/icons/maskable/x384.png deleted file mode 100644 index e63b50d5..00000000 Binary files a/src/front/icons/maskable/x384.png and /dev/null differ diff --git a/src/front/icons/maskable/x48.png b/src/front/icons/maskable/x48.png deleted file mode 100644 index 357a13c3..00000000 Binary files a/src/front/icons/maskable/x48.png and /dev/null differ diff --git a/src/front/icons/maskable/x512.png b/src/front/icons/maskable/x512.png deleted file mode 100644 index a58bb230..00000000 Binary files a/src/front/icons/maskable/x512.png and /dev/null differ diff --git a/src/front/icons/maskable/x72.png b/src/front/icons/maskable/x72.png deleted file mode 100644 index b3d7045b..00000000 Binary files a/src/front/icons/maskable/x72.png and /dev/null differ diff --git a/src/front/icons/maskable/x96.png b/src/front/icons/maskable/x96.png deleted file mode 100644 index 2972d607..00000000 Binary files a/src/front/icons/maskable/x96.png and /dev/null differ diff --git a/src/front/manifest.webmanifest b/src/front/manifest.webmanifest index 8cd571e5..3777ca6d 100644 --- a/src/front/manifest.webmanifest +++ b/src/front/manifest.webmanifest @@ -1 +1,75 @@ -{"name":"cobalt","short_name":"cobalt","start_url":"/","icons":[{"src":"/icons/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/icons/android-chrome-512x512.png","sizes":"512x512","type":"image/png"},{"src":"/icons/generic.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/icons/maskable/x48.png","sizes":"48x48","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x72.png","sizes":"72x72","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x96.png","sizes":"96x96","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x128.png","sizes":"128x128","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x384.png","sizes":"384x384","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"},{"src":"/icons/maskable/x1280.png","sizes":"1280x1280","type":"image/png","purpose":"maskable"}],"theme_color":"#000000","background_color":"#000000","display":"standalone"} \ No newline at end of file +{ + "name": "cobalt", + "short_name": "cobalt", + "start_url": "/", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/generic.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/maskable/48.png", + "sizes": "48x48", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/maskable/512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "share_target": { + "action": "/", + "params": { + "text": "u", + "url": "u" + } + }, + "theme_color": "#000000", + "background_color": "#000000", + "display": "standalone" +} diff --git a/src/front/updateBanners/meowthbusinessman.webp b/src/front/updateBanners/meowthbusinessman.webp new file mode 100644 index 00000000..c7313895 Binary files /dev/null and b/src/front/updateBanners/meowthbusinessman.webp differ diff --git a/src/localization/languages/en.json b/src/localization/languages/en.json index 546c2841..b0987337 100644 --- a/src/localization/languages/en.json +++ b/src/localization/languages/en.json @@ -8,7 +8,7 @@ "LinkInput": "paste the link here", "AboutSummary": "cobalt is your go-to place for downloads from social and media platforms. zero ads, trackers, or other creepy bullshit. simply paste a share link and you're ready to rock!", "EmbedBriefDescription": "save what you love. no ads, trackers, or other creepy bullshit.", - "MadeWithLove": "made with <3 by wukko", + "MadeWithLove": "made with <3 by imput", "AccessibilityInputArea": "link input area", "AccessibilityOpenAbout": "open about popup", "AccessibilityDownloadButton": "download button", @@ -90,7 +90,6 @@ "DonateSub": "help it stay online", "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", "SettingsVideoMuteExplanation": "removes audio from video downloads when possible.", "ErrorSoundCloudNoClientId": "i couldn't get the temporary token that's required to download songs from soundcloud. try again, but if issue persists, {ContactLink}.", @@ -101,19 +100,13 @@ "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 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.", + "ErrorYTUnavailable": "this youtube video is unavailable. it could be age or region restricted. try another one!", + "ErrorYTTryOtherCodec": "i couldn't find anything to download with your settings. try another codec or quality in settings!", "SettingsCodecSubtitle": "youtube codec", - "SettingsCodecDescription": "h264: generally better player support, but quality tops out at 1080p.\nav1: poor player support, but supports 8k & HDR.\nvp9: usually highest bitrate, preserves most detail. supports 4k & HDR.\n\npick h264 if you want best editor/player/social media compatibility.", + "SettingsCodecDescription": "h264: best support across apps/platforms, average detail level. max quality is 1080p.\nav1: best quality, small file size, most detail. supports 8k & HDR.\nvp9: same quality as av1, but file is x2 bigger. supports 4k & HDR.\n\npick h264 if you want best compatibility.\npick av1 if you want best quality and efficiency.", "SettingsAudioDub": "youtube audio track", - "SettingsAudioDubDescription": "defines which audio track will be used. if dubbed track isn't available, original video language is used instead.\n\noriginal: original video language is used.\nauto: default browser (and cobalt) language is used.", - "SettingsDubDefault": "original", - "SettingsDubAuto": "auto", - "SettingsVimeoPrefer": "vimeo downloads type", - "SettingsVimeoPreferDescription": "progressive: direct file link to vimeo's cdn. max quality is 1080p.\ndash: video and audio are merged by cobalt into one file. max quality is 4k.\n\npick \"progressive\" if you want best editor/player/social media compatibility. if progressive download isn't available, dash is used instead.", "ShareURL": "share", "ErrorTweetUnavailable": "couldn't find anything about this tweet. this could be because its visibility is limited. try another one!", - "ErrorTwitterRIP": "twitter has restricted access to any content to unauthenticated users. while there's a way to get regular tweets, spaces are, unfortunately, impossible to get at this time. i am looking into possible solutions.", "PopupCloseDone": "done", "Accessibility": "accessibility", "SettingsReduceTransparency": "reduce transparency", @@ -154,10 +147,14 @@ "SettingsTwitterGifDescription": "converting looping videos to .gif reduces quality and majorly increases file size. if you want best efficiency, keep this setting off.", "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!", - "UpdateEncryption": "encryption and new services", "PrivateAnalytics": "private analytics", "SettingsDisableAnalytics": "opt out of private analytics", "SettingsAnalyticsExplanation": "enable if you don't want to be included in anonymous traffic stats. read more about this in about > privacy policy (tl;dr: nothing about you is ever stored or tracked, no cookies are used).", - "AnalyticsDescription": "cobalt uses a self-hosted plausible instance to get an approximate number of how many people use it.\n\nplausible is fully compliant with GDPR, CCPA and PECR, doesn't use cookies, and never stores any identifiable info, not even your ip address.\n\nall data is aggregated and never personalized. nothing about what you download is ever saved anywhere. it's used just for anonymous traffic stats, nothing more.\n\nplausible is fully open source, just like cobalt, and if you want to learn more about it, you can do so here. if you wish to opt out of traffic stats, you can do it in settings > other." + "AnalyticsDescription": "cobalt uses a self-hosted plausible instance to get an approximate number of how many people use it.\n\nplausible is fully compliant with GDPR, CCPA and PECR, doesn't use cookies, and never stores any identifiable info, not even your ip address.\n\nall data is aggregated and never personalized. nothing about what you download is ever saved anywhere. it's used just for anonymous traffic stats, nothing more.\n\nplausible is fully open source, just like cobalt, and if you want to learn more about it, you can do so here. if you wish to opt out of traffic stats, you can do it in settings > other.", + "SettingsTikTokH265": "prefer h265", + "SettingsTikTokH265Description": "download 1080p videos from tiktok in h265/hevc format when available.", + "SettingsYoutubeDub": "use browser language", + "SettingsYoutubeDubDescription": "uses your browser's default language for youtube dubbed audio tracks. works even if cobalt ui isn't translated to your language.", + "UpdateIstream": "better service support and ux" } } diff --git a/src/localization/languages/ru.json b/src/localization/languages/ru.json index 8f66b5b0..9ba1643b 100644 --- a/src/localization/languages/ru.json +++ b/src/localization/languages/ru.json @@ -45,7 +45,7 @@ "AccessibilityEnableDownloadPopup": "спрашивать, что делать с загрузками", "SettingsQualityDescription": "если выбранное качество недоступно, то выбирается ближайшее к нему.", "NoScriptMessage": "кобальт использует javascript для обработки ссылок и интерактивного интерфейса. ты должен разрешить использование javascript, чтобы пользоваться сайтом. тут нет никаких зловредных скриптов, обещаю.", - "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оба сценария работают только вместе с веб-приложением кобальта.", + "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": "скачать", @@ -91,7 +91,6 @@ "DonateSub": "ты можешь помочь!", "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": "убрать аудио", "SettingsVideoMuteExplanation": "убирает звук при загрузке видео, но только когда это возможно.", "ErrorSoundCloudNoClientId": "мне не удалось достать временный токен, который необходим для скачивания аудио из soundcloud. попробуй ещё раз, но если так и не получится, {ContactLink}.", @@ -102,19 +101,13 @@ "FollowSupport": "подписывайся на соц.сети кобальта для новостей и поддержки:", "SourceCode": "шарься в исходнике, пиши о проблемах, или же форкай репозиторий:", "PrivacyPolicy": "политика конфиденциальности кобальта довольно проста: никакие данные о тебе никогда не собираются и не хранятся. нуль, ноль, нада, ничего.\nто, что ты скачиваешь, - твоё личное дело, а не чьё-либо ещё.\n\nесли твоей загрузке требуется рендер, то зашифрованные данные о ней временно хранятся в ОЗУ сервера. это необходимо для работы данной функции.\n\nзашифрованные данные хранятся в течение 90 секунд и затем безвозвратно удаляются.\n\ncохранённые данные можно расшифровать только с помощью уникальных ключей шифрования из твоей ссылки на скачивание. кроме того, официальная кодовая база кобальта не предусматривает возможности чтения эти данные вне функций обработки.\n\nты всегда можешь посмотреть исходный код кобальта и убедиться, что всё так, как заявлено.", - "ErrorYTUnavailable": "это видео недоступно, возможно оно ограничено по региону или доступу. попробуй другое!", - "ErrorYTTryOtherCodec": "я не нашёл того, что мог бы скачать с твоими настройками. попробуй другой кодек или качество!", - "SettingsCodecSubtitle": "кодек для видео с youtube", - "SettingsCodecDescription": "h264: обширная поддержка плеерами, но макс. качество всего лишь 1080p.\nav1: слабая поддержка плеерами, но поддерживает 8k и HDR.\nvp9: обычно наиболее высокий битрейт, лучше сохраняется качество видео. поддерживает 4k и HDR.\n\nвыбирай h264, если тебе нужна наилучшая совместимость с плеерами/редакторами/соцсетями.", - "SettingsAudioDub": "звуковая дорожка для видео с youtube", - "SettingsAudioDubDescription": "определяет, какая звуковая дорожка используется при скачивании видео. если дублированная дорожка недоступна, то вместо неё используется оригинальная.\n\nоригинал: используется оригинальная дорожка.\nавто: используется язык браузера и интерфейса кобальта.", - "SettingsDubDefault": "оригинал", - "SettingsDubAuto": "авто", - "SettingsVimeoPrefer": "тип загрузок с vimeo", - "SettingsVimeoPreferDescription": "progressive: прямая ссылка на файл с сервера vimeo. максимальное качество: 1080p.\ndash: кобальт совмещает видео и аудио в один файл. максимальное качество: 4k.\n\nвыбирай \"progressive\", если тебе нужна наилучшая совместимость с плеерами/редакторами/соцсетями. если \"progressive\" файл недоступен, кобальт скачает \"dash\".", + "ErrorYTUnavailable": "это видео недоступно. возможно оно ограничено по доступу или региону. попробуй другое!", + "ErrorYTTryOtherCodec": "я не нашёл того, что мог бы скачать с твоими настройками. попробуй другой кодек или качество в настройках!", + "SettingsCodecSubtitle": "кодек для youtube видео", + "SettingsCodecDescription": "h264: лучшая совместимость, средний уровень детализированности. максимальное качество - 1080p.\nav1: лучшее качество, маленький размер файла, наибольшее количество деталей. поддерживает 8k и HDR.\nvp9: такая же детализированность, как и у av1, но файл в 2 раза больше. поддерживает 4k и HDR.\n\nвыбирай h264, если тебе нужна наилучшая совместимость.\nвыбирай av1, если ты хочешь лучшее качество и эффективность.", + "SettingsAudioDub": "звуковая дорожка для youtube видео", "ShareURL": "поделиться", "ErrorTweetUnavailable": "не смог найти что-либо об этом твите. возможно его видимость ограничена. попробуй другой!", - "ErrorTwitterRIP": "твиттер ограничил доступ к любому контенту на сайте для пользователей без аккаунтов. я нашёл лазейку, чтобы доставать обычные твиты, а для spaces, к сожалению, нет. я ищу возможные варианты выхода из ситуации.", "PopupCloseDone": "готово", "Accessibility": "общедоступность", "SettingsReduceTransparency": "уменьшить прозрачность", @@ -156,10 +149,14 @@ "SettingsTwitterGifDescription": "конвертирование зацикленного видео в .gif снижает качество и значительно увеличивает размер файла. если важна максимальная эффективность, то не используй эту функцию.", "ErrorTweetProtected": "этот твит из закрытого аккаунта, поэтому я не могу его увидеть. попробуй другой!", "ErrorTweetNSFW": "этот твит содержит деликатный контент, поэтому я не могу его увидеть. попробуй другой!", - "UpdateEncryption": "шифрование и новые сервисы", "PrivateAnalytics": "приватная аналитика", "SettingsDisableAnalytics": "отключить приватную аналитику", "SettingsAnalyticsExplanation": "включи, если не хочешь быть частью анонимной статистики трафика. подробнее об этом можно прочитать в политике конфиденциальности (tl;dr: ничего о тебе или твоих действиях не хранится и не отслеживается, даже куки нет).", - "AnalyticsDescription": "кобальт использует собственный инстанс plausible чтобы иметь приблизительное представление о том, сколько людей им пользуются.\n\nplausible полностью соответствует GDPR, CCPA и PECR, не использует куки и никогда не хранит никакой идентифицируемой информации, даже ip-адрес.\n\nвсе данные агрегируются и никогда не персонализируются. ничего о том, что ты скачиваешь, никогда не сохраняется. это просто анонимная статистика трафика, ничего больше.\n\nplausible также как и кобальт имеет открытый исходный код, и, если ты хочешь узнать о нём больше, то это можно сделать здесь. а если же ты хочешь исключить себя из статистики, то это можно сделать в настройках > другое." + "AnalyticsDescription": "кобальт использует собственный инстанс plausible чтобы иметь приблизительное представление о том, сколько людей им пользуются.\n\nplausible полностью соответствует GDPR, CCPA и PECR, не использует куки и никогда не хранит никакой идентифицируемой информации, даже ip-адрес.\n\nвсе данные агрегируются и никогда не персонализируются. ничего о том, что ты скачиваешь, никогда не сохраняется. это просто анонимная статистика трафика, ничего больше.\n\nplausible также как и кобальт имеет открытый исходный код, и, если ты хочешь узнать о нём больше, то это можно сделать здесь. а если же ты хочешь исключить себя из статистики, то это можно сделать в настройках > другое.", + "SettingsTikTokH265": "предпочитать h265", + "SettingsTikTokH265Description": "скачивает видео с tiktok в 1080p и h265/hevc, когда это возможно.", + "SettingsYoutubeDub": "использовать язык браузера", + "SettingsYoutubeDubDescription": "использует главный язык браузера для аудиодорожек на youtube. работает даже если кобальт не переведён в твой язык.", + "UpdateIstream": "быстрые загрузки и приятный интерфейс" } } diff --git a/src/localization/manager.js b/src/localization/manager.js index c22f9500..2344241b 100644 --- a/src/localization/manager.js +++ b/src/localization/manager.js @@ -8,7 +8,7 @@ let loc = {} let languages = []; export async function loadLoc() { - const files = await fs.promises.readdir(locPath).catch((e) => { return [] }); + const files = await fs.promises.readdir(locPath).catch(() => []); files.forEach(file => { loc[file.split('.')[0]] = loadJSON(`${locPath}/${file}`); languages.push(file.split('.')[0]) diff --git a/src/modules/build.js b/src/modules/build.js index 887ffb50..a4c4aa6b 100644 --- a/src/modules/build.js +++ b/src/modules/build.js @@ -13,9 +13,6 @@ export async function buildFront(commitHash, branch) { // build html if (!fs.existsSync('./build/')){ fs.mkdirSync('./build/'); - fs.mkdirSync('./build/ios/'); - fs.mkdirSync('./build/pc/'); - fs.mkdirSync('./build/mob/'); } // get rid of old build path if (fs.existsSync('./min')) { @@ -26,16 +23,9 @@ export async function buildFront(commitHash, branch) { let params = { "hash": commitHash, "lang": i, - "useragent": "pc", "branch": branch } - fs.writeFileSync(`./build/pc/${i}.html`, cleanHTML(page(params))); - - params["useragent"] = "iphone os"; - fs.writeFileSync(`./build/ios/${i}.html`, cleanHTML(page(params))); - - params["useragent"] = "android"; - fs.writeFileSync(`./build/mob/${i}.html`, cleanHTML(page(params))); + fs.writeFileSync(`./build/${i}.html`, cleanHTML(page(params))); } // build js & css await esbuild.build({ @@ -45,7 +35,7 @@ export async function buildFront(commitHash, branch) { loader: { '.js': 'js', '.css': 'css', }, charset: 'utf8' }) - } catch (e) { + } catch { return; } } diff --git a/src/modules/changelog/changelog.json b/src/modules/changelog/changelog.json index b0f531b9..94569ec0 100644 --- a/src/modules/changelog/changelog.json +++ b/src/modules/changelog/changelog.json @@ -1,5 +1,17 @@ { "current": { + "version": "7.13", + "date": "May 5, 2024", + "title": "better ux, improvements for youtube, twitter, tiktok, instagram, and more!", + "banner": { + "file": "meowthbusinessman.webp", + "alt": "photo of a businessman holding hands together (merkel-raute pose) with meowth plush head.", + "width": 1440, + "height": 960 + }, + "content": "long time no see! well, actually, you've been using the latest version for some time now. we've moved to a rolling release scheme, allowing for speedy update rollouts :)\n\nsince 7.11, there has been a ton of changes. here are the most notable of them:\n*; youtube downloads are now faster and more reliable than ever.\n*; all posts from twitter are now downloadable, including sensitive ones.\n*; you now can download tiktok videos in 1080p h265! just enable h265 support in settings > video.\n*; added support for sharing links directly to the cobalt web app on android.\n*; added 240p and 144p quality options to the quality picker in settings (for some reason, many of you wanted this).\n*; pasting a link with additional text around it will now work; cobalt will extract the link for you (works only via the paste button).\n*; added anonymous traffic analytics by plausible. we're using a selfhosted instance and don't collect any identifiable information about you. you can learn more in about > privacy policy. you can also opt out of anonymous analytics in settings > other.\n\nservice support improvements:\n*; implemented internal streams functionality, allowing for more fine-grained file streaming and therefore proper youtube support.\n*; added fallback to m4a if opus isn't available for youtube.\n*; added a total of 7 ways to get instagram post info, including mobile api, embed, and graphql api. absolute torture.\n*; added support for reddit user posts.\n*; updated the way tiktok downloads are handled for better reliability and 1080p support.\n*; added tiktok author's username to filename.\n*; added support for rutube shorts and yappy videos.\n*; added support for m.soundcloud.com links.\n*; added support for new post and reel links from instagram.\n*; added support for photo twitter links, only used for gifs.\n*; added support for m.bilibili.com links.\n*; added support for new type of vimeo links.\n*; added support for ddinstagram.com links.\n*; updated youtube codec info in settings to display the fact that av1 is a better choice now.\n*; updated best audio picking for tiktok and soundcloud.\n*; changed the youtube client to web, since android client no longer works.\n*; removed the vimeo download type switcher, as it should've always been automatic instead.\n*; removed an ability to enable the tiktok watermark, as it no longer includes the author's username.\n\nui & ux improvements:\n*; youtube audio dub switcher is now a toggle with a much easier to understand description.\n*; meowbalt now sticks out on the left side of download popup on desktop.\n*; updated \"made with love\" text to include the research & dev team behind cobalt, imput.\n*; fixed grammar of russian localization.\n*; rounded corners are now correctly rendered across all browsers.\n*; various minor improvements, including smaller button padding.\n*; removed the notification (red dot) functionality as the most recent changelog is already always on screen.\n*; removed settings migration from the old domain.\n\nother changes:\n*; various docs updates in github repo, making sure they're functional across branches and forks.\n*; major codebase cleanup.\n\nthank you for using cobalt, and thank you for being one of our 900k friends! i hope you like this update as much as we liked making it.\n\nwe're committed to keeping cobalt the best way to save what you love without ads or invasion of your privacy. there's a ton of cool stuff to come soon; stay tuned and have an amazing rest of your day <3\n\nif you want to help our goal of a better internet for everyone, just share cobalt with a friend!\n\n(original photo of a man in a suit by benzoix on freepik)" + }, + "history": [{ "version": "7.11", "date": "March 6, 2024", "title": "cache encryption, meowbalt, dailymotion, bilibili, and much more!", @@ -10,8 +22,7 @@ "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 unlisted 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!", diff --git a/src/modules/config.js b/src/modules/config.js index 5e079536..c66521e1 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -1,5 +1,6 @@ import UrlPattern from "url-pattern"; import { loadJSON } from "./sub/loadFromFs.js"; + const config = loadJSON("./src/config.json"); const packageJson = loadJSON("./package.json"); const servicesConfigJson = loadJSON("./src/modules/processing/servicesConfig.json"); @@ -12,6 +13,35 @@ Object.values(servicesConfigJson.config).forEach(service => { ) }) +const + apiURL = process.env.API_URL || '', + + // WEB mode related environment variables + webEnvs = { + webPort: process.env.WEB_PORT || 9001, + webURL: process.env.WEB_URL || '', + showSponsors: !!process.env.SHOW_SPONSORS, + isBeta: !!process.env.IS_BETA, + plausibleHostname: process.env.PLAUSIBLE_HOSTNAME, + apiURL + }, + + // API mode related environment variables + apiEnvs = { + apiPort: process.env.API_PORT || 9000, + apiName: process.env.API_NAME || 'unknown', + listenAddress: process.env.API_LISTEN_ADDRESS, + corsWildcard: process.env.CORS_WILDCARD !== '0', + corsURL: process.env.CORS_URL, + cookiePath: process.env.COOKIE_PATH, + processingPriority: process.platform !== 'win32' + && process.env.PROCESSING_PRIORITY + && parseInt(process.env.PROCESSING_PRIORITY), + tiktokDeviceInfo: process.env.TIKTOK_DEVICE_INFO && JSON.parse(process.env.TIKTOK_DEVICE_INFO), + freebindCIDR: process.platform === 'linux' && process.env.FREEBIND_CIDR, + apiURL + } + export const services = servicesConfigJson.config, audioIgnore = servicesConfigJson.audioIgnore, @@ -19,11 +49,14 @@ export const streamLifespan = config.streamLifespan, maxVideoDuration = config.maxVideoDuration, genericUserAgent = config.genericUserAgent, - repo = packageJson["bugs"]["url"].replace('/issues', ''), + repo = packageJson.bugs.url.replace('/issues', ''), authorInfo = config.authorInfo, donations = config.donations, ffmpegArgs = config.ffmpegArgs, supportedAudio = config.supportedAudio, celebrations = config.celebrations, links = config.links, - sponsors = config.sponsors + sponsors = config.sponsors, + mode = (apiURL && !webEnvs.webURL) ? 'API' : + (webEnvs.webURL && apiURL) ? 'WEB' : undefined, + env = mode === 'API' ? apiEnvs : webEnvs \ No newline at end of file diff --git a/src/modules/emoji.js b/src/modules/emoji.js index f4793cb5..82273ac4 100644 --- a/src/modules/emoji.js +++ b/src/modules/emoji.js @@ -61,6 +61,6 @@ export default function(emoji, size, disablePadding, fluent) { if (!names[emoji]) emoji = "❓"; let filePath = `emoji/${names[emoji]}.svg`; - if (fluent) filePath = `emoji/3d/${names[emoji]}.svg`; + if (fluent) filePath = `emoji/3d/${names[emoji]}.png`; return `` } diff --git a/src/modules/pageRender/elements.js b/src/modules/pageRender/elements.js index e59385e1..ae14cd88 100644 --- a/src/modules/pageRender/elements.js +++ b/src/modules/pageRender/elements.js @@ -1,4 +1,4 @@ -import { authorInfo, celebrations, sponsors } from "../config.js"; +import { authorInfo, celebrations, sponsors, env } from "../config.js"; import emoji from "../emoji.js"; import { loadFile } from "../sub/loadFromFs.js"; @@ -266,5 +266,5 @@ export function sponsoredList() { } export function betaTag() { - return process.env.IS_BETA ? 'β' : '' + return env.isBeta ? 'β' : '' } diff --git a/src/modules/pageRender/findRendered.js b/src/modules/pageRender/findRendered.js index 92986480..1cbb01aa 100644 --- a/src/modules/pageRender/findRendered.js +++ b/src/modules/pageRender/findRendered.js @@ -1,11 +1,6 @@ import { languageList } from "../../localization/manager.js"; -export default function(lang, userAgent) { +export default function(lang) { let language = languageList.includes(lang) ? lang : "en"; - - let ua = userAgent.toLowerCase(); - let platform = (ua.match("android") || ua.match("iphone os")) ? "mob" : "pc"; - if (platform === "mob" && ua.match("iphone os")) platform = "ios"; - - return `/build/${platform}/${language}.html`; + return `/build/${language}.html`; } diff --git a/src/modules/pageRender/page.js b/src/modules/pageRender/page.js index 58bb3a97..17643958 100644 --- a/src/modules/pageRender/page.js +++ b/src/modules/pageRender/page.js @@ -1,10 +1,30 @@ -import { checkbox, collapsibleList, explanation, footerButtons, multiPagePopup, popup, popupWithBottomButtons, sep, settingsCategory, switcher, socialLink, socialLinks, urgentNotice, keyboardShortcuts, webLoc, sponsoredList, betaTag, linkSVG } from "./elements.js"; -import { services as s, authorInfo, version, repo, donations, supportedAudio, links } from "../config.js"; +import { services as s, version, repo, donations, supportedAudio, links, env } from "../config.js"; import { getCommitInfo } from "../sub/currentCommit.js"; import loc from "../../localization/manager.js"; import emoji from "../emoji.js"; import changelogManager from "../changelog/changelogManager.js"; +import { + checkbox, + collapsibleList, + explanation, + footerButtons, + multiPagePopup, + popup, + popupWithBottomButtons, + sep, + settingsCategory, + switcher, + socialLink, + socialLinks, + urgentNotice, + keyboardShortcuts, + webLoc, + sponsoredList, + betaTag, + linkSVG +} from "./elements.js"; + let com = getCommitInfo(); let enabledServices = Object.keys(s).filter(p => s[p].enabled).sort().map((p) => { @@ -27,14 +47,9 @@ for (let i in donations["crypto"]) { } export default function(obj) { - const t = (str, replace) => { return loc(obj.lang, str, replace) }; - - let ua = obj.useragent.toLowerCase(); - let isIOS = ua.match("iphone os"); - let isMobile = ua.match("android") || ua.match("iphone os"); - - let platform = isMobile ? "m" : "d"; - if (isMobile && isIOS) platform = "i"; + const t = (str, replace) => { + return loc(obj.lang, str, replace) + } audioFormats[0]["text"] = t('SettingsAudioFormatBest'); @@ -44,17 +59,16 @@ export default function(obj) { - + ${t("AppTitleCobalt")} - + - + - @@ -71,19 +85,21 @@ export default function(obj) { + + - ${process.env.PLAUSIBLE_HOSTNAME ? + ${env.plausibleHostname ? `` : ''} - + @@ -98,7 +114,7 @@ export default function(obj) { header: { aboveTitle: { text: t('MadeWithLove'), - url: authorInfo.link + url: repo }, closeAria: t('AccessibilityGoBack'), title: `${emoji("🔮", 30)} ${t('TitlePopupAbout')}` @@ -169,7 +185,7 @@ export default function(obj) { name: "privacy", title: `${emoji("🔒")} ${t("CollapsePrivacy")}`, body: t("PrivacyPolicy") + `${ - process.env.PLAUSIBLE_HOSTNAME ? `

${t("AnalyticsDescription")}` : '' + env.plausibleHostname ? `

${t("AnalyticsDescription")}` : '' }` }, { name: "legal", @@ -177,7 +193,7 @@ export default function(obj) { body: t("FairUse") }]) }, - ...(process.env.SHOW_SPONSORS ? + ...(env.showSponsors ? [{ text: t("SponsoredBy"), classes: ["sponsored-by-text"], @@ -285,12 +301,6 @@ export default function(obj) { }, { text: donate.replace(/REPLACEME/g, t('ClickToCopy')), classes: ["desc-padding"] - }, { - text: sep(), - raw: true - }, { - text: t('DonateHireMe', authorInfo.link), - classes: ["desc-padding"] }] }) }], @@ -300,7 +310,7 @@ export default function(obj) { closeAria: t('AccessibilityGoBack'), header: { aboveTitle: { - text: `v.${version}-${obj.hash}${platform} (${obj.branch})`, + text: `v.${version}-${obj.hash} (${obj.branch})`, url: `${repo}/commit/${obj.hash}` }, title: `${emoji("⚙️", 30)} ${t('TitlePopupSettings')}` @@ -335,19 +345,15 @@ export default function(obj) { }, { action: "360", text: "360p" + }, { + action: "240", + text: "240p" + }, { + action: "144", + text: "144p" }] }) }) - + settingsCategory({ - name: "twitter", - title: "twitter", - body: checkbox([{ - action: "twitterGif", - name: t("SettingsTwitterGif"), - padding: "no-margin" - }]) - + explanation(t('SettingsTwitterGifDescription')) - }) + settingsCategory({ name: "codec", title: t('SettingsCodecSubtitle'), @@ -367,19 +373,24 @@ export default function(obj) { }) }) + settingsCategory({ - name: "vimeo", - title: t('SettingsVimeoPrefer'), - body: switcher({ - name: "vimeoDash", - explanation: t('SettingsVimeoPreferDescription'), - items: [{ - action: "false", - text: "progressive" - }, { - action: "true", - text: "dash" - }] - }) + name: "twitter", + title: "twitter", + body: checkbox([{ + action: "twitterGif", + name: t("SettingsTwitterGif"), + padding: "no-margin" + }]) + + explanation(t('SettingsTwitterGifDescription')) + }) + + settingsCategory({ + name: "tiktok", + title: "tiktok", + body: checkbox([{ + action: "tiktokH265", + name: t("SettingsTikTokH265"), + padding: "no-margin" + }]) + + explanation(t('SettingsTikTokH265Description')) }) }, { name: "audio", @@ -401,19 +412,14 @@ export default function(obj) { + explanation(t('SettingsVideoMuteExplanation')) }) + settingsCategory({ - name: "dub", + name: "youtube-dub", title: t("SettingsAudioDub"), - body: switcher({ - name: "dubLang", - explanation: t('SettingsAudioDubDescription'), - items: [{ - action: "original", - text: t('SettingsDubDefault') - }, { - action: "auto", - text: t('SettingsDubAuto') - }] - }) + body: checkbox([{ + action: "ytDub", + name: t("SettingsYoutubeDub"), + padding: "no-margin" + }]) + + explanation(t('SettingsYoutubeDubDescription')) }) + settingsCategory({ name: "tiktok-audio", @@ -499,7 +505,7 @@ export default function(obj) { }]) }) + (() => { - if (process.env.PLAUSIBLE_HOSTNAME) { + if (env.plausibleHostname) { return settingsCategory({ name: "privacy", title: t('PrivateAnalytics'), @@ -552,7 +558,7 @@ export default function(obj) { }, body: switcher({ name: "download", - explanation: `${!isIOS ? t('DownloadPopupDescription') : t('DownloadPopupDescriptionIOS')}`, + explanation: t('DownloadPopupDescription'), items: `${t('Download')}
${t('ShareURL')}
${t('CopyURL')}
` @@ -578,8 +584,8 @@ export default function(obj) { diff --git a/src/modules/processing/cookie/manager.js b/src/modules/processing/cookie/manager.js index a0b66f48..2b997d96 100644 --- a/src/modules/processing/cookie/manager.js +++ b/src/modules/processing/cookie/manager.js @@ -1,9 +1,10 @@ import Cookie from './cookie.js'; import { readFile, writeFile } from 'fs/promises'; import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser'; +import { env } from '../../../modules/config.js' const WRITE_INTERVAL = 60000, - cookiePath = process.env.COOKIE_PATH, + cookiePath = env.cookiePath, COUNTER = Symbol('counter'); let cookies = {}, dirty = false, intervalId; diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 1a46d0cd..f7a1b3c6 100644 --- a/src/modules/processing/match.js +++ b/src/modules/processing/match.js @@ -26,9 +26,21 @@ import twitch from "./services/twitch.js"; import rutube from "./services/rutube.js"; import dailymotion from "./services/dailymotion.js"; import snapchat from "./services/snapchat.js"; +import { env } from '../config.js'; +let freebind; export default async function(host, patternMatch, url, lang, obj) { assert(url instanceof URL); + let dispatcher, requestIP; + + if (env.freebindCIDR) { + if (!freebind) { + freebind = await import('freebind'); + } + + requestIP = freebind.ip.random(env.freebindCIDR); + dispatcher = freebind.dispatcherFromIP(requestIP, { strict: false }); + } try { let r, isAudioOnly = !!obj.isAudioOnly, disableMetadata = !!obj.disableMetadata; @@ -67,7 +79,8 @@ export default async function(host, patternMatch, url, lang, obj) { format: obj.vCodec, isAudioOnly: isAudioOnly, isAudioMuted: obj.isAudioMuted, - dubLang: obj.dubLang + dubLang: obj.dubLang, + dispatcher } if (url.hostname === 'music.youtube.com' || isAudioOnly === true) { @@ -81,7 +94,8 @@ export default async function(host, patternMatch, url, lang, obj) { case "reddit": r = await reddit({ sub: patternMatch.sub, - id: patternMatch.id + id: patternMatch.id, + user: patternMatch.user }); break; case "tiktok": @@ -89,7 +103,8 @@ export default async function(host, patternMatch, url, lang, obj) { postId: patternMatch.postId, id: patternMatch.id, fullAudio: obj.isTTFullAudio, - isAudioOnly: isAudioOnly + isAudioOnly: isAudioOnly, + h265: obj.tiktokH265 }); break; case "tumblr": @@ -104,8 +119,7 @@ export default async function(host, patternMatch, url, lang, obj) { id: patternMatch.id.slice(0, 11), password: patternMatch.password, quality: obj.vQuality, - isAudioOnly: isAudioOnly, - forceDash: isAudioOnly ? true : obj.vimeoDash + isAudioOnly: isAudioOnly }); break; case "soundcloud": @@ -114,6 +128,7 @@ export default async function(host, patternMatch, url, lang, obj) { url, author: patternMatch.author, song: patternMatch.song, + format: obj.aFormat, shortLink: patternMatch.shortLink || false, accessKey: patternMatch.accessKey || false }); @@ -121,7 +136,8 @@ export default async function(host, patternMatch, url, lang, obj) { case "instagram": r = await instagram({ ...patternMatch, - quality: obj.vQuality + quality: obj.vQuality, + dispatcher }) break; case "vine": @@ -152,6 +168,7 @@ export default async function(host, patternMatch, url, lang, obj) { case "rutube": r = await rutube({ id: patternMatch.id, + yappyId: patternMatch.yappyId, quality: obj.vQuality, isAudioOnly: isAudioOnly }); @@ -188,7 +205,8 @@ export default async function(host, patternMatch, url, lang, obj) { return matchActionDecider( r, host, obj.aFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, - obj.filenamePattern, obj.twitterGif + obj.filenamePattern, obj.twitterGif, + requestIP ) } catch (e) { return apiJSON(0, { t: genericError(lang, host) }) diff --git a/src/modules/processing/matchActionDecider.js b/src/modules/processing/matchActionDecider.js index 70db973b..23699259 100644 --- a/src/modules/processing/matchActionDecider.js +++ b/src/modules/processing/matchActionDecider.js @@ -3,7 +3,7 @@ import { apiJSON } from "../sub/utils.js"; import loc from "../../localization/manager.js"; import createFilename from "./createFilename.js"; -export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern, toGif) { +export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern, toGif, requestIP) { let action, responseType = 2, defaultParams = { @@ -11,7 +11,8 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di service: host, filename: r.filenameAttributes ? createFilename(r.filenameAttributes, filenamePattern, isAudioOnly, isAudioMuted) : r.filename, - fileMetadata: !disableMetadata ? r.fileMetadata : false + fileMetadata: !disableMetadata ? r.fileMetadata : false, + requestIP }, params = {}, audioFormat = String(userFormat); @@ -139,40 +140,22 @@ export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, di audioFormat = "best" } + const serviceBestAudio = r.bestAudio || services[host]["bestAudio"]; const isBestAudio = audioFormat === "best"; - const isBestOrMp3 = audioFormat === "mp3" || isBestAudio; - const isBestAudioDefined = isBestAudio && services[host]["bestAudio"]; - const isBestHostAudio = services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"]); + const isBestOrMp3 = isBestAudio || audioFormat === "mp3"; + const isBestAudioDefined = isBestAudio && serviceBestAudio; + const isBestHostAudio = serviceBestAudio && (audioFormat === serviceBestAudio); - const isTikTok = host === "tiktok" || host === "douyin"; const isTumblrAudio = host === "tumblr" && !r.filename; const isSoundCloud = host === "soundcloud"; - if (isTikTok && services.tiktok.audioFormats.includes(audioFormat)) { - if (r.isMp3 && isBestOrMp3) { - audioFormat = "mp3"; - processType = "bridge" - } else if (isBestAudio) { - audioFormat = "m4a"; - processType = "bridge" - } - } - - if (isSoundCloud && services.soundcloud.audioFormats.includes(audioFormat)) { - if (r.isMp3 && isBestOrMp3) { - audioFormat = "mp3"; - processType = "render" - copy = true - } else if (isBestAudio || audioFormat === "opus") { - audioFormat = "opus"; - processType = "render" - copy = true - } - } - if (isBestAudioDefined || isBestHostAudio) { - audioFormat = services[host]["bestAudio"]; + audioFormat = serviceBestAudio; processType = "bridge"; + if (isSoundCloud) { + processType = "render" + copy = true + } } else if (isBestAudio && !isSoundCloud) { audioFormat = "m4a"; copy = true diff --git a/src/modules/processing/services/instagram.js b/src/modules/processing/services/instagram.js index 8d8a11d7..bcbf78bb 100644 --- a/src/modules/processing/services/instagram.js +++ b/src/modules/processing/services/instagram.js @@ -2,145 +2,345 @@ import { createStream } from "../../stream/manage.js"; import { genericUserAgent } from "../../config.js"; import { getCookie, updateCookie } from "../cookie/manager.js"; -const commonInstagramHeaders = { - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', - 'User-Agent': genericUserAgent, - 'X-Ig-App-Id': '936619743392459', - 'X-Asbd-Id': '129477', - 'x-requested-with': 'XMLHttpRequest', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'same-origin', - 'upgrade-insecure-requests': '1', - 'accept-encoding': 'gzip, deflate, br', - 'accept-language': 'en-US,en;q=0.9,en;q=0.8', +const commonHeaders = { + "user-agent": genericUserAgent, + "sec-gpc": "1", + "sec-fetch-site": "same-origin", + "x-ig-app-id": "936619743392459" +} +const mobileHeaders = { + "x-ig-app-locale": "en_US", + "x-ig-device-locale": "en_US", + "x-ig-mapped-locale": "en_US", + "user-agent": "Instagram 275.0.0.27.98 Android (33/13; 280dpi; 720x1423; Xiaomi; Redmi 7; onclite; qcom; en_US; 458229237)", + "accept-language": "en-US", + "x-fb-http-engine": "Liger", + "x-fb-client-ip": "True", + "x-fb-server-cluster": "True", + "content-length": "0", +} +const embedHeaders = { + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "Accept-Language": "en-GB,en;q=0.9", + "Cache-Control": "max-age=0", + "Dnt": "1", + "Priority": "u=0, i", + "Sec-Ch-Ua": 'Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": "macOS", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", } -async function request(url, cookie) { - const data = await fetch(url, { - headers: { - ...commonInstagramHeaders, +const cachedDtsg = { + value: '', + expiry: 0 +} + +export default function(obj) { + const dispatcher = obj.dispatcher; + + async function findDtsgId(cookie) { + try { + if (cachedDtsg.expiry > Date.now()) return cachedDtsg.value; + + const data = await fetch('https://www.instagram.com/', { + headers: { + ...commonHeaders, + cookie + }, + dispatcher + }).then(r => r.text()); + + const token = data.match(/"dtsg":{"token":"(.*?)"/)[1]; + + cachedDtsg.value = token; + cachedDtsg.expiry = Date.now() + 86390000; + + if (token) return token; + return false; + } + catch {} + } + + async function request(url, cookie, method = 'GET', requestData) { + let headers = { + ...commonHeaders, 'x-ig-www-claim': cookie?._wwwClaim || '0', 'x-csrftoken': cookie?.values()?.csrftoken, cookie } - }) + if (method === 'POST') { + headers['content-type'] = 'application/x-www-form-urlencoded'; + } - if (data.headers.get('X-Ig-Set-Www-Claim') && cookie) - cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim'); + const data = await fetch(url, { + method, + headers, + body: requestData && new URLSearchParams(requestData), + dispatcher + }); - updateCookie(cookie, data.headers); - return data.json(); -} + if (data.headers.get('X-Ig-Set-Www-Claim') && cookie) + cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim'); -async function getPost(id) { - let data; - try { + updateCookie(cookie, data.headers); + return data.json(); + } + async function getMediaId(id, { cookie, token } = {}) { + const oembedURL = new URL('https://i.instagram.com/api/v1/oembed/'); + oembedURL.searchParams.set('url', `https://www.instagram.com/p/${id}/`); + + const oembed = await fetch(oembedURL, { + headers: { + ...mobileHeaders, + ...( token && { authorization: `Bearer ${token}` } ), + cookie + }, + dispatcher + }).then(r => r.json()).catch(() => {}); + + return oembed?.media_id; + } + + async function requestMobileApi(mediaId, { cookie, token } = {}) { + const mediaInfo = await fetch(`https://i.instagram.com/api/v1/media/${mediaId}/info/`, { + headers: { + ...mobileHeaders, + ...( token && { authorization: `Bearer ${token}` } ), + cookie + }, + dispatcher + }).then(r => r.json()).catch(() => {}); + + return mediaInfo?.items?.[0]; + } + async function requestHTML(id, cookie) { + const data = await fetch(`https://www.instagram.com/p/${id}/embed/captioned/`, { + headers: { + ...embedHeaders, + cookie + }, + dispatcher + }).then(r => r.text()).catch(() => {}); + + let embedData = JSON.parse(data?.match(/"init",\[\],\[(.*?)\]\],/)[1]); + + if (!embedData || !embedData?.contextJSON) return false; + + embedData = JSON.parse(embedData.contextJSON); + + return embedData; + } + async function requestGQL(id, cookie) { + let dtsgId; + + if (cookie) { + dtsgId = await findDtsgId(cookie); + } + const url = new URL('https://www.instagram.com/api/graphql/'); + + const requestData = { + jazoest: '26406', + variables: JSON.stringify({ + shortcode: id, + __relay_internal__pv__PolarisShareMenurelayprovider: false + }), + doc_id: '7153618348081770' + }; + if (dtsgId) { + requestData.fb_dtsg = dtsgId; + } + + return (await request(url, cookie, 'POST', requestData)) + .data + ?.xdt_api__v1__media__shortcode__web_info + ?.items + ?.[0]; + } + + function extractOldPost(data, id) { + const sidecar = data?.gql_data?.shortcode_media?.edge_sidecar_to_children; + if (sidecar) { + const picker = sidecar.edges.filter(e => e.node?.display_url) + .map(e => { + const type = e.node?.is_video ? "video" : "photo"; + const url = type === "video" ? e.node?.video_url : e.node?.display_url; + + return { + type, url, + /* thumbnails have `Cross-Origin-Resource-Policy` + ** set to `same-origin`, so we need to proxy them */ + thumb: createStream({ + service: "instagram", + type: "default", + u: e.node?.display_url, + filename: "image.jpg" + }) + } + }); + + if (picker.length) return { picker } + } else if (data?.gql_data?.shortcode_media?.video_url) { + return { + urls: data.gql_data.shortcode_media.video_url, + filename: `instagram_${id}.mp4`, + audioFilename: `instagram_${id}_audio` + } + } else if (data?.gql_data?.shortcode_media?.display_url) { + return { + urls: data.gql_data?.shortcode_media.display_url, + isPhoto: true + } + } + } + + function extractNewPost(data, id) { + const carousel = data.carousel_media; + if (carousel) { + const picker = carousel.filter(e => e?.image_versions2) + .map(e => { + const type = e.video_versions ? "video" : "photo"; + const imageUrl = e.image_versions2.candidates[0].url; + + let url = imageUrl; + if (type === 'video') { + const video = e.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a); + url = video.url; + } + + return { + type, url, + /* thumbnails have `Cross-Origin-Resource-Policy` + ** set to `same-origin`, so we need to proxy them */ + thumb: createStream({ + service: "instagram", + type: "default", + u: imageUrl, + filename: "image.jpg" + }) + } + }); + + if (picker.length) return { picker } + } else if (data.video_versions) { + const video = data.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a) + return { + urls: video.url, + filename: `instagram_${id}.mp4`, + audioFilename: `instagram_${id}_audio` + } + } else if (data.image_versions2?.candidates) { + return { + urls: data.image_versions2.candidates[0].url, + isPhoto: true + } + } + } + + async function getPost(id) { + let data, result; + try { + const cookie = getCookie('instagram'); + + const bearer = getCookie('instagram_bearer'); + const token = bearer?.values()?.token; + + // get media_id for mobile api, three methods + let media_id = await getMediaId(id); + if (!media_id && token) media_id = await getMediaId(id, { token }); + if (!media_id && cookie) media_id = await getMediaId(id, { cookie }); + + // mobile api (bearer) + if (media_id && token) data = await requestMobileApi(id, { token }); + + // mobile api (no cookie, cookie) + if (!data && media_id) data = await requestMobileApi(id); + if (!data && media_id && cookie) data = await requestMobileApi(id, { cookie }); + + // html embed (no cookie, cookie) + if (!data) data = await requestHTML(id); + if (!data && cookie) data = await requestHTML(id, cookie); + + // web app graphql api (no cookie, cookie) + if (!data) data = await requestGQL(id); + if (!data && cookie) data = await requestGQL(id, cookie); + } catch {} + + if (!data) return { error: 'ErrorCouldntFetch' }; + + if (data?.gql_data) { + result = extractOldPost(data, id) + } else { + result = extractNewPost(data, id) + } + + if (result) return result; + return { error: 'ErrorEmptyDownload' } + } + + async function usernameToId(username, cookie) { + const url = new URL('https://www.instagram.com/api/v1/users/web_profile_info/'); + url.searchParams.set('username', username); + + try { + const data = await request(url, cookie); + return data?.data?.user?.id; + } catch {} + } + + async function getStory(username, id) { const cookie = getCookie('instagram'); + if (!cookie) return { error: 'ErrorUnsupported' }; - const url = new URL('https://www.instagram.com/graphql/query/'); - url.searchParams.set('query_hash', 'b3055c01b4b222b8a47dc12b090e4e64') - url.searchParams.set('variables', JSON.stringify({ - child_comment_count: 3, - fetch_comment_count: 40, - has_threaded_comments: true, - parent_comment_count: 24, - shortcode: id - })) + const userId = await usernameToId(username, cookie); + if (!userId) return { error: 'ErrorEmptyDownload' }; + + const dtsgId = await findDtsgId(cookie); - data = (await request(url, cookie)).data; + const url = new URL('https://www.instagram.com/api/graphql/'); + const requestData = { + fb_dtsg: dtsgId, + jazoest: '26438', + variables: JSON.stringify({ + reel_ids_arr : [ userId ], + }), + server_timestamps: true, + doc_id: '25317500907894419' + }; - } catch {} + let media; + try { + const data = (await request(url, cookie, 'POST', requestData)); + media = data?.data?.xdt_api__v1__feed__reels_media?.reels_media?.find(m => m.id === userId); + } catch {} - if (!data) return { error: 'ErrorCouldntFetch' }; - - const sidecar = data?.shortcode_media?.edge_sidecar_to_children; - if (sidecar) { - const picker = sidecar.edges.filter(e => e.node?.display_url) - .map(e => { - const type = e.node?.is_video ? "video" : "photo"; - const url = type === "video" ? e.node?.video_url : e.node?.display_url; - - return { - type, url, - /* thumbnails have `Cross-Origin-Resource-Policy` - ** set to `same-origin`, so we need to proxy them */ - thumb: createStream({ - service: "instagram", - type: "default", - u: e.node?.display_url, - filename: "image.jpg" - }) - } - }); - - if (picker.length) return { picker } - } else if (data?.shortcode_media?.video_url) { - return { - urls: data.shortcode_media.video_url, - filename: `instagram_${id}.mp4`, - audioFilename: `instagram_${id}_audio` + const item = media.items.find(m => m.pk === id); + if (!item) return { error: 'ErrorEmptyDownload' }; + + if (item.video_versions) { + const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a) + return { + urls: video.url, + filename: `instagram_${id}.mp4`, + audioFilename: `instagram_${id}_audio` + } } - } else if (data?.shortcode_media?.display_url) { - return { - urls: data.shortcode_media.display_url, - isPhoto: true + + if (item.image_versions2?.candidates) { + return { + urls: item.image_versions2.candidates[0].url, + isPhoto: true + } } + + return { error: 'ErrorCouldntFetch' }; } - return { error: 'ErrorEmptyDownload' } -} - -async function usernameToId(username, cookie) { - const url = new URL('https://www.instagram.com/api/v1/users/web_profile_info/'); - url.searchParams.set('username', username); - - try { - const data = await request(url, cookie); - return data?.data?.user?.id; - } catch {} -} - -async function getStory(username, id) { - const cookie = getCookie('instagram'); - if (!cookie) return { error: 'ErrorUnsupported' } - - const userId = await usernameToId(username, cookie); - if (!userId) return { error: 'ErrorEmptyDownload' } - - const url = new URL('https://www.instagram.com/api/v1/feed/reels_media/'); - url.searchParams.set('reel_ids', userId); - url.searchParams.set('media_id', id); - - let media; - try { - const data = await request(url, cookie); - media = data?.reels_media?.find(m => m.id === userId); - } catch {} - - const item = media.items[media.media_ids.indexOf(id)]; - if (!item) return { error: 'ErrorEmptyDownload' }; - - if (item.video_versions) { - const video = item.video_versions.reduce((a, b) => a.width * a.height < b.width * b.height ? b : a) - return { - urls: video.url, - filename: `instagram_${id}.mp4`, - audioFilename: `instagram_${id}_audio` - } - } - - if (item.image_versions2?.candidates) { - return { - urls: item.image_versions2.candidates[0].url, - isPhoto: true - } - } - - return { error: 'ErrorCouldntFetch' }; -} - -export default function(obj) { const { postId, storyId, username } = obj; if (postId) return getPost(postId); if (username && storyId) return getStory(username, storyId); diff --git a/src/modules/processing/services/pinterest.js b/src/modules/processing/services/pinterest.js index 0f14eebf..23d9f4d8 100644 --- a/src/modules/processing/services/pinterest.js +++ b/src/modules/processing/services/pinterest.js @@ -1,22 +1,17 @@ import { genericUserAgent } from "../../config.js"; -const videoLinkBase = { - "regular": "https://v1.pinimg.com/videos/mc/720p/", - "story": "https://v1.pinimg.com/videos/mc/720p/" -} +const videoRegex = /"url":"(https:\/\/v1.pinimg.com\/videos\/.*?)"/g; +const imageRegex = /src="(https:\/\/i\.pinimg\.com\/.*\.(jpg|gif))"/g; export default async function(o) { - let id = o.id, type = "regular"; + let id = o.id; if (!o.id && o.shortLink) { id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" }).then((r) => { return r.headers.get("location").split('pin/')[1].split('/')[0] }).catch(() => {}); } - if (id.includes("--")) { - id = id.split("--")[1]; - type = "story"; - } + if (id.includes("--")) id = id.split("--")[1]; if (!id) return { error: 'ErrorCouldntFetch' }; let html = await fetch(`https://www.pinterest.com/pin/${id}/`, { @@ -25,12 +20,24 @@ export default async function(o) { if (!html) return { error: 'ErrorCouldntFetch' }; - let videoLink = html.split(`"url":"${videoLinkBase[type]}`)[1]?.split('"')[0]; - if (!html.includes(videoLink)) return { error: 'ErrorEmptyDownload' }; + let videoLink = [...html.matchAll(videoRegex)] + .map(([, link]) => link) + .find(a => a.endsWith('.mp4') && a.includes('720p')); - return { - urls: `${videoLinkBase[type]}${videoLink}`, + if (videoLink) return { + urls: videoLink, filename: `pinterest_${o.id}.mp4`, audioFilename: `pinterest_${o.id}_audio` } + + let imageLink = [...html.matchAll(imageRegex)] + .map(([, link]) => link) + .find(a => a.endsWith('.jpg') || a.endsWith('.gif')); + + if (imageLink) return { + urls: imageLink, + isPhoto: true + } + + return { error: 'ErrorEmptyDownload' }; } diff --git a/src/modules/processing/services/reddit.js b/src/modules/processing/services/reddit.js index 8964b24f..e022f62c 100644 --- a/src/modules/processing/services/reddit.js +++ b/src/modules/processing/services/reddit.js @@ -48,43 +48,73 @@ async function getAccessToken() { } export default async function(obj) { - const url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`); + let url = new URL(`https://www.reddit.com/r/${obj.sub}/comments/${obj.id}.json`); + + if (obj.user) { + url.pathname = `/user/${obj.user}/comments/${obj.id}.json`; + } const accessToken = await getAccessToken(); if (accessToken) url.hostname = 'oauth.reddit.com'; let data = await fetch( - url, { headers: accessToken && { authorization: `Bearer ${accessToken}` } } - ).then((r) => { return r.json() }).catch(() => { return false }); - if (!data) return { error: 'ErrorCouldntFetch' }; + url, { + headers: accessToken && { authorization: `Bearer ${accessToken}` } + } + ).then(r => r.json() ).catch(() => {}); - data = data[0]["data"]["children"][0]["data"]; + if (!data || !Array.isArray(data)) return { error: 'ErrorCouldntFetch' }; - if (data.url.endsWith('.gif')) return { typeId: 1, urls: data.url }; + data = data[0]?.data?.children[0]?.data; - if (!("reddit_video" in data["secure_media"])) return { error: 'ErrorEmptyDownload' }; - if (data["secure_media"]["reddit_video"]["duration"] * 1000 > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; + if (data?.url?.endsWith('.gif')) return { + typeId: 1, + urls: data.url + } + + if (!data.secure_media?.reddit_video) + return { error: 'ErrorEmptyDownload' }; + + if (data.secure_media?.reddit_video?.duration * 1000 > maxVideoDuration) + return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; let audio = false, - video = data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0], - audioFileLink = video.match('.mp4') ? `${video.split('_')[0]}_audio.mp4` : `${data["secure_media"]["reddit_video"]["fallback_url"].split('DASH')[0]}audio`; + video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0], + audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`; - await fetch(audioFileLink, { method: "HEAD" }).then((r) => { if (Number(r.status) === 200) audio = true }).catch(() => { audio = false }); + if (video.match('.mp4')) { + audioFileLink = `${video.split('_')[0]}_audio.mp4` + } + + // test the existence of audio + await fetch(audioFileLink, { method: "HEAD" }).then((r) => { + if (Number(r.status) === 200) { + audio = true + } + }).catch(() => {}) // fallback for videos with variable audio quality if (!audio) { audioFileLink = `${video.split('_')[0]}_AUDIO_128.mp4` - await fetch(audioFileLink, { method: "HEAD" }).then((r) => { if (Number(r.status) === 200) audio = true }).catch(() => { audio = false }); + await fetch(audioFileLink, { method: "HEAD" }).then((r) => { + if (Number(r.status) === 200) { + audio = true + } + }).catch(() => {}) } let id = video.split('/')[3]; - if (!audio) return { typeId: 1, urls: video }; + if (!audio) return { + typeId: 1, + urls: video + } + return { typeId: 2, type: "render", urls: [video, audioFileLink], audioFilename: `reddit_${id}_audio`, filename: `reddit_${id}.mp4` - }; + } } diff --git a/src/modules/processing/services/rutube.js b/src/modules/processing/services/rutube.js index af99a31d..b26bfb19 100644 --- a/src/modules/processing/services/rutube.js +++ b/src/modules/processing/services/rutube.js @@ -1,18 +1,47 @@ import HLS from 'hls-parser'; + import { maxVideoDuration } from "../../config.js"; import { cleanString } from '../../sub/utils.js'; +async function requestJSON(url) { + try { + const r = await fetch(url); + return await r.json(); + } catch {} +} + export default async function(obj) { + if (obj.yappyId) { + let yappy = await requestJSON( + `https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15` + ) + let yappyURL = yappy?.results?.find(r => r.id === obj.yappyId)?.link; + if (!yappyURL) return { error: 'ErrorEmptyDownload' }; + + return { + urls: yappyURL, + filename: `rutube_yappy_${obj.yappyId}.mp4`, + audioFilename: `rutube_yappy_${obj.yappyId}_audio` + } + } + let quality = obj.quality === "max" ? "9000" : obj.quality; - let play = await fetch(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`).then((r) => { return r.json() }).catch(() => { return false }); + + let play = await requestJSON( + `https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2` + ) if (!play) return { error: 'ErrorCouldntFetch' }; - if ("hls" in play.live_streams) return { error: 'ErrorLiveVideo' }; - if (!play.video_balancer || play.detail) return { error: 'ErrorEmptyDownload' }; + if (play.detail || !play.video_balancer) return { error: 'ErrorEmptyDownload' }; + if (play.live_streams?.hls) return { error: 'ErrorLiveVideo' }; + + if (play.duration > maxVideoDuration) + return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; + + let m3u8 = await fetch(play.video_balancer.m3u8) + .then(r => r.text()) + .catch(() => {}); - if (play.duration > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] }; - - let m3u8 = await fetch(play.video_balancer.m3u8).then((r) => { return r.text() }).catch(() => { return false }); if (!m3u8) return { error: 'ErrorCouldntFetch' }; m3u8 = HLS.parse(m3u8).variants.sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth)); @@ -21,6 +50,7 @@ export default async function(obj) { if (Number(quality) < bestQuality.resolution.height) { bestQuality = m3u8.find((i) => (Number(quality) === i["resolution"].height)); } + let fileMetadata = { title: cleanString(play.title.trim()), artist: cleanString(play.author.name.trim()), @@ -31,7 +61,7 @@ export default async function(obj) { isM3U8: true, filenameAttributes: { service: "rutube", - id: play.id, + id: obj.id, title: fileMetadata.title, author: fileMetadata.artist, resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`, diff --git a/src/modules/processing/services/soundcloud.js b/src/modules/processing/services/soundcloud.js index 46aae5df..2288b5f3 100644 --- a/src/modules/processing/services/soundcloud.js +++ b/src/modules/processing/services/soundcloud.js @@ -1,7 +1,10 @@ import { maxVideoDuration } from "../../config.js"; import { cleanString } from "../../sub/utils.js"; -let cachedID = {}; +const cachedID = { + version: '', + id: '' +} async function findClientID() { try { @@ -29,9 +32,7 @@ async function findClientID() { cachedID.id = clientid; return clientid; - } catch (e) { - return false; - } + } catch {} } export default async function(obj) { @@ -55,27 +56,31 @@ export default async function(obj) { let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`).then((r) => { return r.status === 200 ? r.json() : false - }).catch(() => { return false }); + }).catch(() => {}); + if (!json) return { error: 'ErrorCouldntFetch' }; if (!json["media"]["transcodings"]) return { error: 'ErrorEmptyDownload' }; - let isMp3, - selectedStream = json.media.transcodings.filter(v => v.preset === "opus_0_0") + let bestAudio = "opus", + selectedStream = json.media.transcodings.find(v => v.preset === "opus_0_0"), + mp3Media = json.media.transcodings.find(v => v.preset === "mp3_0_0"); - // fall back to mp3 if no opus is available - if (selectedStream.length === 0) { - selectedStream = json.media.transcodings.filter(v => v.preset === "mp3_0_0") - isMp3 = true + // use mp3 if present if user prefers it or if opus isn't available + if (mp3Media && (obj.format === "mp3" || !selectedStream)) { + selectedStream = mp3Media; + bestAudio = "mp3" } - let fileUrlBase = selectedStream[0]["url"]; + + let fileUrlBase = selectedStream.url; let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`; if (fileUrl.substring(0, 54) !== "https://api-v2.soundcloud.com/media/soundcloud:tracks:") return { error: 'ErrorEmptyDownload' }; - if (json.duration > maxVideoDuration) return { error: ['ErrorLengthAudioConvert', maxVideoDuration / 60000] }; + if (json.duration > maxVideoDuration) + return { error: ['ErrorLengthAudioConvert', maxVideoDuration / 60000] }; - let file = await fetch(fileUrl).then(async (r) => { return (await r.json()).url }).catch(() => { return false }); + let file = await fetch(fileUrl).then(async (r) => { return (await r.json()).url }).catch(() => {}); if (!file) return { error: 'ErrorCouldntFetch' }; let fileMetadata = { @@ -91,7 +96,7 @@ export default async function(obj) { title: fileMetadata.title, author: fileMetadata.artist }, - isMp3, + bestAudio, fileMetadata } } diff --git a/src/modules/processing/services/tiktok.js b/src/modules/processing/services/tiktok.js index f3629109..b81d57d9 100644 --- a/src/modules/processing/services/tiktok.js +++ b/src/modules/processing/services/tiktok.js @@ -1,4 +1,4 @@ -import { genericUserAgent } from "../../config.js"; +import { genericUserAgent, env } from "../../config.js"; const shortDomain = "https://vt.tiktok.com/"; const apiPath = "https://api22-normal-c-alisg.tiktokv.com/aweme/v1/feed/?region=US&carrier_region=US"; @@ -7,7 +7,7 @@ const apiUserAgent = "TikTok/338014 CFNetwork/1410.1 Darwin/22.6.0"; export default async function(obj) { let postId = obj.postId ? obj.postId : false; - if (!process.env.TIKTOK_DEVICE_INFO) return { error: 'ErrorCouldntFetch' }; + if (!env.tiktokDeviceInfo) return { error: 'ErrorCouldntFetch' }; if (!postId) { let html = await fetch(`${shortDomain}${obj.id}`, { @@ -27,8 +27,7 @@ export default async function(obj) { } if (!postId) return { error: 'ErrorCantGetID' }; - let deviceInfo = JSON.parse(process.env.TIKTOK_DEVICE_INFO); - deviceInfo = new URLSearchParams(deviceInfo).toString(); + let deviceInfo = new URLSearchParams(env.tiktokDeviceInfo).toString(); let apiURL = new URL(apiPath); apiURL.searchParams.append("aweme_id", postId); @@ -42,14 +41,19 @@ export default async function(obj) { detail = detail?.aweme_list?.find(v => v.aweme_id === postId); if (!detail) return { error: 'ErrorCouldntFetch' }; - let video, videoFilename, audioFilename, isMp3, audio, images, - filenameBase = `tiktok_${detail.author.unique_id}_${postId}`; + let video, videoFilename, audioFilename, audio, images, + filenameBase = `tiktok_${detail.author.unique_id}_${postId}`, + bestAudio = 'm4a'; images = detail.image_post_info?.images; let playAddr = detail.video.play_addr_h264; - - if (!playAddr) playAddr = detail.video.play_addr; + if (obj.h265) { + playAddr = detail.video.bit_rate[0].play_addr + } + if (!playAddr && detail.video.play_addr) { + playAddr = detail.video.play_addr + } if (!obj.isAudioOnly && !images) { video = playAddr.url_list[0]; @@ -57,12 +61,12 @@ export default async function(obj) { } else { let fallback = playAddr.url_list[0]; audio = fallback; - audioFilename = `${filenameBase}_audio_fv`; // fv - from video + audioFilename = `${filenameBase}_audio`; if (obj.fullAudio || fallback.includes("music")) { audio = detail.music.play_url.url_list[0] - audioFilename = `${filenameBase}_audio` + audioFilename = `${filenameBase}_audio_original` } - if (audio.slice(-4) === ".mp3") isMp3 = true; + if (audio.slice(-4) === ".mp3") bestAudio = 'mp3'; } if (video) return { @@ -73,7 +77,7 @@ export default async function(obj) { urls: audio, audioFilename: audioFilename, isAudioOnly: true, - isMp3: isMp3 + bestAudio } if (images) { let imageLinks = []; @@ -87,13 +91,13 @@ export default async function(obj) { urls: audio, audioFilename: audioFilename, isAudioOnly: true, - isMp3: isMp3 + bestAudio } } if (audio) return { urls: audio, audioFilename: audioFilename, isAudioOnly: true, - isMp3: isMp3 + bestAudio } } diff --git a/src/modules/processing/services/tumblr.js b/src/modules/processing/services/tumblr.js index 05c7fd84..b2866c8f 100644 --- a/src/modules/processing/services/tumblr.js +++ b/src/modules/processing/services/tumblr.js @@ -1,5 +1,4 @@ import psl from "psl"; -import { genericUserAgent } from "../../config.js"; const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z'; const API_BASE = 'https://api-http2.tumblr.com'; diff --git a/src/modules/processing/services/twitter.js b/src/modules/processing/services/twitter.js index 94b55eb1..e96b6dbc 100644 --- a/src/modules/processing/services/twitter.js +++ b/src/modules/processing/services/twitter.js @@ -1,5 +1,6 @@ import { genericUserAgent } from "../../config.js"; import { createStream } from "../../stream/manage.js"; +import { getCookie, updateCookie } from "../cookie/manager.js"; const graphqlURL = 'https://twitter.com/i/api/graphql/5GOHgZe-8U2j5sVHQzEm9A/TweetResultByRestId'; const tokenURL = 'https://api.twitter.com/1.1/guest/activate.json'; @@ -49,9 +50,26 @@ const getGuestToken = async (forceReload = false) => { } } -const requestTweet = (tweetId, token) => { +const requestTweet = async(tweetId, token, cookie) => { const graphqlTweetURL = new URL(graphqlURL); + let headers = { + ...commonHeaders, + 'content-type': 'application/json', + 'x-guest-token': token, + cookie: `guest_id=${encodeURIComponent(`v1:${token}`)}` + } + + if (cookie) { + headers = { + ...commonHeaders, + 'content-type': 'application/json', + 'X-Twitter-Auth-Type': 'OAuth2Session', + 'x-csrf-token': cookie.values().ct0, + cookie + } + } + graphqlTweetURL.searchParams.set('variables', JSON.stringify({ tweetId, @@ -62,31 +80,39 @@ const requestTweet = (tweetId, token) => { ); graphqlTweetURL.searchParams.set('features', tweetFeatures); - return fetch(graphqlTweetURL, { - headers: { - ...commonHeaders, - 'content-type': 'application/json', - 'x-guest-token': token, - cookie: `guest_id=${encodeURIComponent(`v1:${token}`)}` - } - }) + let result = await fetch(graphqlTweetURL, { headers }); + updateCookie(cookie, result.headers); + + // we might have been missing the `ct0` cookie, retry + if (result.status === 403 && result.headers.get('set-cookie')) { + result = await fetch(graphqlTweetURL, { + headers: { + ...headers, + 'x-csrf-token': cookie.values().ct0 + } + }); + } + + return result } export default async function({ id, index, toGif }) { + const cookie = await getCookie('twitter'); + let guestToken = await getGuestToken(); if (!guestToken) return { error: 'ErrorCouldntFetch' }; let tweet = await requestTweet(id, guestToken); - if ([403, 429].includes(tweet.status)) { // get new token & retry + // get new token & retry if old one expired + if ([403, 429].includes(tweet.status)) { guestToken = await getGuestToken(true); tweet = await requestTweet(id, guestToken) } tweet = await tweet.json(); - // {"data":{"tweetResult":{"result":{"__typename":"TweetUnavailable","reason":"Protected"}}}} - const tweetTypename = tweet?.data?.tweetResult?.result?.__typename; + let tweetTypename = tweet?.data?.tweetResult?.result?.__typename; if (tweetTypename === "TweetUnavailable") { const reason = tweet?.data?.tweetResult?.result?.reason; @@ -94,21 +120,32 @@ export default async function({ id, index, toGif }) { case "Protected": return { error: 'ErrorTweetProtected' } case "NsfwLoggedOut": - return { error: 'ErrorTweetNSFW' } + if (cookie) { + tweet = await requestTweet(id, guestToken, cookie); + tweet = await tweet.json(); + tweetTypename = tweet?.data?.tweetResult?.result?.__typename; + } else return { error: 'ErrorTweetNSFW' } } } - if (tweetTypename !== "Tweet") { + + if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) { return { error: 'ErrorTweetUnavailable' } } - const baseTweet = tweet.data.tweetResult.result.legacy, - repostedTweet = baseTweet.retweeted_status_result?.result.legacy.extended_entities; + let tweetResult = tweet.data.tweetResult.result, + baseTweet = tweetResult.legacy, + repostedTweet = baseTweet?.retweeted_status_result?.result.legacy.extended_entities; + + if (tweetTypename === "TweetWithVisibilityResults") { + baseTweet = tweetResult.tweet.legacy; + repostedTweet = baseTweet?.retweeted_status_result?.result.tweet.legacy.extended_entities; + } 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/) - if ([0, 1, 2, 3].includes(index) && index < media?.length) { + if (index >= 0 && index < media?.length) { media = [media[index]] } diff --git a/src/modules/processing/services/vimeo.js b/src/modules/processing/services/vimeo.js index f51f7220..64c4f9c0 100644 --- a/src/modules/processing/services/vimeo.js +++ b/src/modules/processing/services/vimeo.js @@ -39,7 +39,7 @@ export default async function(obj) { if (!api) return { error: 'ErrorCouldntFetch' }; let downloadType = "dash"; - if (!obj.forceDash && JSON.stringify(api).includes('"progressive":[{')) downloadType = "progressive"; + if (!obj.isAudioOnly && JSON.stringify(api).includes('"progressive":[{')) downloadType = "progressive"; let fileMetadata = { title: cleanString(api.video.title.trim()), diff --git a/src/modules/processing/services/youtube.js b/src/modules/processing/services/youtube.js index 10e813af..d11b2739 100644 --- a/src/modules/processing/services/youtube.js +++ b/src/modules/processing/services/youtube.js @@ -1,10 +1,11 @@ -import { Innertube } from 'youtubei.js'; +import { Innertube, Session } from 'youtubei.js'; import { maxVideoDuration } from '../../config.js'; import { cleanString } from '../../sub/utils.js'; +import { fetch } from 'undici' -const yt = await Innertube.create(); +const ytBase = await Innertube.create(); -const c = { +const codecMatch = { h264: { codec: "avc1", aCodec: "mp4a", @@ -22,8 +23,30 @@ const c = { } } +const cloneInnertube = (customFetch) => { + const session = new Session( + ytBase.session.context, + ytBase.session.key, + ytBase.session.api_version, + ytBase.session.account_index, + ytBase.session.player, + undefined, + customFetch ?? ytBase.session.http.fetch, + ytBase.session.cache + ); + + const yt = new Innertube(session); + return yt; +} + export default async function(o) { - let info, isDubbed, quality = o.quality === "max" ? "9000" : o.quality; //set quality 9000(p) to be interpreted as max + const yt = cloneInnertube( + (input, init) => fetch(input, { ...init, dispatcher: o.dispatcher }) + ); + + let info, isDubbed, format = o.format || "h264"; + let quality = o.quality === "max" ? "9000" : o.quality; // 9000(p) - max quality + function qual(i) { if (!i.quality_label) { return; @@ -33,7 +56,7 @@ export default async function(o) { } try { - info = await yt.getBasicInfo(o.id, 'ANDROID'); + info = await yt.getBasicInfo(o.id, 'WEB'); } catch (e) { return { error: 'ErrorCantConnectToServiceAPI' }; } @@ -43,12 +66,29 @@ export default async function(o) { if (info.playability_status.status !== 'OK') return { error: 'ErrorYTUnavailable' }; if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' }; - let bestQuality, hasAudio, adaptive_formats = info.streaming_data.adaptive_formats.filter(e => - e.mime_type.includes(c[o.format].codec) || e.mime_type.includes(c[o.format].aCodec) + // return a critical error if returned video is "Video Not Available" + // or a similar stub by youtube + if (info.basic_info.id !== o.id) { + return { + error: 'ErrorCantConnectToServiceAPI', + critical: true + } + } + + let bestQuality, hasAudio; + + const filterByCodec = (formats) => formats.filter(e => + e.mime_type.includes(codecMatch[format].codec) || e.mime_type.includes(codecMatch[format].aCodec) ).sort((a, b) => Number(b.bitrate) - Number(a.bitrate)); - bestQuality = adaptive_formats.find(i => i.has_video); - hasAudio = adaptive_formats.find(i => i.has_audio); + let adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats); + if (adaptive_formats.length === 0 && format === "vp9") { + format = "h264" + adaptive_formats = filterByCodec(info.streaming_data.adaptive_formats) + } + + bestQuality = adaptive_formats.find(i => i.has_video && i.content_length); + hasAudio = adaptive_formats.find(i => i.has_audio && i.content_length); if (bestQuality) bestQuality = qual(bestQuality); if (!bestQuality && !o.isAudioOnly || !hasAudio) return { error: 'ErrorYTTryOtherCodec' }; @@ -87,42 +127,37 @@ export default async function(o) { youtubeDubName: isDubbed ? o.dubLang : false } - if (filenameAttributes.title === "Video Not Available" && filenameAttributes.author === "YouTube Viewers") - return { - error: 'ErrorCantConnectToServiceAPI', - critical: true - } - if (hasAudio && o.isAudioOnly) return { type: "render", isAudioOnly: true, - urls: audio.url, + urls: audio.decipher(yt.session.player), filenameAttributes: filenameAttributes, - fileMetadata: fileMetadata + fileMetadata: fileMetadata, + bestAudio: format === "h264" ? 'm4a' : 'opus' } const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality, - checkSingle = i => qual(i) === matchingQuality && i.mime_type.includes(c[o.format].codec), + checkSingle = i => qual(i) === matchingQuality && i.mime_type.includes(codecMatch[format].codec), checkRender = i => qual(i) === matchingQuality && i.has_video && !i.has_audio; let match, type, urls; - if (!o.isAudioOnly && !o.isAudioMuted && o.format === 'h264') { + if (!o.isAudioOnly && !o.isAudioMuted && format === 'h264') { match = info.streaming_data.formats.find(checkSingle); type = "bridge"; - urls = match?.url; + urls = match?.decipher(yt.session.player); } const video = adaptive_formats.find(checkRender); if (!match && video) { match = video; type = "render"; - urls = [video.url, audio.url]; + urls = [video.decipher(yt.session.player), audio.decipher(yt.session.player)]; } if (match) { filenameAttributes.qualityLabel = match.quality_label; filenameAttributes.resolution = `${match.width}x${match.height}`; - filenameAttributes.extension = c[o.format].container; - filenameAttributes.youtubeFormat = o.format; + filenameAttributes.extension = codecMatch[format].container; + filenameAttributes.youtubeFormat = format; return { type, urls, diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index 97fe54b0..fda9246a 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -7,11 +7,12 @@ "video/:comId", "_shortLink/:comShortLink", "_tv/:lang/video/:tvId", "_tv/video/:tvId" ], + "subdomains": ["m"], "enabled": true }, "reddit": { "alias": "reddit videos & gifs", - "patterns": ["r/:sub/comments/:id/:title"], + "patterns": ["r/:sub/comments/:id/:title", "user/:user/comments/:id/:title"], "subdomains": "*", "enabled": true }, @@ -56,7 +57,6 @@ "alias": "tiktok videos, photos & audio", "patterns": [":user/video/:postId", ":id", "t/:id", ":user/photo/:postId"], "subdomains": ["vt", "vm"], - "audioFormats": ["best", "m4a", "mp3"], "enabled": true }, "douyin": { @@ -66,18 +66,18 @@ "enabled": false }, "vimeo": { - "patterns": [":id", "video/:id", ":id/:password"], + "patterns": [":id", "video/:id", ":id/:password", "/channels/:user/:id"], "enabled": true, "bestAudio": "mp3" }, "soundcloud": { "patterns": [":author/:song/s-:accessKey", ":author/:song", ":shortLink"], "subdomains": ["on", "m"], - "audioFormats": ["best", "opus", "mp3"], "enabled": true }, "instagram": { "alias": "instagram reels, posts & stories", + "altDomains": ["ddinstagram.com"], "patterns": [ "reels/:postId", ":username/reel/:postId", "reel/:postId", "p/:postId", ":username/p/:postId", "tv/:postId", "stories/:username/:storyId" @@ -91,7 +91,7 @@ "enabled": true }, "pinterest": { - "alias": "pinterest videos & stories", + "alias": "pinterest (all media)", "patterns": ["pin/:id", "pin/:id/:garbage", "url_shortener/:shortLink"], "enabled": true }, @@ -109,7 +109,7 @@ "rutube": { "alias": "rutube videos", "tld": "ru", - "patterns": ["video/:id", "play/embed/:id"], + "patterns": ["video/:id", "play/embed/:id", "shorts/:id", "yappy/:yappyId"], "enabled": true }, "dailymotion": { diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index 8cf85d19..6746b8c5 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -16,10 +16,11 @@ export const testers = { patternMatch.id?.length <= 128 || patternMatch.shortLink?.length <= 32, "reddit": (patternMatch) => - patternMatch.sub?.length <= 22 && patternMatch.id?.length <= 10, + (patternMatch.sub?.length <= 22 && patternMatch.id?.length <= 10) + || (patternMatch.user?.length <= 22 && patternMatch.id?.length <= 10), "rutube": (patternMatch) => - patternMatch.id?.length === 32, + patternMatch.id?.length === 32 || patternMatch.yappyId?.length === 32, "soundcloud": (patternMatch) => (patternMatch.author?.length <= 255 && patternMatch.song?.length <= 255) diff --git a/src/modules/processing/url.js b/src/modules/processing/url.js index b272ff80..c723cb91 100644 --- a/src/modules/processing/url.js +++ b/src/modules/processing/url.js @@ -64,6 +64,12 @@ export function aliasURL(url) { if (url.hostname === 'dai.ly' && parts.length === 2) { url = new URL(`https://dailymotion.com/video/${parts[1]}`) } + break; + case "ddinstagram": + if (services.instagram.altDomains.includes(host.domain) && [null, 'd', 'g'].includes(host.subdomain)) { + url.hostname = 'instagram.com'; + } + break; } return url diff --git a/src/modules/stream/internal.js b/src/modules/stream/internal.js new file mode 100644 index 00000000..9578da9b --- /dev/null +++ b/src/modules/stream/internal.js @@ -0,0 +1,104 @@ +import { request } from 'undici'; +import { Readable } from 'node:stream'; +import { assert } from 'console'; +import { getHeaders } from './shared.js'; + +const CHUNK_SIZE = BigInt(8e6); // 8 MB +const min = (a, b) => a < b ? a : b; + +async function* readChunks(streamInfo, size) { + let read = 0n; + while (read < size) { + if (streamInfo.controller.signal.aborted) { + throw new Error("controller aborted"); + } + + const chunk = await request(streamInfo.url, { + headers: { + ...getHeaders('youtube'), + Range: `bytes=${read}-${read + CHUNK_SIZE}` + }, + dispatcher: streamInfo.dispatcher, + signal: streamInfo.controller.signal + }); + + const expected = min(CHUNK_SIZE, size - read); + const received = BigInt(chunk.headers['content-length']); + + if (received < expected / 2n) { + streamInfo.controller.abort(); + } + + for await (const data of chunk.body) { + yield data; + } + + read += received; + } +} + +function chunkedStream(streamInfo, size) { + assert(streamInfo.controller instanceof AbortController); + const stream = Readable.from(readChunks(streamInfo, size)); + return stream; +} + +async function handleYoutubeStream(streamInfo, res) { + try { + const req = await fetch(streamInfo.url, { + headers: getHeaders('youtube'), + method: 'HEAD', + dispatcher: streamInfo.dispatcher, + signal: streamInfo.controller.signal + }); + + streamInfo.url = req.url; + const size = BigInt(req.headers.get('content-length')); + + if (req.status !== 200 || !size) + return res.destroy(); + + const stream = chunkedStream(streamInfo, size); + + for (const headerName of ['content-type', 'content-length']) { + const headerValue = req.headers.get(headerName); + if (headerValue) res.setHeader(headerName, headerValue); + } + + stream.pipe(res); + stream.on('error', () => res.destroy()); + } catch { + res.destroy(); + } +} + +export async function internalStream(streamInfo, res) { + if (streamInfo.service === 'youtube') { + return handleYoutubeStream(streamInfo, res); + } + + try { + const req = await request(streamInfo.url, { + headers: { + ...streamInfo.headers, + host: undefined + }, + dispatcher: streamInfo.dispatcher, + signal: streamInfo.controller.signal, + maxRedirections: 16 + }); + + res.status(req.statusCode); + + for (const [ name, value ] of Object.entries(req.headers)) + res.setHeader(name, value) + + if (req.statusCode < 200 || req.statusCode > 299) + return res.destroy(); + + req.body.pipe(res); + req.body.on('error', () => res.destroy()); + } catch { + streamInfo.controller.abort(); + } +} \ No newline at end of file diff --git a/src/modules/stream/manage.js b/src/modules/stream/manage.js index d4cb1e68..7d19354f 100644 --- a/src/modules/stream/manage.js +++ b/src/modules/stream/manage.js @@ -3,7 +3,13 @@ import { randomBytes } from "crypto"; import { nanoid } from 'nanoid'; import { decryptStream, encryptStream, generateHmac } from "../sub/crypto.js"; -import { streamLifespan } from "../config.js"; +import { streamLifespan, env } from "../config.js"; +import { strict as assert } from "assert"; + +// optional dependency +const freebind = env.freebindCIDR && await import('freebind').catch(() => {}); + +const M3U_SERVICES = ['dailymotion', 'vimeo', 'rutube']; const streamNoAccess = { error: "i couldn't verify if you have access to this stream. go back and try again!", @@ -24,6 +30,7 @@ streamCache.on("expired", (key) => { streamCache.del(key); }) +const internalStreamCache = {}; const hmacSalt = randomBytes(64).toString('hex'); export function createStream(obj) { @@ -42,7 +49,8 @@ export function createStream(obj) { isAudioOnly: !!obj.isAudioOnly, copy: !!obj.copy, mute: !!obj.mute, - metadata: obj.fileMetadata || false + metadata: obj.fileMetadata || false, + requestIP: obj.requestIP }; streamCache.set( @@ -50,7 +58,7 @@ export function createStream(obj) { encryptStream(streamData, iv, secret) ) - let streamLink = new URL('/api/stream', process.env.API_URL); + let streamLink = new URL('/api/stream', env.apiURL); const params = { 't': streamID, @@ -67,6 +75,67 @@ export function createStream(obj) { return streamLink.toString(); } +export function getInternalStream(id) { + return internalStreamCache[id]; +} + +export function createInternalStream(url, obj = {}) { + assert(typeof url === 'string'); + + let dispatcher; + if (obj.requestIP) { + dispatcher = freebind?.dispatcherFromIP(obj.requestIP, { strict: false }) + } + + const streamID = nanoid(); + internalStreamCache[streamID] = { + url, + service: obj.service, + controller: new AbortController(), + dispatcher + }; + + let streamLink = new URL('/api/istream', `http://127.0.0.1:${env.apiPort}`); + streamLink.searchParams.set('t', streamID); + return streamLink.toString(); +} + +export function destroyInternalStream(url) { + url = new URL(url); + if (url.hostname !== '127.0.0.1') { + return; + } + + const id = url.searchParams.get('t'); + + if (internalStreamCache[id]) { + internalStreamCache[id].controller.abort(); + delete internalStreamCache[id]; + } +} + +function wrapStream(streamInfo) { + /* m3u8 links are currently not supported + * for internal streams, skip them */ + if (M3U_SERVICES.includes(streamInfo.service)) { + return streamInfo; + } + + const url = streamInfo.urls; + + if (typeof url === 'string') { + streamInfo.urls = createInternalStream(url, streamInfo); + } else if (Array.isArray(url)) { + for (const idx in streamInfo.urls) { + streamInfo.urls[idx] = createInternalStream( + streamInfo.urls[idx], streamInfo + ); + } + } else throw 'invalid urls'; + + return streamInfo; +} + export function verifyStream(id, hmac, exp, secret, iv) { try { const ghmac = generateHmac(`${id},${exp},${iv},${secret}`, hmacSalt); @@ -82,9 +151,9 @@ export function verifyStream(id, hmac, exp, secret, iv) { if (Number(exp) <= new Date().getTime()) return streamNoExist; - return streamInfo; + return wrapStream(streamInfo); } - catch (e) { + catch { return { error: "something went wrong and i couldn't verify this stream. go back and try again!", status: 500 diff --git a/src/modules/stream/shared.js b/src/modules/stream/shared.js new file mode 100644 index 00000000..2f898c52 --- /dev/null +++ b/src/modules/stream/shared.js @@ -0,0 +1,21 @@ +import { genericUserAgent } from "../config.js"; + +const defaultHeaders = { + 'user-agent': genericUserAgent +} + +const serviceHeaders = { + bilibili: { + referer: 'https://www.bilibili.com/' + }, + youtube: { + accept: '*/*', + origin: 'https://www.youtube.com', + referer: 'https://www.youtube.com', + DNT: '?1' + } +} + +export function getHeaders(service) { + return { ...defaultHeaders, ...serviceHeaders[service] } +} \ No newline at end of file diff --git a/src/modules/stream/stream.js b/src/modules/stream/stream.js index f254dacc..3de1cb3e 100644 --- a/src/modules/stream/stream.js +++ b/src/modules/stream/stream.js @@ -1,4 +1,5 @@ import { streamAudioOnly, streamDefault, streamLiveRender, streamVideoOnly, convertToGif } from "./types.js"; +import { internalStream } from './internal.js'; export default async function(res, streamInfo) { try { @@ -7,6 +8,8 @@ export default async function(res, streamInfo) { return; } switch (streamInfo.type) { + case "internal": + return await internalStream(streamInfo, res); case "render": await streamLiveRender(streamInfo, res); break; @@ -21,7 +24,7 @@ export default async function(res, streamInfo) { await streamDefault(streamInfo, res); break; } - } catch (e) { + } catch { res.status(500).json({ status: "error", text: "Internal Server Error" }); } } diff --git a/src/modules/stream/types.js b/src/modules/stream/types.js index 2b7d7482..87c9f600 100644 --- a/src/modules/stream/types.js +++ b/src/modules/stream/types.js @@ -1,10 +1,19 @@ -import { spawn } from "child_process"; -import ffmpeg from "ffmpeg-static"; -import { ffmpegArgs, genericUserAgent } from "../config.js"; -import { metadataManager } from "../sub/utils.js"; import { request } from "undici"; +import ffmpeg from "ffmpeg-static"; +import { spawn } from "child_process"; import { create as contentDisposition } from "content-disposition-header"; +import { metadataManager } from "../sub/utils.js"; +import { destroyInternalStream } from "./manage.js"; +import { env, ffmpegArgs } from "../config.js"; +import { getHeaders } from "./shared.js"; + +function toRawHeaders(headers) { + return Object.entries(headers) + .map(([key, value]) => `${key}: ${value}\r\n`) + .join(''); +} + function closeRequest(controller) { try { controller.abort() } catch {} } @@ -35,15 +44,19 @@ function pipe(from, to, done) { } function getCommand(args) { - if (process.env.PROCESSING_PRIORITY && process.platform !== "win32") { - return ['nice', ['-n', process.env.PROCESSING_PRIORITY, ffmpeg, ...args]] + if (!isNaN(env.processingPriority)) { + return ['nice', ['-n', env.processingPriority.toString(), ffmpeg, ...args]] } return [ffmpeg, args] } export async function streamDefault(streamInfo, res) { const abortController = new AbortController(); - const shutdown = () => (closeRequest(abortController), closeResponse(res)); + const shutdown = () => ( + closeRequest(abortController), + closeResponse(res), + destroyInternalStream(streamInfo.urls) + ); try { let filename = streamInfo.filename; @@ -53,13 +66,16 @@ export async function streamDefault(streamInfo, res) { res.setHeader('Content-disposition', contentDisposition(filename)); const { body: stream, headers } = await request(streamInfo.urls, { - headers: { 'user-agent': genericUserAgent }, + headers: getHeaders(streamInfo.service), signal: abortController.signal, maxRedirections: 16 }); - res.setHeader('content-type', headers['content-type']); - res.setHeader('content-length', headers['content-length']); + for (const headerName of ['content-type', 'content-length']) { + if (headers[headerName]) { + res.setHeader(headerName, headers[headerName]); + } + } pipe(stream, res, shutdown); } catch { @@ -67,68 +83,53 @@ export async function streamDefault(streamInfo, res) { } } -export async function streamLiveRender(streamInfo, res) { - let abortController = new AbortController(), process; +export function streamLiveRender(streamInfo, res) { + let process; const shutdown = () => ( - closeRequest(abortController), killProcess(process), - closeResponse(res) + closeResponse(res), + streamInfo.urls.map(destroyInternalStream) ); + const headers = getHeaders(streamInfo.service); + const rawHeaders = toRawHeaders(headers); + try { if (streamInfo.urls.length !== 2) return shutdown(); - const { body: audio } = await request(streamInfo.urls[1], { - maxRedirections: 16, signal: abortController.signal, - headers: { - 'user-agent': genericUserAgent, - referer: streamInfo.service === 'bilibili' - ? 'https://www.bilibili.com/' - : undefined, - } - }); - 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( + '-headers', rawHeaders, '-i', streamInfo.urls[0], - '-i', 'pipe:3', + '-headers', rawHeaders, + '-i', streamInfo.urls[1], '-map', '0:v', '-map', '1:a', - ); + ] args = args.concat(ffmpegArgs[format]); + if (streamInfo.metadata) { args = args.concat(metadataManager(streamInfo.metadata)) } - args.push('-f', format, 'pipe:4'); + + args.push('-f', format, 'pipe:3'); process = spawn(...getCommand(args), { windowsHide: true, stdio: [ 'inherit', 'inherit', 'inherit', - 'pipe', 'pipe' + 'pipe' ], }); - const [,,, audioInput, muxOutput] = process.stdio; + const [,,, muxOutput] = process.stdio; res.setHeader('Connection', 'keep-alive'); res.setHeader('Content-Disposition', contentDisposition(streamInfo.filename)); - audio.on('error', shutdown); - audioInput.on('error', shutdown); - audio.pipe(audioInput); pipe(muxOutput, res, shutdown); process.on('close', shutdown); @@ -140,18 +141,20 @@ export async function streamLiveRender(streamInfo, res) { export function streamAudioOnly(streamInfo, res) { let process; - const shutdown = () => (killProcess(process), closeResponse(res)); + const shutdown = () => ( + killProcess(process), + closeResponse(res), + destroyInternalStream(streamInfo.urls) + ); try { let args = [ '-loglevel', '-8', - '-user_agent', genericUserAgent - ]; + '-headers', toRawHeaders(getHeaders(streamInfo.service)), + ] 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( @@ -162,12 +165,12 @@ export function streamAudioOnly(streamInfo, res) { if (streamInfo.metadata) { args = args.concat(metadataManager(streamInfo.metadata)) } - let arg = streamInfo.copy ? ffmpegArgs["copy"] : ffmpegArgs["audio"]; - args = args.concat(arg); + args = args.concat(ffmpegArgs[streamInfo.copy ? 'copy' : 'audio']); if (ffmpegArgs[streamInfo.audioFormat]) { args = args.concat(ffmpegArgs[streamInfo.audioFormat]) } + args.push('-f', streamInfo.audioFormat === "m4a" ? "ipod" : streamInfo.audioFormat, 'pipe:3'); process = spawn(...getCommand(args), { @@ -192,17 +195,20 @@ export function streamAudioOnly(streamInfo, res) { export function streamVideoOnly(streamInfo, res) { let process; - const shutdown = () => (killProcess(process), closeResponse(res)); + const shutdown = () => ( + killProcess(process), + closeResponse(res), + destroyInternalStream(streamInfo.urls) + ); try { let args = [ - '-loglevel', '-8' + '-loglevel', '-8', + '-headers', toRawHeaders(getHeaders(streamInfo.service)), ] 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( @@ -222,6 +228,7 @@ export function streamVideoOnly(streamInfo, res) { if (format === "mp4") { args.push('-movflags', 'faststart+frag_keyframe+empty_moov') } + args.push('-f', format, 'pipe:3'); process = spawn(...getCommand(args), { @@ -254,10 +261,12 @@ export function convertToGif(streamInfo, res) { let args = [ '-loglevel', '-8' ] + if (streamInfo.service === "twitter") { args.push('-seekable', '0') } - args.push('-i', streamInfo.urls) + + args.push('-i', streamInfo.urls); args = args.concat(ffmpegArgs["gif"]); args.push('-f', "gif", 'pipe:3'); diff --git a/src/modules/sub/crypto.js b/src/modules/sub/crypto.js index b3a0539b..35b55b38 100644 --- a/src/modules/sub/crypto.js +++ b/src/modules/sub/crypto.js @@ -1,4 +1,4 @@ -import { createHmac, createCipheriv, createDecipheriv, scryptSync } from "crypto"; +import { createHmac, createCipheriv, createDecipheriv } from "crypto"; const algorithm = "aes256" diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 7bd6f9cc..75ac305e 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -9,7 +9,15 @@ const apiVar = { aFormat: ["best", "mp3", "ogg", "wav", "opus"], filenamePattern: ["classic", "pretty", "basic", "nerdy"] }, - booleanOnly: ["isAudioOnly", "isTTFullAudio", "isAudioMuted", "dubLang", "vimeoDash", "disableMetadata", "twitterGif"] + booleanOnly: [ + "isAudioOnly", + "isTTFullAudio", + "isAudioMuted", + "dubLang", + "disableMetadata", + "twitterGif", + "tiktokH265" + ] } const forbiddenCharsString = ['}', '{', '%', '>', '<', '^', ';', '`', '$', '"', "@", '=']; @@ -83,8 +91,8 @@ export function checkJSONPost(obj) { isAudioMuted: false, disableMetadata: false, dubLang: false, - vimeoDash: false, - twitterGif: false + twitterGif: false, + tiktokH265: false } try { let objKeys = Object.keys(obj); diff --git a/src/test/tests.json b/src/test/tests.json index b9f5c5d3..ec728935 100644 --- a/src/test/tests.json +++ b/src/test/tests.json @@ -878,6 +878,30 @@ "code": 200, "status": "redirect" } + }, { + "name": "ddinstagram link", + "url": "https://ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "d.ddinstagram.com link", + "url": "https://d.ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "g.ddinstagram.com link", + "url": "https://g.ddinstagram.com/p/CmCVWoIr9OH/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } }], "vine": [{ "name": "regular vine link (9+10=21)", @@ -952,6 +976,38 @@ "code": 200, "status": "redirect" } + }, { + "name": "regular picture", + "url": "https://www.pinterest.com/pin/412994228343400946/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "regular picture (.ca TLD)", + "url": "https://www.pinterest.ca/pin/412994228343400946/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "regular gif", + "url": "https://www.pinterest.com/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } + }, { + "name": "regular gif (.ca TLD)", + "url": "https://www.pinterest.ca/pin/814447913881127862/", + "params": {}, + "expected": { + "code": 200, + "status": "redirect" + } }], "streamable": [{ "name": "regular video", @@ -1061,6 +1117,22 @@ "code": 200, "status": "stream" } + }, { + "name": "yappy", + "url": "https://rutube.ru/yappy/f1771e86294748eea8ecb2ac88e55740/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } + }, { + "name": "shorts", + "url": "https://rutube.ru/shorts/935c1afafd0e7d52836d671967d53dac/", + "params": {}, + "expected": { + "code": 200, + "status": "stream" + } }, { "name": "vertical video (isAudioOnly)", "url": "https://rutube.ru/video/18a281399b96f9184c647455a86f6724/",