Compare commits

..

6 Commits

Author SHA1 Message Date
cfc0f78a6c feat: no viewer streaminfo implementation 2025-03-15 16:57:24 +01:00
eda2acb1be feat: move to nginx flv 2025-03-15 16:40:03 +01:00
1aaa85da19 feat: chat 2025-03-14 18:21:36 +01:00
f5ff1a486a chore: cleanup livekit api routes 2025-03-13 21:02:55 +01:00
f76216a78c feat: video player and next-ws 2025-03-13 21:01:52 +01:00
0c6c7a910d feat: initial and probably final rtmp server backend 2025-03-13 17:47:36 +01:00
146 changed files with 1347 additions and 3831 deletions

View File

@@ -1,41 +1,34 @@
# Version control
# Ignore node_modules and build output
node_modules
.next
out
# Ignore logs and temporary files
*.log
*.tmp
*.swp
# Ignore local environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Ignore Docker files
Dockerfile
docker-compose.yml
# Ignore git files
.git
.gitignore
# Dependencies
**/node_modules
.pnpm-store
# Build outputs
**/dist
**/.next
**/build
**/out
# Development files
**/.env*
!**/.env.example
**/.vscode
**/.idea
**/coverage
**/.turbo
**/.cache
# System files
.DS_Store
**/Thumbs.db
# Logs
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
# Test files
**/__tests__
**/*.test.*
**/*.spec.*
packages/db/generated
dev/
flv-module/
# Ignore editor directories and files
.vscode
.idea
*.sublime-project
*.sublime-workspace
# Ignore other unnecessary files
README.md
dev/

View File

@@ -1,125 +0,0 @@
name: Publish Docker image
on:
push:
branches:
- main
jobs:
frontend:
name: Push frontend to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
db:
name: Push db to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive-db
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./packages/db/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
chat:
name: Push chat module to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: srizan10/hclive-chat
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/chat/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
secrets: |
TURBO_TOKEN=${{ secrets.TURBO_TOKEN }}
TURBO_TEAM=${{ secrets.TURBO_TEAM }}
deploy:
name: Deploy to server
runs-on: ubuntu-latest
needs: [frontend, db, chat]
steps:
- name: Emit a webhook to the server
env:
AUTH_HEADER: ${{ secrets.WHSERVER_TOKEN }}
run: |
curl -X POST \
-H "Authorization: $AUTH_HEADER" \
https://webhooks.srizan.dev/hooks/hctv

13
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
*node_modules
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
@@ -10,7 +10,7 @@
/coverage
# next.js
*.next/
/.next/
/out/
# production
@@ -27,7 +27,7 @@ yarn-error.log*
# local env files
.env*.local
.env*
.env
# vercel
.vercel
@@ -37,9 +37,4 @@ yarn-error.log*
next-env.d.ts
certificates
dev/psql
dev/redis
.turbo
packages/db/generated/client
*dist
dev/psql

45
Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Stage 1: Building the code
FROM node:lts-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json yarn.lock ./
# Install dependencies
RUN yarn install --frozen-lockfile
# Copy app files
COPY . .
# Build app
RUN yarn build
# Stage 2: Production
FROM node:lts-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Copy necessary files
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/yarn.lock ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/prisma ./prisma
# Install production dependencies only
RUN apk add --no-cache openssl
RUN yarn install --frozen-lockfile --production && \
yarn cache clean
# Remove unnecessary files
RUN rm -rf /app/.git \
/app/.next/cache \
/app/README.md
EXPOSE 3000
CMD ["yarn", "start"]

View File

