Compare commits

...

23 Commits

Author SHA1 Message Date
6710423b92 feat: complete api docs 2025-09-02 00:03:53 +02:00
a4f940b990 feat: docs part 1 2025-09-01 01:57:37 +02:00
307b697ac9 chore: remove all unused livekit packages 2025-09-01 01:51:31 +02:00
49c0da708a chore: bye bye openapi 2025-09-01 01:22:39 +02:00
4c415dacc4 feat: scalar openapi stuff 2025-09-01 01:06:36 +02:00
e616ac20d4 feat: initial docs 2025-08-31 19:05:39 +02:00
387f0943a3 feat: new onboarding 2025-08-31 18:14:54 +02:00
a22937793c fix: builds 2025-08-31 17:44:55 +02:00
302e9be737 chore: remove placeholder link thing 2025-08-26 19:21:53 +02:00
d5d8894f9c fix: auth not working on hls route 2025-08-24 22:36:29 +02:00
c10b4b3674 feat: sentry 2025-08-24 01:00:44 +02:00
c18cdc83b9 fix: redirects not working correctly on programs like ffmpeg 2025-08-23 20:13:19 +02:00
6e10da6f98 fix: badly performing code 2025-08-23 19:44:01 +02:00
6a7b449363 fix: postgres direct connections for migrations 2025-08-23 17:34:24 +02:00
ab72dacb61 fix: more webplayer improvements 2025-08-21 21:17:25 +02:00
31d27f92ca fix: webplayer stuff to eliminate constant buffering 2025-08-21 20:50:50 +02:00
cd685edd78 chore: debug 2025-08-21 19:10:15 +02:00
bd7047d19b fix: dockerfile emojis 2025-08-20 19:14:12 +02:00
9a7c72321d fix: other fixes aa 2025-08-20 18:56:50 +02:00
8d6e097f94 fix: more redis stuff 2025-08-20 18:34:54 +02:00
3fbb2b5d7f fix: ioredis errors 2025-08-20 18:31:40 +02:00
834e5087c3 fix: ci lemao 2025-08-20 18:23:30 +02:00
2e2185568d ci: fix again aaaaa 2025-08-17 21:17:57 +02:00
55 changed files with 6074 additions and 348 deletions

View File

@@ -31,19 +31,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

View File

@@ -52,9 +52,6 @@ jobs:
export SLACK_TOKEN=xoxb-your-token
./slack-import-emojis
```
draft: false
prerelease: false
files: ./slack-import-emojis/target/release/slack-import-emojis
- name: Upload Linux binary
uses: actions/upload-release-asset@v1

8
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"servers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/sr-izan/hctv",
"type": "http"
}
}
}

View File

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

28
apps/docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# deps
/node_modules
# generated content
.contentlayer
.content-collections
.source
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# others
.env*.local
.vercel
next-env.d.ts

45
apps/docs/README.md Normal file
View File

@@ -0,0 +1,45 @@
# docs
This is a Next.js application generated with
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
Run development server:
```bash
npm run dev
# or
pnpm dev
# or
yarn dev
```
Open http://localhost:3000 with your browser to see the result.
## Explore
In the project, you can see:
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
| Route | Description |
| ------------------------- | ------------------------------------------------------ |
| `app/(home)` | The route group for your landing page and other pages. |
| `app/docs` | The documentation layout and pages. |
| `app/api/search/route.ts` | The Route Handler for search. |
### Fumadocs MDX
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
## Learn More
To learn more about Next.js and Fumadocs, take a look at the following
resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs

View 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"
]
}
```

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

View File

@@ -0,0 +1,4 @@
{
"title": "API",
"description": "Documented API endpoints for hackclub.tv"
}

View 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

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

View File

@@ -0,0 +1,4 @@
{
"title": "Guides",
"description": "Useful guides to help you get started with hackclub.tv"
}

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

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

10
apps/docs/next.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);

31
apps/docs/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@hctv/docs",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --turbo -p 3727",
"start": "next start",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "15.7.7",
"fumadocs-mdx": "11.8.2",
"fumadocs-ui": "15.7.7",
"mermaid": "^11.10.1",
"next": "15.5.2",
"next-themes": "^0.4.6",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/mdx": "^2.0.13",
"@types/node": "24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};

View File

@@ -0,0 +1,25 @@
import {
defineConfig,
defineDocs,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
// You can customise Zod schemas for frontmatter and `meta.json` here
// see https://fumadocs.dev/docs/mdx/collections#define-docs
export const docs = defineDocs({
docs: {
schema: frontmatterSchema,
},
meta: {
schema: metaSchema,
},
});
export default defineConfig({
mdxOptions: {
// MDX options
remarkPlugins: [remarkMdxMermaid],
},
});

View File

@@ -0,0 +1,8 @@
export function GET() {
return new Response('Redirecting...', {
status: 302,
headers: {
Location: '/docs',
},
});
}

View File

@@ -0,0 +1,7 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source, {
// https://docs.orama.com/docs/orama-js/supported-languages
language: 'english',
});

