Merge branch 'current' into feat/twitch

This commit is contained in:
wukko
2023-09-16 15:40:09 +06:00
committed by GitHub
84 changed files with 4498 additions and 2136 deletions

View File

@ -0,0 +1,37 @@
import { strict as assert } from 'node:assert';
export default class Cookie {
constructor(input) {
assert(typeof input === 'object');
this._values = {};
this.set(input)
}
set(values) {
Object.entries(values).forEach(
([ key, value ]) => this._values[key] = value
)
}
unset(keys) {
for (const key of keys) delete this._values[key]
}
static fromString(str) {
const obj = {};
str.split('; ').forEach(cookie => {
const key = cookie.split('=')[0];
const value = cookie.split('=').splice(1).join('=');
obj[key] = value
})
return new Cookie(obj)
}
toString() {
return Object.entries(this._values).map(([ name, value ]) => `${name}=${value}`).join('; ')
}
toJSON() {
return this.toString()
}
values() {
return Object.freeze({ ...this._values })
}
}

View File

@ -0,0 +1,5 @@
{
"instagram": [
"mid=replace; ig_did=this; csrftoken=cookie"
]
}

View File

@ -0,0 +1,62 @@
import Cookie from './cookie.js';
import { readFile, writeFile } from 'fs/promises';
import { parse as parseSetCookie, splitCookiesString } from 'set-cookie-parser';
const WRITE_INTERVAL = 60000,
cookiePath = process.env.cookiePath,
COUNTER = Symbol('counter');
let cookies = {}, dirty = false, intervalId;
const setup = async () => {
try {
if (!cookiePath) return;
cookies = await readFile(cookiePath, 'utf8');
cookies = JSON.parse(cookies);
intervalId = setInterval(writeChanges, WRITE_INTERVAL)
} catch { /* no cookies for you */ }
}
setup();
function writeChanges() {
if (!dirty) return;
dirty = false;
writeFile(cookiePath, JSON.stringify(cookies, null, 4)).catch(() => {
clearInterval(intervalId)
})
}
export function getCookie(service) {
if (!cookies[service] || !cookies[service].length) return;
let n;
if (cookies[service][COUNTER] === undefined) {
n = cookies[service][COUNTER] = 0
} else {
++cookies[service][COUNTER]
n = (cookies[service][COUNTER] %= cookies[service].length)
}
const cookie = cookies[service][n];
if (typeof cookie === 'string') cookies[service][n] = Cookie.fromString(cookie);
return cookies[service][n]
}
export function updateCookie(cookie, headers) {
if (!cookie) return;
const parsed = parseSetCookie(
splitCookiesString(headers.get('set-cookie')),
{ decodeValues: false }
), values = {}
cookie.unset(parsed.filter(c => c.expires < new Date()).map(c => c.name));
parsed.filter(c => !c.expires || c.expires > new Date()).forEach(c => values[c.name] = c.value);
cookie.set(values);
if (Object.keys(values).length) dirty = true
}

View File

@ -0,0 +1,40 @@
export default function (inHost, inURL) {
let host = String(inHost);
let url = String(inURL);
switch(host) {
case "youtube":
if (url.startsWith("https://youtube.com/live/") || url.startsWith("https://www.youtube.com/live/")) {
url = url.split("?")[0].replace("www.", "");
url = `https://youtube.com/watch?v=${url.replace("https://youtube.com/live/", "")}`
}
break;
case "youtu":
if (url.startsWith("https://youtu.be/")) {
host = "youtube";
url = `https://youtube.com/watch?v=${url.replace("https://youtu.be/", "")}`
}
break;
case "vxtwitter":
case "x":
if (url.startsWith("https://x.com/")) {
host = "twitter";
url = url.replace("https://x.com/", "https://twitter.com/")
}
if (url.startsWith("https://vxtwitter.com/")) {
host = "twitter";
url = url.replace("https://vxtwitter.com/", "https://twitter.com/")
}
break;
case "tumblr":
if (!url.includes("blog/view")) {
if (url.slice(-1) === '/') url = url.slice(0, -1);
url = url.replace(url.split('/')[5], '')
}
break;
}
return {
host: host,
url: url
}
}

View File

