Compare commits

...

47 Commits

Author SHA1 Message Date
71890520cb chore: console log not right 2025-04-04 22:44:46 +02:00
a5768cfe0a chore: i tripped 2025-04-04 22:37:28 +02:00
17228d20be feat: settings to toggle notifications 2025-04-04 22:35:01 +02:00
502e60d85d feat: send message to channel 2025-04-02 17:03:35 +02:00
1b384de455 feat: follower dm notification thingy 2025-03-31 22:20:52 +02:00
d39d6f8ed7 refactor: some code refactoring 2025-03-31 21:54:14 +02:00
18d69a6aab feat: bullmq poc 2025-03-31 21:45:35 +02:00
648d3296e2 feat: user notifications working, moving to bullmq 2025-03-31 20:40:15 +02:00
836b5b6951 feat: initial workers implementation 2025-03-31 18:16:21 +02:00
e83d0cf713 feat: db self following migration 2025-03-31 16:16:15 +02:00
6974a78201 fix: prevent people from following themselves 2025-03-31 15:31:35 +02:00
185fc910a0 fix: chat connection issues by pinging server 2025-03-28 23:51:27 +01:00
6406df0501 ci: fix builds for chat??? 2025-03-26 17:00:07 +01:00
30d3ad2f2d ci: fix builds finally 2025-03-25 21:13:50 +01:00
dd16124cb5 fix: db builds 2025-03-25 16:59:12 +01:00
6a63fe388e fix: ci builds hopefully 2025-03-25 16:54:23 +01:00
441aa64166 feat: docker stuff 2025-03-23 22:01:13 +01:00
41c50a8ee5 feat: websocket implementation 2025-03-23 21:40:49 +01:00
ede61678aa fix: some prisma shenanigans 2025-03-23 00:43:54 +01:00
f2c0abbcdc feat: initial chat and abstract auth away from next 2025-03-23 00:14:14 +01:00
fbdb43e6df ci: oops copilot sometimes does wrong stuff 2025-03-22 22:31:47 +01:00
6b82975350 feat: bring back schema migrations 2025-03-22 22:29:36 +01:00
72d49df94c ci: caching should work? 2025-03-22 17:48:20 +01:00
9d88f32f6a ci: caching should work? 2025-03-22 17:47:33 +01:00
59b07928c6 chore: Merge pull request #38 from SrIzan10/feat/monorepo
feat: monorepo
2025-03-22 15:41:56 +01:00
9d6bcf25b9 ci: change docker file path 2025-03-22 15:41:11 +01:00
981b0b8b77 feat: move back to yarn and finally fix builds 2025-03-22 15:34:10 +01:00
e57312e48f ci: docker changes 1 2025-03-21 21:18:22 +01:00
e736375d17 fix: some pages not working properly with new db 2025-03-21 21:15:02 +01:00
fe3a146bf1 feat: move to pnpm and implement db repo 2025-03-21 21:08:14 +01:00
f216ddc57d chore: initial prisma migration work 2025-03-21 19:18:26 +01:00
bd29c4848d feat: web now working i guess 2025-03-21 19:07:47 +01:00
089b1fd157 Update package.json 2025-03-21 18:56:14 +01:00
679a741155 ci: update docker 2025-03-21 18:52:09 +01:00
021962b78b chore: move to monorepo folder 2025-03-21 18:48:52 +01:00
Turbobot
9c2b166b8a feat(create-turbo): apply package-manager transform 2025-03-21 18:36:01 +01:00
Turbobot
b935be00f0 feat(create-turbo): apply official-starter transform 2025-03-21 18:36:01 +01:00
b7484bbfa1 ci: use bun instead of node for faster builds 2025-03-21 18:13:15 +01:00
ab6652f2c7 feat: no need to refresh website after onboarding now 2025-03-19 15:24:35 +01:00
5e1609abc2 fix: viewercount to show actual count 2025-03-18 19:24:59 +01:00
9538d23ed1 docs: initial readme 2025-03-17 16:51:44 +01:00
ed81b494f7 feat: secure hls streams 2025-03-17 16:41:54 +01:00
7af4137ff9 fix: workflow ping. test before pushing to prod!! 2025-03-16 19:15:03 +01:00
c702db9121 feat: welcome people to hctv through a workflow 2025-03-16 16:47:08 +01:00
95e821727b fix: viewers stuff 2025-03-16 16:24:33 +01:00
a9924d19e4 feat: show viewers 2025-03-16 16:05:16 +01:00
acd5f5b5f4 revert: "chore: Migrate workflows to Blacksmith (#34)" (#35)
This reverts commit 10db7d5833.
2025-03-16 14:43:14 +01:00
142 changed files with 3451 additions and 1085 deletions

View File

@@ -1,35 +1,41 @@
# 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
# Version control
.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.*
# Ignore editor directories and files
.vscode
.idea
*.sublime-project
*.sublime-workspace
# Ignore other unnecessary files
README.md
packages/db/generated
dev/
flv-module/

View File

@@ -6,9 +6,9 @@ on:
- main
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: blacksmith-2vcpu-ubuntu-2204
frontend:
name: Push frontend to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v3
@@ -30,19 +30,92 @@ jobs:
tags: latest
- name: Build and push Docker image
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
file: ./apps/web/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
build-args: |
BUILDKIT_INLINE_CACHE=1
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 }}

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,4 +37,9 @@ yarn-error.log*
next-env.d.ts
certificates
dev/psql
dev/psql
dev/redis
.turbo
packages/db/generated/client
*dist