@@ -1,13 +1,24 @@
# hackclub.tv
# Sr Izan Stack
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
Sr Izan Stack is a next.js template which runs on modern technologies, with a focus on developer experience and ease-of-use.
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.
## The stack
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
- Framework: [Next.js](https://nextjs.org/)
- Language: [TypeScript](https://www.typescriptlang.org/)
- Styling: [Tailwind CSS](https://tailwindcss.com/)
- UI Library: [shadcn/ui](https://ui.shadcn.com)
- Authentication: [Lucia](https://lucia-auth.com)
- Deployment: [Vercel](https://vercel.com)
- Database: [Supabase Postgres](https://supabase.com) with [Prisma](https://www.prisma.io/)
## Features
## Why (insert tool here)?
- High quality video streaming (low latency coming soon)
- Chat with other viewers
- Multiaccount support (database schema laid out, UI not implemented)
- **Next.js**: I like the next.js app router because it has a very good developer experience and it's very easy to use.
- **TypeScript**: Don't even need to explain why
- **Tailwind CSS**: I like the utility-first approach and the speed of development it provides
- **shadcn/ui**: Copy-pasting components is so fire (also is Radix UI)
- **Lucia**: The DevEX is amazing and it's very easy to use
- **Vercel**: Next.js and Vercel are like bread and butter, but it's a bit slow with the free tier.
- **MongoDB Atlas**: It has a very generous free tier, and I'm choosing NoSQL because Postgres hates me.
- **Prisma**: Even though there are solid competitors like Drizzle, Prisma is easy to use, understand, and fast enough for my use case.

28
apps/chat/.gitignore vendored
View File

@@ -1,28 +0,0 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

View File

@@ -1,44 +0,0 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
RUN yarn global add turbo@^2
COPY . .
RUN turbo prune @hctv/chat --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --concurrency=1
FROM base AS runner
WORKDIR /app
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nodeapp
USER nodeapp
COPY --from=installer --chown=nodeapp:nodejs /app/apps ./apps
COPY --from=installer --chown=nodeapp:nodejs /app/packages ./packages
COPY --from=installer --chown=nodeapp:nodejs /app/node_modules ./node_modules
COPY --from=installer --chown=nodeapp:nodejs /app/package.json ./package.json
ENV NODE_ENV=production
WORKDIR /app/apps/chat
EXPOSE 8000
ENTRYPOINT ["node", "dist/index.js"]

View File

@@ -1,8 +0,0 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

View File

@@ -1,23 +0,0 @@
{
"name": "@hctv/chat",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --build"
},
"dependencies": {
"@hctv/auth": "*",
"@hctv/db": "*",
"@hctv/hono-ws": "*",
"@hono/node-server": "^1.14.0",
"@hono/node-ws": "^1.1.0",
"@oslojs/encoding": "^1.1.0",
"hono": "^4.7.5"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1",
"typescript": "^5.8.2"
}
}

View File

@@ -1,15 +0,0 @@
,---, ___
,--.' | ,--.'|_
| | : | | :,'
: : : : : ' : .---.
: | |,--. ,---. .;__,' / /. ./|
| : ' | / \| | | .-' . ' |
| | /' : / / ':__,'| : /___/ \: |
' : | | |. ' / ' : |__. \ ' .
| | ' | :' ; :__ | | '.'|\ \ '
| : :_:,'' | '.'| ; : ; \ \
| | ,' | : : | , / \ \ |
`--'' \ \ / ---`-' '---"
`----'
This is hctv's chat backend. There's not much here, so go back to where you came from :)

View File

@@ -1,129 +0,0 @@
import { serve } from '@hono/node-server';
import { createNodeWebSocket, type ModifiedWebSocket } from '@hctv/hono-ws';
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import { lucia } from '@hctv/auth';
import { getCookie } from 'hono/cookie';
import { getPersonalChannel } from './utils/personalChannel.js';
import { prisma } from '@hctv/db';
const threed = await readFile('./src/3d.txt', 'utf-8');
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.get('/', async (c) => {
return c.text(threed);
});
app.get('/up', async (c) => {
return c.text('it works');
});
app.get(
'/ws/:username',
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
async onOpen(evt, ws) {
const token = getCookie(c, 'auth_session');
if (!token) {
ws.close();
return;
}
const { user } = await lucia.validateSession(token);
if (!user) {
ws.close();
return;
}
const personalChannel = await getPersonalChannel(user.id);
if (!personalChannel) {
ws.close();
return;
}
const { username } = c.req.param();
ws.targetUsername = username;
ws.user = user;
ws.personalChannel = personalChannel;
if (ws.raw) {
ws.raw.targetUsername = username;
// @ts-ignore
ws.raw.user = user;
ws.raw.personalChannel = personalChannel;
}
await prisma.streamInfo.update({
where: {
username,
},
data: {
viewers: {
increment: 1,
},
},
});
},
async onClose(evt, ws) {
console.log('client disconnected');
const streamInfo = await prisma.streamInfo.findUnique({
where: {
username: ws.targetUsername,
},
select: {
viewers: true,
},
});
if (!streamInfo) return;
await prisma.streamInfo.update({
where: {
username: ws.targetUsername,
},
data: {
viewers: streamInfo.viewers === 0 ? { set: 0 } : { decrement: 1 },
},
});
},
onMessage(evt, ws) {
const msg = JSON.parse(evt.data.toString());
if (msg.type === 'ping') {
ws.send(
JSON.stringify({
type: 'pong',
})
);
return;
} else if (msg.type === 'message') {
ws.wss.clients.forEach((c) => {
const client = c as ModifiedWebSocket;
if (client.readyState === client.OPEN && client.targetUsername === ws.targetUsername) {
c.send(
JSON.stringify({
user: {
id: ws.user.id,
username: ws.personalChannel.name,
pfpUrl: ws.user.pfpUrl,
},
message: msg.message,
})
);
}
});
}
},
}))
);
const server = serve(
{
fetch: app.fetch,
port: 8000,
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
}
);
injectWebSocket(server);

View File

@@ -1,17 +0,0 @@
import { prisma } from "@hctv/db";
export async function getPersonalChannel(id: string) {
const db = await prisma.user.findUnique({
where: {
id,
},
select: {
personalChannel: true,
},
});
if (!db) {
return null;
}
return db.personalChannel;
}

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": [
"node"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "dist",
"rootDir": "src"
}
}

View File

@@ -1,44 +0,0 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
# Replace <your-major-version> with the major version installed in your repository. For example:
# RUN yarn global add turbo@^2
RUN yarn global add turbo@^2
COPY . .
# Generate a partial monorepo with a pruned lockfile for a target workspace.
# Assuming "web" is the name entered in the project's package.json: { name: "web" }
RUN turbo prune @hctv/web --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build
FROM base AS runner
WORKDIR /app
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
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
CMD node apps/web/server.js

Binary file not shown.

View File

@@ -1,38 +0,0 @@
import * as path from 'node:path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const LIVE_SERVER_URL =
process.env.NODE_ENV === 'production'
? 'https://backend.hctv.srizan.dev'
: 'http://localhost:8888';
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: 'picsum.photos',
},
{
hostname: 'secure.gravatar.com',
},
],
},
env: {
LIVE_SERVER_URL,
},
reactStrictMode: false,
output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '../../'),
async rewrites() {
return [
{
source: '/api/stream/chat/:path*',
destination: `http://${process.env.NODE_ENV === 'production' ? 'chat' : 'localhost'}:8000/:path*`,
},
];
}
};
export default nextConfig;

View File

@@ -1,76 +0,0 @@
{
"name": "@hctv/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dd": "docker compose --file ../../dev/docker-compose.yml up -d",
"dev": "next dev --turbo",
"donly": "docker compose --file ../../dev/docker-compose.yml up",
"build": "next build",
"start": "next start",
"lint": "next lint",
"ui:add": "shadcn add",
"check-types": "tsc --noEmit"
},
"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",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.6",
"@slack/web-api": "^7.9.1",
"@uidotdev/usehooks": "^2.4.1",
"arctic": "^3.1.1",
"bullmq": "^5.45.2",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"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",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.2.3",
"next-themes": "^0.4.4",
"pg": "^8.14.1",
"pg-boss": "^10.1.6",
"react": "19",
"react-dom": "19",
"react-hook-form": "^7.54.2",
"sonner": "^1.4.41",
"swr": "^2.3.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"util-utils": "^1.0.3",
"valtio": "^2.1.2",
"ws": "^8.18.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ws": "^8.18.0",
"eslint": "^8",
"eslint-config-next": "15.1.3",
"postcss": "^8",
"shadcn": "^2.1.8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@@ -1,31 +0,0 @@
import { validateRequest } from '@/lib/auth/validate';
import fsP from 'fs/promises';
import fs from 'fs';
export async function GET(request: Request, { params }: { params: Promise<{ path: string }> }) {
const { path } = await params;
const { user } = await validateRequest();
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (path.includes('..')) {
return new Response("nuh uh", { status: 403 });
}
const basePath = '/dev/shm/hls';
const filePath = `${basePath}/${path}`;
const exists = fs.existsSync(filePath);
if (!exists) {
return new Response("Not Found", { status: 404 });
}
const file = await fsP.readFile(filePath);
return new Response(file, {
headers: {
'Content-Type': 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
},
});
}

View File

@@ -1,27 +0,0 @@
'use client';
import { useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { notifyStreamToggle } from '@/lib/form/actions';
export default function NotifyToggle(props: Props) {
const [toggled, setToggled] = useState(props.toggled);
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async () => {
setIsLoading(true);
notifyStreamToggle(props.channel).then((res) => {
if (res.success) {
setToggled(res.toggle!);
}
});
setIsLoading(false);
};
return <Switch checked={toggled} onCheckedChange={handleToggle} disabled={isLoading} />;
}
interface Props {
channel: string;
toggled: boolean;
}

View File

@@ -1,72 +0,0 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import NotifyToggle from './notifyToggle';
export default async function Page() {
const { user } = await validateRequest();
const following = await prisma.follow.findMany({
where: {
userId: user!.id,
},
include: {
channel: true,
},
});
if (!following.length) {
return (
<div className="flex flex-col items-center justify-center w-full h-full">
<h1 className="text-2xl font-bold">No channels followed</h1>
<p className="text-muted-foreground">Go follow some first?</p>
<Link href={'/'}>
<Button>Back home</Button>
</Link>
</div>
);
}
return (
<div className="container py-10">
<h1 className="text-2xl font-bold mb-6">Followed Channels</h1>
<Table className="max-w-2xl mx-auto outline-surface bg-mantle rounded-md overflow-hidden">
<TableHeader>
<TableRow>
<TableHead>Channel</TableHead>
<TableHead className="w-[100px] text-center">Notifications</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{following.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={channel.channel.pfpUrl} alt={channel.channel.name} />
<AvatarFallback>{channel.channel.name.charAt(0)}</AvatarFallback>
</Avatar>
<Link href={`/${channel.channel.name}`} className="hover:underline">
{channel.channel.name}
</Link>
</div>
</TableCell>
<TableCell className="text-center">
<NotifyToggle channel={channel.channel.name} toggled={channel.notifyStream} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -1,36 +0,0 @@
'use client';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
export default function LiveStream(props: Props) {
const isMobile = useIsMobile();
return (
<div className={`${isMobile ? 'flex flex-col' : 'flex'} h-[calc(100vh-64px)] w-full`}>
<div className="flex-1 flex flex-col">
<StreamPlayer />
{isMobile && (
<div className="h-[300px]">
<ChatPanel />
</div>
)}
<UserInfoCard streamInfo={props.streamInfo} />
</div>
{!isMobile && (
<div>
<ChatPanel />
</div>
)}
</div>
);
}
interface Props {
username: string;
streamInfo: StreamInfo & { ownedBy: User };
}

View File

@@ -1,107 +0,0 @@
'use client';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
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 (
<>
<nav className="flex items-center justify-between h-14 md:h-16 px-2 md:px-4 border-b gap-1 md:gap-3 w-full z-40 fixed top-0 left-0 shadow-md bg-mantle">
<div className="flex items-center space-x-2 md:space-x-5 shrink-0">
<Link href="/" className="flex items-center">
<Button variant={'ghost'} className="px-2 md:px-3 text-sm md:text-base">
hackclub.tv
</Button>
</Link>
<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>}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar className="h-8 w-8 md:h-10 md:w-10">
<AvatarImage src={user.pfpUrl} alt={`@${user.id}`} />
<AvatarFallback>{user.pfpUrl}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<Link href={`/settings/follows`}>
<DropdownMenuItem className="cursor-pointer">Follows</DropdownMenuItem>
</Link>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<ThemeSwitcher />
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/auth/slack">
<Button variant="outline" size="sm" className="gap-1 md:gap-2 text-xs md:text-sm">
<Slack className="w-3 h-3 md:w-4 md:h-4" />
<span className="hidden sm:inline">Sign in</span>
<span className="sm:hidden">Login</span>
</Button>
</Link>
)}
</div>
</nav>
</>
);
}
interface Props {
editLivestream: Promise<JSX.Element>;
}

View File

@@ -1,19 +0,0 @@
import { useStreams } from "@/lib/providers/StreamInfoProvider";
import { User } from "lucide-react";
import { useParams } from "next/navigation";
export default function ViewerCount() {
const streamInfo = useStreams();
const { username } = useParams();
if (streamInfo.isLoading) return null;
const viewerCount = streamInfo.stream!.find(s => s.username === username)?.viewers;
return (
<div className="flex items-center space-x-2 *:text-destructive">
<span className="text-sm font-semibold"><User /></span>
<span className="text-sm">{viewerCount}</span>
</div>
);
}

View File

@@ -1,59 +0,0 @@
import { cn } from "@/lib/utils"
export default function ConfusedDino({ className }: { className?: string }) {
return (
<svg
id="Layer_1"
data-name="Layer 1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 153.86 112.82"
className={cn(className, "fill-black dark:fill-white")}
>
<title>confused_dinosaur</title>
<path
d="M1750.68,1812.34a2.49,2.49,0,0,1,1.91-.49c.69,0,1.43-.75,2.12-1.22a23.75,23.75,0,0,1,6.24-3.24,5.16,5.16,0,0,1,1.17-.23c3.38-.16,6.77-.42,10.17-.34,1.43,0,2.86-.13,4.29-.2a2.87,2.87,0,0,1,3,1.88c.62,1.45,1.7,2.72,1.72,4.41,0,3.06-.92,4.15-4,4.6a15.18,15.18,0,0,0-6.21,2,20.77,20.77,0,0,0-2.89,2.11,10,10,0,0,1-5.85,2.22,16,16,0,0,1-9.15-1.06,56.15,56.15,0,0,1-5.4-3.39,64.39,64.39,0,0,0-2.33,8.34,11.67,11.67,0,0,0-.21,5.32c.12.47.15.93.66,1.2s.6.94.72,1.46a16.19,16.19,0,0,0,2.31,5.46,4.07,4.07,0,0,1,.59,1.31,3.94,3.94,0,0,0,2.37,2.68,1.92,1.92,0,0,1,1,1c1.3,3,3.94,3.84,6.86,4.44,4.43.92,8.84-.36,13.23,0,3.15.27,6.17-.38,9.25-.7a33.07,33.07,0,0,1,4.67,0c4.11.21,8-1,11.83-2.1a30,30,0,0,0,6.6-2.42c4.19-2.38,8.11-5.16,11-9.07.64-.87,1.64-1.49,2-2.62.13-.41.67-.34,1.07-.35.88,0,1.76,0,2.63-.11.52,0,1,0,1.11.6s-.38.84-.89.93a8.9,8.9,0,0,0-2.42.52,9.51,9.51,0,0,0-2.84,2.66c-4.34,5-9.5,8.91-15.91,10.81-4.07,1.2-8.11,2.69-12.46,2.64-.77,0-.77.43-.54.94s.51.81.72,1.24a.73.73,0,0,1-.21,1.06.77.77,0,0,1-1.14-.29,7.42,7.42,0,0,1-.84-1.57,1.88,1.88,0,0,0-2.17-1.41,29.15,29.15,0,0,0-5.09.56c-2.73.57-5.5.11-8.25.19-1.56,0-3.11.18-4.66.24-3.89.13-7.83.28-11.45-1.65a6.26,6.26,0,0,1-2.28-1.91,34.32,34.32,0,0,0-3.5-3.63,3.6,3.6,0,0,1-1.08-1.95,3.44,3.44,0,0,0-.71-1.51,14.16,14.16,0,0,1-1.58-3.07,7.14,7.14,0,0,0-1.46-2.84c-.88-.86-.59-2.49-.7-3.79-.32-3.67.9-7.1,1.83-10.56a48.36,48.36,0,0,1,3.66-9.71c1.52-2.93,3-5.9,4.79-8.69,2.13-3.33,4.34-6.6,6.72-9.76a56.68,56.68,0,0,1,8.17-8.22c6.4-5.65,13.87-9.46,21.7-12.67a43.86,43.86,0,0,1,15.53-3.61c.63,0,1.72-.36,1.69.72s-1,.56-1.65.62a54.58,54.58,0,0,0-15.37,3.82,103.07,103.07,0,0,0-16.5,8.7c-2.59,1.6-4.68,3.84-7,5.82-5.22,4.51-8.72,10.35-12.39,16C1753.32,1806.94,1752.22,1809.73,1750.68,1812.34Zm21.06-4.21c-2.11.15-4.24-.12-6.33.32-1.6.33-3.31-.07-4.83.66a30.43,30.43,0,0,0-9,6,1.18,1.18,0,0,1-1,.51c-1-.12-1.08.71-1.3,1.29s-.29,1.43.33,1.75c1.59.84,2.91,2.08,4.53,2.87a12.27,12.27,0,0,0,4.55.87c3.16.26,6.24-.09,8.83-2.14,2.41-1.9,4.91-3.53,8-4a18.7,18.7,0,0,0,2.34-.49,2.56,2.56,0,0,0,1.7-3.89c-.41-.82-.88-1.62-1.21-2.47a2,2,0,0,0-2.17-1.32C1774.7,1808.15,1773.22,1808.13,1771.74,1808.13Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1804.84,1764.83a7.29,7.29,0,0,1,4-1.06c2.31.12,4.63,0,6.94.34a87.53,87.53,0,0,1,14.13,3,172.67,172.67,0,0,1,18.23,6.94c9.43,4.1,17.59,9.88,23.79,18.2a24.9,24.9,0,0,1,3,6.22,49.87,49.87,0,0,1,3,16.62c.07,2.91,0,5.83,0,8.75a115.17,115.17,0,0,1-1.22,13c-.44,3.72-.88,7.43-1.65,11.1q-.59,2.8-1.23,5.6c-.13.56-.39,1.15-1.14.91s-.47-.81-.35-1.38c.75-3.46,1.57-6.91,2.13-10.41a138,138,0,0,0,1.81-30.77,43.27,43.27,0,0,0-3.14-13.68c-3.22-7.91-9.29-13.07-16.13-17.61a68.64,68.64,0,0,0-11.56-6,164.81,164.81,0,0,0-16-6,90.55,90.55,0,0,0-12.23-2.8,55.36,55.36,0,0,0-6.44-.53C1808.84,1765.19,1806.87,1764.83,1804.84,1764.83Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1801,1801.39a15.42,15.42,0,0,0,1.27,7.22.62.62,0,0,1-.26.92,1,1,0,0,1-1.21-.18,2.08,2.08,0,0,1-.41-.86c-1.18-5.31-1.75-10.56,1.12-15.59a14.26,14.26,0,0,1,2.22-2.66,13.88,13.88,0,0,1,14.11-4.14c5.28,1.35,10,5.41,9.86,12-.05,1.9.17,3.82-.78,5.62a5.36,5.36,0,0,1-3.81,3.11c-4.64.94-9.3,1.82-13.9,2.93-2.28.55-4.52,1.28-6.79,1.9a2.34,2.34,0,0,1-.59.09c-.55,0-1.25.11-1.34-.65s.62-.74,1.16-.87c2-.51,3.92-1,5.87-1.58,3.18-.92,6.44-1.49,9.68-2.15,1.83-.37,3.69-.67,5.51-1.09,2.44-.56,3.2-2.6,3.51-4.66a12.42,12.42,0,0,0-1.18-7.45,10.91,10.91,0,0,0-6.1-5.12c-4.54-1.84-8.82-1-12.79,2A13.44,13.44,0,0,0,1801,1801.39Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1740.46,1771.69a16.18,16.18,0,0,1-1.12,6.4c-.37,1.09-.4,2.38-1,3.29-1,1.46-.17,2,.7,3a54.7,54.7,0,0,0,5.25,5.19c.37.33.61.66.33,1.12s-.22,1.07-.9,1.16-.76-.39-1.07-.77c-1.19-1.51-2.21-3.21-4-4.11a2.94,2.94,0,0,1-1.22-1.29,2.58,2.58,0,0,0-1.32-1.3c-1-.35-.79-1.23-.6-2a71.59,71.59,0,0,0,1.86-7.51,14.16,14.16,0,0,0-.21-6.63c-.88-2.65-2.16-3.28-4.83-2.4-1,.33-2,.79-3,1.2-.74.31-1.31.71-1.29,1.66,0,.64-.47,1-1,.68s-.61.14-.76.4c-.37.62-.59,1.7-1.53,1.2-.78-.42-.55-1.46-.16-2.16a10,10,0,0,1,4.57-4.39,11.6,11.6,0,0,1,6.1-.74c2.23.33,3.18,2.23,4.35,3.83A5.79,5.79,0,0,1,1740.46,1771.69Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1811.16,1804.55c-.45,0-1.09,0-1.72,0a3,3,0,0,1-2.58-2.7,9.46,9.46,0,0,0-.2-2.6,1.34,1.34,0,0,1,.06-.71c.33-1,2.92-2.87,4-2.66a21.72,21.72,0,0,0,3.69.17c1.94,0,2.65.75,2.69,2.67a6.08,6.08,0,0,1-1,3.94C1814.83,1804.32,1813.25,1804.77,1811.16,1804.55Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1756.16,1756c.22,2.61-.84,4.93-1.6,7.31a2.12,2.12,0,0,0,.3,2.08c1.82,2.72,3.53,5.5,5.3,8.25.3.47.4.94-.18,1.2a1.1,1.1,0,0,1-1.54-.51c-1.33-3-3.37-5.66-5.19-8.41a3.17,3.17,0,0,1-.28-2.53c.29-1.57,1-3,1.4-4.57a12.23,12.23,0,0,0,.11-5.67,19.09,19.09,0,0,0-4.11-8.64,2.59,2.59,0,0,0-3.36-.85,9.82,9.82,0,0,1-3.77.78,7.1,7.1,0,0,0-4.21,1.82c-.45.37-.88.69-1.3.11a.94.94,0,0,1,.33-1.45c1.68-1,3.36-2.08,5.43-1.93,1.51.11,2.74-.77,4.14-1a3.47,3.47,0,0,1,3.75,1.39,18.86,18.86,0,0,1,4.62,10.16C1756.09,1754.35,1756.11,1755.19,1756.16,1756Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1781.28,1752.19a7.12,7.12,0,0,1-1.11,5,2.64,2.64,0,0,0,.17,2.63c.3.69.63,1.37.87,2.08s.14,1.27-.59,1.48-.85-.45-.94-1c-.19-1.27-1.13-2.26-1.3-3.57a3.48,3.48,0,0,1,.46-2.49c1.75-2.76,1.41-5.62.28-8.46a1.69,1.69,0,0,0-2.46-1,11.27,11.27,0,0,0-1.68.89c-.4.22-.79.25-1.07-.14a.84.84,0,0,1,.11-1.1,4,4,0,0,1,6.73,1A10.27,10.27,0,0,1,1781.28,1752.19Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1761.28,1780.56a6.84,6.84,0,0,1,1-2.52.67.67,0,0,1,.85-.26.6.6,0,0,1,.41.65,3.11,3.11,0,0,0,.36,1.62c.28.45.61.92.1,1.39a1.62,1.62,0,0,1-1.91.56A1.33,1.33,0,0,1,1761.28,1780.56Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1748,1796.72c.06.82,0,1.57-1,1.81a1,1,0,0,1-1.3-.55c-.32-.91-.62-1.88.12-2.71a1.31,1.31,0,0,1,1.67-.15C1748.13,1795.47,1747.91,1796.15,1748,1796.72Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1784.67,1770.84c-.16.51.42,1.5-.59,1.7s-.83-.86-1.18-1.37a2,2,0,0,1-.29-1.37c0-.6,0-1.33.81-1.36s1.16.58,1.25,1.32C1784.7,1770.08,1784.67,1770.4,1784.67,1770.84Z"
transform="translate(-1724.16 -1741.87)"
/>
<path
d="M1761.5,1815.55c-.22-1.62,1.06-2.58,1.56-3.85.17-.42.46-.82.6-1.28.21-.65.5-1.19,1.33-1.08.25,0,.44-.12.65-.24,2-1.15,3.67-.32,4.18,1.91a11.65,11.65,0,0,1-.4,5.19,3.32,3.32,0,0,1-1.74,2.18c-.8.42-1.5,1.28-2.59.6-.38-.23-.63.2-.91.4-.86.62-1.28.44-1.52-.65a1.35,1.35,0,0,0-.35-.73A2.76,2.76,0,0,1,1761.5,1815.55Z"
transform="translate(-1724.16 -1741.87)"
/>
</svg>
);
}

View File

@@ -1,29 +0,0 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -1,117 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,12 +0,0 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await (await import('@/lib/instrumentation/streamInfo')).default();
}
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerWorkers } = await import('@/lib/workers/register');
await registerWorkers();
console.log('bullmq workers registered');
}
}