@ -17,11 +17,13 @@ import vimeo from "./services/vimeo.js";
import soundcloud from "./services/soundcloud.js";
import instagram from "./services/instagram.js";
import vine from "./services/vine.js";
import pinterest from "./services/pinterest.js";
import streamable from "./services/streamable.js";
import twitch from "./services/twitch.js";
export default async function (host, patternMatch, url, lang, obj) {
try {
let r, isAudioOnly = !!obj.isAudioOnly;
let r, isAudioOnly = !!obj.isAudioOnly, disableMetadata = !!obj.disableMetadata;
if (!testers[host]) return apiJSON(0, { t: errorUnsupported(lang) });
if (!(testers[host](patternMatch))) return apiJSON(0, { t: brokenLink(lang, host) });
@ -111,6 +113,16 @@ export default async function (host, patternMatch, url, lang, obj) {
case "vine":
r = await vine({ id: patternMatch["id"] });
break;
case "pinterest":
r = await pinterest({ id: patternMatch["id"] });
break;
case "streamable":
r = await streamable({
id: patternMatch["id"],
quality: obj.vQuality,
isAudioOnly: isAudioOnly,
});
break;
case "twitch":
r = await twitch({
vodId: patternMatch["video"] ? patternMatch["video"] : false,
@ -119,7 +131,6 @@ export default async function (host, patternMatch, url, lang, obj) {
isAudioOnly: obj.isAudioOnly,
format: obj.vFormat
});
break;
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}
@ -129,7 +140,7 @@ export default async function (host, patternMatch, url, lang, obj) {
if (r.error) return apiJSON(0, { t: Array.isArray(r.error) ? loc(lang, r.error[0], r.error[1]) : loc(lang, r.error) });
return matchActionDecider(r, host, obj.ip, obj.aFormat, isAudioOnly, lang, isAudioMuted);
return matchActionDecider(r, host, obj.aFormat, isAudioOnly, lang, isAudioMuted, disableMetadata);
} catch (e) {
return apiJSON(0, { t: genericError(lang, host) })
}

View File

@ -2,22 +2,23 @@ import { audioIgnore, services, supportedAudio } from "../config.js";
import { apiJSON } from "../sub/utils.js";
import loc from "../../localization/manager.js";
export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMuted) {
export default function(r, host, audioFormat, isAudioOnly, lang, isAudioMuted, disableMetadata) {
let action,
responseType = 2,
defaultParams = {
u: r.urls,
service: host,
ip: ip,
filename: r.filename,
fileMetadata: !disableMetadata ? r.fileMetadata : false
},
params = {}
if (!isAudioOnly && !r.picker && !isAudioMuted) action = "video";
if (r.isM3U8) action = "singleM3U8";
if (isAudioOnly && !r.picker) action = "audio";
if (r.picker) action = "picker";
if (isAudioMuted) action = "muteVideo";
if (r.isPhoto) action = "photo";
else if (r.picker) action = "picker"
else if (isAudioMuted) action = "muteVideo";
else if (isAudioOnly) action = "audio";
else if (r.isM3U8) action = "singleM3U8";
else action = "video";
if (action === "picker" || action === "audio") {
defaultParams.filename = r.audioFilename;
@ -26,13 +27,16 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMute
}
switch (action) {
case "photo":
responseType = 1;
break;
case "video":
switch (host) {
case "bilibili":
params = { type: "render", time: r.time };
params = { type: "render" };
break;
case "youtube":
params = { type: r.type, time: r.time };
params = { type: r.type };
break;
case "reddit":
responseType = r.typeId;
@ -56,6 +60,8 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMute
case "instagram":
case "tumblr":
case "twitter":
case "pinterest":
case "streamable":
responseType = 1;
break;
}
@ -69,6 +75,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMute
u: Array.isArray(r.urls) ? r.urls[0] : r.urls,
mute: true
}
if (host === "reddit" && r.typeId === 1) responseType = 1;
break;
case "picker":
@ -113,9 +120,11 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMute
processType = "bridge"
}
}
if ((audioFormat === "best" && services[host]["bestAudio"])
|| services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"])) {
if (host === "tumblr" && !r.filename && (audioFormat === "best" || audioFormat === "mp3")) {
audioFormat = "mp3";
processType = "bridge"
}
if ((audioFormat === "best" && services[host]["bestAudio"]) || (services[host]["bestAudio"] && (audioFormat === services[host]["bestAudio"]))) {
audioFormat = services[host]["bestAudio"];
processType = "bridge"
} else if (audioFormat === "best") {
@ -135,8 +144,7 @@ export default function(r, host, ip, audioFormat, isAudioOnly, lang, isAudioMute
type: processType,
u: Array.isArray(r.urls) ? r.urls[1] : r.urls,
audioFormat: audioFormat,
copy: copy,
fileMetadata: r.fileMetadata ? r.fileMetadata : false
copy: copy
}
break;
default:

View File

@ -11,17 +11,16 @@ export default async function(obj) {
let streamData = JSON.parse(html.split('<script>window.__playinfo__=')[1].split('</script>')[0]);
if (streamData.data.timelength > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let video = streamData["data"]["dash"]["video"].filter((v) => {
if (!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let video = streamData["data"]["dash"]["video"].filter(v =>
!v["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")
).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter((a) => {
if (!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")) return true;
}).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
let audio = streamData["data"]["dash"]["audio"].filter(a =>
!a["baseUrl"].includes("https://upos-sz-mirrorcosov.bilivideo.com/")
).sort((a, b) => Number(b.bandwidth) - Number(a.bandwidth));
return {
urls: [video[0]["baseUrl"], audio[0]["baseUrl"]],
time: streamData.data.timelength,
audioFilename: `bilibili_${obj.id}_audio`,
filename: `bilibili_${obj.id}_${video[0]["width"]}x${video[0]["height"]}.mp4`
};

View File

@ -1,34 +1,100 @@
import got from "got";
import { createStream } from "../../stream/manage.js";
import { genericUserAgent } from "../../config.js";
import { getCookie, updateCookie } from '../cookie/manager.js';
export default async function(obj) {
// i hate this implementation but fetch doesn't work here for some reason (i personally blame facebook)
let html;
let data;
try {
html = await got.get(`https://www.instagram.com/p/${obj.id}/`)
html.on('error', () => {
html = false;
});
html = html ? html.body : false;
const url = new URL('https://www.instagram.com/graphql/query/');
url.searchParams.set('query_hash', 'b3055c01b4b222b8a47dc12b090e4e64')
url.searchParams.set('variables', JSON.stringify({
child_comment_count: 3,
fetch_comment_count: 40,
has_threaded_comments: true,
parent_comment_count: 24,
shortcode: obj.id
}))
const cookie = getCookie('instagram');
data = await fetch(url, {
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'User-Agent': genericUserAgent,
'X-Ig-App-Id': '936619743392459',
'X-Asbd-Id': '129477',
'x-ig-www-claim': cookie?._wwwClaim || '0',
'x-csrftoken': cookie?.values()?.csrftoken,
'x-requested-with': 'XMLHttpRequest',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-origin',
'upgrade-insecure-requests': '1',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'en-US,en;q=0.9,en;q=0.8',
cookie
}
})
if (data.headers.get('X-Ig-Set-Www-Claim') && cookie) {
cookie._wwwClaim = data.headers.get('X-Ig-Set-Www-Claim');
}
updateCookie(cookie, data.headers);
data = (await data.json()).data;
} catch (e) {
html = false;
data = false;
}
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html.includes('application/ld+json')) return { error: 'ErrorEmptyDownload' };
if (!data) return { error: 'ErrorCouldntFetch' };
let single, multiple = [], postInfo = JSON.parse(html.split('script type="application/ld+json"')[1].split('">')[1].split('</script>')[0]);
if (postInfo.video.length > 1) {
for (let i in postInfo.video) { multiple.push({type: "video", thumb: postInfo.video[i]["thumbnailUrl"], url: postInfo.video[i]["contentUrl"]}) }
} else if (postInfo.video.length === 1) {
single = postInfo.video[0]["contentUrl"]
let single, multiple = [];
const sidecar = data?.shortcode_media?.edge_sidecar_to_children;
if (sidecar) {
sidecar.edges.forEach(e => {
if (e.node?.is_video) {
multiple.push({
type: "video",
// thumbnails have `Cross-Origin-Resource-Policy` set to `same-origin`, so we need to proxy them
thumb: createStream({
service: "instagram",
type: "default",
u: e.node?.display_url,
filename: "image.jpg"
}),
url: e.node?.video_url
})
} else {
multiple.push({
type: "photo",
thumb: createStream({
service: "instagram",
type: "default",
u: e.node?.display_url,
filename: "image.jpg"
}),
url: e.node?.display_url
})
}
})
} else if (data?.shortcode_media?.video_url) {
single = data.shortcode_media.video_url
} else if (data?.shortcode_media?.display_url) {
return {
urls: data?.shortcode_media?.display_url,
isPhoto: true
}
} else {
return { error: 'ErrorEmptyDownload' }
}
if (single) {
return { urls: single, filename: `instagram_${obj.id}.mp4`, audioFilename: `instagram_${obj.id}_audio` }
} else if (multiple) {
return {
urls: single,
filename: `instagram_${obj.id}.mp4`,
audioFilename: `instagram_${obj.id}_audio`
}
} else if (multiple.length) {
return { picker: multiple }
} else {
return { error: 'ErrorEmptyDownload' }

View File

@ -0,0 +1,24 @@
import { maxVideoDuration } from "../../config.js";
export default async function(obj) {
const pinId = obj.id.split('--').reverse()[0];
if (!(/^\d+$/.test(pinId))) return { error: 'ErrorCantGetID' };
let data = await fetch(`https://www.pinterest.com/resource/PinResource/get?data=${encodeURIComponent(JSON.stringify({
options: {
field_set_key: "unauth_react_main_pin",
id: pinId
}
}))}`).then((r) => { return r.json() }).catch(() => { return false });
if (!data) return { error: 'ErrorCouldntFetch' };
data = data["resource_response"]["data"];
let video = null;
if (data.videos !== null) video = data.videos.video_list.V_720P;
else if (data.story_pin_data !== null) video = data.story_pin_data.pages[0].blocks[0].video.video_list.V_EXP7;
if (!video) return { error: 'ErrorEmptyDownload' };
if (video.duration > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
return { urls: video.url, filename: `pinterest_${pinId}.mp4`, audioFilename: `pinterest_${pinId}_audio` }
}

View File

@ -11,17 +11,25 @@ export default async function(obj) {
if (!("reddit_video" in data["secure_media"])) return { error: 'ErrorEmptyDownload' };
if (data["secure_media"]["reddit_video"]["duration"] * 1000 > maxVideoDuration) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let video = data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0],
audio = video.match('.mp4') ? `${video.split('_')[0]}_audio.mp4` : `${data["secure_media"]["reddit_video"]["fallback_url"].split('DASH')[0]}audio`;
await fetch(audio, { method: "HEAD" }).then((r) => {if (Number(r.status) !== 200) audio = ''}).catch(() => {audio = ''});
let audio = false,
video = data["secure_media"]["reddit_video"]["fallback_url"].split('?')[0],
audioFileLink = video.match('.mp4') ? `${video.split('_')[0]}_audio.mp4` : `${data["secure_media"]["reddit_video"]["fallback_url"].split('DASH')[0]}audio`;
let id = data["secure_media"]["reddit_video"]["fallback_url"].split('/')[3];
if (!audio.length > 0) return { typeId: 1, urls: video };
await fetch(audioFileLink, { method: "HEAD" }).then((r) => { if (Number(r.status) === 200) audio = true }).catch(() => { audio = false });
// fallback for videos with differentiating audio quality
if (!audio) {
audioFileLink = `${video.split('_')[0]}_AUDIO_128.mp4`
await fetch(audioFileLink, { method: "HEAD" }).then((r) => { if (Number(r.status) === 200) audio = true }).catch(() => { audio = false });
}
let id = video.split('/')[3];
if (!audio) return { typeId: 1, urls: video };
return {
typeId: 2,
type: "render",
urls: [video, audio],
urls: [video, audioFileLink],
audioFilename: `reddit_${id}_audio`,
filename: `reddit_${id}.mp4`
};

View File

@ -1,4 +1,5 @@
import { maxVideoDuration } from "../../config.js";
import { cleanString } from "../../sub/utils.js";
let cachedID = {};
@ -34,28 +35,33 @@ async function findClientID() {
}
export default async function(obj) {
let html;
if (!obj.author && !obj.song && obj.shortLink) {
html = await fetch(`https://on.soundcloud.com/${obj.shortLink}/`).then((r) => { return r.status === 404 ? false : r.text() }).catch(() => { return false });
}
if (obj.author && obj.song) {
html = await fetch(`https://soundcloud.com/${obj.author}/${obj.song}${obj.accessKey ? `/s-${obj.accessKey}` : ''}`).then((r) => { return r.text() }).catch(() => { return false });
}
if (!html) return { error: 'ErrorCouldntFetch' };
if (!(html.includes('<script>window.__sc_hydration = ')
&& html.includes('"format":{"protocol":"progressive","mime_type":"audio/mpeg"},')
&& html.includes('{"hydratable":"sound","data":'))) {
return { error: ['ErrorBrokenLink', 'soundcloud'] }
}
let json = JSON.parse(html.split('{"hydratable":"sound","data":')[1].split('}];</script>')[0])
if (!json["media"]["transcodings"]) return { error: 'ErrorEmptyDownload' };
let clientId = await findClientID();
if (!clientId) return { error: 'ErrorSoundCloudNoClientId' };
let fileUrlBase = json.media.transcodings[0]["url"].replace("/hls", "/progressive"),
let link;
if (obj.shortLink && !obj.author && !obj.song) {
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]
}
return false
}).catch(() => { return false });
}
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) => {
return r.status === 200 ? r.json() : false
}).catch(() => { return false });
if (!json) return { error: 'ErrorCouldntFetch' };
if (!json["media"]["transcodings"]) return { error: 'ErrorEmptyDownload' };
let fileUrlBase = json.media.transcodings.filter(v => v.preset === "opus_0_0")[0]["url"],
fileUrl = `${fileUrlBase}${fileUrlBase.includes("?") ? "&" : "?"}client_id=${clientId}&track_authorization=${json.track_authorization}`;
if (fileUrl.substring(0, 54) !== "https://api-v2.soundcloud.com/media/soundcloud:tracks:") return { error: 'ErrorEmptyDownload' };
if (json.duration > maxVideoDuration) return { error: ['ErrorLengthAudioConvert', maxVideoDuration / 60000] };
@ -67,8 +73,8 @@ export default async function(obj) {
urls: file,
audioFilename: `soundcloud_${json.id}`,
fileMetadata: {
title: json.title,
artist: json.user.username,
title: cleanString(json.title.replace(/\p{Emoji}/gu, '').trim()),
artist: cleanString(json.user.username.replace(/\p{Emoji}/gu, '').trim()),
}
}
}

View File

@ -0,0 +1,19 @@
export default async function(obj) {
let video = await fetch(`https://api.streamable.com/videos/${obj.id}`).then((r) => { return r.status === 200 ? r.json() : false }).catch(() => { return false });
if (!video) return { error: 'ErrorEmptyDownload' };
let best = video.files['mp4-mobile'];
if (video.files.mp4 && (obj.isAudioOnly || obj.quality === "max" || obj.quality >= Number("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' }
}

View File

@ -17,7 +17,7 @@ function selector(j, h, id) {
let t;
switch (h) {
case "tiktok":
t = j["aweme_list"].filter((v) => { if (v["aweme_id"] === id) return true })[0];
t = j["aweme_list"].filter(v => v["aweme_id"] === id)[0];
break;
case "douyin":
t = j['aweme_detail'];
@ -92,7 +92,7 @@ export default async function(obj) {
let imageLinks = [];
for (let i in images) {
let sel = obj.host === "tiktok" ? images[i]["display_image"]["url_list"] : images[i]["url_list"];
sel = sel.filter((p) => { if (p.includes(".jpeg?")) return true; })
sel = sel.filter(p => p.includes(".jpeg?"))
imageLinks.push({url: sel[0]})
}
return {

View File

@ -8,7 +8,21 @@ export default async function(obj) {
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html.includes('property="og:video" content="https://va.media.tumblr.com/')) return { error: 'ErrorEmptyDownload' };
return { urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`, filename: `tumblr_${obj.id}.mp4`, audioFilename: `tumblr_${obj.id}_audio` }
let r;
if (html.includes('property="og:video" content="https://va.media.tumblr.com/')) {
r = {
urls: `https://va.media.tumblr.com/${html.split('property="og:video" content="https://va.media.tumblr.com/')[1].split('"')[0]}`,
filename: `tumblr_${obj.id}.mp4`,
audioFilename: `tumblr_${obj.id}_audio`
}
} else if (html.includes('property="og:audio" content="https://a.tumblr.com/')) {
r = {
urls: `https://a.tumblr.com/${html.split('property="og:audio" content="https://a.tumblr.com/')[1].split('"')[0]}`,
audioFilename: `tumblr_${obj.id}`,
isAudioOnly: true
}
} else r = { error: 'ErrorEmptyDownload' };
return r;
}

View File

@ -1,22 +1,22 @@
import { genericUserAgent } from "../../config.js";
function bestQuality(arr) {
return arr.filter((v) => { if (v["content_type"] === "video/mp4") return true }).sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"].split("?")[0]
return arr.filter(v => v["content_type"] === "video/mp4").sort((a, b) => Number(b.bitrate) - Number(a.bitrate))[0]["url"]
}
const apiURL = "https://api.twitter.com"
export default async function(obj) {
let _headers = {
"user-agent": genericUserAgent,
"authorization": "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA",
// ^ no explicit content, but with multi media support
"host": "api.twitter.com",
"x-twitter-client-language": "en",
"x-twitter-active-user": "yes",
"Accept-Language": "en"
"accept-language": "en"
};
let conversationURL = `${apiURL}/2/timeline/conversation/${obj.id}.json?cards_platform=Web-12&tweet_mode=extended&include_cards=1&include_ext_media_availability=true&include_ext_sensitive_media_warning=true&simple_quoted_tweet=true&trim_user=1`;
let activateURL = `${apiURL}/1.1/guest/activate.json`;
let activateURL = `https://api.twitter.com/1.1/guest/activate.json`;
let graphqlTweetURL = `https://twitter.com/i/api/graphql/0hWvDhmW8YQ-S_ib3azIrw/TweetResultByRestId`;
let graphqlSpaceURL = `https://twitter.com/i/api/graphql/Gdz2uCtmIGMmhjhHG3V7nA/AudioSpaceById`;
let req_act = await fetch(activateURL, {
method: "POST",
@ -24,40 +24,39 @@ export default async function(obj) {
}).then((r) => { return r.status === 200 ? r.json() : false }).catch(() => { return false });
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["host"] = "twitter.com";
_headers["content-type"] = "application/json";
_headers["x-guest-token"] = req_act["guest_token"];
_headers["cookie"] = `guest_id=v1%3A${req_act["guest_token"]};`;
_headers["cookie"] = `guest_id=v1%3A${req_act["guest_token"]}`;
if (!obj.spaceId) {
let conversation = await fetch(conversationURL, { headers: _headers }).then((r) => { return r.status === 200 ? r.json() : false }).catch((e) => { return false });
if (!conversation || !conversation.globalObjects.tweets[obj.id]) {
_headers.authorization = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw";
// ^ explicit content, but no multi media support
delete _headers["x-guest-token"];
delete _headers["cookie"];
req_act = await fetch(activateURL, {
method: "POST",
headers: _headers
}).then((r) => { return r.status === 200 ? r.json() : false}).catch(() => { return false });
if (!req_act) return { error: 'ErrorCouldntFetch' };
_headers["x-guest-token"] = req_act["guest_token"];
_headers['cookie'] = `guest_id=v1%3A${req_act["guest_token"]};`;
conversation = await fetch(conversationURL, { headers: _headers }).then((r) => { return r.status === 200 ? r.json() : false }).catch(() => { return false });
if (obj.id) {
let query = {
variables: {"tweetId": obj.id, "withCommunity": false, "includePromotedContent": false, "withVoice": false},
features: {"creator_subscriptions_tweet_preview_api_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":false,"tweet_awards_web_tipping_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,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_inline_media_enabled":true,"responsive_web_graphql_exclude_directive_enabled":true,"verified_phone_label_enabled":false,"responsive_web_media_download_video_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}
}
if (!conversation || !conversation.globalObjects.tweets[obj.id]) return { error: 'ErrorTweetUnavailable' };
query.variables = new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1);
query.features = new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1);
query = `${graphqlTweetURL}?variables=${query.variables}&features=${query.features}`;
let baseMedia, baseTweet = conversation.globalObjects.tweets[obj.id];
if (baseTweet.retweeted_status_id_str && conversation.globalObjects.tweets[baseTweet.retweeted_status_id_str].extended_entities) {
baseMedia = conversation.globalObjects.tweets[baseTweet.retweeted_status_id_str].extended_entities
let TweetResultByRestId = await fetch(query, { headers: _headers }).then((r) => { return r.status === 200 ? r.json() : false }).catch((e) => { return false });
// {"data":{"tweetResult":{"result":{"__typename":"TweetUnavailable","reason":"Protected"}}}}
if (!TweetResultByRestId || TweetResultByRestId.data.tweetResult.result.__typename !== "Tweet") return { error: 'ErrorTweetUnavailable' };
let baseMedia,
baseTweet = TweetResultByRestId.data.tweetResult.result.legacy;
if (baseTweet.retweeted_status_result && baseTweet.retweeted_status_result.result.legacy.extended_entities.media) {
baseMedia = baseTweet.retweeted_status_result.result.legacy.extended_entities
} else if (baseTweet.extended_entities && baseTweet.extended_entities.media) {
baseMedia = baseTweet.extended_entities
}
if (!baseMedia) return { error: 'ErrorNoVideosInTweet' };
let single, multiple = [], media = baseMedia["media"];
media = media.filter((i) => { if (i["type"] === "video" || i["type"] === "animated_gif") return true })
media = media.filter((i) => { if (i["type"] === "video" || i["type"] === "animated_gif") return true });
if (media.length > 1) {
for (let i in media) { multiple.push({type: "video", thumb: media[i]["media_url_https"], url: bestQuality(media[i]["video_info"]["variants"])}) }
} else if (media.length === 1) {
@ -73,7 +72,9 @@ export default async function(obj) {
} else {
return { error: 'ErrorNoVideosInTweet' }
}
} else {
}
// spaces no longer work with guest authorization
if (obj.spaceId) {
_headers["host"] = "twitter.com";
_headers["content-type"] = "application/json";
@ -83,7 +84,7 @@ export default async function(obj) {
}
query.variables = new URLSearchParams(JSON.stringify(query.variables)).toString().slice(0, -1);
query.features = new URLSearchParams(JSON.stringify(query.features)).toString().slice(0, -1);
query = `https://twitter.com/i/api/graphql/Gdz2uCtmIGMmhjhHG3V7nA/AudioSpaceById?variables=${query.variables}&features=${query.features}`;
query = `${graphqlSpaceURL}?variables=${query.variables}&features=${query.features}`;
let AudioSpaceById = await fetch(query, { headers: _headers }).then((r) => {return r.status === 200 ? r.json() : false}).catch((e) => { return false });
if (!AudioSpaceById) return { error: 'ErrorEmptyDownload' };

View File

@ -1,5 +1,6 @@
import { maxVideoDuration } from "../../config.js";
// vimeo you're fucked in the head for this
const resolutionMatch = {
"3840": "2160",
"2732": "1440",
@ -11,7 +12,6 @@ const resolutionMatch = {
"640": "360",
"426": "240"
}
// ^ vimeo you're fucked in the head for this ^
const qualityMatch = {
"2160": "4K",
@ -64,7 +64,7 @@ export default async function(obj) {
let videoUrl, audioUrl, baseUrl = masterJSONURL.split("/sep/")[0];
switch (type) {
case "parcel":
let masterJSON_Audio = masterJSON.audio.sort((a, b) => Number(b.bitrate) - Number(a.bitrate)).filter((a) => { if (a['mime_type'] === "audio/mp4") return true }),
let masterJSON_Audio = masterJSON.audio.sort((a, b) => Number(b.bitrate) - Number(a.bitrate)).filter(a => a['mime_type'] === "audio/mp4"),
bestAudio = masterJSON_Audio[0];
videoUrl = `${baseUrl}/parcel/video/${bestVideo.index_segment.split('?')[0]}`,
audioUrl = `${baseUrl}/parcel/audio/${bestAudio.index_segment.split('?')[0]}`;

View File

@ -1,59 +1,38 @@
import { xml2json } from "xml-js";
import { genericUserAgent, maxVideoDuration } from "../../config.js";
const representationMatch = {
"2160": 7,
"1440": 6,
"1080": 5,
"720": 4,
"480": 3,
"360": 2,
"240": 1,
"144": 0
}, resolutionMatch = {
"3840": "2160",
"2560": "1440",
"1920": "1080",
"1280": "720",
"852": "480",
"640": "360",
"426": "240",
// "256": "144"
}
const resolutions = ["2160", "1440", "1080", "720", "480", "360", "240"];
export default async function(o) {
let html, url, filename = `vk_${o.userId}_${o.videoId}_`;
let html, url,
quality = o.quality === "max" ? 2160 : o.quality,
filename = `vk_${o.userId}_${o.videoId}_`;
html = await fetch(`https://vk.com/video${o.userId}_${o.videoId}`, {
headers: { "user-agent": genericUserAgent }
}).then((r) => { return r.text() }).catch(() => { return false });
if (!html) return { error: 'ErrorCouldntFetch' };
if (!html.includes(`{"lang":`)) return { error: 'ErrorEmptyDownload' };
let quality = o.quality === "max" ? 7 : representationMatch[o.quality],
js = JSON.parse('{"lang":' + html.split(`{"lang":`)[1].split(']);')[0]);
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 > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
if (js.player.params[0]["manifest"]) {
let mpd = JSON.parse(xml2json(js.player.params[0]["manifest"], { compact: true, spaces: 4 })),
repr = mpd.MPD.Period.AdaptationSet.Representation ? mpd.MPD.Period.AdaptationSet.Representation : mpd.MPD.Period.AdaptationSet[0]["Representation"],
bestQuality = repr[repr.length - 1],
resolutionPick = Number(bestQuality._attributes.width) > Number(bestQuality._attributes.height) ? 'width': 'height';
if (Number(bestQuality._attributes.id) > Number(quality)) bestQuality = repr[quality];
url = js.player.params[0][`url${resolutionMatch[bestQuality._attributes[resolutionPick]]}`];
filename += `${bestQuality._attributes.width}x${bestQuality._attributes.height}.mp4`
} else if (js.player.params[0]["url240"]) { // fallback for when video is too old
url = js.player.params[0]["url240"];
filename += `320x240.mp4`
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}`];
filename += `${quality}p.mp4`
if (url && filename) return {
urls: url,
filename: filename
};
}
return { error: 'ErrorEmptyDownload' }
}

View File

@ -1,5 +1,6 @@
import { Innertube } from 'youtubei.js';
import { maxVideoDuration } from '../../config.js';
import { cleanString } from '../../sub/utils.js';
const yt = await Innertube.create();
@ -23,6 +24,10 @@ const c = {
export default async function(o) {
let info, isDubbed, quality = o.quality === "max" ? "9000" : o.quality; //set quality 9000(p) to be interpreted as max
function qual(i) {
return i['quality_label'].split('p')[0].split('s')[0]
}
try {
info = await yt.getBasicInfo(o.id, 'ANDROID');
} catch (e) {
@ -30,22 +35,23 @@ export default async function(o) {
}
if (!info) return { error: 'ErrorCantConnectToServiceAPI' };
if (info.playability_status.status !== 'OK') return { error: 'ErrorYTUnavailable' };
if (info.basic_info.is_live) return { error: 'ErrorLiveVideo' };
let bestQuality, hasAudio, adaptive_formats = info.streaming_data.adaptive_formats.filter((e) => {
if (e["mime_type"].includes(c[o.format].codec) || e["mime_type"].includes(c[o.format].aCodec)) return true
}).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
let bestQuality, hasAudio, adaptive_formats = info.streaming_data.adaptive_formats.filter(e =>
e["mime_type"].includes(c[o.format].codec) || e["mime_type"].includes(c[o.format].aCodec)
).sort((a, b) => Number(b.bitrate) - Number(a.bitrate));
bestQuality = adaptive_formats.find(i => i["has_video"]);
hasAudio = adaptive_formats.find(i => i["has_audio"]);
if (bestQuality) bestQuality = bestQuality['quality_label'].split('p')[0];
if (bestQuality) bestQuality = qual(bestQuality);
if (!bestQuality && !o.isAudioOnly || !hasAudio) return { error: 'ErrorYTTryOtherCodec' };
if (info.basic_info.duration > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
let checkBestAudio = (i) => (i["has_audio"] && !i["has_video"]),
audio = adaptive_formats.find(i => checkBestAudio(i) && i["is_original"]);
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);
@ -54,35 +60,38 @@ export default async function(o) {
isDubbed = true
}
}
if (hasAudio && o.isAudioOnly) {
let r = {
type: "render",
isAudioOnly: true,
urls: audio.url,
audioFilename: `youtube_${o.id}_audio${isDubbed ? `_${o.dubLang}`:''}`,
fileMetadata: {
title: info.basic_info.title,
artist: 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")
r.fileMetadata.album = descItems[2]
r.fileMetadata.copyright = descItems[3]
if (descItems[4].startsWith("Released on:")) r.fileMetadata.date = descItems[4].replace("Released on: ", '').trim();
};
return r
let fileMetadata = {
title: cleanString(info.basic_info.title.replace(/\p{Emoji}/gu, '').trim()),
artist: cleanString(info.basic_info.author.replace("- Topic", "").replace(/\p{Emoji}/gu, '').trim()),
}
let checkSingle = (i) => ((i['quality_label'].split('p')[0] === quality || i['quality_label'].split('p')[0] === bestQuality) && i["mime_type"].includes(c[o.format].codec)),
checkBestVideo = (i) => (i["has_video"] && !i["has_audio"] && i['quality_label'].split('p')[0] === bestQuality),
checkRightVideo = (i) => (i["has_video"] && !i["has_audio"] && i['quality_label'].split('p')[0] === quality);
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()
}
};
if (hasAudio && o.isAudioOnly) return {
type: "render",
isAudioOnly: true,
urls: audio.url,
audioFilename: `youtube_${o.id}_audio${isDubbed ? `_${o.dubLang}`:''}`,
fileMetadata: fileMetadata
}
let checkSingle = (i) => ((qual(i) === quality || qual(i) === bestQuality) && i["mime_type"].includes(c[o.format].codec)),
checkBestVideo = (i) => (i["has_video"] && !i["has_audio"] && qual(i) === bestQuality),
checkRightVideo = (i) => (i["has_video"] && !i["has_audio"] && qual(i) === quality);
if (!o.isAudioOnly && !o.isAudioMuted && o.format === 'h264') {
let single = info.streaming_data.formats.find(i => checkSingle(i));
if (single) return {
type: "bridge",
urls: single.url,
filename: `youtube_${o.id}_${single.width}x${single.height}_${o.format}.${c[o.format].container}`
filename: `youtube_${o.id}_${single.width}x${single.height}_${o.format}.${c[o.format].container}`,
fileMetadata: fileMetadata
}
};
@ -90,7 +99,8 @@ export default async function(o) {
if (video && audio) return {
type: "render",
urls: [video.url, audio.url],
filename: `youtube_${o.id}_${video.width}x${video.height}_${o.format}${isDubbed ? `_${o.dubLang}`:''}.${c[o.format].container}`
filename: `youtube_${o.id}_${video.width}x${video.height}_${o.format}${isDubbed ? `_${o.dubLang}`:''}.${c[o.format].container}`,
fileMetadata: fileMetadata
};
return { error: 'ErrorYTTryOtherCodec' }

View File

@ -2,7 +2,7 @@
"audioIgnore": ["vk"],
"config": {
"bilibili": {
"alias": "bilibili (.com only)",
"alias": "bilibili.com videos",
"patterns": ["video/:id"],
"enabled": true
},
@ -12,7 +12,7 @@
"enabled": true
},
"twitter": {
"alias": "twitter posts & spaces & voice",
"alias": "twitter videos & voice",
"patterns": [":user/status/:id", ":user/status/:id/video/:v", "i/spaces/:spaceId"],
"enabled": true
},
@ -22,8 +22,8 @@
"enabled": true
},
"youtube": {
"alias": "youtube videos & shorts & music",
"patterns": ["watch?v=:id"],
"alias": "youtube videos, shorts & music",
"patterns": ["watch?v=:id", "embed/:id"],
"bestAudio": "opus",
"enabled": true
},
@ -32,7 +32,7 @@
"enabled": true
},
"tiktok": {
"alias": "tiktok videos & photos & audio",
"alias": "tiktok videos, photos & audio",
"patterns": [":user/video/:postId", ":id", "t/:id"],
"audioFormats": ["best", "m4a", "mp3"],
"enabled": true
@ -43,7 +43,7 @@
"enabled": false
},
"vimeo": {
"patterns": [":id"],
"patterns": [":id", "video/:id"],
"enabled": true,
"bestAudio": "mp3"
},
@ -53,7 +53,7 @@
"enabled": true
},
"instagram": {
"alias": "instagram reels & video posts",
"alias": "instagram reels & posts",
"patterns": ["reels/:id", "reel/:id", "p/:id"],
"enabled": true
},
@ -63,7 +63,17 @@
"patterns": ["v/:id"],
"enabled": true
},
"twitch": {
"pinterest": {
"alias": "pinterest videos & stories",
"patterns": ["pin/:id"],
"enabled": true
},
"streamable": {
"alias": "streamable.com",
"patterns": [":id", "o/:id", "e/:id", "s/:id"],
"enabled": true
},
"twitch": {
"alias": "twitch vods & videos & clips",
"tld": "tv",
"patterns": ["videos/:video", ":channel/clip/:clip"],

View File

@ -23,12 +23,16 @@ export const testers = {
"vimeo": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length <= 11)),
"soundcloud": (patternMatch) => ((patternMatch["author"] && patternMatch["song"]
&& (patternMatch["author"].length + patternMatch["song"].length) <= 96) || (patternMatch["shortLink"] && patternMatch["shortLink"].length <= 32)),
"soundcloud": (patternMatch) => (patternMatch["author"]?.length <= 25 && patternMatch["song"]?.length <= 255)
|| (patternMatch["shortLink"] && patternMatch["shortLink"].length <= 32),
"instagram": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 12),
"vine": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 12),
"pinterest": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length <= 128),
"streamable": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length === 6),
"twitch": (patternMatch) => ((patternMatch["channel"] && patternMatch["clip"] && patternMatch["clip"].length <= 100 || patternMatch["video"] && patternMatch["video"].length <= 10)),
}