View File

@@ -1,45 +0,0 @@
# 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 && yarn run prepare
# Remove unnecessary files
RUN rm -rf /app/.git \
/app/.next/cache \
/app/README.md
EXPOSE 3000
CMD ["yarn", "start"]

View File

@@ -1,24 +1,13 @@
# Sr Izan Stack
# hackclub.tv
Sr Izan Stack is a next.js template which runs on modern technologies, with a focus on developer experience and ease-of-use.
This is the source code for [hackclub.tv (hctv.srizan.dev)](https://hctv.srizan.dev), a livestreaming website for hackclubbers.
## The stack
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.
- 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/)
Join [#hctv](https://hackclub.slack.com/archives/C08HGLXGXAB) on the HC Slack for discussion and updates!
## Why (insert tool here)?
## Features
- **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.
- High quality video streaming (low latency coming soon)
- Chat with other viewers
- Multiaccount support (database schema laid out, UI not implemented)

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

@@ -0,0 +1,28 @@
# 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

44
apps/chat/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
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"]

8
apps/chat/README.md Normal file
View File

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

23
apps/chat/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}
}

15
apps/chat/src/3d.txt Normal file
View File

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

129
apps/chat/src/index.ts Normal file
View File

@@ -0,0 +1,129 @@
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

@@ -0,0 +1,17 @@
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;
}

16
apps/chat/tsconfig.json Normal file
View File

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

44
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
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

BIN
apps/web/bun.lockb Executable file

Binary file not shown.

38
apps/web/next.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
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;

76
apps/web/package.json Normal file
View File

