diff --git a/README.md b/README.md index 0964d5dd..ade04cd2 100644 --- a/README.md +++ b/README.md @@ -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 | ✅ | ✅ | ✅ | | diff --git a/src/modules/processing/hostOverrides.js b/src/modules/processing/hostOverrides.js index a9f2fc22..88553e35 100644 --- a/src/modules/processing/hostOverrides.js +++ b/src/modules/processing/hostOverrides.js @@ -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, diff --git a/src/modules/processing/match.js b/src/modules/processing/match.js index 51333829..7b769dc0 100644 --- a/src/modules/processing/match.js +++ b/src/modules/processing/match.js @@ -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) }); } diff --git a/src/modules/processing/matchActionDecider.js b/src/modules/processing/matchActionDecider.js index 258fd81b..82eb3415 100644 --- a/src/modules/processing/matchActionDecider.js +++ b/src/modules/processing/matchActionDecider.js @@ -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": diff --git a/src/modules/processing/services/twitch.js b/src/modules/processing/services/twitch.js new file mode 100644 index 00000000..6d7e6d94 --- /dev/null +++ b/src/modules/processing/services/twitch.js @@ -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` + } +} diff --git a/src/modules/processing/servicesConfig.json b/src/modules/processing/servicesConfig.json index 29b44894..47f501fe 100644 --- a/src/modules/processing/servicesConfig.json +++ b/src/modules/processing/servicesConfig.json @@ -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 } - } + } } diff --git a/src/modules/processing/servicesPatternTesters.js b/src/modules/processing/servicesPatternTesters.js index cd4d4d61..a22b2d58 100644 --- a/src/modules/processing/servicesPatternTesters.js +++ b/src/modules/processing/servicesPatternTesters.js @@ -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)), } diff --git a/src/modules/sub/utils.js b/src/modules/sub/utils.js index 0a905110..b38fc39c 100644 --- a/src/modules/sub/utils.js +++ b/src/modules/sub/utils.js @@ -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 +} diff --git a/src/test/tests.json b/src/test/tests.json index 692e97fa..80b05c95 100644 --- a/src/test/tests.json +++ b/src/test/tests.json @@ -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" + } }] -} \ No newline at end of file +}