From 3a09ec51120b9246d1147b68f2628a9d8771cc1e Mon Sep 17 00:00:00 2001 From: dragongoose <19649813+dragongoose@users.noreply.github.com> Date: Mon, 13 Mar 2023 21:07:25 -0400 Subject: [PATCH] Create twitch IRC client and begin to implement a HLS proxy --- server/routes/proxy/proxyRoute.ts | 50 +++++++++++++++++++++++ server/types/scraping/Chat.ts | 17 ++++++++ server/util/scraping/chat/chat.ts | 65 ++++++++++++++++++++++++++++++ server/util/scraping/chat/utils.ts | 4 ++ 4 files changed, 136 insertions(+) create mode 100644 server/types/scraping/Chat.ts create mode 100644 server/util/scraping/chat/chat.ts create mode 100644 server/util/scraping/chat/utils.ts diff --git a/server/routes/proxy/proxyRoute.ts b/server/routes/proxy/proxyRoute.ts index 959839f..054a6ab 100644 --- a/server/routes/proxy/proxyRoute.ts +++ b/server/routes/proxy/proxyRoute.ts @@ -1,3 +1,4 @@ +import { Streamlink } from '@dragongoose/streamlink'; import { Router, Response, Request, NextFunction } from 'express' const proxyRouter = Router(); @@ -24,4 +25,53 @@ proxyRouter.get('/img', async (req: Request, res: Response, next: NextFunction) .catch((err) => next(err)) }) +proxyRouter.get('/stream/:username/hls.m3u8', (req: Request, res: Response, next: NextFunction) => { + console.log(req.params.username) + const streamlink = new Streamlink(`https://twitch.tv/${req.params.username}`, { + otherArgs: ['--stream-url'] + }) + + streamlink.begin() + + + streamlink.on('log', async (data) => { + // m3u8 url + let twitchM3u8url = data.toString() + + const urlRegex =/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; + const twitchRes = await fetch(twitchM3u8url) + let m3u8Data = await twitchRes.text() + const matches = m3u8Data.match(urlRegex) + if (!matches) return next(new Error('Error proxying HLS')); + + for (let url of matches) { + const base64data = Buffer.from(url).toString('base64url') + //m3u8Data = m3u8Data.replace(url, `${process.env.URL}/proxy/hls/${base64data}`) + } + + res.setHeader('Content-type','application/vnd.apple.mpegurl') + res.send(m3u8Data) + }) +}) + +proxyRouter.get('/hls/:encodedUrl' , (req: Request, res: Response, next: NextFunction) => { + const unencodedUrl = Buffer.from(req.params.encodedUrl, 'base64url').toString() + fetch(unencodedUrl).then((response) => { + response.body!.pipeTo( + new WritableStream({ + start() { + response.headers.forEach((v, n) => res.setHeader(n, v)); + }, + write(chunk) { + res.write(chunk); + }, + close() { + res.end(); + }, + }) + ); + }) + .catch((err) => next(err)) +}) + export default proxyRouter \ No newline at end of file diff --git a/server/types/scraping/Chat.ts b/server/types/scraping/Chat.ts new file mode 100644 index 0000000..6d2fbdb --- /dev/null +++ b/server/types/scraping/Chat.ts @@ -0,0 +1,17 @@ +export interface TwitchChatOptions { + login: { + username: string, + password: string + }, + channels: string[] +} + +export const MessageTypes = ['PRIVMSG', 'WHISPER'] +export type MessageType = typeof MessageTypes[number]; + +export interface Metadata { + username: string + messageType: MessageType + channel: string + message: string +} diff --git a/server/util/scraping/chat/chat.ts b/server/util/scraping/chat/chat.ts new file mode 100644 index 0000000..8b8e021 --- /dev/null +++ b/server/util/scraping/chat/chat.ts @@ -0,0 +1,65 @@ +import { EventEmitter } from 'stream'; +import WebSocket from 'ws' +import { TwitchChatOptions, Metadata, MessageType, MessageTypes } from '../../../types/scraping/Chat' +import { parseUsername } from './utils'; + +export declare interface TwitchChat { + on(event: 'PRIVMSG', listener: (username: string, messageType: MessageType, channel: string, message: string) => void): this +} + +export class TwitchChat extends EventEmitter{ + public channels: string[] + private url = 'wss://irc-ws.chat.twitch.tv:443' + private ws: WebSocket | null; + + constructor(options: TwitchChatOptions) { + super() + this.channels = options.channels + this.ws = null + } + + private parser() { + this.ws?.on('message', (data) => { + let normalData = data.toString() + let splitted = normalData.split(":") + + let metadata = splitted[1].split(' ') + let message = splitted[2] + + if(!MessageTypes.includes(metadata[1])) return; + + let parsedMetadata: Metadata = { + username: parseUsername(metadata[0]), + messageType: metadata[1], + channel: metadata[2], + message: message + } + + this.createEmit(parsedMetadata) + }) + } + + private createEmit(data: Metadata) { + this.emit(data.messageType, ...Object.values(data)) + } + + public async connect() { + this.ws = new WebSocket(this.url) + + this.ws.on('open', () => { + if(this.ws) { + this.ws.send('PASS none') + this.ws.send('NICK justinfan333333333333') + + for(let channel of this.channels) { + this.ws.send(`JOIN #${channel}`) + } + + this.parser() + return Promise.resolve() + } + }) + + } + +} diff --git a/server/util/scraping/chat/utils.ts b/server/util/scraping/chat/utils.ts new file mode 100644 index 0000000..cadfcd0 --- /dev/null +++ b/server/util/scraping/chat/utils.ts @@ -0,0 +1,4 @@ +export const parseUsername = (rawUsername: String) => { + const splitted = rawUsername.split('!') + return splitted[0] +} \ No newline at end of file