View File

@@ -1,46 +0,0 @@
import { prisma } from "@hctv/db";
import { validateRequest } from "./validate";
export async function resolveOwnedChannels(id?: string) {
const { user } = await validateRequest();
const db = await prisma.user.findUnique({
where: {
id: id ?? user?.id,
},
select: {
ownedChannels: true,
managedChannels: true,
},
});
if (!db) {
return null;
}
const channels = [
...db.ownedChannels.map((channel) => ({
...channel,
isOwner: true,
})),
...db.managedChannels.map((channel) => ({
...channel,
isOwner: false,
})),
];
return channels;
}
export async function resolveFollowedChannels(id?: string) {
const { user } = await validateRequest();
const db = await prisma.follow.findMany({
where: {
userId: id ?? user?.id,
},
include: {
channel: true,
},
});
if (!db) {
return null;
}
return db;
}

View File

@@ -1,31 +0,0 @@
import { cookies } from "next/headers";
import { cache } from "react";
import { lucia } from '@hctv/auth';
export const validateRequest = cache(async () => {
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId)
return {
user: null,
session: null,
};
const { user, session } = await lucia.validateSession(sessionId);
try {
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {
// Next.js throws error attempting to set cookies when rendering page
}
return {
user,
session,
};
});

View File

@@ -1,147 +0,0 @@
import { prisma } from '@hctv/db';
import { HttpFlv } from '../types/liveBackendJson';
import { getNotificationQueue } from '../workers';
import client from '../services/slackNotifier';
export default async function runner() {
// if there are no users it explodes so yeah
if ((await prisma.user.count()) === 0) {
return;
}
await initializeStreamInfo();
await syncStream();
setInterval(syncStream, 5000);
}
export async function initializeStreamInfo(channelId?: string) {
const channels = await prisma.channel.findMany({
where: {
id: channelId,
},
include: {
streamInfo: true,
},
});
for (const channel of channels) {
if (!channel.streamInfo.length) {
await prisma.streamInfo.create({
data: {
username: channel.name,
title: 'Untitled',
category: 'Uncategorized',
startedAt: new Date(0),
thumbnail: 'https://picsum.photos/600/400',
viewers: 0,
isLive: false,
channel: {
connect: { id: channel.id },
},
ownedBy: {
connect: { id: channel.ownerId },
},
},
});
}
}
}
export async function syncStream() {
try {
const response = await fetch(`${process.env.LIVE_SERVER_URL}/stat`, {
headers: {
Authorization: process.env.STAT_AUTH!,
},
});
if (!response.ok) {
console.error(`Failed to fetch stream stats: ${response.status} ${response.statusText}`);
return;
}
const data = await response.json();
const httpFlv = data['http-flv'] as HttpFlv;
if (!httpFlv?.servers?.[0]?.applications) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter((c) => !c.publishing).length,
});
}
for (const dbStream of currentLiveStreams) {
const streamStats = activeStreamMap.get(dbStream.username);
if (!streamStats || !streamStats.isLive) {
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
isLive: false,
viewers: 0,
startedAt: new Date(0),
},
});
}
}
for (const stream of activeStreams) {
if (stream.active) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
});
if (existingStream && !existingStream.isLive) {
await prisma.streamInfo.update({
where: { username: stream.name },
data: {
isLive: true,
startedAt: new Date(),
},
});
const subscribedFollowers = await prisma.follow.findMany({
where: {
channelId: existingStream.channelId,
notifyStream: true,
},
include: {
user: true,
},
});
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,
});
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\`._`,
channel: follower.user.slack_id,
unfurl_links: true,
});
}
}
}
}
} catch (error) {
console.error('Error syncing stream status:', error);
}
}

