twitch clean up
This commit is contained in:
parent
ff9d48740d
commit
ad9b6ebdd3
7 changed files with 233 additions and 225 deletions
|
@ -8,6 +8,9 @@ export default function (inHost, inURL) {
|
||||||
url = url.split("?")[0].replace("www.", "");
|
url = url.split("?")[0].replace("www.", "");
|
||||||
url = `https://youtube.com/watch?v=${url.replace("https://youtube.com/live/", "")}`
|
url = `https://youtube.com/watch?v=${url.replace("https://youtube.com/live/", "")}`
|
||||||
}
|
}
|
||||||
|
if (url.includes('youtube.com/shorts/')) {
|
||||||
|
url = url.split('?')[0].replace('shorts/', 'watch?v=');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "youtu":
|
case "youtu":
|
||||||
if (url.startsWith("https://youtu.be/")) {
|
if (url.startsWith("https://youtu.be/")) {
|
||||||
|
@ -32,6 +35,11 @@ export default function (inHost, inURL) {
|
||||||
url = url.replace(url.split('/')[5], '')
|
url = url.replace(url.split('/')[5], '')
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case "twitch":
|
||||||
|
if (url.includes('clips.twitch.tv')) {
|
||||||
|
url = url.split('?')[0].replace('clips.twitch.tv/', 'twitch.tv/_/clip/');
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
host: host,
|
host: host,
|
||||||
|
|
|
@ -127,10 +127,10 @@ export default async function (host, patternMatch, url, lang, obj) {
|
||||||
r = await twitch({
|
r = await twitch({
|
||||||
vodId: patternMatch["video"] ? patternMatch["video"] : false,
|
vodId: patternMatch["video"] ? patternMatch["video"] : false,
|
||||||
clipId: patternMatch["clip"] ? patternMatch["clip"] : false,
|
clipId: patternMatch["clip"] ? patternMatch["clip"] : false,
|
||||||
lang: lang, quality: obj.vQuality,
|
quality: obj.vQuality,
|
||||||
isAudioOnly: obj.isAudioOnly,
|
isAudioOnly: obj.isAudioOnly
|
||||||
format: obj.vFormat
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return apiJSON(0, { t: errorUnsupported(lang) });
|
return apiJSON(0, { t: errorUnsupported(lang) });
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,155 +1,14 @@
|
||||||
import { maxVideoDuration } from "../../config.js";
|
import { maxVideoDuration } from "../../config.js";
|
||||||
|
import { getM3U8Formats } from "../../sub/utils.js";
|
||||||
|
|
||||||
const gqlURL = "https://gql.twitch.tv/gql";
|
const gqlURL = "https://gql.twitch.tv/gql";
|
||||||
const m3u8URL = "https://usher.ttvnw.net";
|
const m3u8URL = "https://usher.ttvnw.net";
|
||||||
|
const clientIdHead = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
||||||
|
|
||||||
function parseM3U8Line(line) {
|
async function getClip(obj) {
|
||||||
const result = {};
|
let req_metadata = await fetch(gqlURL, {
|
||||||
|
|
||||||
let str = '', inQuotes = false, keyName = null, escaping = false;
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
|
||||||
const char = line[i];
|
|
||||||
if (char === '"' && !escaping) {
|
|
||||||
inQuotes = !inQuotes;
|
|
||||||
continue;
|
|
||||||
} else if (char === ',' && !escaping && !inQuotes) {
|
|
||||||
if (!keyName) break;
|
|
||||||
result[keyName] = str;
|
|
||||||
keyName = null;
|
|
||||||
str = '';
|
|
||||||
continue;
|
|
||||||
} else if (char === '\\' && !escaping) {
|
|
||||||
escaping = true;
|
|
||||||
continue;
|
|
||||||
} else if (char === '=' && !escaping && !inQuotes) {
|
|
||||||
keyName = str;
|
|
||||||
str = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
str += char;
|
|
||||||
escaping = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyName) result[keyName] = str;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getM3U8Formats(m3u8body) {
|
|
||||||
let formats = [];
|
|
||||||
const formatLines = m3u8body.split('\n').slice(2);
|
|
||||||
|
|
||||||
for (let i = 0; i < formatLines.length; i += 3) {
|
|
||||||
const mediaLine = parseM3U8Line(formatLines[i].split(':')[1]);
|
|
||||||
const streamLine = parseM3U8Line(formatLines[i + 1].split(':')[1]);
|
|
||||||
formats.push({
|
|
||||||
id: mediaLine['GROUP-ID'],
|
|
||||||
name: mediaLine.NAME,
|
|
||||||
resolution: streamLine.RESOLUTION ? streamLine.RESOLUTION.split('x') : null,
|
|
||||||
url: formatLines[i + 2]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return formats;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function(obj) {
|
|
||||||
try {
|
|
||||||
let _headers = { "client-id": "kimne78kx3ncx6brgo4mv6wki5h1ko" };
|
|
||||||
|
|
||||||
if (!obj.clipId && !obj.vodId) return { error: 'ErrorCantGetID' };
|
|
||||||
|
|
||||||
if (obj.vodId) {
|
|
||||||
const req_metadata = await fetch(gqlURL, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: _headers,
|
headers: clientIdHead,
|
||||||
body: JSON.stringify([
|
|
||||||
{
|
|
||||||
"operationName": "VideoMetadata",
|
|
||||||
"variables": {
|
|
||||||
"channelLogin": "",
|
|
||||||
"videoID": obj.vodId
|
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"persistedQuery": {
|
|
||||||
"version": 1,
|
|
||||||
"sha256Hash": "226edb3e692509f727fd56821f5653c05740242c82b0388883e0c0e75dcbf687"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
])
|
|
||||||
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
|
|
||||||
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
|
||||||
const vodMetadata = req_metadata[0].data.video;
|
|
||||||
|
|
||||||
if (vodMetadata.previewThumbnailURL.endsWith('/404_processing_{width}x{height}.png')) return { error: 'ErrorLiveVideo' };
|
|
||||||
if (vodMetadata.lengthSeconds > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
|
|
||||||
if (!vodMetadata.owner) return { error: 'ErrorEmptyDownload' }; // Streamer was banned...
|
|
||||||
|
|
||||||
const req_token = await fetch(gqlURL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: _headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: `{
|
|
||||||
videoPlaybackAccessToken(
|
|
||||||
id: "${obj.vodId}",
|
|
||||||
params: {
|
|
||||||
platform: "web",
|
|
||||||
playerBackend: "mediaplayer",
|
|
||||||
playerType: "site"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
{
|
|
||||||
value
|
|
||||||
signature
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
})
|
|
||||||
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
|
|
||||||
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
const access_token = req_token.data.videoPlaybackAccessToken;
|
|
||||||
const req_m3u8 = await fetch(`${m3u8URL}/vod/${obj.vodId}.m3u8?${new URLSearchParams({
|
|
||||||
allow_source: 'true',
|
|
||||||
allow_audio_only: 'true',
|
|
||||||
allow_spectre: 'true',
|
|
||||||
player: 'twitchweb',
|
|
||||||
playlist_include_framerate: 'true',
|
|
||||||
nauth: access_token.value,
|
|
||||||
nauthsig: access_token.signature
|
|
||||||
})}`, {
|
|
||||||
headers: _headers
|
|
||||||
}).then((r) => { return r.status == 200 ? r.text() : false;}).catch(() => {return false});
|
|
||||||
if (!req_m3u8) return { error: 'ErrorCouldntFetch' };
|
|
||||||
|
|
||||||
const formats = getM3U8Formats(req_m3u8);
|
|
||||||
const generalMeta = {
|
|
||||||
title: vodMetadata.title,
|
|
||||||
artist: `Twitch Broadcast by @${vodMetadata.owner.login}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!obj.isAudioOnly) {
|
|
||||||
const format = formats.find(f => f.resolution && f.resolution[1] == obj.quality) || formats[0];
|
|
||||||
|
|
||||||
return {
|
|
||||||
urls: format.url,
|
|
||||||
isM3U8: true,
|
|
||||||
time: vodMetadata.lengthSeconds * 1000,
|
|
||||||
filename: `twitchvod_${obj.vodId}_${format.resolution[0]}x${format.resolution[1]}.mp4`
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
type: "render",
|
|
||||||
isM3U8: true,
|
|
||||||
time: vodMetadata.lengthSeconds * 1000,
|
|
||||||
urls: formats.find(f => f.id === 'audio_only').url,
|
|
||||||
audioFilename: `twitchvod_${obj.vodId}_audio`,
|
|
||||||
fileMetadata: generalMeta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (obj.clipId) {
|
|
||||||
const req_metadata = await fetch(gqlURL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: _headers,
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query: `{
|
query: `{
|
||||||
clip(slug: "${obj.clipId}") {
|
clip(slug: "${obj.clipId}") {
|
||||||
|
@ -171,15 +30,17 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
})
|
})
|
||||||
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
|
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
|
||||||
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
||||||
const clipMetadata = req_metadata.data.clip;
|
|
||||||
if (clipMetadata.durationSeconds > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
|
|
||||||
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) return { error: 'ErrorEmptyDownload' }; // Streamer was banned...
|
|
||||||
|
|
||||||
const req_token = await fetch(gqlURL, {
|
let clipMetadata = req_metadata.data.clip;
|
||||||
|
|
||||||
|
if (clipMetadata.durationSeconds > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
|
||||||
|
if (!clipMetadata.videoQualities || !clipMetadata.broadcaster) return { error: 'ErrorEmptyDownload' };
|
||||||
|
|
||||||
|
let req_token = await fetch(gqlURL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: _headers,
|
headers: clientIdHead,
|
||||||
body: JSON.stringify([
|
body: JSON.stringify([
|
||||||
{
|
{
|
||||||
"operationName": "VideoAccessToken_Clip",
|
"operationName": "VideoAccessToken_Clip",
|
||||||
|
@ -194,30 +55,126 @@ export default async function(obj) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}).then((r) => { return r.status == 200 ? r.json() : false;}).catch(() => {return false});
|
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
|
||||||
|
|
||||||
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
const generalMeta = {
|
let formats = clipMetadata.videoQualities;
|
||||||
title: clipMetadata.title,
|
let format = formats.find(f => f.quality === obj.quality) || formats[0];
|
||||||
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
const access_token = req_token[0].data.clip.playbackAccessToken;
|
|
||||||
const formats = clipMetadata.videoQualities;
|
|
||||||
const format = formats.find(f => f.quality == obj.quality) || formats[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "bridge",
|
type: "bridge",
|
||||||
urls: `${format.sourceURL}?${new URLSearchParams({
|
urls: `${format.sourceURL}?${new URLSearchParams({
|
||||||
sig: access_token.signature,
|
sig: req_token[0].data.clip.playbackAccessToken.signature,
|
||||||
token: access_token.value
|
token: req_token[0].data.clip.playbackAccessToken.value
|
||||||
})}`,
|
})}`,
|
||||||
filename: `twitchclip_${clipMetadata.id}_${format.quality}.mp4`,
|
fileMetadata: {
|
||||||
audioFilename: `twitchclip_${clipMetadata.id}_audio`,
|
title: clipMetadata.title,
|
||||||
|
artist: `Twitch Clip by @${clipMetadata.broadcaster.login}, clipped by @${clipMetadata.curator.login}`,
|
||||||
|
},
|
||||||
|
filename: `twitchclip_${clipMetadata.id}_${format.quality}p.mp4`,
|
||||||
|
audioFilename: `twitchclip_${clipMetadata.id}_audio`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getVideo(obj) {
|
||||||
|
let req_metadata = await fetch(gqlURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientIdHead,
|
||||||
|
body: JSON.stringify([
|
||||||
|
{
|
||||||
|
"operationName": "VideoMetadata",
|
||||||
|
"variables": {
|
||||||
|
"channelLogin": "",
|
||||||
|
"videoID": obj.vodId
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"persistedQuery": {
|
||||||
|
"version": 1,
|
||||||
|
"sha256Hash": "226edb3e692509f727fd56821f5653c05740242c82b0388883e0c0e75dcbf687"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
|
||||||
|
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
let vodMetadata = req_metadata[0].data.video;
|
||||||
|
|
||||||
|
if (vodMetadata.previewThumbnailURL.endsWith('/404_processing_{width}x{height}.png')) return { error: 'ErrorLiveVideo' };
|
||||||
|
if (vodMetadata.lengthSeconds > maxVideoDuration / 1000) return { error: ['ErrorLengthLimit', maxVideoDuration / 60000] };
|
||||||
|
if (!vodMetadata.owner) return { error: 'ErrorEmptyDownload' };
|
||||||
|
|
||||||
|
let req_token = await fetch(gqlURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientIdHead,
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `{
|
||||||
|
videoPlaybackAccessToken(
|
||||||
|
id: "${obj.vodId}",
|
||||||
|
params: {
|
||||||
|
platform: "web",
|
||||||
|
playerBackend: "mediaplayer",
|
||||||
|
playerType: "site"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
{
|
||||||
|
value
|
||||||
|
signature
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
})
|
||||||
|
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
|
||||||
|
if (!req_token) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
let req_m3u8 = await fetch(
|
||||||
|
`${m3u8URL}/vod/${obj.vodId}.m3u8?${
|
||||||
|
new URLSearchParams({
|
||||||
|
allow_source: 'true',
|
||||||
|
allow_audio_only: 'true',
|
||||||
|
allow_spectre: 'true',
|
||||||
|
player: 'twitchweb',
|
||||||
|
playlist_include_framerate: 'true',
|
||||||
|
nauth: req_token.data.videoPlaybackAccessToken.value,
|
||||||
|
nauthsig: req_token.data.videoPlaybackAccessToken.signature
|
||||||
|
}
|
||||||
|
)}`, {
|
||||||
|
headers: clientIdHead
|
||||||
|
}
|
||||||
|
).then((r) => { return r.status === 200 ? r.text() : false; }).catch(() => { return false });
|
||||||
|
|
||||||
|
if (!req_m3u8) return { error: 'ErrorCouldntFetch' };
|
||||||
|
|
||||||
|
let formats = getM3U8Formats(req_m3u8);
|
||||||
|
let generalMeta = {
|
||||||
|
title: vodMetadata.title,
|
||||||
|
artist: `Twitch Broadcast by @${vodMetadata.owner.login}`,
|
||||||
|
}
|
||||||
|
if (obj.isAudioOnly) {
|
||||||
|
return {
|
||||||
|
type: "render",
|
||||||
|
isM3U8: true,
|
||||||
|
time: vodMetadata.lengthSeconds * 1000,
|
||||||
|
urls: formats.find(f => f.id === 'audio_only').url,
|
||||||
|
audioFilename: `twitchvod_${obj.vodId}_audio`,
|
||||||
fileMetadata: generalMeta
|
fileMetadata: generalMeta
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} else {
|
||||||
return { error: 'ErrorBadFetch' };
|
let format = formats.find(f => f.resolution && f.resolution[1] === obj.quality) || formats[0];
|
||||||
|
return {
|
||||||
|
urls: format.url,
|
||||||
|
isM3U8: true,
|
||||||
|
time: vodMetadata.lengthSeconds * 1000,
|
||||||
|
filename: `twitchvod_${obj.vodId}_${format.resolution[0]}x${format.resolution[1]}.mp4`,
|
||||||
|
fileMetadata: generalMeta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
export default async function (obj) {
|
||||||
|
let response = { error: 'ErrorEmptyDownload' };
|
||||||
|
if (obj.clipId) {
|
||||||
|
response = await getClip(obj)
|
||||||
|
} else if (obj.vodId) {
|
||||||
|
response = await getVideo(obj)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"twitch": {
|
"twitch": {
|
||||||
"alias": "twitch vods & videos & clips",
|
"alias": "twitch clips, videos & vods",
|
||||||
"tld": "tv",
|
"tld": "tv",
|
||||||
"patterns": ["videos/:video", ":channel/clip/:clip"],
|
"patterns": ["videos/:video", ":channel/clip/:clip"],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|
|
@ -70,12 +70,6 @@ export function cleanURL(url, host) {
|
||||||
url = url.replaceAll(forbiddenChars[i], '')
|
url = url.replaceAll(forbiddenChars[i], '')
|
||||||
}
|
}
|
||||||
url = url.replace('https//', 'https://')
|
url = url.replace('https//', 'https://')
|
||||||
if (url.includes('youtube.com/shorts/')) {
|
|
||||||
url = url.split('?')[0].replace('shorts/', 'watch?v=');
|
|
||||||
}
|
|
||||||
if (url.includes('clips.twitch.tv')) {
|
|
||||||
url = url.split('?')[0].replace('clips.twitch.tv/', 'twitch.tv/_/clip/');
|
|
||||||
}
|
|
||||||
return url.slice(0, 128)
|
return url.slice(0, 128)
|
||||||
}
|
}
|
||||||
export function cleanString(string) {
|
export function cleanString(string) {
|
||||||
|
@ -155,3 +149,52 @@ export function cleanHTML(html) {
|
||||||
clean = clean.replace(/\n/g, '');
|
clean = clean.replace(/\n/g, '');
|
||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseM3U8Line(line) {
|
||||||
|
let result = {};
|
||||||
|
let str = '', inQuotes = false, keyName = null, escaping = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"' && !escaping) {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
continue
|
||||||
|
} else if (char === ',' && !escaping && !inQuotes) {
|
||||||
|
if (!keyName) break;
|
||||||
|
result[keyName] = str;
|
||||||
|
keyName = null;
|
||||||
|
str = '';
|
||||||
|
continue
|
||||||
|
} else if (char === '\\' && !escaping) {
|
||||||
|
escaping = true;
|
||||||
|
continue
|
||||||
|
} else if (char === '=' && !escaping && !inQuotes) {
|
||||||
|
keyName = str;
|
||||||
|
str = '';
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
str += char;
|
||||||
|
escaping = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyName) result[keyName] = str;
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getM3U8Formats(m3u8body) {
|
||||||
|
const formatLines = m3u8body.split('\n').slice(2);
|
||||||
|
let formats = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < formatLines.length; i += 3) {
|
||||||
|
const mediaLine = parseM3U8Line(formatLines[i].split(':')[1]);
|
||||||
|
const streamLine = parseM3U8Line(formatLines[i + 1].split(':')[1]);
|
||||||
|
formats.push({
|
||||||
|
id: mediaLine['GROUP-ID'],
|
||||||
|
name: mediaLine.NAME,
|
||||||
|
resolution: streamLine.RESOLUTION ? streamLine.RESOLUTION.split('x') : null,
|
||||||
|
url: formatLines[i + 2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return formats
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue