diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 5cc6bdb..e83b8c9 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -17,7 +17,7 @@ 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 +RUN apk add --no-cache libc6-compat ffmpeg WORKDIR /app # First install the dependencies (as they change less often) diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index d3812f8..e3ad6d0 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -18,6 +18,7 @@ const nextConfig = { hostname: 'secure.gravatar.com', }, ], + minimumCacheTTL: 120, }, env: { LIVE_SERVER_URL, @@ -32,7 +33,7 @@ const nextConfig = { destination: `http://${process.env.NODE_ENV === 'production' ? 'chat' : 'localhost'}:8000/:path*`, }, ]; - } + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index cba7519..7c0fb37 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,8 +45,9 @@ "lucia": "^3.2.2", "lucide-react": "^0.473.0", "media-chrome": "^4.8.0", - "next": "^15.2.3", + "next": "^15.2.4", "next-themes": "^0.4.4", + "node-cron": "^3.0.3", "pg": "^8.14.1", "pg-boss": "^10.1.6", "react": "19", @@ -63,6 +64,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/node-cron": "^3.0.11", "@types/react": "^18", "@types/react-dom": "^18", "@types/ws": "^8.18.0", diff --git a/apps/web/src/app/(protected)/api/stream/thumb/[username]/route.ts b/apps/web/src/app/(protected)/api/stream/thumb/[username]/route.ts new file mode 100644 index 0000000..862714e --- /dev/null +++ b/apps/web/src/app/(protected)/api/stream/thumb/[username]/route.ts @@ -0,0 +1,30 @@ +import { validateRequest } from '@/lib/auth/validate'; +import fsP from 'fs/promises'; +import fs from 'fs'; + +export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) { + const { username } = await params; + const { user } = await validateRequest(); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + if (username.includes('..')) { + return new Response("nuh uh", { status: 403 }); + } + + const basePath = '/dev/shm/hctv-thumb'; + const filePath = `${basePath}/${username}.webp`; + + if (!fs.existsSync(filePath)) { + return new Response("Not Found", { status: 404 }); + } + + const fileContent = await fsP.readFile(filePath); + return new Response(fileContent, { + headers: { + 'Content-Type': 'image/webp', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET', + }, + }); +} \ No newline at end of file diff --git a/apps/web/src/app/(public)/page.tsx b/apps/web/src/app/(public)/page.tsx index 2f52e6b..f9d4798 100644 --- a/apps/web/src/app/(public)/page.tsx +++ b/apps/web/src/app/(public)/page.tsx @@ -43,7 +43,7 @@ export default async function Home() {
{stream.title} { + await getLiveThumb(); + }); + } else { + console.log('running local cron job scheduling') + setInterval(async () => { + await getLiveThumb(); + }, 5000); + } + console.log('cron stuff registered'); + } } diff --git a/apps/web/src/lib/instrumentation/getLiveThumb.ts b/apps/web/src/lib/instrumentation/getLiveThumb.ts new file mode 100644 index 0000000..8677f02 --- /dev/null +++ b/apps/web/src/lib/instrumentation/getLiveThumb.ts @@ -0,0 +1,21 @@ +import { prisma } from "@hctv/db"; +import { getThumbnailQueue } from "../workers"; + +export default async function getLiveThumb() { + const liveChannels = await prisma.streamInfo.findMany({ + where: { + isLive: true, + }, + include: { + channel: true, + } + }); + const liveChannelNames = liveChannels.map((channel) => channel.channel.name); + + const thumbQueue = getThumbnailQueue(); + for (const channel of liveChannelNames) { + await thumbQueue.add("getLiveThumb", { + name: channel, + }); + } +} \ No newline at end of file diff --git a/apps/web/src/lib/workers/index.ts b/apps/web/src/lib/workers/index.ts index 06ea381..64d2865 100644 --- a/apps/web/src/lib/workers/index.ts +++ b/apps/web/src/lib/workers/index.ts @@ -1,19 +1,19 @@ 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; + + thumbnailQueue: Queue | null; + thumbnailWorker: 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', { @@ -28,4 +28,20 @@ export function getNotificationQueue(): Queue { }); } return globalForNotifier.notificationQueue; +} + +export function getThumbnailQueue(): Queue { + if (!globalForNotifier.thumbnailQueue) { + globalForNotifier.thumbnailQueue = new Queue('thumbnails', { + connection: getRedisConnection(), + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + } + }); + } + return globalForNotifier.thumbnailQueue; } \ No newline at end of file diff --git a/apps/web/src/lib/workers/register.ts b/apps/web/src/lib/workers/register.ts index fc64759..7df4438 100644 --- a/apps/web/src/lib/workers/register.ts +++ b/apps/web/src/lib/workers/register.ts @@ -1,6 +1,8 @@ import { registerNotificationWorker } from './worker/notification'; +import { registerThumbnailWorker } from './worker/thumbnails'; export async function registerWorkers(): Promise { await registerNotificationWorker(); + await registerThumbnailWorker(); console.log('All workers registered successfully'); } \ No newline at end of file diff --git a/apps/web/src/lib/workers/worker/thumbnails.ts b/apps/web/src/lib/workers/worker/thumbnails.ts new file mode 100644 index 0000000..9ffeeb4 --- /dev/null +++ b/apps/web/src/lib/workers/worker/thumbnails.ts @@ -0,0 +1,74 @@ +import { Worker } from 'bullmq'; +import { getRedisConnection } from '@/lib/services/redis'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import { existsSync } from 'node:fs'; +const pExec = promisify(exec); + +const globalForWorker = global as unknown as { + thumbnailWorker: Worker | null; +}; + +if (!globalForWorker.thumbnailWorker) { + globalForWorker.thumbnailWorker = null; +} + +export async function registerThumbnailWorker(): Promise { + if (globalForWorker.thumbnailWorker) { + console.log('Notification worker already registered'); + return; + } + + console.log('Registering thumbnail worker...'); + const worker = new Worker( + 'thumbnails', + async (job) => { + try { + // this is totally unnecessary, but i'll keep it for security purposes. + const name = job.data.name.replace(/[^a-zA-Z0-9]/g, '_'); + const m3u8location = `/dev/shm/hls/${name}.m3u8`; + + if (!existsSync(m3u8location)) return; + if (!existsSync('/dev/shm/hctv-thumb')) { + await pExec('mkdir -p /dev/shm/hctv-thumb'); + } + // unnecessary for development, but maybe docker volumes mess with permissions in prod + await pExec('chown -R $USER /dev/shm/hctv-thumb'); + + exec( + `/usr/bin/ffmpeg -i ${m3u8location} -vframes 1 -an -y -f image2 /dev/shm/hctv-thumb/${name}.webp`, + (error) => { + if (error) { + console.error(`Error: ${error.message}`); + return { success: false, error: error.message }; + } + } + ); + + 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: 3, + limiter: { + max: 50, + duration: 30000, + }, + } + ); + + globalForWorker.thumbnailWorker = worker; +} + +// Close the worker +export async function closeThumbnailWorker(): Promise { + if (globalForWorker.thumbnailWorker) { + await globalForWorker.thumbnailWorker.close(); + globalForWorker.thumbnailWorker = null; + } +} diff --git a/yarn.lock b/yarn.lock index 925cd01..6e6f7fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -734,10 +734,10 @@ "@emnapi/runtime" "^1.3.1" "@tybys/wasm-util" "^0.9.0" -"@next/env@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/env/-/env-15.2.3.tgz#037ee37c4d61fcbdbb212694cc33d7dcf6c7975a" - integrity sha512-a26KnbW9DFEUsSxAxKBORR/uD9THoYoKbkpFywMN/AFvboTt94b8+g/07T8J6ACsdLag8/PDU60ov4rPxRAixw== +"@next/env@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/env/-/env-15.2.4.tgz#060f8d8ddb02be5c825eab4ccd9ab619001efffb" + integrity sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g== "@next/eslint-plugin-next@15.1.3": version "15.1.3" @@ -746,45 +746,45 @@ dependencies: fast-glob "3.3.1" -"@next/swc-darwin-arm64@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.3.tgz#2688c185651ef7a16e5642c85048cc4e151159fa" - integrity sha512-uaBhA8aLbXLqwjnsHSkxs353WrRgQgiFjduDpc7YXEU0B54IKx3vU+cxQlYwPCyC8uYEEX7THhtQQsfHnvv8dw== +"@next/swc-darwin-arm64@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz#3a54f67aa2e0096a9147bd24dff1492e151819ae" + integrity sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw== -"@next/swc-darwin-x64@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.3.tgz#3e802259b2c9a4e2ad55ff827f41f775b726fc7d" - integrity sha512-pVwKvJ4Zk7h+4hwhqOUuMx7Ib02u3gDX3HXPKIShBi9JlYllI0nU6TWLbPT94dt7FSi6mSBhfc2JrHViwqbOdw== +"@next/swc-darwin-x64@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz#9b540f24afde1b7878623fdba9695344d26b7d67" + integrity sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew== -"@next/swc-linux-arm64-gnu@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.3.tgz#315d7b54b89153f125bdc3e40bcb7ccf94ef124b" - integrity sha512-50ibWdn2RuFFkOEUmo9NCcQbbV9ViQOrUfG48zHBCONciHjaUKtHcYFiCwBVuzD08fzvzkWuuZkd4AqbvKO7UQ== +"@next/swc-linux-arm64-gnu@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz#417a234c9f4dc5495094a8979859ac528c0f1f58" + integrity sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ== -"@next/swc-linux-arm64-musl@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.3.tgz#a1a458eb7cf19c59d2014ee388a7305e9a77973f" - integrity sha512-2gAPA7P652D3HzR4cLyAuVYwYqjG0mt/3pHSWTCyKZq/N/dJcUAEoNQMyUmwTZWCJRKofB+JPuDVP2aD8w2J6Q== +"@next/swc-linux-arm64-musl@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz#9bca76375508a175956f2d51f8547d0d6f9ffa64" + integrity sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA== -"@next/swc-linux-x64-gnu@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.3.tgz#a3cf22eda7601536ccd68e8ba4c1bfb4a1a33460" - integrity sha512-ODSKvrdMgAJOVU4qElflYy1KSZRM3M45JVbeZu42TINCMG3anp7YCBn80RkISV6bhzKwcUqLBAmOiWkaGtBA9w== +"@next/swc-linux-x64-gnu@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz#c3d5041d53a5b228bf521ed49649e0f2a7aff947" + integrity sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw== -"@next/swc-linux-x64-musl@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.3.tgz#0e33c1224c76aa3078cc2249c80ef583f9d7a943" - integrity sha512-ZR9kLwCWrlYxwEoytqPi1jhPd1TlsSJWAc+H/CJHmHkf2nD92MQpSRIURR1iNgA/kuFSdxB8xIPt4p/T78kwsg== +"@next/swc-linux-x64-musl@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz#b2a51a108b1c412c69a504556cde0517631768c7" + integrity sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw== -"@next/swc-win32-arm64-msvc@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.3.tgz#4e0583fb981b931915a9ad22e579f9c9d5b803dd" - integrity sha512-+G2FrDcfm2YDbhDiObDU/qPriWeiz/9cRR0yMWJeTLGGX6/x8oryO3tt7HhodA1vZ8r2ddJPCjtLcpaVl7TE2Q== +"@next/swc-win32-arm64-msvc@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz#7d687b42512abd36f44c2c787d58a1590f174b69" + integrity sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg== -"@next/swc-win32-x64-msvc@15.2.3": - version "15.2.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.3.tgz#727b90c7dcc2279344115a94b99d93d452956f02" - integrity sha512-gHYS9tc+G2W0ZC8rBL+H6RdtXIyk40uLiaos0yj5US85FNhbFEndMA2nW3z47nzOWiSvXTZ5kBClc3rD0zJg0w== +"@next/swc-win32-x64-msvc@15.2.4": + version "15.2.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz#779a0ea272fa4f509387f3b320e2d70803943a95" + integrity sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ== "@node-rs/argon2-android-arm-eabi@2.0.2": version "2.0.2" @@ -1661,6 +1661,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/node-cron@^3.0.11": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/node-cron/-/node-cron-3.0.11.tgz#70b7131f65038ae63cfe841354c8aba363632344" + integrity sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg== + "@types/node@*": version "22.13.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.11.tgz#f0ed6b302dcf0f4229d44ea707e77484ad46d234" @@ -4250,12 +4255,12 @@ next-themes@^0.4.4: resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6" integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== -next@^15.2.3: - version "15.2.3" - resolved "https://registry.yarnpkg.com/next/-/next-15.2.3.tgz#1ac803c08076d47eb5b431cb625135616c6bec7e" - integrity sha512-x6eDkZxk2rPpu46E1ZVUWIBhYCLszmUY6fvHBFcbzJ9dD+qRX6vcHusaqqDlnY+VngKzKbAiG2iRCkPbmi8f7w== +next@^15.2.4: + version "15.2.4" + resolved "https://registry.yarnpkg.com/next/-/next-15.2.4.tgz#e05225e9511df98e3b2edc713e17f4c970bff961" + integrity sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ== dependencies: - "@next/env" "15.2.3" + "@next/env" "15.2.4" "@swc/counter" "0.1.3" "@swc/helpers" "0.5.15" busboy "1.6.0" @@ -4263,14 +4268,14 @@ next@^15.2.3: postcss "8.4.31" styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "15.2.3" - "@next/swc-darwin-x64" "15.2.3" - "@next/swc-linux-arm64-gnu" "15.2.3" - "@next/swc-linux-arm64-musl" "15.2.3" - "@next/swc-linux-x64-gnu" "15.2.3" - "@next/swc-linux-x64-musl" "15.2.3" - "@next/swc-win32-arm64-msvc" "15.2.3" - "@next/swc-win32-x64-msvc" "15.2.3" + "@next/swc-darwin-arm64" "15.2.4" + "@next/swc-darwin-x64" "15.2.4" + "@next/swc-linux-arm64-gnu" "15.2.4" + "@next/swc-linux-arm64-musl" "15.2.4" + "@next/swc-linux-x64-gnu" "15.2.4" + "@next/swc-linux-x64-musl" "15.2.4" + "@next/swc-win32-arm64-msvc" "15.2.4" + "@next/swc-win32-x64-msvc" "15.2.4" sharp "^0.33.5" node-abort-controller@^3.1.1: @@ -4278,6 +4283,13 @@ node-abort-controller@^3.1.1: resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== +node-cron@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/node-cron/-/node-cron-3.0.3.tgz#c4bc7173dd96d96c50bdb51122c64415458caff2" + integrity sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A== + dependencies: + uuid "8.3.2" + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -5928,6 +5940,11 @@ util-utils@^1.0.3: resolved "https://registry.yarnpkg.com/util-utils/-/util-utils-1.0.3.tgz#abde6c79d373eb7fae42f28933273eab6d895dd3" integrity sha512-KXQzb5Y1cmQOubnYn2TMSJDwX+cLrFNi3CRp0bm+yTUdR4tV4LDkk2RAzGSweucwvTdhuoDdZrY67ds7QDRYXQ== +uuid@8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^9.0.0: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"