mirror of
https://github.com/imputnet/cobalt.git
synced 2025-07-18 19:28:29 +00:00
commit 6/24 pt.2
This commit is contained in:
parent
5de578853a
commit
005efe35e6
@ -171,8 +171,8 @@ export default function(obj) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
+ settingsCategory({
|
+ settingsCategory({
|
||||||
name: "twitter",
|
name: "gif",
|
||||||
title: "twitter",
|
title: "gif",
|
||||||
body: checkbox([{
|
body: checkbox([{
|
||||||
action: "twitterGif",
|
action: "twitterGif",
|
||||||
name: t("SettingsTwitterGif"),
|
name: t("SettingsTwitterGif"),
|
||||||
@ -181,8 +181,8 @@ export default function(obj) {
|
|||||||
+ explanation(t('SettingsTwitterGifDescription'))
|
+ explanation(t('SettingsTwitterGifDescription'))
|
||||||
})
|
})
|
||||||
+ settingsCategory({
|
+ settingsCategory({
|
||||||
name: "tiktok",
|
name: "h265",
|
||||||
title: "tiktok",
|
title: "h265",
|
||||||
body: checkbox([{
|
body: checkbox([{
|
||||||
action: "tiktokH265",
|
action: "tiktokH265",
|
||||||
name: t("SettingsTikTokH265"),
|
name: t("SettingsTikTokH265"),
|
||||||
@ -220,8 +220,8 @@ export default function(obj) {
|
|||||||
+ explanation(t('SettingsYoutubeDubDescription'))
|
+ explanation(t('SettingsYoutubeDubDescription'))
|
||||||
})
|
})
|
||||||
+ settingsCategory({
|
+ settingsCategory({
|
||||||
name: "tiktok-audio",
|
name: "full audio",
|
||||||
title: "tiktok",
|
title: "full audio",
|
||||||
body: checkbox([{
|
body: checkbox([{
|
||||||
action: "fullTikTokAudio",
|
action: "fullTikTokAudio",
|
||||||
name: t("SettingsAudioFullTikTok"),
|
name: t("SettingsAudioFullTikTok"),
|
||||||
@ -386,6 +386,7 @@ export default function(obj) {
|
|||||||
visible: true,
|
visible: true,
|
||||||
action: "popup('about', 1, 'changelog')"
|
action: "popup('about', 1, 'changelog')"
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div id="cobalt-main-box" class="center">
|
<div id="cobalt-main-box" class="center">
|
||||||
<div id="logo">${t("AppTitleInsta")}${betaTag()}</div>
|
<div id="logo">${t("AppTitleInsta")}${betaTag()}</div>
|
||||||
<br></br><br></br><br></br>
|
<br></br><br></br><br></br>
|
||||||
|
@ -1,105 +0,0 @@
|
|||||||
import { genericUserAgent, env } from "../../config.js";
|
|
||||||
|
|
||||||
// TO-DO: higher quality downloads (currently requires an account)
|
|
||||||
|
|
||||||
function com_resolveShortlink(shortId) {
|
|
||||||
return fetch(`https://b23.tv/${shortId}`, { redirect: 'manual' })
|
|
||||||
.then(r => r.status > 300 && r.status < 400 && r.headers.get('location'))
|
|
||||||
.then(url => {
|
|
||||||
if (!url) return;
|
|
||||||
const path = new URL(url).pathname;
|
|
||||||
if (path.startsWith('/video/'))
|
|
||||||
return path.split('/')[2];
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBest(content) {
|
|
||||||
return content?.filter(v => v.baseUrl || v.url)
|
|
||||||
.map(v => (v.baseUrl = v.baseUrl || v.url, v))
|
|
||||||
.reduce((a, b) => a?.bandwidth > b?.bandwidth ? a : b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractBestQuality(dashData) {
|
|
||||||
const bestVideo = getBest(dashData.video),
|
|
||||||
bestAudio = getBest(dashData.audio);
|
|
||||||
|
|
||||||
if (!bestVideo || !bestAudio) return [];
|
|
||||||
return [ bestVideo, bestAudio ];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function com_download(id) {
|
|
||||||
let html = await fetch(`https://bilibili.com/video/${id}`, {
|
|
||||||
headers: { "user-agent": genericUserAgent }
|
|
||||||
}).then(r => r.text()).catch(() => {});
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
if (!(html.includes('<script>window.__playinfo__=') && html.includes('"video_codecid"'))) {
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
|
|
||||||
if (streamData.data.timelength > env.durationLimit * 1000) {
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ video, audio ] = extractBestQuality(streamData.data.dash);
|
|
||||||
if (!video || !audio) {
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: [video.baseUrl, audio.baseUrl],
|
|
||||||
audioFilename: `bilibili_${id}_audio`,
|
|
||||||
filename: `bilibili_${id}_${video.width}x${video.height}.mp4`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tv_download(id) {
|
|
||||||
const url = new URL(
|
|
||||||
'https://api.bilibili.tv/intl/gateway/web/playurl'
|
|
||||||
+ '?s_locale=en_US&platform=web&qn=64&type=0&device=wap'
|
|
||||||
+ '&tf=0&spm_id=bstar-web.ugc-video-detail.0.0&from_spm_id='
|
|
||||||
);
|
|
||||||
|
|
||||||
url.searchParams.set('aid', id);
|
|
||||||
|
|
||||||
const { data } = await fetch(url).then(a => a.json());
|
|
||||||
if (!data?.playurl?.video) {
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [ video, audio ] = extractBestQuality({
|
|
||||||
video: data.playurl.video.map(s => s.video_resource)
|
|
||||||
.filter(s => s.codecs.includes('avc1')),
|
|
||||||
audio: data.playurl.audio_resource
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!video || !audio) {
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (video.duration > env.durationLimit * 1000) {
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: [video.url, audio.url],
|
|
||||||
audioFilename: `bilibili_tv_${id}_audio`,
|
|
||||||
filename: `bilibili_tv_${id}.mp4`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function({ comId, tvId, comShortLink }) {
|
|
||||||
if (comShortLink) {
|
|
||||||
comId = await com_resolveShortlink(comShortLink);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (comId) {
|
|
||||||
return com_download(comId);
|
|
||||||
} else if (tvId) {
|
|
||||||
return tv_download(tvId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorCouldntFetch' };
|
|
||||||
}
|
|
@ -1,107 +0,0 @@
|
|||||||
import HLSParser from 'hls-parser';
|
|
||||||
import { env } from '../../config.js';
|
|
||||||
|
|
||||||
let _token;
|
|
||||||
|
|
||||||
function getExp(token) {
|
|
||||||
return JSON.parse(
|
|
||||||
Buffer.from(token.split('.')[1], 'base64')
|
|
||||||
).exp * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getToken = async () => {
|
|
||||||
if (_token && getExp(_token) > new Date().getTime()) {
|
|
||||||
return _token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = await fetch('https://graphql.api.dailymotion.com/oauth/token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
|
||||||
'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
|
|
||||||
'Authorization': 'Basic MGQyZDgyNjQwOWFmOWU3MmRiNWQ6ODcxNmJmYTVjYmEwMmUwMGJkYTVmYTg1NTliNDIwMzQ3NzIyYWMzYQ=='
|
|
||||||
},
|
|
||||||
body: 'traffic_segment=&grant_type=client_credentials'
|
|
||||||
}).then(r => r.json()).catch(() => {});
|
|
||||||
|
|
||||||
if (req.access_token) {
|
|
||||||
return _token = req.access_token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function({ id }) {
|
|
||||||
const token = await getToken();
|
|
||||||
if (!token) return { error: 'ErrorSomethingWentWrong' };
|
|
||||||
|
|
||||||
const req = await fetch('https://graphql.api.dailymotion.com/',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'dailymotion/240213162706 CFNetwork/1492.0.1 Darwin/23.3.0',
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-DM-AppInfo-Version': '7.16.0_240213162706',
|
|
||||||
'X-DM-AppInfo-Type': 'iosapp',
|
|
||||||
'X-DM-AppInfo-Id': 'com.dailymotion.dailymotion'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
operationName: "Media",
|
|
||||||
query: `
|
|
||||||
query Media($xid: String!, $password: String) {
|
|
||||||
media(xid: $xid, password: $password) {
|
|
||||||
__typename
|
|
||||||
... on Video {
|
|
||||||
xid
|
|
||||||
hlsURL
|
|
||||||
duration
|
|
||||||
title
|
|
||||||
channel {
|
|
||||||
displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
variables: { xid: id }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
).then(r => r.status === 200 && r.json()).catch(() => {});
|
|
||||||
|
|
||||||
const media = req?.data?.media;
|
|
||||||
|
|
||||||
if (media?.__typename !== 'Video' || !media.hlsURL) {
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (media.duration > env.durationLimit) {
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = await fetch(media.hlsURL).then(r => r.text()).catch(() => {});
|
|
||||||
if (!manifest) return { error: 'ErrorSomethingWentWrong' };
|
|
||||||
|
|
||||||
const bestQuality = HLSParser.parse(manifest).variants
|
|
||||||
.filter(v => v.codecs.includes('avc1'))
|
|
||||||
.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b);
|
|
||||||
if (!bestQuality) return { error: 'ErrorEmptyDownload' }
|
|
||||||
|
|
||||||
const fileMetadata = {
|
|
||||||
title: media.title,
|
|
||||||
artist: media.channel.displayName
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: bestQuality.uri,
|
|
||||||
isM3U8: true,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: 'dailymotion',
|
|
||||||
id: media.xid,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist,
|
|
||||||
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
|
||||||
qualityLabel: `${bestQuality.resolution.height}p`,
|
|
||||||
extension: 'mp4'
|
|
||||||
},
|
|
||||||
fileMetadata
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
import { genericUserAgent } from "../../config.js";
|
|
||||||
|
|
||||||
export default async function({ id }) {
|
|
||||||
const gql = await fetch(`https://www.loom.com/api/campaigns/sessions/${id}/transcoded-url`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"user-agent": genericUserAgent,
|
|
||||||
origin: "https://www.loom.com",
|
|
||||||
referer: `https://www.loom.com/share/${id}`,
|
|
||||||
cookie: `loom_referral_video=${id};`,
|
|
||||||
|
|
||||||
"apollographql-client-name": "web",
|
|
||||||
"apollographql-client-version": "14c0b42",
|
|
||||||
"x-loom-request-source": "loom_web_14c0b42",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
force_original: false,
|
|
||||||
password: null,
|
|
||||||
anonID: null,
|
|
||||||
deviceID: null
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (!gql) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
const videoUrl = gql?.url;
|
|
||||||
|
|
||||||
if (videoUrl?.includes('.mp4?')) {
|
|
||||||
return {
|
|
||||||
urls: videoUrl,
|
|
||||||
filename: `loom_${id}.mp4`,
|
|
||||||
audioFilename: `loom_${id}_audio`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
import { genericUserAgent, env } from "../../config.js";
|
|
||||||
import { cleanString } from "../../sub/utils.js";
|
|
||||||
|
|
||||||
const resolutions = {
|
|
||||||
"ultra": "2160",
|
|
||||||
"quad": "1440",
|
|
||||||
"full": "1080",
|
|
||||||
"hd": "720",
|
|
||||||
"sd": "480",
|
|
||||||
"low": "360",
|
|
||||||
"lowest": "240",
|
|
||||||
"mobile": "144"
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(o) {
|
|
||||||
let quality = o.quality === "max" ? "2160" : o.quality;
|
|
||||||
|
|
||||||
let html = await fetch(`https://ok.ru/video/${o.id}`, {
|
|
||||||
headers: { "user-agent": genericUserAgent }
|
|
||||||
}).then(r => r.text()).catch(() => {});
|
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
|
||||||
if (!html.includes(`<div data-module="OKVideo" data-options="{`)) {
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoData = html.split(`<div data-module="OKVideo" data-options="`)[1]
|
|
||||||
.split('" data-')[0]
|
|
||||||
.replaceAll(""", '"');
|
|
||||||
|
|
||||||
videoData = JSON.parse(JSON.parse(videoData).flashvars.metadata);
|
|
||||||
|
|
||||||
if (videoData.provider !== "UPLOADED_ODKL")
|
|
||||||
return { error: 'ErrorUnsupported' };
|
|
||||||
|
|
||||||
if (videoData.movie.is_live)
|
|
||||||
return { error: 'ErrorLiveVideo' };
|
|
||||||
|
|
||||||
if (videoData.movie.duration > env.durationLimit)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let videos = videoData.videos.filter(v => !v.disallowed);
|
|
||||||
let bestVideo = videos.find(v => resolutions[v.name] === quality) || videos[videos.length - 1];
|
|
||||||
|
|
||||||
let fileMetadata = {
|
|
||||||
title: cleanString(videoData.movie.title.trim()),
|
|
||||||
author: cleanString(videoData.author.name.trim()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestVideo) return {
|
|
||||||
urls: bestVideo.url,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: "ok",
|
|
||||||
id: o.id,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.author,
|
|
||||||
resolution: `${resolutions[bestVideo.name]}p`,
|
|
||||||
qualityLabel: `${resolutions[bestVideo.name]}p`,
|
|
||||||
extension: "mp4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
import { genericUserAgent } from "../../config.js";
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (!o.id && o.shortLink) {
|
|
||||||
id = await fetch(`https://api.pinterest.com/url_shortener/${o.shortLink}/redirect/`, { redirect: "manual" })
|
|
||||||
.then(r => r.headers.get("location").split('pin/')[1].split('/')[0])
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
if (id.includes("--")) id = id.split("--")[1];
|
|
||||||
if (!id) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let html = await fetch(`https://www.pinterest.com/pin/${id}/`, {
|
|
||||||
headers: { "user-agent": genericUserAgent }
|
|
||||||
}).then(r => r.text()).catch(() => {});
|
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let videoLink = [...html.matchAll(videoRegex)]
|
|
||||||
.map(([, link]) => link)
|
|
||||||
.find(a => a.endsWith('.mp4') && a.includes('720p'));
|
|
||||||
|
|
||||||
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' };
|
|
||||||
}
|
|
@ -1,124 +0,0 @@
|
|||||||
import { genericUserAgent, env } from "../../config.js";
|
|
||||||
import { getCookie, updateCookieValues } from "../cookie/manager.js";
|
|
||||||
|
|
||||||
async function getAccessToken() {
|
|
||||||
/* "cookie" in cookiefile needs to contain:
|
|
||||||
* client_id, client_secret, refresh_token
|
|
||||||
* e.g. client_id=bla; client_secret=bla; refresh_token=bla
|
|
||||||
*
|
|
||||||
* you can get these by making a reddit app and
|
|
||||||
* authenticating an account against reddit's oauth2 api
|
|
||||||
* see: https://github.com/reddit-archive/reddit/wiki/OAuth2
|
|
||||||
*
|
|
||||||
* any additional cookie fields are managed by this code and you
|
|
||||||
* should not touch them unless you know what you're doing. **/
|
|
||||||
const cookie = await getCookie('reddit');
|
|
||||||
if (!cookie) return;
|
|
||||||
|
|
||||||
const values = cookie.values(),
|
|
||||||
needRefresh = !values.access_token
|
|
||||||
|| !values.expiry
|
|
||||||
|| Number(values.expiry) < new Date().getTime();
|
|
||||||
if (!needRefresh) return values.access_token;
|
|
||||||
|
|
||||||
const data = await fetch('https://www.reddit.com/api/v1/access_token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'authorization': `Basic ${Buffer.from(
|
|
||||||
[values.client_id, values.client_secret].join(':')
|
|
||||||
).toString('base64')}`,
|
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
|
||||||
'user-agent': genericUserAgent,
|
|
||||||
'accept': 'application/json'
|
|
||||||
},
|
|
||||||
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(values.refresh_token)}`
|
|
||||||
}).then(r => r.json()).catch(() => {});
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const { access_token, refresh_token, expires_in } = data;
|
|
||||||
if (!access_token) return;
|
|
||||||
|
|
||||||
updateCookieValues(cookie, {
|
|
||||||
...cookie.values(),
|
|
||||||
access_token, refresh_token,
|
|
||||||
expiry: new Date().getTime() + (expires_in * 1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
return access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(obj) {
|
|
||||||
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: {
|
|
||||||
'User-Agent': genericUserAgent,
|
|
||||||
accept: 'application/json',
|
|
||||||
authorization: accessToken && `Bearer ${accessToken}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).then(r => r.json()).catch(() => {});
|
|
||||||
|
|
||||||
if (!data || !Array.isArray(data)) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
data = data[0]?.data?.children[0]?.data;
|
|
||||||
|
|
||||||
if (data?.url?.endsWith('.gif')) return {
|
|
||||||
typeId: "redirect",
|
|
||||||
urls: data.url
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.secure_media?.reddit_video)
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
if (data.secure_media?.reddit_video?.duration > env.durationLimit)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let audio = false,
|
|
||||||
video = data.secure_media?.reddit_video?.fallback_url?.split('?')[0],
|
|
||||||
audioFileLink = `${data.secure_media?.reddit_video?.fallback_url?.split('DASH')[0]}audio`;
|
|
||||||
|
|
||||||
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(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = `${String(obj.sub).toLowerCase()}_${obj.id}`;
|
|
||||||
|
|
||||||
if (!audio) return {
|
|
||||||
typeId: "redirect",
|
|
||||||
urls: video
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
typeId: "stream",
|
|
||||||
type: "render",
|
|
||||||
urls: [video, audioFileLink],
|
|
||||||
audioFilename: `reddit_${id}_audio`,
|
|
||||||
filename: `reddit_${id}.mp4`
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
import HLS from 'hls-parser';
|
|
||||||
|
|
||||||
import { env } 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) {
|
|
||||||
const yappy = await requestJSON(
|
|
||||||
`https://rutube.ru/pangolin/api/web/yappy/yappypage/?client=wdp&videoId=${obj.yappyId}&page=1&page_size=15`
|
|
||||||
)
|
|
||||||
const 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`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const quality = obj.quality === "max" ? "9000" : obj.quality;
|
|
||||||
|
|
||||||
const requestURL = new URL(`https://rutube.ru/api/play/options/${obj.id}/?no_404=true&referer&pver=v2`);
|
|
||||||
if (obj.key) requestURL.searchParams.set('p', obj.key);
|
|
||||||
|
|
||||||
const play = await requestJSON(requestURL);
|
|
||||||
if (!play) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
if (play.detail || !play.video_balancer) return { error: 'ErrorEmptyDownload' };
|
|
||||||
if (play.live_streams?.hls) return { error: 'ErrorLiveVideo' };
|
|
||||||
|
|
||||||
if (play.duration > env.durationLimit * 1000)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let m3u8 = await fetch(play.video_balancer.m3u8)
|
|
||||||
.then(r => r.text())
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (!m3u8) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
m3u8 = HLS.parse(m3u8).variants.sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
|
|
||||||
|
|
||||||
let bestQuality = m3u8[0];
|
|
||||||
if (Number(quality) < bestQuality.resolution.height) {
|
|
||||||
bestQuality = m3u8.find((i) => (Number(quality) === i.resolution.height));
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileMetadata = {
|
|
||||||
title: cleanString(play.title.trim()),
|
|
||||||
artist: cleanString(play.author.name.trim()),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: bestQuality.uri,
|
|
||||||
isM3U8: true,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: "rutube",
|
|
||||||
id: obj.id,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist,
|
|
||||||
resolution: `${bestQuality.resolution.width}x${bestQuality.resolution.height}`,
|
|
||||||
qualityLabel: `${bestQuality.resolution.height}p`,
|
|
||||||
extension: "mp4"
|
|
||||||
},
|
|
||||||
fileMetadata: fileMetadata
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,105 +0,0 @@
|
|||||||
import { env } from "../../config.js";
|
|
||||||
import { cleanString } from "../../sub/utils.js";
|
|
||||||
|
|
||||||
const cachedID = {
|
|
||||||
version: '',
|
|
||||||
id: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findClientID() {
|
|
||||||
try {
|
|
||||||
let sc = await fetch('https://soundcloud.com/').then(r => r.text()).catch(() => {});
|
|
||||||
let scVersion = String(sc.match(/<script>window\.__sc_version="[0-9]{10}"<\/script>/)[0].match(/[0-9]{10}/));
|
|
||||||
|
|
||||||
if (cachedID.version === scVersion) return cachedID.id;
|
|
||||||
|
|
||||||
let scripts = sc.matchAll(/<script.+src="(.+)">/g);
|
|
||||||
let clientid;
|
|
||||||
for (let script of scripts) {
|
|
||||||
let url = script[1];
|
|
||||||
|
|
||||||
if (url && !url.startsWith('https://a-v2.sndcdn.com')) return;
|
|
||||||
|
|
||||||
let scrf = await fetch(url).then(r => r.text()).catch(() => {});
|
|
||||||
let id = scrf.match(/\("client_id=[A-Za-z0-9]{32}"\)/);
|
|
||||||
|
|
||||||
if (id && typeof id[0] === 'string') {
|
|
||||||
clientid = id[0].match(/[A-Za-z0-9]{32}/)[0];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cachedID.version = scVersion;
|
|
||||||
cachedID.id = clientid;
|
|
||||||
|
|
||||||
return clientid;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(obj) {
|
|
||||||
let clientId = await findClientID();
|
|
||||||
if (!clientId) return { error: 'ErrorSoundCloudNoClientId' };
|
|
||||||
|
|
||||||
let link;
|
|
||||||
if (obj.url.hostname === 'on.soundcloud.com' && obj.shortLink) {
|
|
||||||
link = await fetch(`https://on.soundcloud.com/${obj.shortLink}/`, { redirect: "manual" }).then(r => {
|
|
||||||
if (r.status === 302 && r.headers.get("location").startsWith("https://soundcloud.com/")) {
|
|
||||||
return r.headers.get("location").split('?', 1)[0]
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!link && obj.author && obj.song) {
|
|
||||||
link = `https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!link) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let json = await fetch(`https://api-v2.soundcloud.com/resolve?url=${link}&client_id=${clientId}`)
|
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (!json) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
if (!json.media.transcodings) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
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");
|
|
||||||
|
|
||||||
// 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.url;
|
|
||||||
let fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
|
|
||||||
|
|
||||||
if (!fileUrl.startsWith("https://api-v2.soundcloud.com/media/soundcloud:tracks:"))
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
if (json.duration > env.durationLimit * 1000)
|
|
||||||
return { error: ['ErrorLengthAudioConvert', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let file = await fetch(fileUrl)
|
|
||||||
.then(async r => (await r.json()).url)
|
|
||||||
.catch(() => {});
|
|
||||||
if (!file) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let fileMetadata = {
|
|
||||||
title: cleanString(json.title.trim()),
|
|
||||||
artist: cleanString(json.user.username.trim()),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: file,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: "soundcloud",
|
|
||||||
id: json.id,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist
|
|
||||||
},
|
|
||||||
bestAudio,
|
|
||||||
fileMetadata
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
export default async function(obj) {
|
|
||||||
let video = await fetch(`https://api.streamable.com/videos/${obj.id}`)
|
|
||||||
.then(r => r.status === 200 ? r.json() : false)
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (!video) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
let best = video.files['mp4-mobile'];
|
|
||||||
if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= 720)) {
|
|
||||||
best = video.files.mp4;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best) return {
|
|
||||||
urls: best.url,
|
|
||||||
filename: `streamable_${obj.id}_${best.width}x${best.height}.mp4`,
|
|
||||||
audioFilename: `streamable_${obj.id}_audio`,
|
|
||||||
fileMetadata: {
|
|
||||||
title: video.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,120 +0,0 @@
|
|||||||
import { genericUserAgent } from "../../config.js";
|
|
||||||
import { updateCookie } from "../cookie/manager.js";
|
|
||||||
import { extract } from "../url.js";
|
|
||||||
import Cookie from "../cookie/cookie.js";
|
|
||||||
|
|
||||||
const shortDomain = "https://vt.tiktok.com/";
|
|
||||||
|
|
||||||
export default async function(obj) {
|
|
||||||
const cookie = new Cookie({});
|
|
||||||
let postId = obj.postId;
|
|
||||||
|
|
||||||
if (!postId) {
|
|
||||||
let html = await fetch(`${shortDomain}${obj.id}`, {
|
|
||||||
redirect: "manual",
|
|
||||||
headers: {
|
|
||||||
"user-agent": genericUserAgent.split(' Chrome/1')[0]
|
|
||||||
}
|
|
||||||
}).then(r => r.text()).catch(() => {});
|
|
||||||
|
|
||||||
if (!html) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
if (html.startsWith('<a href="https://')) {
|
|
||||||
const extractedURL = html.split('<a href="')[1].split('?')[0];
|
|
||||||
const { patternMatch } = extract(extractedURL);
|
|
||||||
postId = patternMatch.postId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!postId) return { error: 'ErrorCantGetID' };
|
|
||||||
|
|
||||||
// should always be /video/, even for photos
|
|
||||||
const res = await fetch(`https://tiktok.com/@i/video/${postId}`, {
|
|
||||||
headers: {
|
|
||||||
"user-agent": genericUserAgent,
|
|
||||||
cookie,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
updateCookie(cookie, res.headers);
|
|
||||||
|
|
||||||
const html = await res.text();
|
|
||||||
|
|
||||||
let detail;
|
|
||||||
try {
|
|
||||||
const json = html
|
|
||||||
.split('<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__" type="application/json">')[1]
|
|
||||||
.split('</script>')[0]
|
|
||||||
const data = JSON.parse(json)
|
|
||||||
detail = data["__DEFAULT_SCOPE__"]["webapp.video-detail"]["itemInfo"]["itemStruct"]
|
|
||||||
} catch {
|
|
||||||
return { error: 'ErrorCouldntFetch' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let video, videoFilename, audioFilename, audio, images,
|
|
||||||
filenameBase = `tiktok_${detail.author.uniqueId}_${postId}`,
|
|
||||||
bestAudio = 'm4a';
|
|
||||||
|
|
||||||
images = detail.imagePost?.images;
|
|
||||||
|
|
||||||
let playAddr = detail.video.playAddr;
|
|
||||||
if (obj.h265) {
|
|
||||||
const h265PlayAddr = detail?.video?.bitrateInfo?.find(b => b.CodecType.includes("h265"))?.PlayAddr.UrlList[0]
|
|
||||||
playAddr = h265PlayAddr || playAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!obj.isAudioOnly && !images) {
|
|
||||||
video = playAddr;
|
|
||||||
videoFilename = `${filenameBase}.mp4`;
|
|
||||||
} else {
|
|
||||||
audio = playAddr;
|
|
||||||
audioFilename = `${filenameBase}_audio`;
|
|
||||||
|
|
||||||
if (obj.fullAudio || !audio) {
|
|
||||||
audio = detail.music.playUrl;
|
|
||||||
audioFilename += `_original`
|
|
||||||
}
|
|
||||||
if (audio.includes("mime_type=audio_mpeg")) bestAudio = 'mp3';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (video) {
|
|
||||||
return {
|
|
||||||
urls: video,
|
|
||||||
filename: videoFilename,
|
|
||||||
headers: { cookie }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (images && obj.isAudioOnly) {
|
|
||||||
return {
|
|
||||||
urls: audio,
|
|
||||||
audioFilename: audioFilename,
|
|
||||||
isAudioOnly: true,
|
|
||||||
bestAudio,
|
|
||||||
headers: { cookie }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (images) {
|
|
||||||
let imageLinks = images
|
|
||||||
.map(i => i.imageURL.urlList.find(p => p.includes(".jpeg?")))
|
|
||||||
.map(url => ({ url }));
|
|
||||||
|
|
||||||
return {
|
|
||||||
picker: imageLinks,
|
|
||||||
urls: audio,
|
|
||||||
audioFilename: audioFilename,
|
|
||||||
isAudioOnly: true,
|
|
||||||
bestAudio,
|
|
||||||
headers: { cookie }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audio) {
|
|
||||||
return {
|
|
||||||
urls: audio,
|
|
||||||
audioFilename: audioFilename,
|
|
||||||
isAudioOnly: true,
|
|
||||||
bestAudio,
|
|
||||||
headers: { cookie }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,70 +0,0 @@
|
|||||||
import psl from "psl";
|
|
||||||
|
|
||||||
const API_KEY = 'jrsCWX1XDuVxAFO4GkK147syAoN8BJZ5voz8tS80bPcj26Vc5Z';
|
|
||||||
const API_BASE = 'https://api-http2.tumblr.com';
|
|
||||||
|
|
||||||
function request(domain, id) {
|
|
||||||
const url = new URL(`/v2/blog/${domain}/posts/${id}/permalink`, API_BASE);
|
|
||||||
url.searchParams.set('api_key', API_KEY);
|
|
||||||
url.searchParams.set('fields[blogs]', 'uuid,name,avatar,?description,?can_message,?can_be_followed,?is_adult,?reply_conditions,'
|
|
||||||
+ '?theme,?title,?url,?is_blocked_from_primary,?placement_id,?primary,?updated,?followed,'
|
|
||||||
+ '?ask,?can_subscribe,?paywall_access,?subscription_plan,?is_blogless_advertiser,?tumblrmart_accessories');
|
|
||||||
|
|
||||||
return fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Tumblr/iPhone/33.3/333010/17.3.1/tumblr',
|
|
||||||
'X-Version': 'iPhone/33.3/333010/17.3.1/tumblr'
|
|
||||||
}
|
|
||||||
}).then(a => a.json()).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(input) {
|
|
||||||
let { subdomain } = psl.parse(input.url.hostname);
|
|
||||||
|
|
||||||
if (subdomain?.includes('.')) {
|
|
||||||
return { error: ['ErrorBrokenLink', 'tumblr'] }
|
|
||||||
} else if (subdomain === 'www' || subdomain === 'at') {
|
|
||||||
subdomain = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = `${subdomain ?? input.user}.tumblr.com`;
|
|
||||||
const data = await request(domain, input.id);
|
|
||||||
|
|
||||||
const element = data?.response?.timeline?.elements?.[0];
|
|
||||||
if (!element) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
const contents = [
|
|
||||||
...element.content,
|
|
||||||
...element?.trail?.map(t => t.content).flat()
|
|
||||||
]
|
|
||||||
|
|
||||||
const audio = contents.find(c => c.type === 'audio');
|
|
||||||
if (audio && audio.provider === 'tumblr') {
|
|
||||||
const fileMetadata = {
|
|
||||||
title: audio?.title,
|
|
||||||
artist: audio?.artist
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: audio.media.url,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: 'tumblr',
|
|
||||||
id: input.id,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist
|
|
||||||
},
|
|
||||||
isAudioOnly: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const video = contents.find(c => c.type === 'video');
|
|
||||||
if (video && video.provider === 'tumblr') {
|
|
||||||
return {
|
|
||||||
urls: video.media.url,
|
|
||||||
filename: `tumblr_${input.id}.mp4`,
|
|
||||||
audioFilename: `tumblr_${input.id}_audio`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,87 +0,0 @@
|
|||||||
import { env } from "../../config.js";
|
|
||||||
import { cleanString } from '../../sub/utils.js';
|
|
||||||
|
|
||||||
const gqlURL = "https://gql.twitch.tv/gql";
|
|
||||||
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
|
||||||
|
|
||||||
export default async function (obj) {
|
|
||||||
let req_metadata = await fetch(gqlURL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: clientIdHead,
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `{
|
|
||||||
clip(slug: "${obj.clipId}") {
|
|
||||||
broadcaster {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
curator {
|
|
||||||
login
|
|
||||||
}
|
|
||||||
durationSeconds
|
|
||||||
id
|
|
||||||
medium: thumbnailURL(width: 480, height: 272)
|
|
||||||
title
|
|
||||||
videoQualities {
|
|
||||||
quality
|
|
||||||
sourceURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
})
|
|
||||||
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
|
||||||
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let clipMetadata = req_metadata.data.clip;
|
|
||||||
|
|
||||||
if (clipMetadata.durationSeconds > env.durationLimit)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster)
|
|
||||||
return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
let req_token = await fetch(gqlURL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: clientIdHead,
|
|
||||||
body: JSON.stringify([
|
|
||||||
{
|
|
||||||
"operationName": "VideoAccessToken_Clip",
|
|
||||||
"variables": {
|
|
||||||
"slug": obj.clipId
|
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"persistedQuery": {
|
|
||||||
"version": 1,
|
|
||||||
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}).then(r => r.status === 200 ? r.json() : false).catch(() => {});
|
|
||||||
|
|
||||||
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let formats = clipMetadata.videoQualities;
|
|
||||||
let format = formats.find(f => f.quality === obj.quality) || formats[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "bridge",
|
|
||||||
urls: `${format.sourceURL}?${new URLSearchParams({
|
|
||||||
sig: req_token[0].data.clip.playbackAccessToken.signature,
|
|
||||||
token: req_token[0].data.clip.playbackAccessToken.value
|
|
||||||
})}`,
|
|
||||||
fileMetadata: {
|
|
||||||
title: cleanString(clipMetadata.title.trim()),
|
|
||||||
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
|
|
||||||
},
|
|
||||||
filenameAttributes: {
|
|
||||||
service: "twitch",
|
|
||||||
id: clipMetadata.id,
|
|
||||||
title: cleanString(clipMetadata.title.trim()),
|
|
||||||
author: `${clipMetadata.broadcaster.login}, clipped by ${clipMetadata.curator.login}`,
|
|
||||||
qualityLabel: `${format.quality}p`,
|
|
||||||
extension: 'mp4'
|
|
||||||
},
|
|
||||||
filename: `twitchclip_${clipMetadata.id}_${format.quality}p.mp4`,
|
|
||||||
audioFilename: `twitchclip_${clipMetadata.id}_audio`
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,191 +0,0 @@
|
|||||||
import { genericUserAgent } from "../../config.js";
|
|
||||||
import { createStream } from "../../stream/manage.js";
|
|
||||||
import { getCookie, updateCookie } from "../cookie/manager.js";
|
|
||||||
|
|
||||||
const graphqlURL = 'https://api.x.com/graphql/I9GDzyCGZL2wSoYFFrrTVw/TweetResultByRestId';
|
|
||||||
const tokenURL = 'https://api.x.com/1.1/guest/activate.json';
|
|
||||||
|
|
||||||
const tweetFeatures = JSON.stringify({"creator_subscriptions_tweet_preview_api_enabled":true,"communities_web_enable_tweet_community_results_fetch":true,"c9s_tweet_anatomy_moderator_badge_enabled":true,"articles_preview_enabled":true,"tweetypie_unmention_optimization_enabled":true,"responsive_web_edit_tweet_api_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"view_counts_everywhere_api_enabled":true,"longform_notetweets_consumption_enabled":true,"responsive_web_twitter_article_tweet_consumption_enabled":true,"tweet_awards_web_tipping_enabled":false,"creator_subscriptions_quote_tweet_preview_enabled":false,"freedom_of_speech_not_reach_fetch_enabled":true,"standardized_nudges_misinfo":true,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"rweb_video_timestamps_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"rweb_tipjar_consumption_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_enhance_cards_enabled":false});
|
|
||||||
|
|
||||||
const tweetFieldToggles = JSON.stringify({"withArticleRichContentState":true,"withArticlePlainText":false,"withGrokAnalyze":false});
|
|
||||||
|
|
||||||
const commonHeaders = {
|
|
||||||
"user-agent": genericUserAgent,
|
|
||||||
"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
|
|
||||||
"x-twitter-client-language": "en",
|
|
||||||
"x-twitter-active-user": "yes",
|
|
||||||
"accept-language": "en"
|
|
||||||
}
|
|
||||||
|
|
||||||
// fix all videos affected by the container bug in twitter muxer (took them over two weeks to fix it????)
|
|
||||||
const TWITTER_EPOCH = 1288834974657n;
|
|
||||||
const badContainerStart = new Date(1701446400000);
|
|
||||||
const badContainerEnd = new Date(1702605600000);
|
|
||||||
|
|
||||||
function needsFixing(media) {
|
|
||||||
const representativeId = media.source_status_id_str ?? media.id_str;
|
|
||||||
const mediaTimestamp = new Date(
|
|
||||||
Number((BigInt(representativeId) >> 22n) + TWITTER_EPOCH)
|
|
||||||
);
|
|
||||||
return mediaTimestamp > badContainerStart && mediaTimestamp < badContainerEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
function bestQuality(arr) {
|
|
||||||
return arr.filter(v => v.content_type === "video/mp4")
|
|
||||||
.reduce((a, b) => Number(a?.bitrate) > Number(b?.bitrate) ? a : b)
|
|
||||||
.url
|
|
||||||
}
|
|
||||||
|
|
||||||
let _cachedToken;
|
|
||||||
const getGuestToken = async (dispatcher, forceReload = false) => {
|
|
||||||
if (_cachedToken && !forceReload) {
|
|
||||||
return _cachedToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenURL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: commonHeaders,
|
|
||||||
dispatcher
|
|
||||||
}).then(r => r.status === 200 && r.json()).catch(() => {})
|
|
||||||
|
|
||||||
if (tokenResponse?.guest_token) {
|
|
||||||
return _cachedToken = tokenResponse.guest_token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestTweet = async(dispatcher, 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,
|
|
||||||
withCommunity: false,
|
|
||||||
includePromotedContent: false,
|
|
||||||
withVoice: false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
graphqlTweetURL.searchParams.set('features', tweetFeatures);
|
|
||||||
graphqlTweetURL.searchParams.set('fieldToggles', tweetFieldToggles);
|
|
||||||
|
|
||||||
let result = await fetch(graphqlTweetURL, { headers, dispatcher });
|
|
||||||
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
|
|
||||||
},
|
|
||||||
dispatcher
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function({ id, index, toGif, dispatcher }) {
|
|
||||||
const cookie = await getCookie('twitter');
|
|
||||||
|
|
||||||
let guestToken = await getGuestToken(dispatcher);
|
|
||||||
if (!guestToken) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let tweet = await requestTweet(dispatcher, id, guestToken);
|
|
||||||
|
|
||||||
// get new token & retry if old one expired
|
|
||||||
if ([403, 429].includes(tweet.status)) {
|
|
||||||
guestToken = await getGuestToken(dispatcher, true);
|
|
||||||
tweet = await requestTweet(dispatcher, id, guestToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
tweet = await tweet.json();
|
|
||||||
|
|
||||||
let tweetTypename = tweet?.data?.tweetResult?.result?.__typename;
|
|
||||||
|
|
||||||
if (tweetTypename === "TweetUnavailable") {
|
|
||||||
const reason = tweet?.data?.tweetResult?.result?.reason;
|
|
||||||
switch(reason) {
|
|
||||||
case "Protected":
|
|
||||||
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: 'ErrorTweetNSFW' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["Tweet", "TweetWithVisibilityResults"].includes(tweetTypename)) {
|
|
||||||
return { error: 'ErrorTweetUnavailable' }
|
|
||||||
}
|
|
||||||
|
|
||||||
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/<index>)
|
|
||||||
if (index >= 0 && index < media?.length) {
|
|
||||||
media = [media[index]]
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (media?.length) {
|
|
||||||
case undefined:
|
|
||||||
case 0:
|
|
||||||
return { error: 'ErrorNoVideosInTweet' };
|
|
||||||
case 1:
|
|
||||||
return {
|
|
||||||
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"
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
const picker = media.map((content, i) => {
|
|
||||||
let url = bestQuality(content.video_info.variants);
|
|
||||||
const shouldRenderGif = content.type === 'animated_gif' && toGif;
|
|
||||||
|
|
||||||
if (needsFixing(content) || shouldRenderGif) {
|
|
||||||
url = createStream({
|
|
||||||
service: 'twitter',
|
|
||||||
type: shouldRenderGif ? 'gif' : 'remux',
|
|
||||||
u: url,
|
|
||||||
filename: `twitter_${id}_${i + 1}.mp4`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'video',
|
|
||||||
url,
|
|
||||||
thumb: content.media_url_https,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { picker };
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,109 +0,0 @@
|
|||||||
import { env } from "../../config.js";
|
|
||||||
import { cleanString } from '../../sub/utils.js';
|
|
||||||
|
|
||||||
const resolutionMatch = {
|
|
||||||
"3840": "2160",
|
|
||||||
"2732": "1440",
|
|
||||||
"2560": "1440",
|
|
||||||
"2048": "1080",
|
|
||||||
"1920": "1080",
|
|
||||||
"1366": "720",
|
|
||||||
"1280": "720",
|
|
||||||
"960": "480",
|
|
||||||
"640": "360",
|
|
||||||
"426": "240"
|
|
||||||
}
|
|
||||||
|
|
||||||
const qualityMatch = {
|
|
||||||
"2160": "4K",
|
|
||||||
"1440": "2K",
|
|
||||||
"480": "540",
|
|
||||||
|
|
||||||
"4K": "2160",
|
|
||||||
"2K": "1440",
|
|
||||||
"540": "480"
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function(obj) {
|
|
||||||
let quality = obj.quality === "max" ? "9000" : obj.quality;
|
|
||||||
if (!quality || obj.isAudioOnly) quality = "9000";
|
|
||||||
|
|
||||||
const url = new URL(`https://player.vimeo.com/video/${obj.id}/config`);
|
|
||||||
if (obj.password) {
|
|
||||||
url.searchParams.set('h', obj.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
let api = await fetch(url)
|
|
||||||
.then(r => r.json())
|
|
||||||
.catch(() => {});
|
|
||||||
if (!api) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
let downloadType = "dash";
|
|
||||||
|
|
||||||
if (!obj.isAudioOnly && JSON.stringify(api).includes('"progressive":[{'))
|
|
||||||
downloadType = "progressive";
|
|
||||||
|
|
||||||
let fileMetadata = {
|
|
||||||
title: cleanString(api.video.title.trim()),
|
|
||||||
artist: cleanString(api.video.owner.name.trim()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadType !== "dash") {
|
|
||||||
if (qualityMatch[quality]) quality = qualityMatch[quality];
|
|
||||||
let all = api.request.files.progressive.sort((a, b) => Number(b.width) - Number(a.width));
|
|
||||||
let best = all[0];
|
|
||||||
|
|
||||||
let bestQuality = all[0].quality.split('p')[0];
|
|
||||||
if (qualityMatch[bestQuality]) {
|
|
||||||
bestQuality = qualityMatch[bestQuality]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(quality) < Number(bestQuality)) {
|
|
||||||
best = all.find(i => i.quality.split('p')[0] === quality);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!best) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: best.url,
|
|
||||||
audioFilename: `vimeo_${obj.id}_audio`,
|
|
||||||
filename: `vimeo_${obj.id}_${best.width}x${best.height}.mp4`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (api.video.duration > env.durationLimit)
|
|
||||||
return { error: ['ErrorLengthLimit', env.durationLimit / 60] };
|
|
||||||
|
|
||||||
let masterJSONURL = api.request.files.dash.cdns.akfire_interconnect_quic.url;
|
|
||||||
let masterJSON = await fetch(masterJSONURL).then(r => r.json()).catch(() => {});
|
|
||||||
|
|
||||||
if (!masterJSON) return { error: 'ErrorCouldntFetch' };
|
|
||||||
if (!masterJSON.video) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
let masterJSON_Video = masterJSON.video
|
|
||||||
.sort((a, b) => Number(b.width) - Number(a.width))
|
|
||||||
.filter(a => ["dash", "mp42"].includes(a.format));
|
|
||||||
|
|
||||||
let bestVideo = masterJSON_Video[0];
|
|
||||||
if (Number(quality) < Number(resolutionMatch[bestVideo.width])) {
|
|
||||||
bestVideo = masterJSON_Video.find(i => resolutionMatch[i.width] === quality)
|
|
||||||
}
|
|
||||||
|
|
||||||
let masterM3U8 = `${masterJSONURL.split("/sep/")[0]}/sep/video/${bestVideo.id}/master.m3u8`;
|
|
||||||
const fallbackResolution = bestVideo.height > bestVideo.width ? bestVideo.width : bestVideo.height;
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: masterM3U8,
|
|
||||||
isM3U8: true,
|
|
||||||
fileMetadata: fileMetadata,
|
|
||||||
filenameAttributes: {
|
|
||||||
service: "vimeo",
|
|
||||||
id: obj.id,
|
|
||||||
title: fileMetadata.title,
|
|
||||||
author: fileMetadata.artist,
|
|
||||||
resolution: `${bestVideo.width}x${bestVideo.height}`,
|
|
||||||
qualityLabel: `${resolutionMatch[bestVideo.width] || fallbackResolution}p`,
|
|
||||||
extension: "mp4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
export default async function(obj) {
|
|
||||||
let post = await fetch(`https://archive.vine.co/posts/${obj.id}.json`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.catch(() => {});
|
|
||||||
|
|
||||||
if (!post) return { error: 'ErrorEmptyDownload' };
|
|
||||||
|
|
||||||
if (post.videoUrl) return {
|
|
||||||
urls: post.videoUrl.replace("http://", "https://"),
|
|
||||||
filename: `vine_${obj.id}.mp4`,
|
|
||||||
audioFilename: `vine_${obj.id}_audio`
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { error: 'ErrorEmptyDownload' }
|
|
||||||
}
|
|
@ -1,235 +0,0 @@
|
|||||||
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)];
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: 'ErrorYTTryOtherCodec' }
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user