View File

@@ -1,30 +0,0 @@
import Redis from 'ioredis';
const createRedisConnection = () => {
return new Redis(process.env.REDIS_URL || 'redis://localhost:6379', { maxRetriesPerRequest: null });
};
const globalForQueue = global as unknown as {
redisConnection: Redis | null;
};
if (!globalForQueue.redisConnection) {
globalForQueue.redisConnection = null;
}
export function getRedisConnection(): Redis {
if (!globalForQueue.redisConnection) {
console.log('Creating new Redis connection...');
globalForQueue.redisConnection = createRedisConnection();
}
return globalForQueue.redisConnection;
}
export async function closeRedisConnection(): Promise<void> {
// Close Redis connection
if (globalForQueue.redisConnection) {
await globalForQueue.redisConnection.quit();
globalForQueue.redisConnection = null;
console.log('Redis connection closed');
}
}

View File

@@ -1,4 +0,0 @@
import { WebClient } from '@slack/web-api';
const client = new WebClient(process.env.SLACK_NOTIFIER_TOKEN);
export default client;

View File

@@ -1,31 +0,0 @@
import { Queue, Worker } from 'bullmq';
import { getRedisConnection } from '@/lib/services/redis';
// Singleton instances for notifier
const globalForNotifier = global as unknown as {
notificationQueue: Queue | null;
notificationWorker: Worker | null;
};
// Initialize if they don't exist
if (!globalForNotifier.notificationQueue) {
globalForNotifier.notificationQueue = null;
globalForNotifier.notificationWorker = null;
}
// Get or create the notification queue
export function getNotificationQueue(): Queue {
if (!globalForNotifier.notificationQueue) {
globalForNotifier.notificationQueue = new Queue('notifications', {
connection: getRedisConnection(),
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
}
});
}
return globalForNotifier.notificationQueue;
}

