mirror of
https://codeberg.org/SafeTwitch/safetwitch.git
synced 2024-12-21 21:03:00 -05:00
Initial commit
This commit is contained in:
parent
e7dc781620
commit
bc9336468b
33 changed files with 12345 additions and 0 deletions
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
|
||||
}
|
15
frontend/.eslintrc.cjs
Normal file
15
frontend/.eslintrc.cjs
Normal file
|
@ -0,0 +1,15 @@
|
|||
/* eslint-env node */
|
||||
require('@rushstack/eslint-patch/modern-module-resolution')
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
'extends': [
|
||||
'plugin:vue/vue3-essential',
|
||||
'eslint:recommended',
|
||||
'@vue/eslint-config-typescript',
|
||||
'@vue/eslint-config-prettier/skip-formatting'
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest'
|
||||
}
|
||||
}
|
28
frontend/.gitignore
vendored
Normal file
28
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
8
frontend/.prettierrc.json
Normal file
8
frontend/.prettierrc.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "none"
|
||||
}
|
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
# test
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body class="bg-ctp-base">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
7987
frontend/package-lock.json
generated
Normal file
7987
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"name": "test",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dragongoose/streamlink": "^1.0.2",
|
||||
"@videojs-player/vue": "^1.0.0",
|
||||
"oh-vue-icons": "^1.0.0-rc3",
|
||||
"video.js": "^8.0.4",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/tailwindcss": "^0.1.1",
|
||||
"@rushstack/eslint-patch": "^1.2.0",
|
||||
"@types/node": "^18.14.2",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vue/eslint-config-prettier": "^7.1.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.2",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-plugin-vue": "^9.9.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.4",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "~4.8.4",
|
||||
"vite": "^4.1.4",
|
||||
"vue-tsc": "^1.2.0"
|
||||
}
|
||||
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
12
frontend/src/App.vue
Normal file
12
frontend/src/App.vue
Normal file
|
@ -0,0 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavbarItem from './components/Navbar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<navbar-item></navbar-item>
|
||||
|
||||
<Suspense>
|
||||
<RouterView />
|
||||
</Suspense>
|
||||
</template>
|
3
frontend/src/assets/index.css
Normal file
3
frontend/src/assets/index.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
26
frontend/src/components/Navbar.vue
Normal file
26
frontend/src/components/Navbar.vue
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
export default {};
|
||||
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 flex items-center justify-between bg-ctp-base text-white">
|
||||
<h1 class="font-bold text-2xl">Naqvbar</h1>
|
||||
|
||||
<div>
|
||||
<form class="relative">
|
||||
<label for="searchBar" class="hidden">Search</label>
|
||||
<v-icon name="io-search-outline" class="text-black 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">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="inline-flex space-x-6 font-medium">
|
||||
<router-link to="">Github</router-link>
|
||||
<router-link to="/preferences">Preferences</router-link>
|
||||
<router-link to="/about">About</router-link>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
23
frontend/src/main.ts
Normal file
23
frontend/src/main.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import './assets/index.css'
|
||||
|
||||
import VueVideoPlayer from '@videojs-player/vue'
|
||||
import 'video.js/dist/video-js.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(VueVideoPlayer)
|
||||
|
||||
import { OhVueIcon, addIcons } from "oh-vue-icons";
|
||||
import { IoSearchOutline, IoLink, FaCircleNotch, BiTwitter, BiInstagram, BiDiscord, BiYoutube, BiTiktok } from "oh-vue-icons/icons";
|
||||
|
||||
addIcons(IoSearchOutline, IoLink, FaCircleNotch, BiTwitter, BiInstagram, BiDiscord, BiYoutube, BiTiktok)
|
||||
|
||||
app.component("v-icon", OhVueIcon);
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
|
22
frontend/src/router/index.ts
Normal file
22
frontend/src/router/index.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import UserView from '../views/UserView.vue'
|
||||
import PageNotFound from '../views/PageNotFound.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/:username',
|
||||
component: UserView
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', component: PageNotFound}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
7
frontend/src/views/HomeView.vue
Normal file
7
frontend/src/views/HomeView.vue
Normal file
|
@ -0,0 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
11
frontend/src/views/PageNotFound.vue
Normal file
11
frontend/src/views/PageNotFound.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center pt-10 font-bold text-5xl text-white">
|
||||
<h1>oops....</h1>
|
||||
<h1>this page wasn't found(◞‸◟;)</h1>
|
||||
<h2 class="text-4xl">maybe go <RouterLink to="/" class="text-gray-500">home</RouterLink>?</h2>
|
||||
</div>
|
||||
</template>
|
122
frontend/src/views/UserView.vue
Normal file
122
frontend/src/views/UserView.vue
Normal file
|
@ -0,0 +1,122 @@
|
|||
<script lang="ts" >
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from "vue-router";
|
||||
import type { StreamerData } from '../../../server/routes/profile/profileRoute'
|
||||
|
||||
import { VideoPlayer } from '@videojs-player/vue'
|
||||
import 'video.js/dist/video-js.css'
|
||||
|
||||
export default {
|
||||
async setup() {
|
||||
const route = useRoute()
|
||||
const username = route.params.username
|
||||
|
||||
const getUser = async () => {
|
||||
const res = await fetch(`http://localhost:7000/api/users/${username}`)
|
||||
|
||||
if (res.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data: StreamerData = await res.json()
|
||||
data.pfp = `http://localhost:7000/proxy/img?imageUrl=${encodeURIComponent(data.pfp)}`
|
||||
return data
|
||||
}
|
||||
|
||||
const data = ref()
|
||||
onMounted(async () => {
|
||||
const fetchedUser = await getUser()
|
||||
data.value = fetchedUser
|
||||
})
|
||||
|
||||
return {
|
||||
data
|
||||
}
|
||||
},
|
||||
components: {
|
||||
VideoPlayer
|
||||
},
|
||||
methods: {
|
||||
truncate(value: string, length: number) {
|
||||
if (value.length > length) {
|
||||
return value.substring(0, length) + "...";
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!data" id="loadingDiv" class="flex mx-auto justify-center bg-ctp-crust rounded-lg w-2/3 p-2 text-white">
|
||||
<div class="flex space-x-3">
|
||||
<h1 class="text-4xl font-bold"> Searching... </h1>
|
||||
<v-icon name="fa-circle-notch" class="animate-spin w-10 h-10"></v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-wrap mx-auto bg-ctp-crust p-6 rounded-lg max-w-prose text-white">
|
||||
|
||||
|
||||
<div v-if="data.isLive" class="w-full mx-auto rounded-lg mb-5">
|
||||
<video-player
|
||||
src="https://video-weaver.jfk04.hls.ttvnw.net/v1/playlist/CpwF74ZwzfoheHjVAIg7VWZzSnB9W3FgXzezhTA3ye7QTA9aJjprgctuMZwlTRQ1PUqoltrgVBu7SJ3vJfALlIkNYAhuYjZMnFaFnEqzv-jIH0gXW-AWgu3f-vDbarfun78h4vUHecELAqyJM_c6IEDM2b5uHCE1ZfSnZnk2ZV8XHyzbwUz8Uc-e1YM1DyCpGYHrtz_Z-0TR-ordiDzHJ8lFCzn8F8zbbePoFMD2kex4f_6neLyuAfbh2jLZj6JNPt6o63fP2N9WiHxlRT75ACUihbBbI2llL-UCdpA_lXyTWFUud28R2wJGCqt8WUm-wjECnS8BINcThhPEnN_I1imGFN-CwqxqeSMpFkzA05_Q4HoP9Bu-Z4ln2UUw7ljy9OZeYcR0rM29rmKNCa5VPA4mzuAM_2al7JLiabC9t2rJvYZgklWgFaxc6UoChOU5yQsgpac_MNNNz7wIWqgjHSRL-AI4XQDIAHWZwm7GeEB5KJhxvI_8dgnnui7t9MBFU8A2h73iScGGZOmYfK3jQrIG0-kEhjBMCopu4XPv0RxSbSD9SvncTUmNer6ybUKkjXC793AC7xREE1z6xEGQfT5wbTbC2WscKQn883ShGD5XtfW5rZIenitxvNFFgM3Ttwv5FeSY0o8angPRPzexZ5fijOZ2eAmsXgrW_pN380ba4qpbrsaalJcdtysN9Da-QmCWbsjnoKMkQ_-12PwwpDYAIyWjmeW3JrvVMzClK_WV7Z70pEt6yl_9W4Qf0zt4mEyF8ghG4nxPZutSCt4HPilKLzPCLYfcuEiIlW6hZ9riNTP7jf0Yp-afDE89lYGYrj0xNh4C8LahZTNtGWKVkxRxw8w_v2lhBuvmrNJrwjs2Rwyw_EZANqi0_CnvWEMaDKw-7_jk6XNkCi0NtyABKgl1cy1lYXN0LTIwgQY.m3u8"
|
||||
poster="/your-path/poster.jpg" controls :loop="true" :volume="0.6" :autoplay="'muted'" :fluid="true" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="inline-flex justify-between">
|
||||
<div class="flex">
|
||||
<div class="container w-28 h-28 relative">
|
||||
<img :src="data.pfp" class="rounded-full border-4 border-ctp-teal p-0.5 w-auto h-28">
|
||||
<span class="absolute bottom-0 right-[1.8rem] bg-ctp-red font-bold p-2.5 py-0.5 rounded-md">LIVE</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-3">
|
||||
<h1 class="text-4xl font-bold mb-3">{{ data.username }}</h1>
|
||||
<h1 v-if="!data.stream" class="font-bold text-md self-end">{{ data.followersAbbv }} Followers</h1>
|
||||
<div v-else class="w-[12rem]">
|
||||
<p class="text-md font-bold self-end"> {{ truncate(data.stream.title, 200) }} </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="pt-5 pr-5 flex rounded-lg">
|
||||
<span v-if="!data.isLive"
|
||||
class=" font-bold text-sm bg-ctp-mantle border border-ctp-red p-3.5 rounded-lg">OFFLINE</span>
|
||||
<div v-else class="justify-end">
|
||||
<ul class="flex font-bold flex-wrap text-sm justify-end float-right max-h-24 overflow-y-auto">
|
||||
<li v-for="tag in data.stream.tags" class="p-2.5 py-1 m-0.5 bg-ctp-mantle rounded-md inline-flex">
|
||||
{{ tag }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-ctp-mantle m-5 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>
|
||||
|
||||
<p class="mb-5">{{ data.about }}</p>
|
||||
|
||||
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
|
||||
|
||||
<ul class="flex font-semibold text-md justify-start flex-wrap flex-row">
|
||||
<li v-for="link in data.socials">
|
||||
<a :href="link.link" class="text-white hover:text-gray-400 mr-4">
|
||||
<v-icon :name="link.type ? `bi-${link.type}` : 'io-link'" class="w-6 h-6 mr-1"></v-icon>
|
||||
<span>{{ link.text }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
15
frontend/tailwind.config.js
Normal file
15
frontend/tailwind.config.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
module.exports = {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx,vue}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@catppuccin/tailwindcss')({
|
||||
prefix: 'ctp',
|
||||
defaultFlavour: 'mocha',
|
||||
})
|
||||
],
|
||||
}
|
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
8
frontend/tsconfig.node.json
Normal file
8
frontend/tsconfig.node.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
14
frontend/vite.config.ts
Normal file
14
frontend/vite.config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
6
package.json
Normal file
6
package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"scripts": {
|
||||
"build": "cd frontend && npm run build",
|
||||
"prod": "cd server && npm run prod"
|
||||
}
|
||||
}
|
1
server/.env
Normal file
1
server/.env
Normal file
|
@ -0,0 +1 @@
|
|||
PORT=7000
|
36
server/index.ts
Normal file
36
server/index.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import express, { Express, NextFunction, Request, Response } from 'express';
|
||||
import dotenv from 'dotenv'
|
||||
import history from 'connect-history-api-fallback'
|
||||
import routes from './routes'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT
|
||||
|
||||
app.use(routes)
|
||||
app.use(history())
|
||||
app.use(express.static('../frontend/dist'))
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(404).send('404')
|
||||
}
|
||||
});
|
||||
|
||||
// handle errors
|
||||
app.use(errorHandler)
|
||||
|
||||
function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
|
||||
if (res.headersSent) {
|
||||
return next(err)
|
||||
}
|
||||
res.status(500)
|
||||
res.send('error')
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log('Server up')
|
||||
})
|
3491
server/package-lock.json
generated
Normal file
3491
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
server/package.json
Normal file
21
server/package.json
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@dragongoose/streamlink": "^1.0.3",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"puppeteer": "^19.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/connect-history-api-fallback": "^1.3.5",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^18.14.6",
|
||||
"nodemon": "^2.0.21",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npx nodemon index.ts",
|
||||
"prod": "npx ts-node index.ts"
|
||||
}
|
||||
}
|
10
server/routes.ts
Normal file
10
server/routes.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Router } from 'express';
|
||||
import profileRoute from './routes/profile/profileRoute'
|
||||
import proxyRoute from './routes/proxy/proxyRoute'
|
||||
|
||||
const routes = Router();
|
||||
|
||||
routes.use('/api', profileRoute)
|
||||
routes.use('/proxy', proxyRoute)
|
||||
|
||||
export default routes
|
220
server/routes/profile/profileRoute.ts
Normal file
220
server/routes/profile/profileRoute.ts
Normal file
|
@ -0,0 +1,220 @@
|
|||
import { Router } from 'express'
|
||||
import puppeteer, { Browser, Page } from 'puppeteer'
|
||||
import { LooseObject } from '../../types/looseTypes'
|
||||
import { Streamlink } from '@dragongoose/streamlink'
|
||||
|
||||
const profileRouter = Router()
|
||||
|
||||
export interface Socials {
|
||||
type: string | null
|
||||
text: string,
|
||||
link: string
|
||||
}
|
||||
|
||||
export interface StreamData {
|
||||
tags: string[]
|
||||
title: string
|
||||
topic: string
|
||||
startedAt: number
|
||||
qualities: string[]
|
||||
}
|
||||
|
||||
export interface StreamerData {
|
||||
username: string,
|
||||
followers: number,
|
||||
followersAbbv: string,
|
||||
isLive: boolean,
|
||||
about: string,
|
||||
socials?: string[],
|
||||
pfp: string;
|
||||
stream?: StreamData
|
||||
}
|
||||
|
||||
const abbreviatedNumberToNumber = (num: string) => {
|
||||
const base = parseFloat(num)
|
||||
|
||||
const matches: {[k: string]: number} = {
|
||||
'k': 1000,
|
||||
'm': 1000000,
|
||||
'b': 1000000000
|
||||
}
|
||||
|
||||
const abbreviation: string = num.charAt(num.length - 1).toLowerCase()
|
||||
|
||||
|
||||
if(matches[abbreviation]) {
|
||||
const numberOnly: number = Number(num.slice(0, -1))
|
||||
return numberOnly * matches[abbreviation]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// https:// advancedweb.hu/how-to-speed-up-puppeteer-scraping-with-parallelization/
|
||||
const withBrowser = async (fn: Function) => {
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
args: ['--no-sandbox']
|
||||
});
|
||||
try {
|
||||
return await fn(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const withPage = (browser: Browser) => async (fn: Function) => {
|
||||
const page = await browser.newPage();
|
||||
//turns request interceptor on
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
//if the page makes a request to a resource type of image or stylesheet then abort that request
|
||||
page.on('request', request => {
|
||||
if (request.resourceType() === 'image')
|
||||
request.abort();
|
||||
else
|
||||
request.continue();
|
||||
});
|
||||
|
||||
try {
|
||||
return await fn(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
let isLive: boolean
|
||||
|
||||
const getStreamData = async (page: Page) => {
|
||||
const streamData: LooseObject = {}
|
||||
|
||||
if(!isLive) return null
|
||||
|
||||
// Get stream tags
|
||||
const tagsSelector = '.eUxEWt * span'
|
||||
const tags: string[] = await page.$$eval(tagsSelector, elements => elements.map(el => el.innerHTML))
|
||||
streamData.tags = tags
|
||||
|
||||
// Get stream title
|
||||
const titleSelector = 'h2.CoreText-sc-1txzju1-0'
|
||||
const title: string = await page.$eval(titleSelector, element => element.innerText)
|
||||
streamData.title = title
|
||||
|
||||
// Get topic
|
||||
const topicSelector = '.hfMGmo'
|
||||
const topic = await page.$eval(topicSelector, element => element.textContent)
|
||||
streamData.topic = topic
|
||||
|
||||
// Get Start time
|
||||
const liveTimeSelector = '.live-time'
|
||||
|
||||
// formated as HH:MM:SS
|
||||
const liveTime = await page.$eval(liveTimeSelector, element => element.textContent)
|
||||
if(!liveTime) return
|
||||
const liveTimeSplit: number[] = liveTime.split(':').map(Number)
|
||||
let date = new Date()
|
||||
let { hours, minutes, seconds } = { hours: date.getHours(), minutes: date.getMinutes(), seconds: date.getSeconds()}
|
||||
|
||||
// Subtracts current live time from current
|
||||
// date to get the time the stream started
|
||||
date.setHours(hours - liveTimeSplit[0])
|
||||
date.setMinutes(minutes - liveTimeSplit[1])
|
||||
date.setSeconds(seconds - liveTimeSplit[2])
|
||||
|
||||
streamData.startedAt = date.getTime()
|
||||
|
||||
return streamData as StreamData
|
||||
}
|
||||
|
||||
const getAboutData = async (page: Page) => {
|
||||
const aboutData: LooseObject = {}
|
||||
|
||||
if (!isLive) {
|
||||
// Get data from about page
|
||||
const aboutPageButtonSelector = 'li.InjectLayout-sc-1i43xsx-0:nth-child(2) > a:nth-child(1) > div:nth-child(1) > div:nth-child(1) > p:nth-child(1)'
|
||||
await page.click(aboutPageButtonSelector)
|
||||
}
|
||||
await page.waitForSelector('.ccXeNc')
|
||||
|
||||
const followersSelector = '.kuAEke'
|
||||
const followers = await page.$eval(followersSelector, element => element.innerHTML)
|
||||
aboutData.followersAbbv = followers
|
||||
aboutData.followers = abbreviatedNumberToNumber(followers)
|
||||
|
||||
const aboutSectionSelector = '.kLFSJC'
|
||||
const aboutSection = await page.$eval(aboutSectionSelector, element => element.innerHTML)
|
||||
aboutData.about = aboutSection
|
||||
|
||||
const socialSelector = '.ccXeNc * a'
|
||||
const socials: Socials[] = await page.$$eval(socialSelector, elements => elements.map((el) => {
|
||||
|
||||
const getHostName = (url: string) => {
|
||||
const match = url.match(/:\/\/(www[0-9]?\.)?(.[^/:]+)/i);
|
||||
if (match != null && match.length > 2 && typeof match[2] === 'string' && match[2].length > 0) {
|
||||
const hostname = match[2].split(".");
|
||||
return hostname[0];
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const validHosts = ['instagram', 'youtube', 'discord', 'tiktok','twitter']
|
||||
const socialHost = getHostName(el.href) || el.href || ''
|
||||
let type: string | null = socialHost
|
||||
if(!validHosts.includes(socialHost))
|
||||
type = null
|
||||
|
||||
return {
|
||||
type,
|
||||
link: el.href,
|
||||
text: el.innerText
|
||||
}
|
||||
}))
|
||||
aboutData.socials = socials
|
||||
|
||||
const profilePictureSelector = 'figure.ScAvatar-sc-144b42z-0:nth-child(2) > img:nth-child(1)'
|
||||
const profilePicutre = await page.$eval(profilePictureSelector, element => element.getAttribute('src'))
|
||||
aboutData.pfp = profilePicutre
|
||||
|
||||
return aboutData as StreamerData
|
||||
}
|
||||
|
||||
const getStreamerData = async (username: string) => {
|
||||
let recoveredData: LooseObject = {}
|
||||
|
||||
await withBrowser(async (browser: Browser) => {
|
||||
const result = await withPage(browser)(async (page: Page) => {
|
||||
await page.goto(`https://twitch.tv/${username}`)
|
||||
|
||||
return Promise.all([getStreamData(page), getAboutData(page)])
|
||||
})
|
||||
|
||||
|
||||
recoveredData = result[1]
|
||||
recoveredData.stream = result[0]
|
||||
if(result[0] !== null) recoveredData.isLive = true
|
||||
|
||||
await browser.close()
|
||||
})
|
||||
|
||||
|
||||
recoveredData.username = username
|
||||
return recoveredData as StreamerData
|
||||
}
|
||||
|
||||
profileRouter.get('/users/:username', async (req, res) => {
|
||||
const username = req.params.username
|
||||
const streamlink = new Streamlink(`https://twitch.tv/${username}`, {})
|
||||
isLive = await streamlink.isLive()
|
||||
const qualities = await streamlink.getQualities()
|
||||
|
||||
|
||||
let streamerData = await getStreamerData(username)
|
||||
if(streamerData.stream)
|
||||
streamerData.stream.qualities = qualities
|
||||
|
||||
res.send(streamerData)
|
||||
})
|
||||
|
||||
export default profileRouter
|
27
server/routes/proxy/proxyRoute.ts
Normal file
27
server/routes/proxy/proxyRoute.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Router, Response, Request, NextFunction } from 'express'
|
||||
|
||||
const proxyRouter = Router();
|
||||
|
||||
proxyRouter.get('/img', async (req: Request, res: Response, next: NextFunction) => {
|
||||
const imageUrl = req.query.imageUrl?.toString()
|
||||
if(!imageUrl) return;
|
||||
|
||||
fetch(imageUrl).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
|
103
server/tsconfig.json
Normal file
103
server/tsconfig.json
Normal file
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
3
server/types/looseTypes.ts
Normal file
3
server/types/looseTypes.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export interface LooseObject {
|
||||
[key: string]: any
|
||||
}
|
Loading…
Reference in a new issue