View File

@@ -0,0 +1,52 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
} from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getMDXComponents } from '@/mdx-components';
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDXContent = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDXContent
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
// @ts-ignore
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(
props: PageProps<'/docs/[[...slug]]'>,
): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -0,0 +1,11 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
export default function Layout({ children }: LayoutProps<'/docs'>) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions()}>
{children}
</DocsLayout>
);
}

View File

@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';

View File

@@ -0,0 +1,17 @@
import '@/app/global.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.className} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,60 @@
'use client';
import { use, useEffect, useId, useState } from 'react';
import { useTheme } from 'next-themes';
export function Mermaid({ chart }: { chart: string }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return;
return <MermaidContent chart={chart} />;
}
const cache = new Map<string, Promise<unknown>>();
function cachePromise<T>(
key: string,
setPromise: () => Promise<T>,
): Promise<T> {
const cached = cache.get(key);
if (cached) return cached as Promise<T>;
const promise = setPromise();
cache.set(key, promise);
return promise;
}
function MermaidContent({ chart }: { chart: string }) {
const id = useId();
const { resolvedTheme } = useTheme();
const { default: mermaid } = use(
cachePromise('mermaid', () => import('mermaid')),
);
mermaid.initialize({
startOnLoad: false,
securityLevel: 'loose',
fontFamily: 'inherit',
themeCSS: 'margin: 1.5rem auto 0;',
theme: resolvedTheme === 'dark' ? 'dark' : 'default',
});
const { svg, bindFunctions } = use(
cachePromise(`${chart}-${resolvedTheme}`, () => {
return mermaid.render(id, chart.replaceAll('\\n', '\n'));
}),
);
return (
<div
ref={(container) => {
if (container) bindFunctions?.(container);
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}

View File

@@ -0,0 +1,30 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
/**
* Shared layout configurations
*
* you can customise layouts individually from:
* Home Layout: app/(home)/layout.tsx
* Docs Layout: app/docs/layout.tsx
*/
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<>
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
aria-label="Logo"
>
<circle cx={12} cy={12} r={12} fill="currentColor" />
</svg>
My App
</>
),
},
// see https://fumadocs.dev/docs/ui/navigation/links
links: [],
};
}

View File

@@ -0,0 +1,9 @@
import { docs } from '@/.source';
import { loader } from 'fumadocs-core/source';
// See https://fumadocs.vercel.app/docs/headless/source-api for more info
export const source = loader({
// it assigns a URL to your pages
baseUrl: '/docs',
source: docs.toFumadocsSource(),
});

View File

@@ -0,0 +1,12 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Mermaid } from '@/components/mdx/mermaid';
import type { MDXComponents } from 'mdx/types';
// use this function to get MDX components, you will need it for rendering MDX
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
Mermaid,
...components,
};
}

45
apps/docs/tsconfig.json Normal file
View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/.source": [
"./.source/index.ts"
],
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

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

View File

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

View File

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

View 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,
});

View 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,
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -26,15 +26,27 @@ export default function StreamPlayer() {
autoplay
config={{
lowLatencyMode: true,
liveSyncDurationCount: 2, // Use only 1 segment for sync
liveMaxLatencyDurationCount: 3, // Maximum latency allowed
liveSyncDurationCount: 2,
liveMaxLatencyDurationCount: 4,
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: 2,
startLevel: 0,
maxBufferLength: 4,
maxMaxBufferLength: 8,
startFragPrefetch: true,
testBandwidth: false,
progressive: true,
maxBufferSize: 30 * 1000 * 1000,
maxBufferHole: 0.3,
highBufferWatchdogPeriod: 1,
nudgeOffset: 0.05,
nudgeMaxRetry: 2,
manifestLoadingTimeOut: 5000,
manifestLoadingMaxRetry: 2,
levelLoadingTimeOut: 5000,
fragLoadingTimeOut: 10000,
debug: process.env.NODE_ENV === 'development',
}}
/>
<MediaLoadingIndicator slot="centered-chrome" noAutohide />

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

View File

@@ -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...');

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ export async function registerThumbnailWorker(): Promise<void> {
}
},
{
connection: getRedisConnection(),
connection: getRedisConnection().options,
concurrency: 3,
limiter: {
max: 50,

View File

@@ -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_playlist_length 20s;
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;
}
}
}

View File

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

View File

@@ -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": {

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "StreamInfo_username_idx" ON "StreamInfo"("username");

View File

@@ -13,6 +13,7 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_DIRECT_URL")
}
model User {
@@ -80,7 +81,7 @@ model StreamInfo {
enableNotifications Boolean @default(true)
// TODO: index on username
@@index([username])
}
model Follow {

View File

@@ -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"],

5260
yarn.lock

File diff suppressed because it is too large Load Diff