View File

@@ -1,6 +0,0 @@
import { registerNotificationWorker } from './worker/notification';
export async function registerWorkers(): Promise<void> {
await registerNotificationWorker();
console.log('All workers registered successfully');
}

View File

@@ -1,48 +0,0 @@
import { Worker } from 'bullmq';
import { getRedisConnection } from '@/lib/services/redis';
import snClient from '@/lib/services/slackNotifier';
const globalForWorker = global as unknown as {
notificationWorker: Worker | null;
};
if (!globalForWorker.notificationWorker) {
globalForWorker.notificationWorker = null;
}
export async function registerNotificationWorker(): Promise<void> {
if (globalForWorker.notificationWorker) {
console.log('Notification worker already registered');
return;
}
console.log('Registering notification worker...');
const worker = new Worker('notifications', async (job) => {
try {
await snClient.chat.postMessage(job.data);
return { success: true };
} catch (e) {
console.error('Slack notification failed:', e);
// @ts-ignore e is unknown
return { success: false, error: e.message };
}
}, {
connection: getRedisConnection(),
concurrency: 1,
limiter: {
max: 45,
duration: 60000
}
});
globalForWorker.notificationWorker = worker;
}
// Close the worker
export async function closeNotificationWorker(): Promise<void> {
if (globalForWorker.notificationWorker) {
await globalForWorker.notificationWorker.close();
globalForWorker.notificationWorker = null;
}
}

View File

@@ -1,7 +1,6 @@
services:
psql:
image: postgres
user: 1000:1000
environment:
POSTGRES_USER: postgres
# my condolences
@@ -10,12 +9,6 @@ services:
- ./psql:/var/lib/postgresql/data
ports:
- 5555:5432
redis:
image: redis:7.4-alpine
volumes:
- ./redis:/data
ports:
- 6379:6379
nginx-rtmp:
# ports:
# - 1935:1935
@@ -31,23 +24,23 @@ services:
- /dev/shm/hls:/dev/shm/hls
image: flv-module
entrypoint:
- /bin/sh
- -c
- |
# Process the template file
mkdir -p /usr/local/nginx/conf
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
- /bin/sh
- -c
- |
# Process the template file
mkdir -p /usr/local/nginx/conf
envsubst '$${API_AUTH}' < /etc/nginx/templates/nginx.conf.template > /usr/local/nginx/conf/nginx.conf
echo "Setting UID to $${UID} and GID to $${GID}"
usermod -u $${UID} nginx || echo "failed to change uid"
groupmod -g $${GID} nginx || echo "failed to change gid"
echo "Setting UID to $${UID} and GID to $${GID}"
usermod -u $${UID} nginx || echo "failed to change uid"
groupmod -g $${GID} nginx || echo "failed to change gid"
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
chown -R nginx:nginx /usr/local/nginx
mkdir -p /var/www/html
chown -R nginx:nginx /var/www/html
mkdir -p /usr/local/nginx/proxy_temp /usr/local/nginx/client_body_temp
chown -R nginx:nginx /usr/local/nginx
mkdir -p /var/www/html
chown -R nginx:nginx /var/www/html
echo "testing nginx config..."
/usr/local/nginx/sbin/nginx -t
echo "testing nginx config..."
/usr/local/nginx/sbin/nginx -t
/usr/local/nginx/sbin/nginx -g 'daemon off;'
/usr/local/nginx/sbin/nginx -g 'daemon off;'

View File

@@ -59,5 +59,16 @@ http {
rtmp_stat all;
rtmp_stat_format json;
}
location /hls {
alias /dev/shm/hls;
add_header Cache-Control no-cache;
add_header Access-Control-Allow-Origin *;
types {
application/vnd.apple.mpegurl m3u8;
video/mp2t ts;
}
}
}
}

18
next.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: 'picsum.photos',
},
{
hostname: 'secure.gravatar.com',
}
]
},
env: {
LIVE_SERVER_URL: process.env.NODE_ENV === 'production' ? 'https://backend.hctv.srizan.dev' : 'http://localhost:8888',
}
};
export default nextConfig;

View File

