0
Fork 0
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:
dragongoose 2023-03-19 19:51:55 -04:00
parent a5f40b4e02
commit e920c7d2f0
8 changed files with 369 additions and 11 deletions

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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