Per-stream visibility: isPublic on StreamPlan, PATCH endpoint, feed + profile updates

This commit is contained in:
2026-03-03 21:37:00 +01:00
parent ed83c651d8
commit bc6c01940a
9 changed files with 280 additions and 45 deletions

46
package-lock.json generated
View File

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

View File

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

View File

@@ -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[]

View File

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

View File

@@ -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,13 +86,16 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
: [];
const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
const feedItems: FeedItemResponse[] = items.map(plan => ({
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 => ({
@@ -108,10 +117,19 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
})),
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,

View File

@@ -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,7 +156,9 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
if (!user) throw new AppError(404, 'User not found');
const isFollowing = await fastify.prisma.follow.findUnique({
let isFollowing = false;
if (request.userId) {
const follow = await fastify.prisma.follow.findUnique({
where: {
followerId_followingId: {
followerId: request.userId,
@@ -163,6 +166,20 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
},
},
});
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 = {
id: user.id,
@@ -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;
});

View File

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

View File

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

View File

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