@@ -1,23 +1,73 @@
{
"name": "hctv",
"module": "index.ts",
"type": "module",
"name": "srizan-stack",
"version": "0.1.0",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"build": "dotenvx run -f .env.docker -- turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"docker:web": "dotenvx run -f .env.docker -- docker buildx build --platform linux/amd64 -f apps/web/Dockerfile . --secret id=TURBO_TOKEN,env=TURBO_TOKEN --secret id=TURBO_TEAM,env=TURBO_TEAM --no-cache",
"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"
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev --turbo",
"donly": "docker compose --file dev/docker-compose.yml up",
"setup": "docker compose --file dev/docker-compose.yml up -d && prisma migrate deploy",
"build": "prisma generate && next build",
"start": "prisma migrate deploy && next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev --name",
"ui:add": "shadcn add",
"prepare": "next-ws patch"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@livekit/components-react": "^2.7.0",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@node-rs/argon2": "^2.0.2",
"@prisma/client": "^6.0.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-select": "^2.1.5",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@uidotdev/usehooks": "^2.4.1",
"arctic": "^3.1.1",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "1.0.0",
"hls-video-element": "^1.5.0",
"livekit-client": "^2.8.0",
"livekit-server-sdk": "^2.9.7",
"lucia": "^3.2.2",
"lucide-react": "^0.473.0",
"media-chrome": "^4.8.0",
"next": "^15.1.6",
"next-themes": "^0.4.4",
"next-ws": "^2.0.4",
"react": "19",
"react-dom": "19",
"react-hook-form": "^7.54.2",
"sonner": "^1.4.41",
"swr": "^2.3.0",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"util-utils": "^1.0.3",
"valtio": "^2.1.2",
"ws": "^8.18.1",
"zod": "^3.24.1"
},
"devDependencies": {
"turbo": "^2.4.4"
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/ws": "^8.18.0",
"eslint": "^8",
"eslint-config-next": "15.1.3",
"postcss": "^8",
"prisma": "^6.0.1",
"shadcn": "^2.1.8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -1,24 +0,0 @@
{
"name": "@hctv/auth",
"version": "1.0.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"type": "module",
"scripts": {
"build": "tsc --build"
},
"dependencies": {
"@hctv/db": "*",
"@lucia-auth/adapter-prisma": "^4.0.1",
"arctic": "^3.1.1",
"lucia": "^3.2.2"
},
"devDependencies": {
"typescript": "^5.8.2"
}
}

View File

@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true,
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,44 +0,0 @@
FROM node:lts-alpine AS base
FROM base AS builder
RUN apk update
RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
RUN yarn global add turbo@^2
COPY . .
# Generate a partial monorepo with a pruned lockfile for the db package
RUN turbo prune @hctv/db --docker
FROM base AS installer
RUN apk update
RUN apk add --no-cache libc6-compat
WORKDIR /app
# First install the dependencies
COPY --from=builder /app/out/json/ .
RUN yarn install --frozen-lockfile
COPY --from=builder /app/out/full/ .
RUN --mount=type=secret,id=TURBO_TOKEN --mount=type=secret,id=TURBO_TEAM TURBO_TOKEN=$(cat /run/secrets/TURBO_TOKEN) TURBO_TEAM=$(cat /run/secrets/TURBO_TEAM) yarn turbo run build --filter=@hctv/db
FROM base AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 prisma
USER prisma
COPY --from=installer --chown=prisma:nodejs /app/packages ./packages
COPY --from=installer --chown=prisma:nodejs /app/node_modules ./node_modules
COPY --from=installer --chown=prisma:nodejs /app/package.json ./package.json
# Set environment variables for database connection
ENV NODE_ENV=production
# Set the working directory to the db package
WORKDIR /app/packages/db
# Run Prisma migrations as the entrypoint
ENTRYPOINT ["npx", "prisma", "migrate", "deploy"]

View File

@@ -1,25 +0,0 @@
{
"name": "@hctv/db",
"version": "0.0.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"type": "module",
"dependencies": {
"@prisma/client": "^6.5.0",
"prisma": "^6.5.0"
},
"scripts": {
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"build": "tsc --build"
},
"devDependencies": {
"typescript": "^5.8.2"
}
}

View File

@@ -1,28 +0,0 @@
-- Remove follows where a user is following their own personal channel
DELETE FROM "Follow" f
USING "User" u
WHERE f."userId" = u.id
AND f."channelId" = u."personalChannelId"
AND u."personalChannelId" IS NOT NULL;
-- Create a function to prevent self follows
CREATE OR REPLACE FUNCTION prevent_self_follows()
RETURNS TRIGGER AS $$
BEGIN
IF EXISTS (
SELECT 1 FROM "User"
WHERE id = NEW."userId"
AND "personalChannelId" = NEW."channelId"
AND "personalChannelId" IS NOT NULL
) THEN
RAISE EXCEPTION 'Users cannot follow their own personal channel';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create a trigger that uses this function
CREATE TRIGGER check_self_follow
BEFORE INSERT OR UPDATE ON "Follow"
FOR EACH ROW
EXECUTE FUNCTION prevent_self_follows();

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Follow" ADD COLUMN "notifyStream" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -1,8 +0,0 @@
import { PrismaClient } from "../generated/client/index.js";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -1,4 +0,0 @@
// from https://www.prisma.io/docs/guides/turborepo
export { prisma } from './client.js' // exports instance of prisma
export * from "../generated/client/index.js" // exports generated types from prisma

View File

@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true,
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,35 +0,0 @@
# WebSocket helper for Node.js
[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=node-ws)](https://codecov.io/github/honojs/middleware)
A WebSocket helper for Node.js
## Usage
```ts
import { createNodeWebSocket } from '@hono/node-ws'
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
app.get(
'/ws',
upgradeWebSocket((c) => ({
// https://hono.dev/helpers/websocket
}))
)
const server = serve(app)
injectWebSocket(server)
```
## Author
Shotaro Nakamura <https://github.com/nakasyou>
## License
MIT

View File

@@ -1,38 +0,0 @@
{
"name": "@hctv/hono-ws",
"version": "1.1.0",
"description": "Hono WebSocket helper for Node.js (in-house fork)",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup ./src/index.ts --format esm,cjs --dts"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.js"
}
},
"type": "module",
"license": "MIT",
"homepage": "https://github.com/honojs/middleware",
"devDependencies": {
"@types/ws": "^8",
"tsup": "^8.0.1"
},
"dependencies": {
"ws": "^8.17.0",
"@hctv/db": "*"
},
"peerDependencies": {
"@hono/node-server": "^1.11.1",
"hono": "^4.6.0"
},
"engines": {
"node": ">=18.14.1"
}
}

View File

