Merge commit 'refs/pull/613/head' of https://github.com/imputnet/cobalt

This commit is contained in:
Всеволод О. 2024-12-08 15:35:40 +03:00
commit f03188fea4
6 changed files with 760 additions and 204 deletions

View File

@ -36,6 +36,17 @@ function bestQuality(arr) {
.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;
const getGuestToken = async (dispatcher, forceReload = false) => {
if (_cachedToken && !forceReload) {
@ -101,11 +112,11 @@ const requestTweet = async(dispatcher, tweetId, token, cookie) => {
return result
}
export default async function({ id, index, toGif, dispatcher, alwaysProxy }) {
export default async function({ id, index, toGif, dispatcher }) {
const cookie = await getCookie('twitter');
let guestToken = await getGuestToken(dispatcher);
if (!guestToken) return { error: "fetch.fail" };
if (!guestToken) return { error: 'ErrorCouldntFetch' };
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;
if (!tweetTypename) {
return { error: "fetch.empty" }
}
if (tweetTypename === "TweetUnavailable") {
const reason = tweet?.data?.tweetResult?.result?.reason;
switch(reason) {
case "Protected":
return { error: "content.post.private" }
return { error: 'ErrorTweetProtected' }
case "NsfwLoggedOut":
if (cookie) {
tweet = await requestTweet(dispatcher, id, guestToken, cookie);
tweet = await tweet.json();
tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
} else return { error: "content.post.age" }
} else return { error: 'ErrorTweetNSFW' }
}
}
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
return { error: "content.post.unavailable" }
return { error: 'ErrorTweetUnavailable' }
}
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);
media = media?.filter(m => m.video_info?.variants?.length);
// check if there's a video at given index (/video/<index>)
if (index >= 0 && index < media?.length) {
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) {
case undefined:
case 0:
return {
error: "fetch.empty"
}
return { error: 'ErrorNoVideosInTweet' };
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 {
type: needsFixing(media[0]) ? "remux" : "proxy",
type: needsFixing(media[0]) ? "remux" : "normal",
urls: bestQuality(media[0].video_info.variants),
filename: `twitter_${id}.mp4`,
audioFilename: `twitter_${id}_audio`,
isGif: media[0].type === "animated_gif"
}
isGif: media[0].type === "animated_gif",
mediaMetadata: buildMediaMetadata(tweetResult, media[0])
};
default:
const proxyThumb = (url, i) =>
proxyMedia(url, `twitter_${id}_${i + 1}.${getFileExt(url)}`);
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);
const shouldRenderGif = content.type === "animated_gif" && toGif;
const videoFilename = `twitter_${id}_${i + 1}.${shouldRenderGif ? "gif" : "mp4"}`;
let type = "video";
if (shouldRenderGif) type = "gif";
const shouldRenderGif = content.type === 'animated_gif' && toGif;
if (needsFixing(content) || shouldRenderGif) {
url = createStream({
service: "twitter",
type: shouldRenderGif ? "gif" : "remux",
url,
filename: videoFilename,
service: 'twitter',
type: shouldRenderGif ? 'gif' : 'remux',
u: url,
filename: `twitter_${id}_${i + 1}.mp4`
})
} else if (alwaysProxy) {
url = proxyMedia(url, videoFilename);
}
return {
type,
type: 'video',
url,
thumb: proxyThumb(content.media_url_https, i),
thumb: content.media_url_https,
mediaMetadata: buildMediaMetadata(tweetResult, content)
}
});
return { picker };

View File