@@ -0,0 +1,76 @@
{
"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

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 "@/lib/db";
import { prisma } from '@hctv/db';
export default async function Page({ params }: { params: Promise<{ username: string }> }) {
const { username } = await params;

View File

@@ -0,0 +1,31 @@
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,4 +1,4 @@
import prisma from '@/lib/db';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {

View File

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

View File

@@ -1,5 +1,6 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { getNotificationQueue } from '@/lib/workers';
import { prisma } from '@hctv/db';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
@@ -12,6 +13,17 @@ 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({
@@ -30,6 +42,7 @@ 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) {
@@ -38,6 +51,17 @@ 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({
@@ -77,6 +101,11 @@ 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 db from '@/lib/db';
import { prisma } from '@hctv/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 db.follow.count({
const count = await prisma.follow.count({
where: {
channelId,
},

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
'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

@@ -0,0 +1,72 @@
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,8 +1,8 @@
import { slack, lucia } from '@/lib/auth';
import { slack, lucia } from '@hctv/auth';
import { cookies as nextCookies } from 'next/headers';
import { decodeIdToken, OAuth2RequestError } from 'arctic';
import { generateIdFromEntropySize } from 'lucia';
import prisma from '@/lib/db';
import { prisma } from '@hctv/db';
export async function GET(request: Request): Promise<Response> {
const cookies = await nextCookies();
@@ -11,6 +11,7 @@ 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 "@/lib/auth";
import { slack } from '@hctv/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>
<h1 className='text-red-500 animate-pulse animate-bounce'>REFRESH THE SITE AFTER SUBMITTING THE FORM!!</h1>
<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 },
@@ -28,8 +28,7 @@ export default function OnboardingClient() {
schemaName="onboard"
action={onboard}
onActionComplete={() => {
router.refresh();
redirect('/');
window.location.href = '/';
}}
/>
</CardContent>

View File

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

View File

@@ -1,8 +1,8 @@
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';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar';
import Image from 'next/image';
import Link from 'next/link';

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';
import { validateRequest } from '@/lib/auth/validate';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from '@/lib/providers/ThemeProvider';
import { SidebarProvider } from '@/components/ui/sidebar';

View File

@@ -17,7 +17,7 @@ export default function ChatPanel() {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/${username}`
}/api/stream/chat/ws/${username}`
);
socketRef.current = socket;
@@ -28,6 +28,7 @@ export default function ChatPanel() {
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'ping' || data.type === 'pong' || !data.user) return;
setChatMessages((prev) => [...prev, data]);
} catch (e) {
console.log('Received message confirmation:', event.data);
@@ -56,21 +57,30 @@ export default function ChatPanel() {
if (!message.trim()) return;
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
socketRef.current.send(message);
socketRef.current.send(JSON.stringify({ type: 'message', message }));
setMessage('');
} else {
const socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${
window.location.host
}/api/stream/chat/${username}`
}/api/stream/chat/ws/${username}`
);
socket.onopen = () => {
socket.send(message);
socket.send(JSON.stringify({ type: 'message', 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 ref={scrollRef} className="flex-1 p-4 overflow-y-auto flex flex-col">

View File

@@ -1,5 +1,5 @@
import { validateRequest } from '@/lib/auth';
import prisma from '@/lib/db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/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 '@prisma/client';
import { StreamInfo } from '@hctv/db';
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 '@prisma/client';
import type { Channel } from '@hctv/db';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import useSWR, { Fetcher } from 'swr';
import { fetcher } from '@/lib/services/swr';

View File

@@ -3,7 +3,7 @@
import StreamPlayer from '../StreamPlayer/StreamPlayer';
import UserInfoCard from '../UserInfoCard/UserInfoCard';
import ChatPanel from '../ChatPanel/ChatPanel';
import type { StreamInfo, User } from '@prisma/client';
import type { StreamInfo, User } from '@hctv/db';
import { useIsMobile } from '@/lib/hooks/useMobile';
export default function LiveStream(props: Props) {

View File

@@ -14,7 +14,6 @@ import {
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';
@@ -66,6 +65,12 @@ export default function Navbar(props: Props) {
<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"

View File

@@ -22,11 +22,6 @@ 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) || [];
@@ -97,7 +92,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>
{false && streamer.isLive && (
{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={`${process.env.LIVE_SERVER_URL}/hls/${username}.m3u8`}
src={`/api/rtmp/hls/${username}.m3u8`}
slot="media"
crossOrigin="anonymous"
autoplay

View File

@@ -1,7 +1,8 @@
import { Avatar, AvatarImage } from '@/components/ui/avatar';
import type { StreamInfo, User } from '@prisma/client';
import type { StreamInfo, User } from '@hctv/db';
import FollowButton from './follow';
import FollowCountText from './followCount';
import ViewerCount from './viewerCount';
export default function UserInfoCard(props: Props) {
return (
@@ -17,23 +18,12 @@ export default function UserInfoCard(props: Props) {
<FollowCountText channel={props.streamInfo.username} />
</div>
</div>
<FollowButton channel={props.streamInfo.username} />
<div className="flex items-center space-x-4">
<ViewerCount />
<FollowButton channel={props.streamInfo.username} />
</div>
</div>
<p className="mb-4">markdown description here</p>
{/* <div className="flex items-center space-x-4 text-gray-400">
<div className="flex items-center">
<Users className="h-5 w-5 mr-2" />
<span>1.2K viewers</span>
</div>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white">
<Heart className="h-5 w-5 mr-2" />
Like
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-white">
<Share2 className="h-5 w-5 mr-2" />
Share
</Button>
</div> */}
</div>
);
}

View File

@@ -10,25 +10,26 @@ import React from 'react';
export default function FollowButton(props: Props) {
const [ref, isHovering] = useHover();
// const [following, setFollowing] = React.useState(props.isFollowing);
// make a get request to check if the user is following the channel and set it as the initial state. use swr to make the request
const [bye, setBye] = React.useState(false);
const { data: followingData, isLoading: isLoadingFollowing } = useSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url)
);
const [following, setFollowing] = React.useState(false);
const { trigger, data, isMutating } = mutatedUseSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url, { method: 'POST' })
);
React.useEffect(() => {
if (followingData) {
setFollowing(followingData.following);
}
if (followingData === undefined) {
setBye(true);
}
}, [followingData]);
const { trigger, data, isMutating } = mutatedUseSWR(
`/api/stream/follow?username=${props.channel}`,
async (url) => fetcher(url, { method: 'POST' })
);
React.useEffect(() => {
if (data) {
setFollowing(data.following);
@@ -44,6 +45,7 @@ export default function FollowButton(props: Props) {
disabled={isMutating || isLoadingFollowing}
ref={ref}
variant='outlineMantle'
className={bye ? 'hidden' : ''}
>
{isHovering && following ? <HeartCrack className={followingCn} /> : <Heart className={following ? followingCn : notFollowingCn} />}
</Button>

View File

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

@@ -0,0 +1,29 @@
"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

@@ -0,0 +1,117 @@
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

@@ -0,0 +1,12 @@
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,7 +1,8 @@
'use server';
import { cookies } from 'next/headers';
import { lucia, validateRequest } from '.';
import { lucia } from '@hctv/auth';
import { validateRequest } from '@/lib/auth/validate';
import { redirect } from 'next/navigation';
export async function logout() {

View File

@@ -1,5 +1,5 @@
import { validateRequest } from ".";
import prisma from "../db";
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
export async function getPersonalChannel(id?: string) {
const { user } = await validateRequest();
@@ -15,4 +15,4 @@ export async function getPersonalChannel(id?: string) {
return null;
}
return db.personalChannel;
}
}

View File

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,31 @@
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,7 +1,7 @@
import db from '@/lib/db';
import { prisma } from '@hctv/db';
export async function resolveChannelNameId(channelName: string) {
const channel = await db.channel.findUnique({
const channel = await prisma.channel.findUnique({
where: {
name: channelName,
},
@@ -15,7 +15,7 @@ export async function resolveChannelNameId(channelName: string) {
}
export async function resolveUserPersonalChannel(userId: string) {
const channel = await db.channel.findFirst({
const channel = await prisma.channel.findFirst({
where: {
personalFor: {
id: userId,

View File

@@ -1,11 +1,12 @@
'use server';
import { revalidatePath } from 'next/cache';
import { validateRequest } from '../auth';
import prisma from '../db';
import { validateRequest } from '@/lib/auth/validate';
import { prisma } from '@hctv/db';
import zodVerify from '../zodVerify';
import { onboardSchema, streamInfoEditSchema } from './zod';
import { initializeStreamInfo } from '../instrumentation/streamInfo';
import { resolveFollowedChannels } from '../auth/resolve';
export async function editStreamInfo(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -89,5 +90,35 @@ export async function onboard(prev: any, formData: FormData) {
});
await initializeStreamInfo(createdChannel.id);
await fetch(process.env.WELCOME_WORKFLOW_URL!, {
method: 'POST',
body: JSON.stringify({
username: zod.data.username,
}),
})
return { success: true };
}
export async function notifyStreamToggle(channelName: string) {
const { user } = await validateRequest();
if (!user) {
return { success: false, error: 'Unauthorized' };
}
const followed = await resolveFollowedChannels();
if (!followed) {
return { success: false, error: 'No followed channels' };
}
const channel = followed.find((f) => f.channel.name === channelName);
if (!channel) {
return { success: false, error: 'Channel not found' };
}
await prisma.follow.update({
where: { id: channel.id },
data: { notifyStream: !channel.notifyStream },
});
return { success: true, toggle: !channel.notifyStream };
}

View File

@@ -1,5 +1,7 @@
import prisma from '@/lib/db';
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
@@ -51,43 +53,40 @@ export async function syncStream() {
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;
// Handle case where the RTMP server is not available or doesn't have the expected data structure
if (!httpFlv?.servers?.[0]?.applications) {
return;
}
const channelLiveApp = httpFlv.servers[0].applications.find(app => app.name === 'channel-live');
const channelLiveApp = httpFlv.servers[0].applications.find(
(app) => app.name === 'channel-live'
);
const activeStreams = channelLiveApp?.live?.streams || [];
// Get all streams that are currently marked as live in the database
const currentLiveStreams = await prisma.streamInfo.findMany({
where: { isLive: true },
});
// Create a map of active streams from the RTMP server
const activeStreamMap = new Map();
for (const stream of activeStreams) {
activeStreamMap.set(stream.name, {
isLive: stream.active,
viewers: stream.clients.filter(c => !c.publishing).length,
viewers: stream.clients.filter((c) => !c.publishing).length,
});
}
// Update all streams
for (const dbStream of currentLiveStreams) {
const streamStats = activeStreamMap.get(dbStream.username);
if (!streamStats || !streamStats.isLive) {
// Stream is no longer active, mark it as offline
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
@@ -96,38 +95,53 @@ export async function syncStream() {
startedAt: new Date(0),
},
});
} else {
// Stream is still active, update viewers
await prisma.streamInfo.update({
where: { username: dbStream.username },
data: {
viewers: streamStats.viewers,
},
});
}
}
// Process new streams that aren't in the database yet
for (const stream of activeStreams) {
if (stream.active) {
const existingStream = await prisma.streamInfo.findUnique({
where: { username: stream.name },
});
if (existingStream && !existingStream.isLive) {
// Stream just went live
await prisma.streamInfo.update({
where: { username: stream.name },
data: {
isLive: true,
startedAt: new Date(),
viewers: stream.clients.filter(c => !c.publishing).length,
},
});
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);
console.error('Error syncing stream status:', error);
}
}
}

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, ReactNode } from 'react'
import useSWR from 'swr'
import { Channel, StreamInfo } from '@prisma/client'
import type { Channel, StreamInfo } from '@hctv/db'
import { fetcher } from '../services/swr'
const StreamContext = createContext<{

View File

@@ -0,0 +1,30 @@
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

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

View File

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

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

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