@@ -1,263 +0,0 @@
import type { Hono } from 'hono';
import type { WebSocket } from 'ws';
import { WebSocketServer } from 'ws';
import type { IncomingMessage } from 'http';
import type { Server } from 'node:http';
import type { Http2SecureServer, Http2Server } from 'node:http2';
import type { Duplex } from 'node:stream';
import type { Buffer } from 'node:buffer';
import type { Channel, User } from '@hctv/db';
/**
* @link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
*/
export const CloseEvent =
globalThis.CloseEvent ??
class extends Event {
#eventInitDict;
constructor(type: string, eventInitDict: CloseEventInit = {}) {
super(type, eventInitDict);
this.#eventInitDict = eventInitDict;
}
get wasClean(): boolean {
return this.#eventInitDict.wasClean ?? false;
}
get code(): number {
return this.#eventInitDict.code ?? 0;
}
get reason(): string {
return this.#eventInitDict.reason ?? '';
}
};
/**
* Create WebSockets for Node.js
* @param init Options
* @returns NodeWebSocket
*/
export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => {
const wss = new WebSocketServer({ noServer: true });
const waiterMap = new Map<
IncomingMessage,
{ resolve: (ws: ModifiedWebSocket) => void; response: Response }
>();
wss.on('connection', (ws, request) => {
const waiter = waiterMap.get(request);
if (waiter) {
waiter.resolve(ws);
waiterMap.delete(request);
}
});
const nodeUpgradeWebSocket = (request: IncomingMessage, response: Response) => {
return new Promise<ModifiedWebSocket>((resolve) => {
waiterMap.set(request, { resolve, response });
});
};
return {
injectWebSocket(server) {
server.on('upgrade', async (request, socket: Duplex, head) => {
const url = new URL(request.url ?? '/', init.baseUrl ?? 'http://localhost');
const headers = new Headers();
for (const key in request.headers) {
const value = request.headers[key];
if (!value) {
continue;
}
headers.append(key, Array.isArray(value) ? value[0] : value);
}
const response = await init.app.request(
url,
{ headers: headers },
{ incoming: request, outgoing: undefined }
);
const waiter = waiterMap.get(request);
if (!waiter || waiter.response !== response) {
socket.end(
'HTTP/1.1 400 Bad Request\r\n' +
'Connection: close\r\n' +
'Content-Length: 0\r\n' +
'\r\n'
);
waiterMap.delete(request);
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
},
upgradeWebSocket: (createEvents) =>
async function upgradeWebSocket(c, next) {
if (c.req.header('upgrade')?.toLowerCase() !== 'websocket') {
// Not websocket
await next();
return;
}
const response = new Response();
(async () => {
const ws = await nodeUpgradeWebSocket(c.env.incoming, response);
const events = await createEvents(c);
const ctx: ModifiedWSContext = {
binaryType: 'arraybuffer',
close(code, reason) {
ws.close(code, reason);
},
protocol: ws.protocol,
raw: ws,
get readyState() {
return ws.readyState;
},
send(source, opts) {
ws.send(source, {
compress: opts?.compress,
});
},
url: new URL(c.req.url),
wss,
};
events.onOpen?.(new Event('open'), ctx);
ws.on('message', (data, isBinary) => {
const datas = Array.isArray(data) ? data : [data];
for (const data of datas) {
events.onMessage?.(
new MessageEvent('message', {
data: isBinary ? data : data.toString('utf-8'),
}),
ctx
);
}
});
ws.on('close', () => {
events.onClose?.(new CloseEvent('close'), ctx);
});
ws.on('error', (error) => {
events.onError?.(
new ErrorEvent('error', {
error: error,
}),
ctx
);
});
})();
return response;
},
};
};
// hono types file
import type { Context, MiddlewareHandler } from 'hono';
/**
* WebSocket Event Listeners type
*/
export interface WSEvents {
onOpen?: (evt: Event, ws: ModifiedWSContext) => void;
onMessage?: (evt: MessageEvent<WSMessageReceive>, ws: ModifiedWSContext) => void;
onClose?: (evt: CloseEvent, ws: ModifiedWSContext) => void;
onError?: (evt: Event, ws: ModifiedWSContext) => void;
}
/**
* Upgrade WebSocket Type
*/
export type UpgradeWebSocket<U = any, _WSEvents = WSEvents> = (
createEvents: (c: Context) => _WSEvents | Promise<_WSEvents>,
options?: U
) => MiddlewareHandler<
any,
string,
{
outputFormat: 'ws';
}
>;
/**
* ReadyState for WebSocket
*/
export type WSReadyState = 0 | 1 | 2 | 3;
/**
* An argument for WSContext class
*/
export interface WSContextInit<T = unknown> {
send(data: string | ArrayBuffer | Uint8Array, options: SendOptions): void;
close(code?: number, reason?: string): void;
raw?: T;
readyState: WSReadyState;
url?: string | URL | null;
protocol?: string | null;
}
/**
* Options for sending message
*/
export interface SendOptions {
compress?: boolean;
}
/**
* A context for controlling WebSockets
*/
export declare class WSContext<T = unknown> {
constructor(init: WSContextInit<T>);
send(source: string | ArrayBuffer | Uint8Array, options?: SendOptions): void;
raw?: T;
binaryType: BinaryType;
get readyState(): WSReadyState;
url: URL | null;
protocol: string | null;
close(code?: number, reason?: string): void;
}
export type WSMessageReceive = string | Blob | ArrayBufferLike | Buffer;
export declare const createWSMessageEvent: (
source: WSMessageReceive
) => MessageEvent<WSMessageReceive>;
export interface WebSocketHelperDefineContext {}
export type WebSocketHelperDefineHandler<U> = (
c: Context,
events: WSEvents,
options?: U
) => Promise<Response | void> | Response | void;
/**
* Create a WebSocket adapter/helper
*/
export declare const defineWebSocketHelper: <T = unknown, U = any>(
handler: WebSocketHelperDefineHandler<U>
) => UpgradeWebSocket<T, U>;
export interface NodeWebSocket {
upgradeWebSocket: UpgradeWebSocket<ModifiedWebSocket>;
injectWebSocket(server: Server | Http2Server | Http2SecureServer): void;
}
export interface NodeWebSocketInit {
app: Hono<any, any, any>;
baseUrl?: string | URL;
}
// Define the extended WebSocket context
interface ModifiedWSContext extends WSContext<ModifiedWebSocket> {
wss: WebSocketServer;
targetUsername?: string;
user?: any;
personalChannel?: any;
}
export interface ModifiedWebSocket extends WebSocket {
targetUsername?: string;
user?: User;
personalChannel?: Channel;
}
interface CloseEventInit extends EventInit {
code?: number;
reason?: string;
wasClean?: boolean;
}

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"target": "ES2022",
"module": "NodeNext",
"declaration": true,
"moduleResolution": "nodenext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -6,8 +6,6 @@
generator client {
provider = "prisma-client-js"
output = "../generated/client"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
@@ -88,8 +86,6 @@ model Follow {
channel Channel @relation("ChannelFollowers", fields: [channelId], references: [id], onDelete: Cascade)
channelId String
notifyStream Boolean @default(true)
@@unique([userId, channelId])
@@index([userId])

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 629 B

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -1,5 +1,5 @@
import LiveStream from "@/components/app/Livestream/Livestream";
import { prisma } from '@hctv/db';
import prisma from "@/lib/db";
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params;

View File

@@ -1,4 +1,4 @@
import { prisma } from '@hctv/db';
import prisma from '@/lib/db';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { validateRequest } from "@/lib/auth";
import prisma from "@/lib/db";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {

View File

@@ -0,0 +1,59 @@
import { lucia } from '@/lib/auth';
import { resolveUserPersonalChannel } from '@/lib/db/resolve';
export async function SOCKET(
client: import('ws').WebSocket,
request: import('http').IncomingMessage,
server: import('ws').WebSocketServer
) {
const cookies = parseCookieString(request.headers.cookie!);
const { user } = await lucia.validateSession(cookies.auth_session);
if (!user) {
client.close();
return;
}
const personalChannel = await resolveUserPersonalChannel(user.id);
if (!personalChannel) {
client.close();
return;
}
client.on('message', (message) => {
const msg = message.toString();
server.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(
JSON.stringify({
user: {
id: user.id,
username: personalChannel.name,
pfpUrl: user.pfpUrl,
},
message: msg,
})
);
/* if (msg === 'BOMB') {
for (let i = 0; i < 10000; i++) {
client.send(JSON.stringify({
user: {
id: user.id,
username: personalChannel.name,
pfpUrl: user.pfpUrl,
},
message: 'HIIIII',
}));
}
} */
}
});
});
}
function parseCookieString(cookie: string) {
return cookie.split(';').reduce((acc, cookie) => {
const [key, value] = cookie.split('=');
acc[key.trim()] = value;
return acc;
}, {} as Record<string, string>);
}

View File

@@ -1,6 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { getNotificationQueue } from '@/lib/workers';
import { prisma } from '@hctv/db';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
@@ -13,17 +12,6 @@ export async function GET(request: NextRequest) {
if (!username) {
return new Response('Bad Request', { status: 400 });
}
const channelOwner = await prisma.channel.findFirst({
where: {
name: username,
}
})
if (!channelOwner) {
return new Response('Not Found', { status: 404 });
}
if (channelOwner.ownerId === user.id) {
return new Response('you are of course not able to follow yourself', { status: 418 });
}
const isFollowing =
(await prisma.follow.count({
@@ -42,7 +30,6 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
const { user } = await validateRequest();
const queue = getNotificationQueue();
const searchParams = new URL(request.url).searchParams;
const username = searchParams.get('username');
if (!user) {
@@ -51,17 +38,6 @@ export async function POST(request: NextRequest) {
if (!username) {
return new Response('Bad Request', { status: 400 });
}
const channelOwner = await prisma.channel.findFirst({
where: {
name: username,
}
})
if (!channelOwner) {
return new Response('Not Found', { status: 404 });
}
if (channelOwner.ownerId === user.id) {
return new Response('you are of course not able to follow yourself', { status: 418 });
}
const isFollowing =
(await prisma.follow.count({
@@ -101,11 +77,6 @@ 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\`._`,
channel: user.slack_id,
});
}
return new Response(JSON.stringify({ following: !isFollowing }), { status: 200 });

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@hctv/db';
import db from '@/lib/db';
import { resolveChannelNameId } from '@/lib/db/resolve';
export async function GET(
@@ -15,7 +15,7 @@ export async function GET(
const channelId = await resolveChannelNameId(channel);
const count = await prisma.follow.count({
const count = await db.follow.count({
where: {
channelId,
},

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import type { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {

View File

@@ -1,4 +1,4 @@
import { validateRequest } from '@/lib/auth/validate';
import { validateRequest } from '@/lib/auth';
import { redirect, RedirectType } from 'next/navigation';
export default async function Layout({ children }: { children: React.ReactNode }) {

View File

@@ -1,8 +1,8 @@
import { slack, lucia } from '@hctv/auth';
import { slack, lucia } from '@/lib/auth';
import { cookies as nextCookies } from 'next/headers';
import { decodeIdToken, OAuth2RequestError } from 'arctic';
import { generateIdFromEntropySize } from 'lucia';
import { prisma } from '@hctv/db';
import prisma from '@/lib/db';
export async function GET(request: Request): Promise<Response> {
const cookies = await nextCookies();
@@ -11,7 +11,6 @@ export async function GET(request: Request): Promise<Response> {
const state = url.searchParams.get("state");
const storedState = cookies.get("slack_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
console.log('invalid state stuff');
return new Response(null, {
status: 400
});

View File

@@ -1,5 +1,5 @@
import { generateState } from "arctic";
import { slack } from '@hctv/auth';
import { slack } from "@/lib/auth";
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {

View File

@@ -19,7 +19,7 @@ export default function OnboardingClient() {
</CardDescription>
</CardHeader>
<CardContent>
<p>join #hctv! you will get welcomed to the channel after submitting the form!</p>
<h1 className='text-red-500 animate-pulse animate-bounce'>REFRESH THE SITE AFTER SUBMITTING THE FORM!!</h1>
<UniversalForm
fields={[
{ name: 'userId', label: 'User ID', type: 'hidden', value: user?.id },
@@ -28,7 +28,8 @@ export default function OnboardingClient() {
schemaName="onboard"
action={onboard}
onActionComplete={() => {
window.location.href = '/';
router.refresh();
redirect('/');
}}
/>
</CardContent>

View File

@@ -1,4 +1,4 @@
import { validateRequest } from '@/lib/auth/validate';
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
import OnboardingClient from "./page.client";

View File

@@ -1,8 +1,7 @@
import LandingPage from '@/components/app/LandingPage/LandingPage';
import { Card, CardContent } from '@/components/ui/card';
import ConfusedDino from '@/components/ui/confuseddino';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
import Image from 'next/image';
import Link from 'next/link';
@@ -25,17 +24,11 @@ export default async function Home() {
return <LandingPage />;
}
if (!streams.length) {
return (
<div className="flex justify-center items-center text-center flex-col pt-4 gap-2">
<h2>No streams found!!</h2>
<p>...maybe start one?</p>
<ConfusedDino className='w-40 h-40' />
</div>
);
return <div>No streams found</div>;
}
return (
<div className="p-4">
<div className='p-4'>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{streams.map((stream) => (
<Link href={`/${stream.username}`} key={stream.id}>

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -3,7 +3,7 @@ import { Inter } from 'next/font/google';
import './globals.css';
import Navbar from '@/components/app/NavBar/NavBar';
import { SessionProvider } from '@/lib/providers/SessionProvider';
import { validateRequest } from '@/lib/auth/validate';
import { validateRequest } from '@/lib/auth';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
import { SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -0,0 +1,71 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useChat } from '@livekit/components-react';
export default function ChatPanel() {
const [message, setMessage] = useState('');
const chat = useChat();
const scrollRef = useRef<HTMLDivElement>(null);
// auto scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [chat.chatMessages]);
return (
<div className="border-l flex flex-col w-[350px] min-w-[350px] h-full">
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{chat.chatMessages.map((msg, i) => {
const splitName = msg.from?.name?.split('-');
const name = splitName?.slice(0, -1).join('-');
return (
// jank asf, but works (thanks claude)
<div key={i} className="flex space-x-2">
<div className="font-bold shrink-0">{name}</div>
<div
lang="en"
className="max-w-[calc(100%-4rem)] break-all whitespace-pre-wrap hyphens-auto"
>
{msg.message}
</div>
</div>
);
})}
</div>
</div>
<div className="p-4 border-t">
<div className="flex space-x-2">
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
chat.send(message);
setMessage('');
}
}}
placeholder="Type a message"
className="flex-1 bg-transparent focus-visible:ring-offset-0"
/>
<Button
size="icon"
className="text-black transition-colors"
onClick={() => {
chat.send(message);
setMessage('');
}}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -4,85 +4,76 @@ import { useState, useRef, useEffect } from 'react';
import { Send } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useParams } from 'next/navigation';
interface User {
id: string;
username: string;
pfpUrl: string;
}
interface ChatMessage {
user: User;
message: string;
}
export default function ChatPanel() {
const { username } = useParams();
const [message, setMessage] = useState('');
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);
const socketRef = useRef<WebSocket | null>(null);
useEffect(() => {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
);
const socket = new WebSocket('ws://localhost:3000/api/stream/chat');
socketRef.current = socket;
socket.onopen = () => {
console.log('WebSocket connected');
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
setChatMessages((prev) => [...prev, data]);
setChatMessages(prev => [...prev, data]);
} catch (e) {
console.log('Received message confirmation:', event.data);
}
};
socket.onclose = () => {
console.log('WebSocket closed');
};
return () => {
socket.close();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
if (chatMessages.length > 100) {
setChatMessages((prev) => prev.slice(chatMessages.length - 100));
setChatMessages(prev => prev.slice(chatMessages.length - 100));
}
}, [chatMessages]);
const sendMessage = () => {
if (!message.trim()) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'message', message }));
socketRef.current.send(message);
setMessage('');
} else {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/ws/${username}`
);
const socket = new WebSocket('ws://localhost:3000/api/stream/chat');
socket.onopen = () => {
socket.send(JSON.stringify({ type: 'message', message }));
socket.send(message);
setMessage('');
};
}
};
useEffect(() => {
const interval = setInterval(() => {
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<div className="md:border flex flex-col w-full min-w-[350px] h-full bg-mantle">
<div className="border-l flex flex-col w-[350px] min-w-[350px] h-full">
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">
<div className="space-y-4 flex-1">
{chatMessages.map((msg, i) => (
@@ -113,22 +104,15 @@ export default function ChatPanel() {
placeholder="Type a message"
className="flex-1 bg-transparent focus-visible:ring-offset-0"
/>
<Button size="icon" className="text-black transition-colors" onClick={sendMessage}>
<Button
size="icon"
className="text-black transition-colors"
onClick={sendMessage}
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
interface User {
id: string;
username: string;
pfpUrl: string;
}
interface ChatMessage {
user: User;
message: string;
}
}

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import EditLivestreamDialog from './dialog';
export default async function EditLivestream() {

View File

@@ -8,7 +8,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { StreamInfo } from '@hctv/db';
import { StreamInfo } from '@prisma/client';
import { UniversalForm } from '../UniversalForm/UniversalForm';
import { editStreamInfo } from '@/lib/form/actions';
import RegenerateKey from '../RegenerateKey/RegenerateKey';
@@ -31,7 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Channel } from '@hctv/db';
import type { Channel } from '@prisma/client';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import useSWR, { Fetcher } from 'swr';
import { fetcher } from '@/lib/services/swr';

View File

@@ -0,0 +1,23 @@
'use client';
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User } from '@prisma/client';
export default function LiveStream(props: Props) {
return (
<div className="flex h-[calc(100vh-64px)] w-full">
<div className="flex-1">
<StreamPlayer />
<UserInfoCard streamInfo={props.streamInfo} />
</div>
<ChatPanel />
</div>
);
}
interface Props {
username: string;
streamInfo: StreamInfo & { ownedBy: User };
}

View File

@@ -0,0 +1,96 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { logout } from '@/lib/auth/actions';
import { useSession } from '@/lib/providers/SessionProvider';
import Link from 'next/link';
import MobileNavbarLinks from '../MobileNavbarLinks/MobileNavbarLinks';
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 (
<>
<nav className="flex items-center h-16 px-4 border-b gap-3 w-full z-20 fixed top-0 left-0 shadow-md bg-mantle">
<div className="flex items-center space-x-5">
<Link href="/" className="flex items-center">
<Button variant={'ghost'}>hackclub.tv</Button>
</Link>
<SidebarTrigger />
</div>
<MobileNavbarLinks />
<div className="flex-1" />
<div className="hidden md:flex">
<NavbarLinks />
</div>
<div className="flex-1" />
{props.editLivestream}
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar>
<AvatarImage src={user.pfpUrl} alt={`@${user.id}`} />
<AvatarFallback>{user.pfpUrl}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
logout();
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<ThemeSwitcher />
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/auth/slack">
<Button variant="outline" className="gap-2">
<Slack className="w-4 h-4" />
Sign in
</Button>
</Link>
)}
</nav>
</>
);
}
interface Props {
editLivestream: Promise<JSX.Element>;
}

View File

@@ -22,6 +22,11 @@ export default function Sidebar({ ...props }: React.ComponentProps<typeof UISide
const { stream, isLoading } = useStreams();
const [followedExpanded, setFollowedExpanded] = React.useState(true);
// console log stream every time it changes
React.useEffect(() => {
console.log('stream info', stream);
}, [stream]);
if (isLoading) return <SidebarSkeleton />;
const liveStreamers = stream?.filter((s) => s.isLive) || [];
@@ -92,7 +97,7 @@ function StreamerItem({ streamer }: { streamer: StreamInfoResponse[0] }) {
<div className="flex-1">
<p className="font-medium truncate">{streamer.username}</p>
<p className="text-sm truncate">{streamer.category}</p>
{streamer.isLive && (
{false && streamer.isLive && (
<p className="text-sm">
{streamer.viewers} viewer{streamer.viewers === 1 ? '' : 's'}
</p>

View File

@@ -20,7 +20,7 @@ export default function StreamPlayer() {
return (
<MediaController className='w-full aspect-video'>
<HlsVideo
src={`/api/rtmp/hls/${username}.m3u8`}
src={`${process.env.LIVE_SERVER_URL}/hls/${username}.m3u8`}
slot="media"
crossOrigin="anonymous"
autoplay

Some files were not shown because too many files have changed in this diff Show More