diff --git a/package-lock.json b/package-lock.json index b118360..cda85a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.2.1", "@fastify/websocket": "^11.2.0", "@prisma/client": "^6.4.1", @@ -490,6 +491,12 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", @@ -530,6 +537,22 @@ "mnemonist": "0.40.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/error": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", @@ -600,6 +623,29 @@ "dequal": "^2.0.3" } }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, "node_modules/@fastify/proxy-addr": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", diff --git a/package.json b/package.json index 855e683..0d2bd4a 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dependencies": { "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^10.2.1", "@fastify/websocket": "^11.2.0", "@prisma/client": "^6.4.1", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a87e89a..d8029b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,6 +78,7 @@ model StreamPlan { status String @default("DRAFT") executionMode String @default("IN_GAME") gameId String @default("") + isPublic Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt destinations StreamDestination[] diff --git a/src/app.ts b/src/app.ts index 02a548e..aaffa39 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; +import multipart from '@fastify/multipart'; import rateLimit from '@fastify/rate-limit'; import websocket from '@fastify/websocket'; import prismaPlugin from './plugins/prisma.js'; @@ -15,6 +16,7 @@ import youtubeRoutes from './routes/providers/youtube.js'; import twitchRoutes from './routes/providers/twitch.js'; import planRoutes from './routes/streams/plans.js'; import lifecycleRoutes from './routes/streams/lifecycle.js'; +import previewRoutes from './routes/streams/preview.js'; import followingRoutes from './routes/social/following.js'; import feedRoutes from './routes/social/feed.js'; import likesRoutes from './routes/social/likes.js'; @@ -43,6 +45,7 @@ export async function buildApp() { await app.register(prismaPlugin); await app.register(authPlugin); await app.register(websocket); + await app.register(multipart); // Chat manager (instantiated after prisma is available) const chatManager = new ChatManager(app.prisma, app.log); @@ -58,6 +61,7 @@ export async function buildApp() { await app.register(twitchRoutes); await app.register(planRoutes); await app.register(lifecycleRoutes); + await app.register(previewRoutes); await app.register(followingRoutes); await app.register(feedRoutes); await app.register(likesRoutes); diff --git a/src/routes/social/feed.ts b/src/routes/social/feed.ts index 82536c6..a2f229b 100644 --- a/src/routes/social/feed.ts +++ b/src/routes/social/feed.ts @@ -1,5 +1,8 @@ import { FastifyPluginAsync } from 'fastify'; +import { existsSync } from 'fs'; +import { join } from 'path'; import { optionalAuth } from '../../middleware/require-auth.js'; +import { PREVIEWS_DIR } from '../streams/preview.js'; import type { FeedResponse, FeedItemResponse } from '../../types/api.js'; const feedRoutes: FastifyPluginAsync = async (fastify) => { @@ -11,10 +14,13 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); const cursorId = request.query.cursor; - // Base condition: LIVE + ENDED plans from public users + // Base condition: LIVE + ENDED plans that are public OR owned by the current user const baseWhere: any = { status: { in: ['LIVE', 'ENDED'] }, - user: { isPublic: true }, + OR: [ + { isPublic: true }, + ...(request.userId ? [{ userId: request.userId }] : []), + ], }; // If following filter, restrict to followed users (requires auth) @@ -80,38 +86,50 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => { : []; const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName])); - const feedItems: FeedItemResponse[] = items.map(plan => ({ - plan: { - id: plan.id, - name: plan.name, - status: plan.status, - executionMode: plan.executionMode, - gameId: plan.gameId, - createdAt: plan.createdAt.toISOString(), - updatedAt: plan.updatedAt.toISOString(), - destinations: plan.destinations.map(d => ({ - id: d.id, - serviceId: d.serviceId, - linkedAccountId: d.linkedAccountId, - title: d.title, - description: d.description, - privacyStatus: d.privacyStatus, - gameId: d.gameId, - tags: d.tags, - rtmpUrl: '', - streamKey: '', - // Twitch embed needs channel name, not numeric account ID - broadcastId: d.serviceId === 'TWITCH' - ? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId) - : d.broadcastId, - status: d.status, - })), - user: plan.user, - }, - likeCount: plan._count.likes, - commentCount: 0, // portal comments are ephemeral via WebSocket - isLiked: likedSet.has(plan.id), - })); + const feedItems: FeedItemResponse[] = items.map(plan => { + const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`)); + return { + plan: { + id: plan.id, + name: plan.name, + status: plan.status, + executionMode: plan.executionMode, + gameId: plan.gameId, + isPublic: plan.isPublic, + createdAt: plan.createdAt.toISOString(), + updatedAt: plan.updatedAt.toISOString(), + destinations: plan.destinations.map(d => ({ + id: d.id, + serviceId: d.serviceId, + linkedAccountId: d.linkedAccountId, + title: d.title, + description: d.description, + privacyStatus: d.privacyStatus, + gameId: d.gameId, + tags: d.tags, + rtmpUrl: '', + streamKey: '', + // Twitch embed needs channel name, not numeric account ID + broadcastId: d.serviceId === 'TWITCH' + ? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId) + : d.broadcastId, + status: d.status, + })), + user: plan.user, + }, + previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null, + likeCount: plan._count.likes, + commentCount: 0, // portal comments are ephemeral via WebSocket + isLiked: likedSet.has(plan.id), + }; + }); + + // LIVE streams first, then ENDED + feedItems.sort((a, b) => { + const aLive = a.plan.status === 'LIVE' ? 0 : 1; + const bLive = b.plan.status === 'LIVE' ? 0 : 1; + return aLive - bLive; + }); const response: FeedResponse = { items: feedItems, diff --git a/src/routes/social/following.ts b/src/routes/social/following.ts index 2140761..07ed0cf 100644 --- a/src/routes/social/following.ts +++ b/src/routes/social/following.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; -import { requireAuth } from '../../middleware/require-auth.js'; +import { requireAuth, optionalAuth } from '../../middleware/require-auth.js'; import { AppError } from '../../plugins/error-handler.js'; +import { formatPlan } from '../streams/plans.js'; import type { PublicUserResponse, FollowListResponse } from '../../types/api.js'; const followingRoutes: FastifyPluginAsync = async (fastify) => { @@ -142,7 +143,7 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => { // GET /social/users/:userId — public profile fastify.get<{ Params: { userId: string } }>('/social/users/:userId', { - preHandler: [requireAuth], + preHandler: [optionalAuth], }, async (request) => { const { userId: targetId } = request.params; @@ -155,13 +156,29 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => { if (!user) throw new AppError(404, 'User not found'); - const isFollowing = await fastify.prisma.follow.findUnique({ - where: { - followerId_followingId: { - followerId: request.userId, - followingId: targetId, + let isFollowing = false; + if (request.userId) { + const follow = await fastify.prisma.follow.findUnique({ + where: { + followerId_followingId: { + followerId: request.userId, + followingId: targetId, + }, }, + }); + isFollowing = !!follow; + } + + // Fetch this user's public LIVE + ENDED streams + const publicPlans = await fastify.prisma.streamPlan.findMany({ + where: { + userId: targetId, + isPublic: true, + status: { in: ['LIVE', 'ENDED'] }, }, + include: { destinations: true }, + orderBy: { updatedAt: 'desc' }, + take: 20, }); const response: PublicUserResponse = { @@ -171,7 +188,8 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => { bio: user.bio, followerCount: user._count.followers, followingCount: user._count.following, - isFollowing: !!isFollowing, + isFollowing, + streams: publicPlans.map(formatPlan), }; return response; }); diff --git a/src/routes/streams/plans.ts b/src/routes/streams/plans.ts index 59ea615..faa926a 100644 --- a/src/routes/streams/plans.ts +++ b/src/routes/streams/plans.ts @@ -1,17 +1,21 @@ import { FastifyPluginAsync } from 'fastify'; +import { existsSync, unlinkSync } from 'fs'; +import { join } from 'path'; import { requireAuth } from '../../middleware/require-auth.js'; import { AppError } from '../../plugins/error-handler.js'; import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js'; import { decrypt, encrypt } from '../../services/crypto.service.js'; +import { PREVIEWS_DIR } from './preview.js'; import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; -function formatPlan(plan: any): StreamPlanResponse { +export function formatPlan(plan: any): StreamPlanResponse { return { id: plan.id, name: plan.name, status: plan.status, executionMode: plan.executionMode ?? 'IN_GAME', gameId: plan.gameId ?? '', + isPublic: plan.isPublic ?? false, createdAt: plan.createdAt.toISOString(), updatedAt: plan.updatedAt.toISOString(), destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({ @@ -121,6 +125,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { name: { type: 'string', minLength: 1, maxLength: 200 }, executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, gameId: { type: 'string', maxLength: 200 }, + isPublic: { type: 'boolean' }, destinations: { type: 'array', maxItems: 10, @@ -145,7 +150,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { }, }, }, async (request, reply) => { - const { name, executionMode, gameId, destinations } = request.body; + const { name, executionMode, gameId, isPublic, destinations } = request.body; // Verify user has the required linked accounts const linkedAccounts = await fastify.prisma.linkedAccount.findMany({ @@ -185,6 +190,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { name, executionMode: executionMode ?? 'IN_GAME', gameId: gameId ?? '', + isPublic: isPublic ?? true, destinations: { create: resolvedDestinations.map((d) => { const isCustom = d.rtmpUrl && d.streamKey; @@ -277,6 +283,13 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan'); await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); + + // Clean up preview file if exists + const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`); + if (existsSync(previewPath)) { + try { unlinkSync(previewPath); } catch { /* ignore */ } + } + reply.status(200).send({ success: true }); }); @@ -295,6 +308,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { name: { type: 'string', minLength: 1, maxLength: 200 }, executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, gameId: { type: 'string', maxLength: 200 }, + isPublic: { type: 'boolean' }, destinations: { type: 'array', maxItems: 10, @@ -326,7 +340,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { if (!plan) throw new AppError(404, 'Stream plan not found'); if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited'); - const { name, executionMode, gameId, destinations } = request.body; + const { name, executionMode, gameId, isPublic, destinations } = request.body; // If destinations provided, verify accounts and replace them if (destinations) { @@ -406,12 +420,44 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { ...(name !== undefined && { name }), ...(executionMode !== undefined && { executionMode }), ...(gameId !== undefined && { gameId }), + ...(isPublic !== undefined && { isPublic }), }, include: { destinations: true }, }); reply.status(200).send(formatPlan(updated)); }); + + // PATCH /streams/plans/:id/visibility — toggle visibility on any status + fastify.patch<{ Params: { id: string }; Body: { isPublic: boolean } }>('/streams/plans/:id/visibility', { + preHandler: [requireAuth], + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['isPublic'], + properties: { isPublic: { type: 'boolean' } }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const plan = await fastify.prisma.streamPlan.findFirst({ + where: { id: request.params.id, userId: request.userId }, + }); + if (!plan) throw new AppError(404, 'Stream plan not found'); + + const updated = await fastify.prisma.streamPlan.update({ + where: { id: plan.id }, + data: { isPublic: request.body.isPublic }, + include: { destinations: true }, + }); + + reply.status(200).send(formatPlan(updated)); + }); }; export default planRoutes; diff --git a/src/routes/streams/preview.ts b/src/routes/streams/preview.ts new file mode 100644 index 0000000..e0ec0b5 --- /dev/null +++ b/src/routes/streams/preview.ts @@ -0,0 +1,96 @@ +import { FastifyPluginAsync } from 'fastify'; +import { requireAuth } from '../../middleware/require-auth.js'; +import { AppError } from '../../plugins/error-handler.js'; +import { join } from 'path'; +import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs'; +import { pipeline } from 'stream/promises'; + +const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews'); +const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + +// Ensure previews directory exists +if (!existsSync(PREVIEWS_DIR)) { + mkdirSync(PREVIEWS_DIR, { recursive: true }); +} + +const previewRoutes: FastifyPluginAsync = async (fastify) => { + + // POST /streams/plans/:id/preview — upload preview clip (multipart) + fastify.post<{ Params: { id: string } }>('/streams/plans/:id/preview', { + preHandler: [requireAuth], + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + const plan = await fastify.prisma.streamPlan.findFirst({ + where: { id: request.params.id, userId: request.userId }, + }); + if (!plan) throw new AppError(404, 'Stream plan not found'); + + const data = await request.file({ limits: { fileSize: MAX_FILE_SIZE } }); + if (!data) throw new AppError(400, 'No file uploaded'); + + const outputPath = join(PREVIEWS_DIR, `${plan.id}.mp4`); + await pipeline(data.file, createWriteStream(outputPath)); + + // Check if file was truncated (exceeded size limit) + if (data.file.truncated) { + unlinkSync(outputPath); + throw new AppError(413, 'File too large (max 10MB)'); + } + + request.log.info({ planId: plan.id }, 'Preview clip uploaded'); + reply.status(200).send({ success: true }); + }); + + // GET /streams/plans/:id/preview — serve preview clip with range support + fastify.get<{ Params: { id: string } }>('/streams/plans/:id/preview', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + const filePath = join(PREVIEWS_DIR, `${request.params.id}.mp4`); + if (!existsSync(filePath)) { + throw new AppError(404, 'Preview not found'); + } + + const stat = statSync(filePath); + const fileSize = stat.size; + const range = request.headers.range; + + reply.header('Accept-Ranges', 'bytes'); + reply.header('Cache-Control', 'public, max-age=30'); + reply.header('Content-Type', 'video/mp4'); + + if (range) { + // Parse byte range + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + + if (start >= fileSize || end >= fileSize || start > end) { + reply.status(416).header('Content-Range', `bytes */${fileSize}`).send(); + return; + } + + reply.status(206); + reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`); + reply.header('Content-Length', end - start + 1); + return reply.send(createReadStream(filePath, { start, end })); + } + + reply.header('Content-Length', fileSize); + return reply.send(createReadStream(filePath)); + }); +}; + +export default previewRoutes; +export { PREVIEWS_DIR }; diff --git a/src/types/api.ts b/src/types/api.ts index a3b9230..17e71de 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -71,6 +71,7 @@ export interface CreateStreamPlanBody { name: string; executionMode?: string; gameId?: string; + isPublic?: boolean; destinations: CreateDestinationBody[]; } @@ -78,6 +79,7 @@ export interface UpdateStreamPlanBody { name?: string; executionMode?: string; gameId?: string; + isPublic?: boolean; destinations?: CreateDestinationBody[]; } @@ -98,6 +100,7 @@ export interface StreamPlanResponse { status: string; executionMode: string; gameId: string; + isPublic: boolean; createdAt: string; updatedAt: string; destinations: StreamDestinationResponse[]; @@ -140,10 +143,12 @@ export interface PublicUserResponse { followerCount: number; followingCount: number; isFollowing: boolean; + streams?: StreamPlanResponse[]; } export interface FeedItemResponse { plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } }; + previewUrl: string | null; likeCount: number; commentCount: number; isLiked: boolean;