mirror of
https://codeberg.org/SafeTwitch/safetwitch.git
synced 2024-12-22 05:12:57 -05:00
Create category discovery page
This commit is contained in:
parent
a5f40b4e02
commit
e920c7d2f0
8 changed files with 369 additions and 11 deletions
80
frontend/src/views/CategoryView.vue
Normal file
80
frontend/src/views/CategoryView.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export default {
|
||||
async setup() {
|
||||
const route = useRoute()
|
||||
const game = route.params.game
|
||||
const res = await fetch(`http://localhost:7000/api/discover/${game}`)
|
||||
|
||||
return {
|
||||
data: await res.json()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
abbreviate(text: number) {
|
||||
return Intl.NumberFormat('en-US', {
|
||||
//@ts-ignore
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1
|
||||
}).format(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col max-w-5xl mx-auto">
|
||||
<div class="flex space-x-4 p-3">
|
||||
<img :src="data.cover" class="self-start rounded-md">
|
||||
|
||||
<div>
|
||||
<h1 class="font-bold text-5xl text-white">{{ data.name }}</h1>
|
||||
<div class="inline-flex my-1 space-x-3">
|
||||
<p class="font-bold text-white text-lg">Followers: {{ abbreviate(data.followers) }}</p>
|
||||
<p class="font-bold text-white text-lg">Viewers: {{ abbreviate(data.viewers) }}</p>
|
||||
</div>
|
||||
|
||||
<ul class="mb-5">
|
||||
<li v-for="tag in data.tags" :key="tag" class="inline-flex">
|
||||
<span class="text-white p-1 py-0.5 mr-1 text-sm font-bold bg-ctp-overlay1 rounded-sm">{{ tag }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-md text-gray-400 overflow-y-auto">{{ data.description }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="max-w-[58rem] mx-auto">
|
||||
<ul>
|
||||
<li v-for="stream in data.streams" :key="stream" class="inline-flex m-2 hover:scale-105 transition-transform">
|
||||
<div class="bg-ctp-crust rounded-lg">
|
||||
<a :href="`http://localhost:5173/${stream.streamer.name}`">
|
||||
<img :src="stream.preview" class="rounded-lg rounded-b-none">
|
||||
</a>
|
||||
|
||||
<div class="text-white p-2 inline-flex space-x-2 w-full">
|
||||
|
||||
|
||||
<div class="inline-flex w-full">
|
||||
<div class="inline-flex">
|
||||
<img :src="stream.streamer.pfp" class="rounded-full mr-2">
|
||||
<div>
|
||||
<p class="font-bold w-[22.9rem] truncate">{{ stream.title }}</p>
|
||||
<div class="inline-flex w-full justify-between">
|
||||
<p class="text-gray-300">{{ stream.streamer.name }}</p>
|
||||
<p class="self-end float-right"> <v-icon name="io-person"></v-icon> {{ abbreviate(stream.viewers) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
72
frontend/src/views/HomepageView.vue
Normal file
72
frontend/src/views/HomepageView.vue
Normal file
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
|
||||
|
||||
export default {
|
||||
async setup() {
|
||||
const res = await fetch(`http://localhost:7000/api/discover`)
|
||||
|
||||
return {
|
||||
data: await res.json()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
abbreviate(text: number) {
|
||||
return Intl.NumberFormat('en-US', {
|
||||
//@ts-ignore
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1
|
||||
}).format(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="p-2">
|
||||
<h1 class="font-bold text-5xl text-white"> Discover </h1>
|
||||
<p class="text-xl text-white"> Sort through popular categories</p>
|
||||
|
||||
<div class="pt-5 inline-flex text-white">
|
||||
<p class="mr-2 font-bold text-white">Filter by tag</p>
|
||||
<form class="relative">
|
||||
<label for="searchBar" class="hidden">Search</label>
|
||||
<v-icon
|
||||
name="io-search-outline"
|
||||
class="absolute my-auto inset-y-0 left-2"
|
||||
></v-icon>
|
||||
<input
|
||||
type="text"
|
||||
id="searchBar"
|
||||
name="searchBar"
|
||||
placeholder="Search"
|
||||
class="rounded-md p-1 pl-8 text-black bg-neutral-500 placeholder:text-white"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="">
|
||||
<li v-for="category in data" :key="category" class="inline-flex m-2 hover:scale-105 transition-transform">
|
||||
<div class="bg-ctp-crust max-w-[13.5rem] rounded-lg">
|
||||
<a :href="`http://localhost:5173/game/${category.name}`">
|
||||
<img :src="category.image" class="rounded-lg rounded-b-none">
|
||||
</a>
|
||||
|
||||
<div class="p-2">
|
||||
<div>
|
||||
<p class="font-bold text-white text-xl"> {{ category.displayName }}</p>
|
||||
<p class="text-sm text-white"> {{ abbreviate(category.viewers) }} viewers</p>
|
||||
</div>
|
||||
|
||||
<ul class="h-8 overflow-hidden">
|
||||
<li v-for="tag in category.tags" :key="tag" class="inline-flex">
|
||||
<span class="p-2.5 py-1.5 bg-ctp-surface0 rounded-md m-0.5 text-xs font-bold text-white">{{ tag }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
|
@ -101,8 +101,8 @@ export default {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full inline-flex space-x-4 justify-center p-4">
|
||||
<div class="flex flex-wrap bg-ctp-crust p-6 rounded-lg max-w-prose min-w-[65ch] text-white">
|
||||
<div v-else class="w-full justify-center inline-flex space-x-4 p-4">
|
||||
<div class="flex bg-ctp-crust flex-col p-6 rounded-lg max-w-prose min-w-[65ch] text-white">
|
||||
<div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5">
|
||||
<video-player :options="videoOptions"> </video-player>
|
||||
</div>
|
||||
|
@ -127,9 +127,9 @@ export default {
|
|||
<h1 v-if="!data.stream" class="font-bold text-md self-end">
|
||||
{{ data.followersAbbv }} Followers
|
||||
</h1>
|
||||
<div v-else class="w-[12rem]">
|
||||
<div v-else class="w-[17rem]">
|
||||
<p class="text-sm font-bold text-gray-200 self-end">
|
||||
{{ truncate(data.stream.title, 75) }}
|
||||
{{ truncate(data.stream.title, 130) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -155,9 +155,18 @@ export default {
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 pl- inline-flex">
|
||||
<button class="text-white text-sm font-bold p-2 py-1 rounded-md bg-purple-600">
|
||||
<v-icon name="bi-heart-fill" scale="0.85"></v-icon>
|
||||
Follow
|
||||
</button>
|
||||
|
||||
<p class="align-baseline font-bold ml-3">{{ data.followersAbbv }} Followers</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ctp-mantle m-5 p-5 pt-3 rounded-lg w-full space-y-3">
|
||||
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
|
||||
<div class="inline-flex w-full">
|
||||
<span class="pr-3 font-bold text-3xl">About</span>
|
||||
</div>
|
||||
|
|
|
@ -14,4 +14,14 @@ profileRouter.get('/users/:username', async (req, res, next) => {
|
|||
res.send(streamerData)
|
||||
})
|
||||
|
||||
profileRouter.get('/discover', async (req, res, next) => {
|
||||
let discoveryData = await twitch.getDirectory(50)
|
||||
res.send(discoveryData)
|
||||
})
|
||||
|
||||
profileRouter.get('/discover/:game', async (req, res, next) => {
|
||||
let discoveryData = await twitch.getDirectoryGame(req.params.game, 50)
|
||||
res.send(discoveryData)
|
||||
})
|
||||
|
||||
export default profileRouter
|
|
@ -30,7 +30,6 @@ proxyRouter.get('/img', async (req: Request, res: Response, next: NextFunction)
|
|||
|
||||
|
||||
proxyRouter.get('/stream/:username/hls.m3u8', async (req: Request, res: Response, next: NextFunction) => {
|
||||
console.log(req.params.username)
|
||||
let m3u8Data = await twitch.getStream(req.params.username)
|
||||
const urlRegex =/(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig;
|
||||
const matches = m3u8Data.match(urlRegex)
|
||||
|
@ -46,7 +45,6 @@ proxyRouter.get('/stream/:username/hls.m3u8', async (req: Request, res: Response
|
|||
})
|
||||
|
||||
proxyRouter.get('/hls/:encodedUrl' , async (req: Request, res: Response, next: NextFunction) => {
|
||||
console.log('hi')
|
||||
const unencodedUrl = Buffer.from(req.params.encodedUrl, 'base64url').toString()
|
||||
const m3u8Fetch = await fetch(unencodedUrl)
|
||||
var m3u8Data = await m3u8Fetch.text()
|
||||
|
|
|
@ -15,7 +15,15 @@ const logLevels = {
|
|||
|
||||
export const logger = createLogger({
|
||||
format: format.combine(format.timestamp(), format.json()),
|
||||
transports: [new transports.Console({}), new transports.File({ filename: './serverLog.log' })],
|
||||
transports: [
|
||||
new transports.Console({
|
||||
format: format.combine(
|
||||
format.colorize(),
|
||||
format.simple()
|
||||
)
|
||||
}),
|
||||
new transports.File({ filename: './serverLog.log' })
|
||||
],
|
||||
levels: logLevels
|
||||
});
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { EventEmitter } from 'stream';
|
|||
import WebSocket from 'ws'
|
||||
import { TwitchChatOptions, Metadata, MessageType, MessageTypes } from '../../../types/scraping/Chat'
|
||||
import { parseUsername } from './utils';
|
||||
import { logger } from '../../logger';
|
||||
|
||||
export declare interface TwitchChat {
|
||||
on(event: 'PRIVMSG', listener: (username: string, messageType: MessageType, channel: string, message: string) => void): this
|
||||
|
@ -12,6 +13,7 @@ export class TwitchChat extends EventEmitter{
|
|||
private url = 'wss://irc-ws.chat.twitch.tv:443'
|
||||
private ws: WebSocket | null;
|
||||
private isConnected: boolean = false
|
||||
private manualDisconnect: boolean = false
|
||||
|
||||
constructor(options: TwitchChatOptions) {
|
||||
super()
|
||||
|
@ -21,6 +23,7 @@ export class TwitchChat extends EventEmitter{
|
|||
|
||||
private parser() {
|
||||
this.ws?.on('message', (data) => {
|
||||
console.log(this.channels)
|
||||
let normalData = data.toString()
|
||||
let splitted = normalData.split(":")
|
||||
|
||||
|
@ -45,10 +48,26 @@ export class TwitchChat extends EventEmitter{
|
|||
}
|
||||
|
||||
public async connect() {
|
||||
console.log('ss')
|
||||
this.ws = new WebSocket(this.url)
|
||||
this.isConnected = true
|
||||
|
||||
this.ws.onclose = () => {
|
||||
logger.info('Disconnected from twitch IRC'),
|
||||
logger.info(`Subscribed to channels ${this.channels}`)
|
||||
if(this.manualDisconnect) return
|
||||
const toEmit = {
|
||||
type: 'SERVERMSG',
|
||||
message: 'Disconnected'
|
||||
}
|
||||
this.emit(JSON.stringify(toEmit))
|
||||
|
||||
this.ws = null
|
||||
this.isConnected = false
|
||||
this.connect()
|
||||
}
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.ws.onopen = () => {
|
||||
if(this.ws) {
|
||||
this.ws.send('PASS none')
|
||||
this.ws.send('NICK justinfan333333333333')
|
||||
|
@ -60,12 +79,13 @@ export class TwitchChat extends EventEmitter{
|
|||
this.parser()
|
||||
return Promise.resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public addStreamer(streamerName: string) {
|
||||
if(!this.isConnected) return;
|
||||
this.channels.push(streamerName)
|
||||
this.ws!.send(`JOIN #${streamerName}`)
|
||||
}
|
||||
|
||||
|
|
|
@ -211,8 +211,13 @@ export class TwitchAPI {
|
|||
return Promise.resolve(false)
|
||||
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the stream playlist file from twitch
|
||||
* @param streamerName The username of the streamer
|
||||
* @returns Promise<boolean>
|
||||
*/
|
||||
public getStream = async (streamerName: string) => {
|
||||
const isLive = await this.isLive(streamerName)
|
||||
if(!isLive) return Promise.reject(new Error(`Streamer ${streamerName} is not live`))
|
||||
|
@ -249,4 +254,160 @@ export class TwitchAPI {
|
|||
|
||||
return await res.text()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the homepage discovery tab of twitch
|
||||
* @param limit Maximum categories to get
|
||||
* @param cursor The current page you're at (for pagination)
|
||||
|
||||
*/
|
||||
public getDirectory = async (limit: number, cursor?: number) => {
|
||||
const payload: any[] = [
|
||||
{
|
||||
"operationName": "BrowsePage_AllDirectories",
|
||||
"variables": {
|
||||
"limit": limit,
|
||||
"options": {
|
||||
"recommendationsContext": {
|
||||
"platform": "web"
|
||||
},
|
||||
"requestID": "JIRA-VXP-2397",
|
||||
"sort": "RELEVANCE",
|
||||
"tags": []
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "1d1914ca3cbfaa607ecd5595b2e305e96acf987c8f25328f7713b25f604c4668"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if(cursor)
|
||||
payload[0].variables.cursor = cursor
|
||||
|
||||
const res = await fetch(this.twitchUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: this.headers
|
||||
})
|
||||
const data = await res.json()
|
||||
const categories = data[0].data.directoriesWithTags.edges
|
||||
let formattedCategories = []
|
||||
|
||||
for (let category of categories) {
|
||||
let tags = []
|
||||
for (let tag of category.node.tags) {
|
||||
tags.push(tag.tagName)
|
||||
}
|
||||
|
||||
formattedCategories.push({
|
||||
name: category.node.name,
|
||||
displayName: category.node.displayName,
|
||||
viewers: category.node.viewersCount,
|
||||
tags: tags,
|
||||
createdAt: category.node.originalReleaseDate,
|
||||
image: `${process.env.URL}/proxy/img?imageUrl=${category.node.avatarURL}`
|
||||
})
|
||||
}
|
||||
|
||||
return formattedCategories
|
||||
}
|
||||
|
||||
public getDirectoryGame = async (name: string, streamLimit: number) => {
|
||||
const payload: any[] = [
|
||||
{
|
||||
"operationName": "DirectoryPage_Game",
|
||||
"variables": {
|
||||
"imageWidth": 50,
|
||||
"name": name,
|
||||
"options": {
|
||||
"sort": "RELEVANCE",
|
||||
"recommendationsContext": {
|
||||
"platform": "web"
|
||||
},
|
||||
"requestID": "JIRA-VXP-2397",
|
||||
"freeformTags": null,
|
||||
"tags": []
|
||||
},
|
||||
"sortTypeIsRecency": false,
|
||||
"limit": streamLimit
|
||||
},
|
||||
"extensions": {
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "df4bb6cc45055237bfaf3ead608bbafb79815c7100b6ee126719fac3762ddf8b"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"operationName": "Directory_DirectoryBanner",
|
||||
"variables": {
|
||||
"name": name
|
||||
},
|
||||
"extensions": {
|
||||
"persistedQuery": {
|
||||
"version": 1,
|
||||
"sha256Hash": "2670fbecd8fbea0211c56528d6eff5752ef9d6c73cd5238d395784b46335ded4"
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
const res = await fetch(this.twitchUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: this.headers
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if(!data[0].data.game) return null;
|
||||
|
||||
|
||||
let streams = []
|
||||
if(data[0].data.game.streams)
|
||||
streams = data[0].data.game.streams.edges
|
||||
let formatedStreams = []
|
||||
|
||||
for(let stream of streams) {
|
||||
let tags = []
|
||||
for (let tag of stream.node.freeformTags) {
|
||||
tags.push(tag.name)
|
||||
}
|
||||
|
||||
formatedStreams.push({
|
||||
title: stream.node.title,
|
||||
viewers: stream.node.viewersCount,
|
||||
preview: stream.node.previewImageURL,
|
||||
tags,
|
||||
streamer: {
|
||||
name: stream.node.broadcaster.displayName,
|
||||
pfp: stream.node.broadcaster.profileImageURL,
|
||||
colorHex: stream.node.broadcaster.primaryColorHex
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rawGameData = data[1].data.game;
|
||||
let tags = []
|
||||
for(let tag of rawGameData.tags) {
|
||||
tags.push(tag.tagName)
|
||||
}
|
||||
|
||||
const formatedGameData = {
|
||||
name: rawGameData.name,
|
||||
cover: rawGameData.avatarURL,
|
||||
description: rawGameData.description,
|
||||
viewers: rawGameData.viewersCount,
|
||||
followers: rawGameData.followersCount,
|
||||
tags,
|
||||
streams: formatedStreams
|
||||
}
|
||||
|
||||
|
||||
return formatedGameData
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue