mirror of
https://github.com/SrIzan10/hctv.git
synced 2026-06-06 00:56:56 +00:00
Compare commits
38 Commits
feat/chat-
...
emoji-impo
| Author | SHA1 | Date | |
|---|---|---|---|
| 061d1d3348 | |||
| 95ec96fe72 | |||
| 9eca54cbb5 | |||
| 31fa5f36de | |||
| d327da90ef | |||
| 7072b762d8 | |||
| 82a13007c8 | |||
| a35fd858dc | |||
| 4f03f002ab | |||
| d36e590ab6 | |||
| c77d7a16e6 | |||
| d656d0f579 | |||
| 2896cae2bb | |||
| 6710423b92 | |||
| a4f940b990 | |||
| 307b697ac9 | |||
| 49c0da708a | |||
| 4c415dacc4 | |||
| e616ac20d4 | |||
| 387f0943a3 | |||
| a22937793c | |||
| 302e9be737 | |||
| d5d8894f9c | |||
| c10b4b3674 | |||
| c18cdc83b9 | |||
| 6e10da6f98 | |||
| 6a7b449363 | |||
| ab72dacb61 | |||
| 31d27f92ca | |||
| cd685edd78 | |||
| bd7047d19b | |||
| 9a7c72321d | |||
| 8d6e097f94 | |||
| 3fbb2b5d7f | |||
| 834e5087c3 | |||
| 2e2185568d | |||
| 451952e3f8 | |||
| f236086dba |
18
.github/workflows/docker.yml
vendored
18
.github/workflows/docker.yml
vendored
@@ -10,6 +10,12 @@ jobs:
|
||||
name: Push frontend to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait
|
||||
uses: NathanFirmo/wait-for-other-action@v1.0.4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
workflow: 'emojis.yml'
|
||||
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
@@ -31,19 +37,19 @@ jobs:
|
||||
|
||||
- name: Download latest emoji importer
|
||||
run: |
|
||||
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hclive/releases/latest | \
|
||||
RELEASE_URL=$(curl -s https://api.github.com/repos/srizan10/hctv/releases/latest | \
|
||||
grep "browser_download_url.*slack-import-emojis-linux-x86_64" | \
|
||||
cut -d '"' -f 4)
|
||||
|
||||
curl -L -o slack-import-emojis $RELEASE_URL
|
||||
chmod +x slack-import-emojis
|
||||
curl -L -o slack-import-emojis-bin $RELEASE_URL
|
||||
chmod +x slack-import-emojis-bin
|
||||
|
||||
mkdir -p apps/web/src/lib/instrumentation/
|
||||
|
||||
export SLACK_TOKEN=${{ secrets.SLACK_TOKEN }}
|
||||
./slack-import-emojis
|
||||
|
||||
mv emojis.json apps/web/src/lib/instrumentation/
|
||||
./slack-import-emojis-bin
|
||||
|
||||
cp emojis.json apps/web/
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
||||
8
.github/workflows/emojis.yml
vendored
8
.github/workflows/emojis.yml
vendored
@@ -38,12 +38,10 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: emoji-importer-v${{ steps.get_version.outputs.version }}
|
||||
release_name: Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
name: Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
body: |
|
||||
Slack Emoji Importer v${{ steps.get_version.outputs.version }}
|
||||
|
||||
@@ -54,8 +52,6 @@ jobs:
|
||||
export SLACK_TOKEN=xoxb-your-token
|
||||
./slack-import-emojis
|
||||
```
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Linux binary
|
||||
uses: actions/upload-release-asset@v1
|
||||
|
||||
8
.vscode/mcp.json
vendored
Normal file
8
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"servers": {
|
||||
"Sentry": {
|
||||
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,3 @@ This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.
|
||||
Development has been ongoing for a few months, and the site is now live! There are some half-baked features, but I'm all ears for feedback.
|
||||
|
||||
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
|
||||
|
||||
## Features
|
||||
|
||||
- High quality video streaming (low latency coming soon)
|
||||
- Chat with other viewers
|
||||
- Multiaccount support (database schema laid out, UI not implemented)
|
||||
21
apps/docs/.gitignore
vendored
Normal file
21
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
4
apps/docs/.vscode/extensions.json
vendored
Normal file
4
apps/docs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
apps/docs/.vscode/launch.json
vendored
Normal file
11
apps/docs/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
apps/docs/README.md
Normal file
49
apps/docs/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Starlight Starter Kit: Basics
|
||||
|
||||
[](https://starlight.astro.build)
|
||||
|
||||
```
|
||||
yarn create astro@latest -- --template starlight
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
.
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── content/
|
||||
│ │ └── docs/
|
||||
│ └── content.config.ts
|
||||
├── astro.config.mjs
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
|
||||
|
||||
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
|
||||
|
||||
Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `yarn install` | Installs dependencies |
|
||||
| `yarn dev` | Starts local dev server at `localhost:4321` |
|
||||
| `yarn build` | Build your production site to `./dist/` |
|
||||
| `yarn preview` | Preview your build locally, before deploying |
|
||||
| `yarn astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `yarn astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
25
apps/docs/astro.config.mjs
Normal file
25
apps/docs/astro.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
import mermaid from 'astro-mermaid';
|
||||
import catppuccin from "@catppuccin/starlight";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
mermaid({
|
||||
theme: 'base',
|
||||
autoTheme: true
|
||||
}),
|
||||
starlight({
|
||||
title: 'hctv docs',
|
||||
social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/SrIzan10/hctv' }],
|
||||
plugins: [
|
||||
catppuccin({
|
||||
dark: { flavor: "mocha", accent: "blue" },
|
||||
light: { flavor: "latte", accent: "blue" }
|
||||
}),
|
||||
]
|
||||
}),
|
||||
],
|
||||
});
|
||||
20
apps/docs/package.json
Normal file
20
apps/docs/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@hctv/docs",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.35.2",
|
||||
"@catppuccin/starlight": "^1.0.2",
|
||||
"astro": "^5.6.1",
|
||||
"astro-mermaid": "^1.0.4",
|
||||
"mermaid": "^11.10.1",
|
||||
"sharp": "^0.34.2"
|
||||
}
|
||||
}
|
||||
1
apps/docs/public/favicon.svg
Normal file
1
apps/docs/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>
|
||||
|
After Width: | Height: | Size: 696 B |
BIN
apps/docs/src/assets/houston.webp
Normal file
BIN
apps/docs/src/assets/houston.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
7
apps/docs/src/content.config.ts
Normal file
7
apps/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineCollection } from 'astro:content';
|
||||
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||
import { docsSchema } from '@astrojs/starlight/schema';
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
155
apps/docs/src/content/docs/api/chat.mdx
Normal file
155
apps/docs/src/content/docs/api/chat.mdx
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Chat
|
||||
description: Chat websocket
|
||||
---
|
||||
|
||||
The chat system is powered by a websocket server. Please read the entire page before implementing anything, as there are some important notes.
|
||||
|
||||
## Connection and messages
|
||||
|
||||
The websocket server is located at `wss://hctv.srizan.dev/api/chat/ws/:username`, where `:username` is the channel you want to connect to.
|
||||
You'll need to provide authentication, which can be done by providing an `auth_session` cookie, just like the REST API.
|
||||
|
||||
Once connected, you must implement a subroutine in your code to send ping messages every 5 seconds. This is because of Cloudflare limitations.
|
||||
|
||||
Messages are sent and received in JSON format. The following message types are supported:
|
||||
- `message`: a chat message.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"content": "Hello, world!"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
}
|
||||
```
|
||||
- `ping`: a ping message to keep the connection alive.
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "ping"
|
||||
}
|
||||
```
|
||||
- `history`: a message containing the chat history. This is sent upon connection.
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "history",
|
||||
"messages": [
|
||||
{
|
||||
"user": {
|
||||
"id": "user_id",
|
||||
"username": "user_who_sent_message",
|
||||
"avatar": "https://emoji.slack-edge.com/avatar.png"
|
||||
},
|
||||
"message": "Hello, world!",
|
||||
"type": "message",
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
## Emoji handling
|
||||
|
||||
*diagram source: devin deepwiki*
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Emoji Processing Pipeline"
|
||||
CHAT_MSG["Chat Message"]
|
||||
PATTERN_MATCH["Regex :emoji: Pattern"]
|
||||
EMOJI_REQUEST["emojiMsg WebSocket"]
|
||||
REDIS_LOOKUP["Redis HGET emojis"]
|
||||
FUZZY_SEARCH["uFuzzy"]
|
||||
EMOJI_RESPONSE["emojiMsgResponse"]
|
||||
end
|
||||
|
||||
subgraph "Redis Storage"
|
||||
EMOJI_HASH["emojis hash key"]
|
||||
EMOJI_PREFIXED["emoji:{name} url"]
|
||||
EMOJIS_PREFIXED["emojis:{name} url"]
|
||||
end
|
||||
|
||||
CHAT_MSG --> PATTERN_MATCH
|
||||
PATTERN_MATCH --":emojiname:"--> EMOJI_REQUEST
|
||||
EMOJI_REQUEST --> REDIS_LOOKUP
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_HASH
|
||||
REDIS_LOOKUP --> EMOJI_PREFIXED
|
||||
REDIS_LOOKUP --> EMOJIS_PREFIXED
|
||||
|
||||
REDIS_LOOKUP --> EMOJI_RESPONSE
|
||||
|
||||
FUZZY_SEARCH --> EMOJI_HASH
|
||||
FUZZY_SEARCH --"search results"--> EMOJI_RESPONSE
|
||||
```
|
||||
|
||||
When a chat message is sent, the server looks for patterns in the format `:emojiname:` using regex. For each match, it sends a request to the `emojiMsg` WebSocket.
|
||||
The server then checks Redis for the emoji URL and returns it.
|
||||
|
||||
When a user wants to look up an emoji (by typing `:(partial name)`), the server uses uFuzzy to find matching emojis in the Redis `emojis` hash key and returns the results.
|
||||
|
||||
Here's what gets sent on the websocket:
|
||||
- `emojiMsg`: Looks up emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsg",
|
||||
"emojis": ["aga", "yapa", "heavysob", "yay", "yay-bounce"]
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiMsgResponse",
|
||||
"emojis": {
|
||||
// rough example of urls
|
||||
"aga": "https://emoji.slack-edge.com/aga.png",
|
||||
"yapa": "https://emoji.slack-edge.com/yapa.png",
|
||||
"heavysob": "https://emoji.slack-edge.com/heavysob.png",
|
||||
"yay": "https://emoji.slack-edge.com/yay.png",
|
||||
"yay-bounce": "https://emoji.slack-edge.com/yay-bounce.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `emojiSearch`: Searches for emojis
|
||||
- sent by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearch",
|
||||
"searchTerm": "aga"
|
||||
}
|
||||
```
|
||||
- received by client:
|
||||
```json
|
||||
{
|
||||
"type": "emojiSearchResponse",
|
||||
"results": [
|
||||
// real results btw
|
||||
"aga",
|
||||
"aga-brick-throw",
|
||||
"aga-dance",
|
||||
"aga-transparent",
|
||||
"a-aga",
|
||||
"a-aga-transparent",
|
||||
"agaban",
|
||||
"agaboing",
|
||||
"agabounce",
|
||||
"agabusiness"
|
||||
]
|
||||
}
|
||||
```
|
||||
19
apps/docs/src/content/docs/api/index.mdx
Normal file
19
apps/docs/src/content/docs/api/index.mdx
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: API Documentation
|
||||
description: Documented API endpoints for hackclub.tv
|
||||
---
|
||||
|
||||
hctv is meant to be one of the most hackable streaming platforms out there. to that end, we have a (currently limited) public API that you can use to interact with the platform.
|
||||
|
||||
since this is beta software, the API is subject to change. additionally, many endpoints are yet not implemented or not fun to implement. please send a message in #hctv on the Hack Club Slack if you have any requests.
|
||||
|
||||
## Base url
|
||||
|
||||
base url for all endpoints is `https://hctv.srizan.dev/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
most endpoints require authentication. this will be pointed out in the documentation.
|
||||
for now, it is done via a cookie called `auth_session` (as per lucia auth). this will change in the future as bot accounts are planned.
|
||||
|
||||
you'll need your user account to authenticate. as a recommendation, open an incognito window, log in to hctv, and copy the `auth_session` cookie from there.
|
||||
4
apps/docs/src/content/docs/api/meta.json
Normal file
4
apps/docs/src/content/docs/api/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "API",
|
||||
"description": "Documented API endpoints for hackclub.tv"
|
||||
}
|
||||
16
apps/docs/src/content/docs/api/rtmp.mdx
Normal file
16
apps/docs/src/content/docs/api/rtmp.mdx
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: RTMP
|
||||
description: RTMP related endpoint group
|
||||
---
|
||||
|
||||
## GET `/rtmp/hls/:path`
|
||||
gets HLS segments, the backbone of hctv livestreaming. **authentication required**.
|
||||
not really sure why you would need this? but check the browser console when playing a stream for an insight on how it's used.
|
||||
|
||||
## POST `/rtmp/streamKey`
|
||||
regenerates your stream key. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to regenerate the key for. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `key`: string - the new stream key
|
||||
47
apps/docs/src/content/docs/api/stream.mdx
Normal file
47
apps/docs/src/content/docs/api/stream.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Stream
|
||||
description: Stream related endpoint group
|
||||
---
|
||||
|
||||
## GET `/stream/follow`
|
||||
checks if **authenticated user** is following a certain channel.
|
||||
query parameters:
|
||||
- `username`: string - the channel name you want to check.
|
||||
|
||||
response (json):
|
||||
- `following`: boolean - whether the authenticated user is following the channel or not.
|
||||
|
||||
## POST `/stream/follow`
|
||||
cycle through follow or unfollow a channel. **authentication required**.
|
||||
body parameters (json):
|
||||
- `channel`: string - the channel name you want to make the action. must be one of your channels.
|
||||
|
||||
response (json):
|
||||
- `success`: boolean - whether the operation was successful or not.
|
||||
|
||||
## GET `/stream/followers/:channel`
|
||||
gets the followcount of a channel.
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the followcount of.
|
||||
|
||||
response (json):
|
||||
- `count`: integer - the number of followers the channel has.
|
||||
- `success`: boolean - whether the operation was successful or not. (true if 200 status code)
|
||||
|
||||
## GET `/stream/info`
|
||||
get stream info of certain channels by filtering. **authentication on some**.
|
||||
query parameters:
|
||||
- `owned`: boolean (optional) - if true, only returns channels owned by the authenticated user. requires authentication.
|
||||
- `personal`: boolean (optional) - if true, only returns personal channels. requires authentication.
|
||||
- `live`: boolean (optional) - if true, only returns channels that are currently live.
|
||||
|
||||
response (json):
|
||||
- StreamInfo[] (check database schema for all returned data or just try it out!)
|
||||
|
||||
## GET `/stream/thumb/:channel`
|
||||
gets the preview thumbnail of a channel's livestream. **authentication required**
|
||||
|
||||
path parameters:
|
||||
- `channel`: string - the channel name you want to get the thumbnail of.
|
||||
|
||||
response: image (webp)
|
||||
4
apps/docs/src/content/docs/guides/meta.json
Normal file
4
apps/docs/src/content/docs/guides/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"description": "Useful guides to help you get started with hackclub.tv"
|
||||
}
|
||||
15
apps/docs/src/content/docs/guides/start-stream.mdx
Normal file
15
apps/docs/src/content/docs/guides/start-stream.mdx
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: How to stream
|
||||
description: Get started with OBS and streaming on hackclub.tv
|
||||
---
|
||||
|
||||
- open obs
|
||||
- open settings
|
||||
- open "Stream"
|
||||
- set service to custom
|
||||
- set url to `rtmp://hackclub.app:45913/live`
|
||||
- on the website, click "Regenerate key"
|
||||
- paste the key into your obs "Stream Key"
|
||||
- start streaming!
|
||||
|
||||
(screenshots to be added soon)
|
||||
8
apps/docs/src/content/docs/index.mdx
Normal file
8
apps/docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: About hctv docs
|
||||
description: Index documentation page!
|
||||
---
|
||||
|
||||
Welcome to the hackclub.tv docs! Here you'll find tips and tricks to get the most out of hackclub.tv and its features.
|
||||
|
||||
Additionally, some useful guides are available to help you get started with common tasks.
|
||||
5
apps/docs/tsconfig.json
Normal file
5
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
0
apps/docs/yarn.lock
Normal file
0
apps/docs/yarn.lock
Normal file
@@ -63,5 +63,6 @@ USER nextjs
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --chown=nextjs:nodejs apps/web/emojis.json .
|
||||
|
||||
CMD ["/usr/local/bin/start.sh"]
|
||||
@@ -1,3 +1,4 @@
|
||||
import {withSentryConfig} from '@sentry/nextjs';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -8,7 +9,7 @@ const __dirname = path.dirname(__filename);
|
||||
|
||||
const LIVE_SERVER_URL =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://backend.hctv.srizan.dev'
|
||||
? 'http://nginx-rtmp:8888'
|
||||
: 'http://localhost:8888';
|
||||
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
@@ -49,4 +50,35 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withSentryConfig(nextConfig, {
|
||||
// For all available options, see:
|
||||
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
|
||||
|
||||
org: "sr-izan",
|
||||
|
||||
project: "hctv",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
// This can increase your server load as well as your hosting bill.
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: true,
|
||||
});
|
||||
@@ -11,13 +11,13 @@
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"ui:add": "shadcn add",
|
||||
"check-types": "tsc --noEmit"
|
||||
"check-types": "tsc --noEmit",
|
||||
"openapi": "next-openapi-gen"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hctv/auth": "*",
|
||||
"@hctv/db": "*",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@livekit/components-react": "^2.7.0",
|
||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
@@ -33,9 +33,12 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@scalar/api-reference-react": "^0.7.42",
|
||||
"@sentry/nextjs": "^10",
|
||||
"@slack/web-api": "^7.9.1",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uploadthing/react": "^7.3.1",
|
||||
"ajv": "^8.17.1",
|
||||
"arctic": "^3.7.0",
|
||||
"bullmq": "^5.45.2",
|
||||
"cheerio": "^1.0.0",
|
||||
@@ -43,9 +46,7 @@
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "1.0.0",
|
||||
"hls-video-element": "^1.5.0",
|
||||
"ioredis": "^5.6.0",
|
||||
"livekit-client": "^2.8.0",
|
||||
"livekit-server-sdk": "^2.9.7",
|
||||
"ioredis": "5.7.0",
|
||||
"lucia": "^3.2.2",
|
||||
"lucide-react": "^0.473.0",
|
||||
"media-chrome": "^4.8.0",
|
||||
@@ -63,7 +64,7 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"sharp": "^0.34.2",
|
||||
"sharp": "^0.34.3",
|
||||
"sonner": "^1.4.41",
|
||||
"swr": "^2.3.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
|
||||
19
apps/web/sentry.edge.config.ts
Normal file
19
apps/web/sentry.edge.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
18
apps/web/sentry.server.config.ts
Normal file
18
apps/web/sentry.server.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
@@ -6,9 +6,16 @@ import { cookies } from 'next/headers';
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
|
||||
const { path } = await params;
|
||||
const c = await cookies();
|
||||
if (!getRedisConnection().exists(`sessions:${c.get('auth_session')?.value}`)) {
|
||||
|
||||
const sessionCookie = c.get('auth_session')?.value;
|
||||
if (!sessionCookie) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
const sessionExists = await getRedisConnection().exists(`sessions:${sessionCookie}`);
|
||||
if (sessionExists === 0) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (path.includes('..')) {
|
||||
return new Response("nuh uh", { status: 403 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { NextRequest } from 'next/server';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const formData = await request.formData();
|
||||
const streamKey = formData.get('name')?.toString() || '';
|
||||
const streamKey = formData.get('name');
|
||||
if (typeof streamKey !== 'string') {
|
||||
return new Response('bad request', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const key = await prisma.streamKey.findFirst({
|
||||
where: {
|
||||
@@ -19,12 +24,10 @@ export async function POST(request: NextRequest) {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append('Location', `rtmp://127.0.0.1/channel-live/${key.channel.name}`);
|
||||
|
||||
return new Response(null, {
|
||||
return new Response('', {
|
||||
status: 302,
|
||||
headers: headers,
|
||||
headers: {
|
||||
'Location': key.channel.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export async function POST(request: NextRequest) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
if (!channel || typeof channel !== 'string') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const channelInfo = await prisma.channel.findUnique({
|
||||
where: { name: channel },
|
||||
include: {
|
||||
|
||||
@@ -103,7 +103,7 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
await queue.add(`newFollow:${username}`, {
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are enabled by default. If you want to disable them, you can do so in \`Profile > Notifications\`._`,
|
||||
text: `You started following \`${username}\`!\n_Stream notifications are disabled by default. If you want to enable them, you can do so in \`Profile > Notifications\`._`,
|
||||
channel: user.slack_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ interface ChannelSettingsClientProps {
|
||||
streamKey: StreamKey | null;
|
||||
followers: (Follow & { user: { id: string; slack_id: string } })[];
|
||||
followerPersonalChannels: (Channel | null)[];
|
||||
is247: boolean;
|
||||
};
|
||||
isOwner: boolean;
|
||||
currentUser: User;
|
||||
@@ -306,6 +307,27 @@ export default function ChannelSettingsClient({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'is247',
|
||||
value: channel.is247,
|
||||
component: ({ field }) => (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">24/7 Channel</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mark this channel as always live. It will disable notifications on #hctv-streams.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" {...field} value={field.value ? 'true' : 'false'} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
]}
|
||||
schemaName="updateChannelSettings"
|
||||
action={updateChannelSettings}
|
||||
|
||||
@@ -4,32 +4,127 @@ import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { onboard } from '@/lib/form/actions';
|
||||
import { useSession } from '@/lib/providers/SessionProvider';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { User, Tv, Heart, MessageSquare } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function OnboardingClient() {
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<Card className="mx-auto max-w-sm border-0 shadow-none">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle>Welcome to hackclub.tv!</CardTitle>
|
||||
<CardDescription>
|
||||
To get started, please enter the username of your personal channel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="min-h-[93vh] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
{/* welcome header */}
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<Avatar className="h-20 w-20 border-4 border-primary/20">
|
||||
<AvatarImage src={user?.pfpUrl} alt={`@${user?.id}`} />
|
||||
<AvatarFallback className="text-2xl font-bold">
|
||||
{user?.id?.charAt(0)?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Welcome to hackclub.tv!
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground flex gap-2 justify-center">
|
||||
Let's get you set up <Image src="https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png" alt=":blahaj-heart:" width={24} height={24} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* explanation */}
|
||||
<Card className="border-2 border-primary/10 bg-primary/5">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
Why do you need a personal channel?
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Tv className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Stream content</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share your coding sessions and projects!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Chat with others</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect with other Hack Clubbers and grow your audience
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Heart className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Follow hackclubbers</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stay updated with your favorite creators and streams
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-secondary/50 rounded-lg border border-muted">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>Your personal channel</strong> is your home base on hctv.
|
||||
It's where your profile, streams, and content will live. You can always create
|
||||
additional channels later for different types of content!
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* form */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">Choose Your Channel Username</CardTitle>
|
||||
<CardDescription>
|
||||
This will be your unique identifier on hctv. Choose something memorable!
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Channel Username',
|
||||
type: 'text',
|
||||
placeholder: 'e.g., yourname or yourname-codes'
|
||||
},
|
||||
]}
|
||||
schemaName="onboard"
|
||||
action={onboard}
|
||||
onActionComplete={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 p-3 bg-muted/30 rounded-md">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Username rules:</strong> Only lowercase letters (a-z), numbers (0-9),
|
||||
underscores (_), and dashes (-) are allowed. Must be unique across the platform.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
apps/web/src/app/global-error.tsx
Normal file
23
apps/web/src/app/global-error.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import NextError from "next/error";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { links } from "../NavBar/NavBar";
|
||||
|
||||
export default function MobileNavbarLinks() {
|
||||
return (
|
||||
<div className="flex md:hidden">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>Menu</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>stack</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<DropdownMenuItem>{link.name}</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,20 +18,6 @@ import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
|
||||
import { Slack } from 'lucide-react';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
export const links = [{ href: '/', name: 'home (placeholder link)' }];
|
||||
|
||||
function NavbarLinks() {
|
||||
return (
|
||||
<>
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<Button variant={'link'}>{link.name}</Button>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar(props: Props) {
|
||||
const { user } = useSession();
|
||||
return (
|
||||
@@ -46,10 +32,6 @@ export default function Navbar(props: Props) {
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex">
|
||||
<NavbarLinks />
|
||||
</div>
|
||||
|
||||
{/* Right Side Items */}
|
||||
<div className="flex items-center gap-1 md:gap-3 shrink-0">
|
||||
{props.editLivestream && <div className="hidden sm:block">{props.editLivestream}</div>}
|
||||
|
||||
@@ -10,15 +10,15 @@ import {
|
||||
MediaSeekForwardButton,
|
||||
MediaMuteButton,
|
||||
MediaVolumeRange,
|
||||
MediaFullscreenButton
|
||||
MediaFullscreenButton,
|
||||
} from 'media-chrome/react';
|
||||
import HlsVideo from 'hls-video-element/react'
|
||||
import HlsVideo from 'hls-video-element/react';
|
||||
|
||||
export default function StreamPlayer() {
|
||||
const { username } = useParams();
|
||||
|
||||
return (
|
||||
<MediaController className='w-full aspect-video'>
|
||||
<MediaController className="w-full aspect-video">
|
||||
<HlsVideo
|
||||
src={`/api/rtmp/hls/${username}.m3u8`}
|
||||
slot="media"
|
||||
@@ -26,19 +26,35 @@ export default function StreamPlayer() {
|
||||
autoplay
|
||||
config={{
|
||||
lowLatencyMode: true,
|
||||
liveSyncDurationCount: 2, // Use only 1 segment for sync
|
||||
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
|
||||
liveSyncDurationCount: 1,
|
||||
liveMaxLatencyDurationCount: 2,
|
||||
liveDurationInfinity: true,
|
||||
enableWorker: true,
|
||||
backBufferLength: 0, // No back buffer
|
||||
startLevel: -1, // Auto level selection
|
||||
maxBufferLength: 4, // Maximum buffer length in seconds
|
||||
maxMaxBufferLength: 6,
|
||||
debug: false,
|
||||
backBufferLength: 1,
|
||||
startLevel: -1,
|
||||
maxBufferLength: 2,
|
||||
maxMaxBufferLength: 4,
|
||||
startFragPrefetch: true,
|
||||
testBandwidth: false,
|
||||
progressive: false,
|
||||
maxBufferSize: 10 * 1000 * 1000,
|
||||
maxBufferHole: 0.1,
|
||||
highBufferWatchdogPeriod: 0.5,
|
||||
nudgeOffset: 0.01,
|
||||
nudgeMaxRetry: 3,
|
||||
manifestLoadingTimeOut: 3000,
|
||||
manifestLoadingMaxRetry: 3,
|
||||
levelLoadingTimeOut: 3000,
|
||||
fragLoadingTimeOut: 5000,
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
liveSyncDuration: 1,
|
||||
liveMaxLatencyDuration: 3,
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
liveBackBufferLength: 0,
|
||||
}}
|
||||
/>
|
||||
<MediaLoadingIndicator slot="centered-chrome" noAutohide />
|
||||
<MediaControlBar className='w-full px-2'>
|
||||
<MediaControlBar className="w-full px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaPlayButton />
|
||||
<MediaMuteButton />
|
||||
|
||||
@@ -39,7 +39,7 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
otherSubmitButton,
|
||||
submitButtonDivClassname,
|
||||
}: UniversalFormProps<T>) {
|
||||
// @ts-ignore idk why this error is happening, first apprearing on the react 19 update.
|
||||
// @ts-expect-error - idk
|
||||
const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null);
|
||||
const schema = schemaDb.find((s) => s.name === schemaName)?.zod;
|
||||
|
||||
@@ -56,9 +56,11 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
return { ...values, ...defaultValues };
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
const form = useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues as z.infer<T>,
|
||||
type FormData = z.infer<T>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(schema as any),
|
||||
defaultValues: initialValues as FormData,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -77,10 +79,10 @@ export function UniversalForm<T extends z.ZodType>({
|
||||
<FormField
|
||||
key={field.name}
|
||||
control={form.control}
|
||||
name={field.name as Path<z.infer<T>>}
|
||||
name={field.name as Path<FormData>}
|
||||
render={({ field: formField }) => (
|
||||
<FormItem>
|
||||
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
|
||||
{(field.type !== 'hidden' || field.label) && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.component ? (
|
||||
field.component({ field: formField, ...field.componentProps })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { schemaDb } from './UniversalForm';
|
||||
|
||||
export type FormFieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
type?: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
|
||||
19
apps/web/src/instrumentation-client.ts
Normal file
19
apps/web/src/instrumentation-client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The added config here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://f3c26671c39af48406c6e23702a4f3dd@o4506961023860736.ingest.us.sentry.io/4509895816773632",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: 1,
|
||||
// Enable logs to be sent to Sentry
|
||||
enableLogs: true,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
@@ -202,6 +202,7 @@ export async function updateChannelSettings(prev: any, formData: FormData) {
|
||||
data: {
|
||||
description: zod.data.description || undefined,
|
||||
pfpUrl: zod.data.pfpUrl,
|
||||
is247: zod.data.is247,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -24,4 +24,5 @@ export const updateChannelSettingsSchema = z.object({
|
||||
channelId: z.string().min(1),
|
||||
pfpUrl: z.string(),
|
||||
description: z.string().min(1).max(500),
|
||||
is247: z.boolean(),
|
||||
});
|
||||
@@ -24,12 +24,15 @@ export async function emojisWriteRedis() {
|
||||
|
||||
function getPath() {
|
||||
const possiblePaths = [
|
||||
// original
|
||||
// docker shit
|
||||
'/app/emojis.json',
|
||||
'/app/apps/web/emojis.json',
|
||||
// cwd shit
|
||||
path.join(process.cwd(), 'emojis.json'),
|
||||
path.join(process.cwd(), 'apps/web/emojis.json'),
|
||||
// fallbacks
|
||||
'./emojis.json',
|
||||
'src/lib/instrumentation/emojis.json',
|
||||
// relative
|
||||
path.join(__dirname, 'emojis.json'),
|
||||
// standalone nextjs
|
||||
path.join(process.cwd(), 'src/lib/instrumentation/emojis.json')
|
||||
];
|
||||
console.log('Writing emojis to Redis...');
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export async function syncStream() {
|
||||
if (stream.active) {
|
||||
const existingStream = await prisma.streamInfo.findUnique({
|
||||
where: { username: stream.name },
|
||||
include: { channel: true },
|
||||
});
|
||||
|
||||
if (existingStream && !existingStream.isLive) {
|
||||
@@ -125,12 +126,14 @@ export async function syncStream() {
|
||||
|
||||
const queue = getNotificationQueue();
|
||||
|
||||
queue.add(`streamStartChannel:${existingStream.username}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
unfurl_links: true,
|
||||
});
|
||||
if (existingStream.enableNotifications) {
|
||||
if (!existingStream.channel.is247) {
|
||||
queue.add(`streamStartChannel:${existingStream.username}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>`,
|
||||
channel: process.env.NOTIFICATION_CHANNEL_ID!,
|
||||
unfurl_links: true,
|
||||
});
|
||||
}
|
||||
if (existingStream.enableNotifications && !existingStream.channel.is247) {
|
||||
for (const follower of subscribedFollowers) {
|
||||
queue.add(`streamStartDm:${follower.user.id}`, {
|
||||
text: `${existingStream.username} is now *live*, streaming *${existingStream.title}* (${existingStream.category})!\n<https://hctv.srizan.dev/${existingStream.username}|Go check them out>\n_Stream notifications are enabled for this user. If you want to disable them, you can do so in \`Profile > Follows\`._`,
|
||||
|
||||
@@ -2,21 +2,39 @@ import { getRedisConnection, prisma } from "@hctv/db";
|
||||
|
||||
export async function viewerCountSync() {
|
||||
const streams = await prisma.streamInfo.findMany({
|
||||
where: {
|
||||
isLive: true
|
||||
},
|
||||
include: {
|
||||
channel: true
|
||||
}
|
||||
})
|
||||
const redis = getRedisConnection();
|
||||
|
||||
for (const stream of streams) {
|
||||
const viewerCount = await redis.keys(`viewer:${stream.channel.name}:*`);
|
||||
await prisma.streamInfo.update({
|
||||
where: {
|
||||
username: stream.username,
|
||||
},
|
||||
data: {
|
||||
viewers: viewerCount.length,
|
||||
},
|
||||
});
|
||||
if (streams.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const redis = getRedisConnection();
|
||||
const multi = redis.multi();
|
||||
for (const stream of streams) {
|
||||
multi.keys(`viewer:${stream.channel.name}:*`);
|
||||
}
|
||||
const results = await multi.exec();
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updates = results?.map((res, index) => {
|
||||
const count = Array.isArray(res[1]) ? res[1].length : 0;
|
||||
const stream = streams[index];
|
||||
return tx.streamInfo.update({
|
||||
where: {
|
||||
// using username here because it uses a map
|
||||
username: stream.username
|
||||
},
|
||||
data: {
|
||||
viewers: count
|
||||
}
|
||||
})
|
||||
})
|
||||
await Promise.all(updates || []);
|
||||
})
|
||||
}
|
||||
@@ -17,7 +17,7 @@ if (!globalForNotifier.notificationQueue) {
|
||||
export function getNotificationQueue(): Queue {
|
||||
if (!globalForNotifier.notificationQueue) {
|
||||
globalForNotifier.notificationQueue = new Queue('notifications', {
|
||||
connection: getRedisConnection(),
|
||||
connection: getRedisConnection().options,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
@@ -33,7 +33,7 @@ export function getNotificationQueue(): Queue {
|
||||
export function getThumbnailQueue(): Queue {
|
||||
if (!globalForNotifier.thumbnailQueue) {
|
||||
globalForNotifier.thumbnailQueue = new Queue('thumbnails', {
|
||||
connection: getRedisConnection(),
|
||||
connection: getRedisConnection().options,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function registerNotificationWorker(): Promise<void> {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}, {
|
||||
connection: getRedisConnection(),
|
||||
connection: getRedisConnection().options,
|
||||
concurrency: 1,
|
||||
limiter: {
|
||||
max: 45,
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function registerThumbnailWorker(): Promise<void> {
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: getRedisConnection(),
|
||||
connection: getRedisConnection().options,
|
||||
concurrency: 3,
|
||||
limiter: {
|
||||
max: 50,
|
||||
|
||||
@@ -10,7 +10,10 @@ rtmp {
|
||||
live on;
|
||||
record off;
|
||||
|
||||
push rtmp://localhost:1935/channel-live;
|
||||
on_publish http://localhost:3000/api/rtmp/publish;
|
||||
|
||||
deny play all;
|
||||
}
|
||||
|
||||
application channel-live {
|
||||
@@ -19,17 +22,22 @@ rtmp {
|
||||
|
||||
allow publish 127.0.0.1;
|
||||
deny publish all;
|
||||
|
||||
deny play all;
|
||||
|
||||
hls on;
|
||||
hls_type live;
|
||||
hls_path /dev/shm/hls;
|
||||
hls_fragment 2s;
|
||||
hls_playlist_length 10s;
|
||||
hls_fragment 1s;
|
||||
hls_playlist_length 3s;
|
||||
hls_cleanup on;
|
||||
|
||||
hls_variant _low BANDWIDTH=500000;
|
||||
hls_variant _mid BANDWIDTH=1000000;
|
||||
hls_variant _hi BANDWIDTH=1500000;
|
||||
hls_fragment_naming timestamp;
|
||||
hls_fragment_slicing aligned;
|
||||
|
||||
hls_variant _low BANDWIDTH=300000 RESOLUTION=480x270;
|
||||
hls_variant _mid BANDWIDTH=600000 RESOLUTION=640x360;
|
||||
hls_variant _hi BANDWIDTH=1000000 RESOLUTION=854x480;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"docker:chat": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/chat/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
|
||||
"act": "act --secret-file .env.ci",
|
||||
"db:migrate": "yarn workspace @hctv/db db:migrate",
|
||||
"ui:add": "yarn workspace @hctv/web ui:add"
|
||||
"ui:add": "yarn workspace @hctv/web ui:add",
|
||||
"prisma": "yarn workspace @hctv/db prisma",
|
||||
"r:rtmp": "docker compose -f dev/docker-compose.yml restart nginx-rtmp -t 0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^2.4.4"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.5.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"ioredis": "5.7.0",
|
||||
"prisma": "^6.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StreamInfo_username_idx" ON "StreamInfo"("username");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Channel" ADD COLUMN "is247" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -13,6 +13,7 @@ generator client {
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
directUrl = env("DATABASE_DIRECT_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -51,6 +52,7 @@ model Channel {
|
||||
followers Follow[] @relation("ChannelFollowers")
|
||||
streamKey StreamKey?
|
||||
obsChatGrantToken String @unique @default(cuid())
|
||||
is247 Boolean @default(false)
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
@@ -80,7 +82,7 @@ model StreamInfo {
|
||||
|
||||
enableNotifications Boolean @default(true)
|
||||
|
||||
// TODO: index on username
|
||||
@@index([username])
|
||||
}
|
||||
|
||||
model Follow {
|
||||
|
||||
2
slack-import-emojis/Cargo.lock
generated
2
slack-import-emojis/Cargo.lock
generated
@@ -899,7 +899,7 @@ checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
|
||||
|
||||
[[package]]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "slack-import-emojis"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -6,10 +6,21 @@ use std::io::Write;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SlackEmojiResponse {
|
||||
emoji: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[allow(dead_code)]
|
||||
error: Option<String>,
|
||||
emoji: HashMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[allow(dead_code)]
|
||||
error: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DefaultEmojiResponse {
|
||||
emoji: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EmojiData {
|
||||
unified: String,
|
||||
has_img_twitter: bool,
|
||||
short_name: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -19,14 +30,23 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let emojis = slack_request()
|
||||
let mut slack_emojis = slack_request()
|
||||
.await
|
||||
.expect("Failed to fetch emojis from Slack API");
|
||||
.expect("Failed to fetch slack_emojis from Slack API");
|
||||
println!("{:?} slack_emojis fetched", slack_emojis.emoji.len());
|
||||
|
||||
let default_emojis = default_request()
|
||||
.await
|
||||
.expect("Failed to fetch default_emojis from GitHub");
|
||||
println!("{:?} default_emojis fetched", default_emojis.emoji.len());
|
||||
|
||||
slack_emojis.emoji.extend(default_emojis.emoji);
|
||||
|
||||
println!("{:?} emojis fetched", emojis.emoji.len());
|
||||
let mut file = File::create("emojis.json").expect("failed to create file for some reason");
|
||||
let json_data = serde_json::to_string(&emojis.emoji).expect("failed to serialize emojis wtf");
|
||||
file.write_all(json_data.as_bytes())
|
||||
let json_data =
|
||||
serde_json::to_string(&slack_emojis.emoji).expect("failed to serialize emojis wtf");
|
||||
file
|
||||
.write_all(json_data.as_bytes())
|
||||
.expect("failed to write emojis to file");
|
||||
println!("saved :yay:");
|
||||
}
|
||||
@@ -37,7 +57,10 @@ async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error
|
||||
.get("https://slack.com/api/emoji.list")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")),
|
||||
format!(
|
||||
"Bearer {}",
|
||||
std::env::var("SLACK_TOKEN").expect("SLACK_TOKEN not set")
|
||||
),
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
@@ -50,3 +73,29 @@ async fn slack_request() -> Result<SlackEmojiResponse, Box<dyn std::error::Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn default_request() -> Result<DefaultEmojiResponse, Box<dyn std::error::Error>> {
|
||||
const CDN_URL: &str =
|
||||
"https://cdn.jsdelivr.net/npm/emoji-datasource-twitter@15.1.2/img/twitter/64/";
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get("https://raw.githubusercontent.com/iamcal/emoji-data/refs/heads/master/emoji.json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(response) => {
|
||||
let emoji_data: Vec<EmojiData> = response.json().await?;
|
||||
let emoji_map: HashMap<String, String> = emoji_data
|
||||
.into_iter()
|
||||
.filter(|e| e.has_img_twitter)
|
||||
.map(|e| (e.short_name, format!("{}{}.png", CDN_URL, e.unified.to_lowercase())))
|
||||
.collect();
|
||||
Ok(DefaultEmojiResponse { emoji: emoji_map })
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Error: {:?}", err);
|
||||
Err(Box::new(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "!.next/cache/**", "dist/**", "generated/client/**"],
|
||||
"env": ["commit", "NODE_ENV", "TURBO_TOKEN", "TURBO_TEAM"]
|
||||
"env": ["commit", "NODE_ENV", "TURBO_TOKEN", "TURBO_TEAM", "SENTRY_AUTH_TOKEN"]
|
||||
},
|
||||
"setup": {
|
||||
"dependsOn": ["^dd", "^db:generate", "^build"],
|
||||
|
||||
Reference in New Issue
Block a user