mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-14 17:28:27 +00:00
Merge commit 'refs/pull/613/head' of https://github.com/imputnet/cobalt
This commit is contained in:
commit
f03188fea4
@ -36,6 +36,17 @@ function bestQuality(arr) {
|
|||||||
.url
|
.url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMediaMetadata(tweetResult, media){
|
||||||
|
return {
|
||||||
|
duration: Math.round(media.video_info.duration_millis / 1000) || 0,
|
||||||
|
likes: tweetResult.legacy.favorite_count || 0,
|
||||||
|
views: Number(tweetResult.views.count) || 0,
|
||||||
|
title: (tweetResult.legacy && tweetResult.legacy.full_text && Array.isArray(tweetResult.legacy.display_text_range) && tweetResult.legacy.display_text_range[0] !== undefined && tweetResult.legacy.display_text_range[1] !== undefined)
|
||||||
|
? tweetResult.legacy.full_text.substr(tweetResult.legacy.display_text_range[0], tweetResult.legacy.display_text_range[1] - tweetResult.legacy.display_text_range[0])
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _cachedToken;
|
let _cachedToken;
|
||||||
const getGuestToken = async (dispatcher, forceReload = false) => {
|
const getGuestToken = async (dispatcher, forceReload = false) => {
|
||||||
if (_cachedToken && !forceReload) {
|
if (_cachedToken && !forceReload) {
|
||||||
@ -101,11 +112,11 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
export default async function({ id, index, toGif, dispatcher }) {
|
||||||
const cookie = await getCookie('twitter');
|
const cookie = await getCookie('twitter');
|
||||||
|
|
||||||
let guestToken = await getGuestToken(dispatcher);
|
let guestToken = await getGuestToken(dispatcher);
|
||||||
if (!guestToken) return { error: "fetch.fail" };
|
if (!guestToken) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
let tweet = await requestTweet(dispatcher, id, guestToken);
|
let tweet = await requestTweet(dispatcher, id, guestToken);
|
||||||
|
|
||||||
@ -119,26 +130,22 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|||||||
|
|
||||||
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
|
|
||||||
if (!tweetTypename) {
|
|
||||||
return { error: "fetch.empty" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tweetTypename === "TweetUnavailable") {
|
if (tweetTypename === "TweetUnavailable") {
|
||||||
const reason = tweet?.data?.tweetResult?.result?.reason;
|
const reason = tweet?.data?.tweetResult?.result?.reason;
|
||||||
switch(reason) {
|
switch(reason) {
|
||||||
case "Protected":
|
case "Protected":
|
||||||
return { error: "content.post.private" }
|
return { error: 'ErrorTweetProtected' }
|
||||||
case "NsfwLoggedOut":
|
case "NsfwLoggedOut":
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
|
||||||
tweet = await tweet.json();
|
tweet = await tweet.json();
|
||||||
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
||||||
} else return { error: "content.post.age" }
|
} else return { error: 'ErrorTweetNSFW' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
|
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
|
||||||
return { error: "content.post.unavailable" }
|
return { error: 'ErrorTweetUnavailable' }
|
||||||
}
|
}
|
||||||
|
|
||||||
let tweetResult = tweet.data.tweetResult.result,
|
let tweetResult = tweet.data.tweetResult.result,
|
||||||
@ -151,83 +158,45 @@ export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
let media = (repostedTweet?.media || baseTweet?.extended_entities?.media);
|
||||||
|
media = media?.filter(m => m.video_info?.variants?.length);
|
||||||
|
|
||||||
// check if there's a video at given index (/video/<index>)
|
// check if there's a video at given index (/video/<index>)
|
||||||
if (index >= 0 && index < media?.length) {
|
if (index >= 0 && index < media?.length) {
|
||||||
media = [media[index]]
|
media = [media[index]]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFileExt = (url) => new URL(url).pathname.split(".", 2)[1];
|
|
||||||
|
|
||||||
const proxyMedia = (url, filename) => createStream({
|
|
||||||
service: "twitter",
|
|
||||||
type: "proxy",
|
|
||||||
url, filename,
|
|
||||||
})
|
|
||||||
|
|
||||||
switch (media?.length) {
|
switch (media?.length) {
|
||||||
case undefined:
|
case undefined:
|
||||||
case 0:
|
case 0:
|
||||||
return {
|
return { error: 'ErrorNoVideosInTweet' };
|
||||||
error: "fetch.empty"
|
|
||||||
}
|
|
||||||
case 1:
|
case 1:
|
||||||
if (media[0].type === "photo") {
|
|
||||||
return {
|
|
||||||
type: "proxy",
|
|
||||||
isPhoto: true,
|
|
||||||
filename: `twitter_${id}.${getFileExt(media[0].media_url_https)}`,
|
|
||||||
urls: `${media[0].media_url_https}?name=4096x4096`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: needsFixing(media[0]) ? "remux" : "proxy",
|
type: needsFixing(media[0]) ? "remux" : "normal",
|
||||||
urls: bestQuality(media[0].video_info.variants),
|
urls: bestQuality(media[0].video_info.variants),
|
||||||
filename: `twitter_${id}.mp4`,
|
filename: `twitter_${id}.mp4`,
|
||||||
audioFilename: `twitter_${id}_audio`,
|
audioFilename: `twitter_${id}_audio`,
|
||||||
isGif: media[0].type === "animated_gif"
|
isGif: media[0].type === "animated_gif",
|
||||||
}
|
mediaMetadata: buildMediaMetadata(tweetResult, media[0])
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
const proxyThumb = (url, i) =>
|
|
||||||
proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
|
|
||||||
|
|
||||||
const picker = media.map((content, i) => {
|
const picker = media.map((content, i) => {
|
||||||
if (content.type === "photo") {
|
|
||||||
let url = `${content.media_url_https}?name=4096x4096`;
|
|
||||||
let proxiedImage = proxyThumb(url, i);
|
|
||||||
|
|
||||||
if (alwaysProxy) url = proxiedImage;
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "photo",
|
|
||||||
url,
|
|
||||||
thumb: proxiedImage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = bestQuality(content.video_info.variants);
|
let url = bestQuality(content.video_info.variants);
|
||||||
const shouldRenderGif = content.type === "animated_gif" && toGif;
|
const shouldRenderGif = content.type === 'animated_gif' && toGif;
|
||||||
const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
|
|
||||||
|
|
||||||
let type = "video";
|
|
||||||
if (shouldRenderGif) type = "gif";
|
|
||||||
|
|
||||||
if (needsFixing(content) || shouldRenderGif) {
|
if (needsFixing(content) || shouldRenderGif) {
|
||||||
url = createStream({
|
url = createStream({
|
||||||
service: "twitter",
|
service: 'twitter',
|
||||||
type: shouldRenderGif ? "gif" : "remux",
|
type: shouldRenderGif ? 'gif' : 'remux',
|
||||||
url,
|
u: url,
|
||||||
filename: videoFilename,
|
filename: `twitter_${id}_${i + 1}.mp4`
|
||||||
})
|
})
|
||||||
} else if (alwaysProxy) {
|
|
||||||
url = proxyMedia(url, videoFilename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type: 'video',
|
||||||
url,
|
url,
|
||||||
thumb: proxyThumb(content.media_url_https, i),
|
thumb: content.media_url_https,
|
||||||
|
mediaMetadata: buildMediaMetadata(tweetResult, content)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { picker };
|
return { picker };
|
||||||
|
183
docs/api.md
183
docs/api.md
@ -1,161 +1,78 @@
|
|||||||
# cobalt api documentation
|
# cobalt api documentation
|
||||||
this document provides info about methods and acceptable variables for all cobalt api requests.
|
this document provides info about methods and acceptable variables for all cobalt api requests.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> hosted api instances (such as `api.cobalt.tools`) use bot protection and are **not** intended to be used in other projects without explicit permission. if you want to access the cobalt api reliably, you should [host your own instance](/docs/run-an-instance.md) or ask an instance owner for access.
|
|
||||||
|
|
||||||
## authentication
|
|
||||||
an api instance may be configured to require you to authenticate yourself.
|
|
||||||
if this is the case, you will typically receive an [error response](#error-response)
|
|
||||||
with a **`api.auth.<method>.missing`** code, which tells you that a particular method
|
|
||||||
of authentication is required.
|
|
||||||
|
|
||||||
authentication is done by passing the `Authorization` header, containing
|
|
||||||
the authentication scheme and the token:
|
|
||||||
```
|
```
|
||||||
Authorization: <scheme> <token>
|
👍 you can use api.cobalt.tools in your projects for free, just don't be an asshole.
|
||||||
```
|
```
|
||||||
|
|
||||||
currently, cobalt supports two ways of authentication. an instance can
|
## POST: `/api/json`
|
||||||
choose to configure both, or neither:
|
|
||||||
- [`Api-Key`](#api-key-authentication)
|
|
||||||
- [`Bearer`](#bearer-authentication)
|
|
||||||
|
|
||||||
### api-key authentication
|
|
||||||
the api key authentication is the most straightforward. the instance owner
|
|
||||||
will assign you an api key which you can then use to authenticate like so:
|
|
||||||
```
|
|
||||||
Authorization: Api-Key aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
|
|
||||||
```
|
|
||||||
|
|
||||||
if you are an instance owner and wish to configure api key authentication,
|
|
||||||
see the [instance](run-an-instance.md#api-key-file-format) documentation!
|
|
||||||
|
|
||||||
### bearer authentication
|
|
||||||
the cobalt server may be configured to issue JWT bearers, which are short-lived
|
|
||||||
tokens intended for use by regular users (e.g. after passing a challenge).
|
|
||||||
currently, cobalt can issue tokens for successfully solved [turnstile](run-an-instance.md#list-of-all-environment-variables)
|
|
||||||
challenge, if the instance has turnstile configured. the resulting token is passed like so:
|
|
||||||
```
|
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|
||||||
```
|
|
||||||
|
|
||||||
## POST: `/`
|
|
||||||
cobalt's main processing endpoint.
|
cobalt's main processing endpoint.
|
||||||
|
|
||||||
request body type: `application/json`
|
request body type: `application/json`
|
||||||
response body type: `application/json`
|
response body type: `application/json`
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> you must include `Accept` and `Content-Type` headers with every `POST /` request.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
⚠️ you must include Accept and Content-Type headers with every POST /api/json request.
|
||||||
|
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
### request body
|
### request body variables
|
||||||
| key | type | expected value(s) | default | description |
|
| key | type | variables | default | description |
|
||||||
|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
|
|:------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
|
||||||
| `url` | `string` | URL to download | -- | **must** be included in every request. |
|
| `url` | `string` | URL encoded as URI | `null` | **must** be included in every request. |
|
||||||
| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
|
| `vCodec` | `string` | `h264 / av1 / vp9` | `h264` | applies only to youtube downloads. `h264` is recommended for phones. |
|
||||||
| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
|
| `vQuality` | `string` | `144 / ... / 2160 / max` | `720` | `720` quality is recommended for phones. |
|
||||||
| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. |
|
| `aFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
|
||||||
| `filenameStyle` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
|
| `filenamePattern` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
|
||||||
| `downloadMode` | `string` | `auto / audio / mute` | `auto` | `audio` downloads only the audio, `mute` skips the audio track in videos. |
|
| `isAudioOnly` | `boolean` | `true / false` | `false` | |
|
||||||
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
|
| `isTTFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
||||||
| `youtubeDubLang` | `string` | `en / ru / cs / ja / es-US / ...` | -- | specifies the language of audio to download when a youtube video is dubbed. |
|
| `isAudioMuted` | `boolean` | `true / false` | `false` | disables audio track in video downloads. |
|
||||||
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
|
| `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`. |
|
| `disableMetadata` | `boolean` | `true / false` | `false` | disables file metadata when set to `true`. |
|
||||||
| `tiktokFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
|
| `twitterGif` | `boolean` | `true / false` | `false` | changes whether twitter gifs are converted to .gif |
|
||||||
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
|
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
|
||||||
| `twitterGif` | `boolean` | `true / false` | `true` | changes whether twitter gifs are converted to .gif |
|
|
||||||
| `youtubeHLS` | `boolean` | `true / false` | `false` | specifies whether to use HLS for downloading video or audio from youtube. |
|
|
||||||
|
|
||||||
### response
|
### response body variables
|
||||||
the response will always be a JSON object containing the `status` key, which will be one of:
|
| key | type | variables |
|
||||||
- `error` - something went wrong
|
|:----------------|:---------|:------------------------------------------------------------|
|
||||||
- `picker` - we have multiple items to choose from
|
| `status` | `string` | `error / redirect / stream / success / rate-limit / picker` |
|
||||||
- `redirect` - you are being redirected to the direct service URL
|
| `text` | `string` | various text, mostly used for errors |
|
||||||
- `tunnel` - cobalt is proxying the download for you
|
| `url` | `string` | direct link to a file or a link to cobalt's live render |
|
||||||
|
| `pickerType` | `string` | `various / images` |
|
||||||
|
| `picker` | `array` | array of picker items |
|
||||||
|
| `audio` | `string` | direct link to a file or a link to cobalt's live render |
|
||||||
|
| `mediaMetadata` | `object` | Supported only on YouTube and Twitter videos. Object, that contains values `duration` (duration of the video in seconds), `likes`, `views`, `title` |
|
||||||
|
|
||||||
### tunnel/redirect response
|
### picker item variables
|
||||||
| key | type | values |
|
item type: `object`
|
||||||
|:-------------|:---------|:------------------------------------------------------------|
|
|
||||||
| `status` | `string` | `tunnel / redirect` |
|
|
||||||
| `url` | `string` | url for the cobalt tunnel, or redirect to an external link |
|
|
||||||
| `filename` | `string` | cobalt-generated filename for the file being downloaded |
|
|
||||||
|
|
||||||
### picker response
|
| key | type | variables | description |
|
||||||
| key | type | values |
|
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
|
||||||
|:----------------|:---------|:-------------------------------------------------------------------------------------------------|
|
| `type` | `string` | `video` | used only if `pickerType`is `various`. |
|
||||||
| `status` | `string` | `picker` |
|
| `url` | `string` | direct link to a file or a link to cobalt's live render | |
|
||||||
| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio |
|
| `thumb` | `string` | item thumbnail that's displayed in the picker | used only for `video` type. |
|
||||||
| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists |
|
|
||||||
| `picker` | `array` | array of objects containing the individual media |
|
|
||||||
|
|
||||||
#### picker object
|
## GET: `/api/stream`
|
||||||
| key | type | values |
|
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
|
||||||
|:-------------|:----------|:------------------------------------------------------------|
|
from a successful call to `/api/json`. however, the parameters passed to it are **opaque**
|
||||||
| `type` | `string` | `photo` / `video` / `gif` |
|
and **unmodifiable** from your (the api client's) perspective, and can change between versions.
|
||||||
| `url` | `string` | |
|
|
||||||
| `thumb` | `string` | **optional** thumbnail url |
|
|
||||||
|
|
||||||
### error response
|
therefore you don't need to worry about what they mean - but if you really want to know, you can
|
||||||
| key | type | values |
|
[read the source code](/src/modules/stream/manage.js).
|
||||||
|:-------------|:---------|:------------------------------------------------------------|
|
|
||||||
| `status` | `string` | `error` |
|
|
||||||
| `error` | `object` | contains more context about the error |
|
|
||||||
|
|
||||||
#### error object
|
## GET: `/api/serverInfo`
|
||||||
| key | type | values |
|
|
||||||
|:-------------|:---------|:------------------------------------------------------------|
|
|
||||||
| `code` | `string` | machine-readable error code explaining the failure reason |
|
|
||||||
| `context` | `object` | **optional** container for providing more context |
|
|
||||||
|
|
||||||
#### error.context object
|
|
||||||
| key | type | values |
|
|
||||||
|:-------------|:---------|:---------------------------------------------------------------------------------------------------------------|
|
|
||||||
| `service` | `string` | **optional**, stating which service was being downloaded from |
|
|
||||||
| `limit` | `number` | **optional** number providing the ratelimit maximum number of requests, or maximum downloadable video duration |
|
|
||||||
|
|
||||||
## GET: `/`
|
|
||||||
returns current basic server info.
|
returns current basic server info.
|
||||||
response body type: `application/json`
|
response body type: `application/json`
|
||||||
|
|
||||||
### response body
|
### response body variables
|
||||||
| key | type | variables |
|
|
||||||
|:------------|:---------|:---------------------------------------------------------|
|
|
||||||
| `cobalt` | `object` | information about the cobalt instance |
|
|
||||||
| `git` | `object` | information about the codebase that is currently running |
|
|
||||||
|
|
||||||
#### cobalt object
|
|
||||||
| key | type | description |
|
|
||||||
|:----------------|:-----------|:-----------------------------------------------|
|
|
||||||
| `version` | `string` | current version |
|
|
||||||
| `url` | `string` | server url |
|
|
||||||
| `startTime` | `string` | server start time in unix milliseconds |
|
|
||||||
| `durationLimit` | `number` | maximum downloadable video length in seconds |
|
|
||||||
| `services` | `string[]` | array of services which this instance supports |
|
|
||||||
|
|
||||||
#### git object
|
|
||||||
| key | type | variables |
|
| key | type | variables |
|
||||||
|:------------|:---------|:------------------|
|
|:------------|:---------|:------------------|
|
||||||
| `commit` | `string` | commit hash |
|
| `version` | `string` | cobalt version |
|
||||||
|
| `commit` | `string` | git commit |
|
||||||
| `branch` | `string` | git branch |
|
| `branch` | `string` | git branch |
|
||||||
| `remote` | `string` | git remote |
|
| `name` | `string` | server name |
|
||||||
|
| `url` | `string` | server url |
|
||||||
## POST: `/session`
|
| `cors` | `number` | cors status |
|
||||||
|
| `startTime` | `string` | server start time |
|
||||||
used for generating JWT tokens, if enabled. currently, cobalt only supports
|
|
||||||
generating tokens when a [turnstile](run-an-instance.md#list-of-all-environment-variables) challenge solution
|
|
||||||
is submitted by the client.
|
|
||||||
|
|
||||||
the turnstile challenge response is submitted via the `cf-turnstile-response` header.
|
|
||||||
### response body
|
|
||||||
| key | type | description |
|
|
||||||
|:----------------|:-----------|:-------------------------------------------------------|
|
|
||||||
| `token` | `string` | a `Bearer` token used for later request authentication |
|
|
||||||
| `exp` | `number` | number in seconds indicating the token lifetime |
|
|
||||||
|
|
||||||
on failure, an [error response](#error-response) is returned.
|
|
||||||
|
200
src/modules/processing/matchActionDecider.js
Normal file
200
src/modules/processing/matchActionDecider.js
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { audioIgnore, services, supportedAudio } from "../config.js";
|
||||||
|
import { createResponse } from "../processing/request.js";
|
||||||
|
import loc from "../../localization/manager.js";
|
||||||
|
import createFilename from "./createFilename.js";
|
||||||
|
import { createStream } from "../stream/manage.js";
|
||||||
|
|
||||||
|
export default function(r, host, userFormat, isAudioOnly, lang, isAudioMuted, disableMetadata, filenamePattern, toGif, requestIP) {
|
||||||
|
let action,
|
||||||
|
responseType = "stream",
|
||||||
|
defaultParams = {
|
||||||
|
u: r.urls,
|
||||||
|
headers: r.headers,
|
||||||
|
service: host,
|
||||||
|
filename: r.filenameAttributes ?
|
||||||
|
createFilename(r.filenameAttributes, filenamePattern, isAudioOnly, isAudioMuted) : r.filename,
|
||||||
|
fileMetadata: !disableMetadata ? r.fileMetadata : false,
|
||||||
|
mediaMetadata: r.mediaMetadata,
|
||||||
|
requestIP
|
||||||
|
},
|
||||||
|
params = {},
|
||||||
|
audioFormat = String(userFormat);
|
||||||
|
|
||||||
|
if (r.isPhoto) action = "photo";
|
||||||
|
else if (r.picker) action = "picker"
|
||||||
|
else if (r.isGif && toGif) action = "gif";
|
||||||
|
else if (isAudioMuted) action = "muteVideo";
|
||||||
|
else if (isAudioOnly) action = "audio";
|
||||||
|
else if (r.isM3U8) action = "m3u8";
|
||||||
|
else action = "video";
|
||||||
|
|
||||||
|
if (action === "picker" || action === "audio") {
|
||||||
|
if (!r.filenameAttributes) defaultParams.filename = r.audioFilename;
|
||||||
|
defaultParams.isAudioOnly = true;
|
||||||
|
defaultParams.audioFormat = audioFormat;
|
||||||
|
}
|
||||||
|
if (isAudioMuted && !r.filenameAttributes) {
|
||||||
|
defaultParams.filename = r.filename.replace('.', '_mute.')
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
default:
|
||||||
|
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') });
|
||||||
|
|
||||||
|
case "photo":
|
||||||
|
responseType = "redirect";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "gif":
|
||||||
|
params = { type: "gif" }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "m3u8":
|
||||||
|
params = {
|
||||||
|
type: Array.isArray(r.urls) ? "render" : "remux"
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "muteVideo":
|
||||||
|
let muteType = "mute";
|
||||||
|
if (Array.isArray(r.urls) && !r.isM3U8) {
|
||||||
|
muteType = "bridge";
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
type: muteType,
|
||||||
|
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
|
||||||
|
mute: true
|
||||||
|
}
|
||||||
|
if (host === "reddit" && r.typeId === "redirect")
|
||||||
|
responseType = "redirect";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "picker":
|
||||||
|
responseType = "picker";
|
||||||
|
switch (host) {
|
||||||
|
case "instagram":
|
||||||
|
case "twitter":
|
||||||
|
params = { picker: r.picker };
|
||||||
|
break;
|
||||||
|
case "tiktok":
|
||||||
|
let audioStreamType = "render";
|
||||||
|
if (r.bestAudio === "mp3" && (audioFormat === "mp3" || audioFormat === "best")) {
|
||||||
|
audioFormat = "mp3";
|
||||||
|
audioStreamType = "bridge"
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
picker: r.picker,
|
||||||
|
u: createStream({
|
||||||
|
service: "tiktok",
|
||||||
|
type: audioStreamType,
|
||||||
|
u: r.urls,
|
||||||
|
headers: r.headers,
|
||||||
|
filename: r.audioFilename,
|
||||||
|
isAudioOnly: true,
|
||||||
|
audioFormat,
|
||||||
|
}),
|
||||||
|
copy: audioFormat === "best"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
switch (host) {
|
||||||
|
case "bilibili":
|
||||||
|
params = { type: "render" };
|
||||||
|
break;
|
||||||
|
case "youtube":
|
||||||
|
params = { type: r.type };
|
||||||
|
break;
|
||||||
|
case "reddit":
|
||||||
|
responseType = r.typeId;
|
||||||
|
params = { type: r.type };
|
||||||
|
break;
|
||||||
|
case "vimeo":
|
||||||
|
if (Array.isArray(r.urls)) {
|
||||||
|
params = { type: "render" }
|
||||||
|
} else {
|
||||||
|
responseType = "redirect";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "twitter":
|
||||||
|
if (r.type === "remux") {
|
||||||
|
params = { type: r.type };
|
||||||
|
} else {
|
||||||
|
responseType = "redirect";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "vk":
|
||||||
|
case "tiktok":
|
||||||
|
params = { type: "bridge" };
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "vine":
|
||||||
|
case "instagram":
|
||||||
|
case "tumblr":
|
||||||
|
case "pinterest":
|
||||||
|
case "streamable":
|
||||||
|
case "loom":
|
||||||
|
responseType = "redirect";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
if (audioIgnore.includes(host)
|
||||||
|
|| (host === "reddit" && r.typeId === "redirect")) {
|
||||||
|
return createResponse("error", { t: loc(lang, 'ErrorEmptyDownload') })
|
||||||
|
}
|
||||||
|
|
||||||
|
let processType = "render",
|
||||||
|
copy = false;
|
||||||
|
|
||||||
|
if (!supportedAudio.includes(audioFormat)) {
|
||||||
|
audioFormat = "best"
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceBestAudio = r.bestAudio || services[host]["bestAudio"];
|
||||||
|
const isBestAudio = audioFormat === "best";
|
||||||
|
const isBestOrMp3 = isBestAudio || audioFormat === "mp3";
|
||||||
|
const isBestAudioDefined = isBestAudio && serviceBestAudio;
|
||||||
|
const isBestHostAudio = serviceBestAudio && (audioFormat === serviceBestAudio);
|
||||||
|
|
||||||
|
const isTumblrAudio = host === "tumblr" && !r.filename;
|
||||||
|
const isSoundCloud = host === "soundcloud";
|
||||||
|
const isTiktok = host === "tiktok";
|
||||||
|
|
||||||
|
if (isBestAudioDefined || isBestHostAudio) {
|
||||||
|
audioFormat = serviceBestAudio;
|
||||||
|
processType = "bridge";
|
||||||
|
if (isSoundCloud || (isTiktok && audioFormat === "m4a")) {
|
||||||
|
processType = "render"
|
||||||
|
copy = true
|
||||||
|
}
|
||||||
|
} else if (isBestAudio && !isSoundCloud) {
|
||||||
|
audioFormat = "m4a";
|
||||||
|
copy = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTumblrAudio && isBestOrMp3) {
|
||||||
|
audioFormat = "mp3";
|
||||||
|
processType = "bridge"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.isM3U8 || host === "vimeo") {
|
||||||
|
copy = false;
|
||||||
|
processType = "render"
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
type: processType,
|
||||||
|
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
|
||||||
|
audioFormat: audioFormat,
|
||||||
|
copy: copy
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createResponse(responseType, {...defaultParams, ...params})
|
||||||
|
}
|
164
src/modules/processing/request.js
Normal file
164
src/modules/processing/request.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import ipaddr from "ipaddr.js";
|
||||||
|
|
||||||
|
import { normalizeURL } from "../processing/url.js";
|
||||||
|
import { createStream } from "../stream/manage.js";
|
||||||
|
import { verifyLanguageCode } from "../sub/utils.js";
|
||||||
|
|
||||||
|
const apiVar = {
|
||||||
|
allowed: {
|
||||||
|
vCodec: ["h264", "av1", "vp9"],
|
||||||
|
vQuality: ["max", "4320", "2160", "1440", "1080", "720", "480", "360", "240", "144"],
|
||||||
|
aFormat: ["best", "mp3", "ogg", "wav", "opus"],
|
||||||
|
filenamePattern: ["classic", "pretty", "basic", "nerdy"]
|
||||||
|
},
|
||||||
|
booleanOnly: [
|
||||||
|
"isAudioOnly",
|
||||||
|
"isTTFullAudio",
|
||||||
|
"isAudioMuted",
|
||||||
|
"dubLang",
|
||||||
|
"disableMetadata",
|
||||||
|
"twitterGif",
|
||||||
|
"tiktokH265"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createResponse(responseType, responseData) {
|
||||||
|
const internalError = (text) => {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
status: "error",
|
||||||
|
text: text || "Internal Server Error",
|
||||||
|
critical: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let status = 200,
|
||||||
|
response = {};
|
||||||
|
|
||||||
|
switch(responseType) {
|
||||||
|
case "error":
|
||||||
|
status = 400;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rate-limit":
|
||||||
|
status = 429;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (responseType) {
|
||||||
|
case "error":
|
||||||
|
case "success":
|
||||||
|
case "rate-limit":
|
||||||
|
response = {
|
||||||
|
text: responseData.t
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "redirect":
|
||||||
|
response = {
|
||||||
|
url: responseData.u,
|
||||||
|
mediaMetadata: responseData.mediaMetadata,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "stream":
|
||||||
|
response = {
|
||||||
|
url: createStream(responseData),
|
||||||
|
mediaMetadata: responseData.mediaMetadata,
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "picker":
|
||||||
|
let pickerType = "various",
|
||||||
|
audio = false;
|
||||||
|
|
||||||
|
if (responseData.service === "tiktok") {
|
||||||
|
audio = responseData.u
|
||||||
|
pickerType = "images"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = {
|
||||||
|
pickerType: pickerType,
|
||||||
|
picker: responseData.picker,
|
||||||
|
audio: audio
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "critical":
|
||||||
|
return internalError(responseData.t)
|
||||||
|
default:
|
||||||
|
throw "unreachable"
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
status: responseType,
|
||||||
|
...response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return internalError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRequest(request) {
|
||||||
|
try {
|
||||||
|
let template = {
|
||||||
|
url: normalizeURL(decodeURIComponent(request.url)),
|
||||||
|
vCodec: "h264",
|
||||||
|
vQuality: "720",
|
||||||
|
aFormat: "mp3",
|
||||||
|
filenamePattern: "classic",
|
||||||
|
isAudioOnly: false,
|
||||||
|
isTTFullAudio: false,
|
||||||
|
isAudioMuted: false,
|
||||||
|
disableMetadata: false,
|
||||||
|
dubLang: false,
|
||||||
|
twitterGif: false,
|
||||||
|
tiktokH265: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestKeys = Object.keys(request);
|
||||||
|
const templateKeys = Object.keys(template);
|
||||||
|
|
||||||
|
if (requestKeys.length > templateKeys.length + 1 || !request.url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const i in requestKeys) {
|
||||||
|
const key = requestKeys[i];
|
||||||
|
const item = request[key];
|
||||||
|
|
||||||
|
if (String(key) !== "url" && templateKeys.includes(key)) {
|
||||||
|
if (apiVar.booleanOnly.includes(key)) {
|
||||||
|
template[key] = !!item;
|
||||||
|
} else if (apiVar.allowed[key] && apiVar.allowed[key].includes(item)) {
|
||||||
|
template[key] = String(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.dubLang)
|
||||||
|
template.dubLang = verifyLanguageCode(request.dubLang);
|
||||||
|
|
||||||
|
return template
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIP(req) {
|
||||||
|
const strippedIP = req.ip.replace(/^::ffff:/, '');
|
||||||
|
const ip = ipaddr.parse(strippedIP);
|
||||||
|
if (ip.kind() === 'ipv4') {
|
||||||
|
return strippedIP;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = 56;
|
||||||
|
const v6Bytes = ip.toByteArray();
|
||||||
|
v6Bytes.fill(0, prefix / 8);
|
||||||
|
|
||||||
|
return ipaddr.fromByteArray(v6Bytes).toString();
|
||||||
|
}
|
61
src/modules/processing/services/vk.js
Normal file
61
src/modules/processing/services/vk.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { genericUserAgent, env } from "../../config.js";
|
||||||
|
import { cleanString } from "../../sub/utils.js";
|
||||||
|
|
||||||
|
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
|
||||||
|
|
||||||
|
export default async function(o) {
|
||||||
|
let html, url, quality = o.quality === "max" ? 2160 : o.quality;
|
||||||
|
|
||||||
|
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
|
||||||
|
headers: { "user-agent": genericUserAgent }
|
||||||
|
}).then(r => r.arrayBuffer()).catch(() => {});
|
||||||
|
|
||||||
|
if (!html) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
// decode cyrillic from windows-1251 because vk still uses apis from prehistoric times
|
||||||
|
let decoder = new TextDecoder('windows-1251');
|
||||||
|
html = decoder.decode(html);
|
||||||
|
|
||||||
|
if (!html.includes(`{"lang":`)) return { error: 'ErrorEmptyDownload' };
|
||||||
|
|
||||||
|
let js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
|
||||||
|
|
||||||
|
if (Number(js.mvData.is_active_live) !== 0) return { error: 'ErrorLiveVideo' };
|
||||||
|
if (js.mvData.duration > env.durationLimit)
|
||||||
|
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
||||||
|
|
||||||
|
for (let i in resolutions) {
|
||||||
|
if (js.player.params[0][`url${resolutions[i]}`]) {
|
||||||
|
quality = resolutions[i];
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Number(quality) > Number(o.quality)) quality = o.quality;
|
||||||
|
|
||||||
|
url = js.player.params[0][`url${quality}`];
|
||||||
|
|
||||||
|
let fileMetadata = {
|
||||||
|
title: cleanString(js.player.params[0].md_title.trim()),
|
||||||
|
author: cleanString(js.player.params[0].md_author.trim()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) return {
|
||||||
|
urls: url,
|
||||||
|
filenameAttributes: {
|
||||||
|
service: "vk",
|
||||||
|
id: `${o.userId}_${o.videoId}`,
|
||||||
|
title: fileMetadata.title,
|
||||||
|
author: fileMetadata.author,
|
||||||
|
resolution: `${quality}p`,
|
||||||
|
qualityLabel: `${quality}p`,
|
||||||
|
extension: "mp4"
|
||||||
|
},
|
||||||
|
mediaMetadata: {
|
||||||
|
duration: js.player.params[0].duration,
|
||||||
|
likes: js.mvData.likes,
|
||||||
|
views: js.videoModalInfoData.views,
|
||||||
|
title: js.mvData.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { error: 'ErrorEmptyDownload' }
|
||||||
|
}
|
245
src/modules/processing/services/youtube.js
Normal file
245
src/modules/processing/services/youtube.js
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { Innertube, Session } from 'youtubei.js';
|
||||||
|
import { env } from '../../config.js';
|
||||||
|
import { cleanString } from '../../sub/utils.js';
|
||||||
|
import { fetch } from 'undici'
|
||||||
|
import { getCookie, updateCookieValues } from '../cookie/manager.js'
|
||||||
|
|
||||||
|
const ytBase = Innertube.create().catch(e => e);
|
||||||
|
|
||||||
|
const codecMatch = {
|
||||||
|
h264: {
|
||||||
|
codec: "avc1",
|
||||||
|
aCodec: "mp4a",
|
||||||
|
container: "mp4"
|
||||||
|
},
|
||||||
|
av1: {
|
||||||
|
codec: "av01",
|
||||||
|
aCodec: "mp4a",
|
||||||
|
container: "mp4"
|
||||||
|
},
|
||||||
|
vp9: {
|
||||||
|
codec: "vp9",
|
||||||
|
aCodec: "opus",
|
||||||
|
container: "webm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformSessionData = (cookie) => {
|
||||||
|
if (!cookie)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const values = cookie.values();
|
||||||
|
const REQUIRED_VALUES = [
|
||||||
|
'access_token', 'refresh_token',
|
||||||
|
'client_id', 'client_secret',
|
||||||
|
'expires'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (REQUIRED_VALUES.some(x => typeof values[x] !== 'string')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...values,
|
||||||
|
expires: new Date(values.expires),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneInnertube = async (customFetch) => {
|
||||||
|
const innertube = await ytBase;
|
||||||
|
if (innertube instanceof Error) {
|
||||||
|
throw innertube;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new Session(
|
||||||
|
innertube.session.context,
|
||||||
|
innertube.session.key,
|
||||||
|
innertube.session.api_version,
|
||||||
|
innertube.session.account_index,
|
||||||
|
innertube.session.player,
|
||||||
|
undefined,
|
||||||
|
customFetch ?? innertube.session.http.fetch,
|
||||||
|
innertube.session.cache
|
||||||
|
);
|
||||||
|
|
||||||
|
const cookie = getCookie('youtube_oauth');
|
||||||
|
const oauthData = transformSessionData(cookie);
|
||||||
|
|
||||||
|
if (!session.logged_in && oauthData) {
|
||||||
|
await session.oauth.init(oauthData);
|
||||||
|
session.logged_in = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.logged_in) {
|
||||||
|
await session.oauth.refreshIfRequired();
|
||||||
|
const oldExpiry = new Date(cookie.values().expires);
|
||||||
|
const newExpiry = session.oauth.credentials.expires;
|
||||||
|
|
||||||
|
if (oldExpiry.getTime() !== newExpiry.getTime()) {
|
||||||
|
updateCookieValues(cookie, {
|
||||||
|
...session.oauth.credentials,
|
||||||
|
expires: session.oauth.credentials.expires.toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const yt = new Innertube(session);
|
||||||
|
return yt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function(o) {
|
||||||
|
const yt = await 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.quality_label.split('p')[0].split('s')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
info = await yt.getBasicInfo(o.id, yt.session.logged_in ? 'ANDROID' : 'IOS');
|
||||||
|
} catch(e) {
|
||||||
|
if (e?.message === 'This video is unavailable') {
|
||||||
|
return { error: 'ErrorCouldntFetch' };
|
||||||
|
} else {
|
||||||
|
return { error: 'ErrorCantConnectToServiceAPI' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
|
||||||
|
|
||||||
|
const playability = info.playability_status;
|
||||||
|
|
||||||
|
if (playability.status === 'LOGIN_REQUIRED') {
|
||||||
|
if (playability.reason.endsWith('bot')) {
|
||||||
|
return { error: 'ErrorYTLogin' }
|
||||||
|
}
|
||||||
|
if (playability.reason.endsWith('age')) {
|
||||||
|
return { error: 'ErrorYTAgeRestrict' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (playability.status === "UNPLAYABLE" && playability.reason.endsWith('request limit.')) {
|
||||||
|
return { error: 'ErrorYTRateLimit' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playability.status !== 'OK') return { error: 'ErrorYTUnavailable' };
|
||||||
|
if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' };
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
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' };
|
||||||
|
if (info.basic_info.duration > env.durationLimit) return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
||||||
|
|
||||||
|
let checkBestAudio = (i) => (i.has_audio && !i.has_video),
|
||||||
|
audio = adaptive_formats.find(i => checkBestAudio(i) && !i.is_dubbed);
|
||||||
|
|
||||||
|
if (o.dubLang) {
|
||||||
|
let dubbedAudio = adaptive_formats.find(i =>
|
||||||
|
checkBestAudio(i) && i.language === o.dubLang && i.audio_track && !i.audio_track.audio_is_default
|
||||||
|
);
|
||||||
|
if (dubbedAudio) {
|
||||||
|
audio = dubbedAudio;
|
||||||
|
isDubbed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fileMetadata = {
|
||||||
|
title: cleanString(info.basic_info.title.trim()),
|
||||||
|
artist: cleanString(info.basic_info.author.replace("- Topic", "").trim()),
|
||||||
|
}
|
||||||
|
if (info.basic_info.short_description && info.basic_info.short_description.startsWith("Provided to YouTube by")) {
|
||||||
|
let descItems = info.basic_info.short_description.split("\n\n");
|
||||||
|
fileMetadata.album = descItems[2];
|
||||||
|
fileMetadata.copyright = descItems[3];
|
||||||
|
if (descItems[4].startsWith("Released on:")) {
|
||||||
|
fileMetadata.date = descItems[4].replace("Released on: ", '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let filenameAttributes = {
|
||||||
|
service: "youtube",
|
||||||
|
id: o.id,
|
||||||
|
title: fileMetadata.title,
|
||||||
|
author: fileMetadata.artist,
|
||||||
|
youtubeDubName: isDubbed ? o.dubLang : false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAudio && o.isAudioOnly) return {
|
||||||
|
type: "render",
|
||||||
|
isAudioOnly: true,
|
||||||
|
urls: audio.decipher(yt.session.player),
|
||||||
|
filenameAttributes: filenameAttributes,
|
||||||
|
fileMetadata: fileMetadata,
|
||||||
|
bestAudio: format === "h264" ? 'm4a' : 'opus'
|
||||||
|
}
|
||||||
|
const matchingQuality = Number(quality) > Number(bestQuality) ? bestQuality : quality,
|
||||||
|
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 && format === 'h264') {
|
||||||
|
match = info.streaming_data.formats.find(checkSingle);
|
||||||
|
type = "bridge";
|
||||||
|
urls = match?.decipher(yt.session.player);
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = adaptive_formats.find(checkRender);
|
||||||
|
if (!match && video) {
|
||||||
|
match = video;
|
||||||
|
type = "render";
|
||||||
|
urls = [video.decipher(yt.session.player), audio.decipher(yt.session.player)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaMetadata = {
|
||||||
|
duration: info.basic_info.duration,
|
||||||
|
likes: info.basic_info.like_count,
|
||||||
|
views: info.basic_info.view_count,
|
||||||
|
title: info.basic_info.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
filenameAttributes.qualityLabel = match.quality_label;
|
||||||
|
filenameAttributes.resolution = `${match.width}x${match.height}`;
|
||||||
|
filenameAttributes.extension = codecMatch[format].container;
|
||||||
|
filenameAttributes.youtubeFormat = format;
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
urls,
|
||||||
|
filenameAttributes,
|
||||||
|
fileMetadata,
|
||||||
|
mediaMetadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: 'ErrorYTTryOtherCodec' }
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user