twitch clean up

This commit is contained in:
wukko 2023-09-16 16:27:53 +06:00
parent ff9d48740d
commit ad9b6ebdd3
7 changed files with 233 additions and 225 deletions

View file

@ -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,

View file

@ -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) });
} }

View file

@ -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
}

View file

@ -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

View file

@ -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
}