@ -1,161 +1,78 @@
# cobalt api documentation
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>
```
currently, cobalt supports two ways of authentication. an instance can
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.
request body type: `application/json`
response body type: `application/json`
> [!IMPORTANT]
> you must include `Accept` and `Content-Type` headers with every `POST /` request.
this document provides info about methods and acceptable variables for all cobalt api requests.
```
👍 you can use api.cobalt.tools in your projects for free, just don't be an asshole.
```
## POST: `/api/json`
cobalt's main processing endpoint.
request body type: `application/json`
response body type: `application/json`
```
⚠️ you must include Accept and Content-Type headers with every POST /api/json request.
Accept: application/json
Content-Type: application/json
```
### request body
| key | type | expected value(s) | default | description |
|:-----------------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
| `url` | `string` | URL to download | -- | **must** be included in every request. |
| `videoQuality` | `string` | `144 / ... / 2160 / 4320 / max` | `1080` | `720` quality is recommended for phones. |
| `audioFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
| `audioBitrate` | `string` | `320 / 256 / 128 / 96 / 64 / 8` | `128` | specifies the bitrate to use for the audio. applies only to audio conversion. |
| `filenameStyle` | `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. |
| `youtubeVideoCodec` | `string` | `h264 / av1 / vp9` | `h264` | `h264` is recommended for phones. |
| `youtubeDubLang` | `string` | `en / ru / cs / ja / es-US / ...` | -- | specifies the language of audio to download when a youtube video is dubbed. |
| `alwaysProxy` | `boolean` | `true / false` | `false` | tunnels all downloads through the processing server, even when not necessary. |
| `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. |
| `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. |
### request body variables
| key | type | variables | default | description |
|:------------------|:----------|:-----------------------------------|:----------|:--------------------------------------------------------------------------------|
| `url` | `string` | URL encoded as URI | `null` | **must** be included in every request. |
| `vCodec` | `string` | `h264 / av1 / vp9` | `h264` | applies only to youtube downloads. `h264` is recommended for phones. |
| `vQuality` | `string` | `144 / ... / 2160 / max` | `720` | `720` quality is recommended for phones. |
| `aFormat` | `string` | `best / mp3 / ogg / wav / opus` | `mp3` | |
| `filenamePattern` | `string` | `classic / pretty / basic / nerdy` | `classic` | changes the way files are named. previews can be seen in the web app. |
| `isAudioOnly` | `boolean` | `true / false` | `false` | |
| `isTTFullAudio` | `boolean` | `true / false` | `false` | enables download of original sound used in a tiktok video. |
| `isAudioMuted` | `boolean` | `true / false` | `false` | disables audio track in video downloads. |
| `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 |
| `tiktokH265` | `boolean` | `true / false` | `false` | changes whether 1080p h265 videos are preferred or not. |
### response
the response will always be a JSON object containing the `status` key, which will be one of:
- `error` - something went wrong
- `picker` - we have multiple items to choose from
- `redirect` - you are being redirected to the direct service URL
- `tunnel` - cobalt is proxying the download for you
### response body variables
| key | type | variables |
|:----------------|:---------|:------------------------------------------------------------|
| `status` | `string` | `error / redirect / stream / success / rate-limit / picker` |
| `text` | `string` | various text, mostly used for errors |
| `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
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `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 item variables
item type: `object`
### picker response
| key | type | values |
|:----------------|:---------|:-------------------------------------------------------------------------------------------------|
| `status` | `string` | `picker` |
| `audio` | `string` | **optional** returned when an image slideshow (such as on tiktok) has a general background audio |
| `audioFilename` | `string` | **optional** cobalt-generated filename, returned if `audio` exists |
| `picker` | `array` | array of objects containing the individual media |
| key | type | variables | description |
|:--------|:---------|:--------------------------------------------------------|:---------------------------------------|
| `type` | `string` | `video` | used only if `pickerType`is `various`. |
| `url` | `string` | direct link to a file or a link to cobalt's live render | |
| `thumb` | `string` | item thumbnail that's displayed in the picker | used only for `video` type. |
#### picker object
| key | type | values |
|:-------------|:----------|:------------------------------------------------------------|
| `type` | `string` | `photo` / `video` / `gif` |
| `url` | `string` | |
| `thumb` | `string` | **optional** thumbnail url |
## GET: `/api/stream`
cobalt's live render (or stream) endpoint. usually, you will receive a url to this endpoint
from a successful call to `/api/json`. however, the parameters passed to it are **opaque**
and **unmodifiable** from your (the api client's) perspective, and can change between versions.
### error response
| key | type | values |
|:-------------|:---------|:------------------------------------------------------------|
| `status` | `string` | `error` |
| `error` | `object` | contains more context about the error |
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).
#### error object
| 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.
## GET: `/api/serverInfo`
returns current basic server info.
response body type: `application/json`
### response body
| 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
### response body variables
| key | type | variables |
|:------------|:---------|:------------------|
| `commit` | `string` | commit hash |
| `version` | `string` | cobalt version |
| `commit` | `string` | git commit |
| `branch` | `string` | git branch |
| `remote` | `string` | git remote |
## POST: `/session`
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.
| `name` | `string` | server name |
| `url` | `string` | server url |
| `cors` | `number` | cors status |
| `startTime` | `string` | server start time |

View 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})
}

View 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();
}

View 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' }
}

View 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' }
}