add support for twitch clips

merge pull request #79 from Snazzah/feat/twitch
This commit is contained in:
wukko 2023-09-16 18:01:08 +06:00 committed by GitHub
commit 2ca4d67e29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 186 additions and 10 deletions

View file

@ -24,6 +24,7 @@ Paste the link, get the video, move on. It's that simple. Just how it should be.
| Streamable | ✅ | ✅ | ✅ | |
| TikTok | ✅ | ✅ | ✅ | Supports downloads of: videos with or without watermark, images from slideshow without watermark, full (original) audios. |
| Tumblr | ✅ | ✅ | ✅ | Support for audio file downloads. |
| Twitch Clips | ✅ | ✅ | ✅ | |
| Twitter/X * | ✅ | ✅ | ✅ | Ability to pick what to save from multi-media tweets. |
| Vimeo | ✅ | ✅ | ✅ | Audio downloads are only available for dash files. |
| Vine Archive | ✅ | ✅ | ✅ | |

View file

@ -8,6 +8,9 @@ export default function (inHost, inURL) {
url = url.split("?")[0].replace("www.", "");
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;
case "youtu":
if (url.startsWith("https://youtu.be/")) {
@ -32,6 +35,11 @@ export default function (inHost, inURL) {
url = url.replace(url.split('/')[5], '')
}
break;
case "twitch":
if (url.includes('clips.twitch.tv')) {
url = url.split('?')[0].replace('clips.twitch.tv/', 'twitch.tv/_/clip/');
}
break;
}
return {
host: host,

View file

@ -19,6 +19,7 @@ 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 {
@ -122,6 +123,13 @@ export default async function (host, patternMatch, url, lang, obj) {
isAudioOnly: isAudioOnly,
});
break;
case "twitch":
r = await twitch({
clipId: patternMatch["clip"] ? patternMatch["clip"] : false,
quality: obj.vQuality,
isAudioOnly: obj.isAudioOnly
});
break;
default:
return apiJSON(0, { t: errorUnsupported(lang) });
}

View file

@ -55,7 +55,7 @@ export default function(r, host, audioFormat, isAudioOnly, lang, isAudioMuted, d
case "tiktok":
params = { type: "bridge" };
break;
case "vine":
case "instagram":
case "tumblr":

View file

@ -0,0 +1,76 @@
import { maxVideoDuration } from "../../config.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) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
if (!req_metadata) return { error: 'ErrorCouldntFetch' };
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",
headers: clientIdHead,
body: JSON.stringify([
{
"operationName": "VideoAccessToken_Clip",
"variables": {
"slug": obj.clipId
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"
}
}
}
])
}).then((r) => { return r.status === 200 ? r.json() : false; }).catch(() => { return false });
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: 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`
}
}

View file

@ -72,6 +72,12 @@
"alias": "streamable.com",
"patterns": [":id", "o/:id", "e/:id", "s/:id"],
"enabled": true
},
"twitch": {
"alias": "twitch clips",
"tld": "tv",
"patterns": [":channel/clip/:clip"],
"enabled": true
}
}
}
}

View file

@ -22,15 +22,17 @@ export const testers = {
|| (patternMatch["id"] && patternMatch["id"].length < 21 && patternMatch["user"] && patternMatch["user"].length <= 32)),
"vimeo": (patternMatch) => ((patternMatch["id"] && patternMatch["id"].length <= 11)),
"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)
"streamable": (patternMatch) => (patternMatch["id"] && patternMatch["id"].length === 6),
"twitch": (patternMatch) => ((patternMatch["channel"] && patternMatch["clip"] && patternMatch["clip"].length <= 100)),
}

View file

@ -70,9 +70,6 @@ export function cleanURL(url, host) {
url = url.replaceAll(forbiddenChars[i], '')
}
url = url.replace('https//', 'https://')
if (url.includes('youtube.com/shorts/')) {
url = url.split('?')[0].replace('shorts/', 'watch?v=');
}
return url.slice(0, 128)
}
export function cleanString(string) {
@ -152,3 +149,52 @@ export function cleanHTML(html) {
clean = clean.replace(/\n/g, '');
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
}

View file

@ -1070,5 +1070,34 @@
"code": 400,
"status": "error"
}
}],
"twitch": [{
"name": "clip",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "clip (isAudioOnly)",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {
"isAudioOnly": true
},
"expected": {
"code": 200,
"status": "stream"
}
}, {
"name": "clip (isAudioMuted)",
"url": "https://twitch.tv/rtgame/clip/TubularInventiveSardineCorgiDerp-PM47mJQQ2vsL5B5G",
"params": {
"isAudioMuted": true
},
"expected": {
"code": 200,
"status": "stream"
}
}]
}
}