Per-stream visibility: isPublic on StreamPlan, PATCH endpoint, feed + profile updates
This commit is contained in:
46
package-lock.json
generated
46
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.2.1",
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
@@ -490,6 +491,12 @@
|
|||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/cookie": {
|
||||||
"version": "11.0.2",
|
"version": "11.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||||
@@ -530,6 +537,22 @@
|
|||||||
"mnemonist": "0.40.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
@@ -600,6 +623,29 @@
|
|||||||
"dequal": "^2.0.3"
|
"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": {
|
"node_modules/@fastify/proxy-addr": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.1",
|
"@fastify/cookie": "^11.0.1",
|
||||||
"@fastify/cors": "^10.0.1",
|
"@fastify/cors": "^10.0.1",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.2.1",
|
"@fastify/rate-limit": "^10.2.1",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ model StreamPlan {
|
|||||||
status String @default("DRAFT")
|
status String @default("DRAFT")
|
||||||
executionMode String @default("IN_GAME")
|
executionMode String @default("IN_GAME")
|
||||||
gameId String @default("")
|
gameId String @default("")
|
||||||
|
isPublic Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
destinations StreamDestination[]
|
destinations StreamDestination[]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import prismaPlugin from './plugins/prisma.js';
|
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 twitchRoutes from './routes/providers/twitch.js';
|
||||||
import planRoutes from './routes/streams/plans.js';
|
import planRoutes from './routes/streams/plans.js';
|
||||||
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
||||||
|
import previewRoutes from './routes/streams/preview.js';
|
||||||
import followingRoutes from './routes/social/following.js';
|
import followingRoutes from './routes/social/following.js';
|
||||||
import feedRoutes from './routes/social/feed.js';
|
import feedRoutes from './routes/social/feed.js';
|
||||||
import likesRoutes from './routes/social/likes.js';
|
import likesRoutes from './routes/social/likes.js';
|
||||||
@@ -43,6 +45,7 @@ export async function buildApp() {
|
|||||||
await app.register(prismaPlugin);
|
await app.register(prismaPlugin);
|
||||||
await app.register(authPlugin);
|
await app.register(authPlugin);
|
||||||
await app.register(websocket);
|
await app.register(websocket);
|
||||||
|
await app.register(multipart);
|
||||||
|
|
||||||
// Chat manager (instantiated after prisma is available)
|
// Chat manager (instantiated after prisma is available)
|
||||||
const chatManager = new ChatManager(app.prisma, app.log);
|
const chatManager = new ChatManager(app.prisma, app.log);
|
||||||
@@ -58,6 +61,7 @@ export async function buildApp() {
|
|||||||
await app.register(twitchRoutes);
|
await app.register(twitchRoutes);
|
||||||
await app.register(planRoutes);
|
await app.register(planRoutes);
|
||||||
await app.register(lifecycleRoutes);
|
await app.register(lifecycleRoutes);
|
||||||
|
await app.register(previewRoutes);
|
||||||
await app.register(followingRoutes);
|
await app.register(followingRoutes);
|
||||||
await app.register(feedRoutes);
|
await app.register(feedRoutes);
|
||||||
await app.register(likesRoutes);
|
await app.register(likesRoutes);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { optionalAuth } from '../../middleware/require-auth.js';
|
import { optionalAuth } from '../../middleware/require-auth.js';
|
||||||
|
import { PREVIEWS_DIR } from '../streams/preview.js';
|
||||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||||
|
|
||||||
const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
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 limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||||
const cursorId = request.query.cursor;
|
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 = {
|
const baseWhere: any = {
|
||||||
status: { in: ['LIVE', 'ENDED'] },
|
status: { in: ['LIVE', 'ENDED'] },
|
||||||
user: { isPublic: true },
|
OR: [
|
||||||
|
{ isPublic: true },
|
||||||
|
...(request.userId ? [{ userId: request.userId }] : []),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// If following filter, restrict to followed users (requires auth)
|
// 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 twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
|
||||||
|
|
||||||
const feedItems: FeedItemResponse[] = items.map(plan => ({
|
const feedItems: FeedItemResponse[] = items.map(plan => {
|
||||||
plan: {
|
const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
|
||||||
id: plan.id,
|
return {
|
||||||
name: plan.name,
|
plan: {
|
||||||
status: plan.status,
|
id: plan.id,
|
||||||
executionMode: plan.executionMode,
|
name: plan.name,
|
||||||
gameId: plan.gameId,
|
status: plan.status,
|
||||||
createdAt: plan.createdAt.toISOString(),
|
executionMode: plan.executionMode,
|
||||||
updatedAt: plan.updatedAt.toISOString(),
|
gameId: plan.gameId,
|
||||||
destinations: plan.destinations.map(d => ({
|
isPublic: plan.isPublic,
|
||||||
id: d.id,
|
createdAt: plan.createdAt.toISOString(),
|
||||||
serviceId: d.serviceId,
|
updatedAt: plan.updatedAt.toISOString(),
|
||||||
linkedAccountId: d.linkedAccountId,
|
destinations: plan.destinations.map(d => ({
|
||||||
title: d.title,
|
id: d.id,
|
||||||
description: d.description,
|
serviceId: d.serviceId,
|
||||||
privacyStatus: d.privacyStatus,
|
linkedAccountId: d.linkedAccountId,
|
||||||
gameId: d.gameId,
|
title: d.title,
|
||||||
tags: d.tags,
|
description: d.description,
|
||||||
rtmpUrl: '',
|
privacyStatus: d.privacyStatus,
|
||||||
streamKey: '',
|
gameId: d.gameId,
|
||||||
// Twitch embed needs channel name, not numeric account ID
|
tags: d.tags,
|
||||||
broadcastId: d.serviceId === 'TWITCH'
|
rtmpUrl: '',
|
||||||
? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId)
|
streamKey: '',
|
||||||
: d.broadcastId,
|
// Twitch embed needs channel name, not numeric account ID
|
||||||
status: d.status,
|
broadcastId: d.serviceId === 'TWITCH'
|
||||||
})),
|
? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId)
|
||||||
user: plan.user,
|
: d.broadcastId,
|
||||||
},
|
status: d.status,
|
||||||
likeCount: plan._count.likes,
|
})),
|
||||||
commentCount: 0, // portal comments are ephemeral via WebSocket
|
user: plan.user,
|
||||||
isLiked: likedSet.has(plan.id),
|
},
|
||||||
}));
|
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 = {
|
const response: FeedResponse = {
|
||||||
items: feedItems,
|
items: feedItems,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
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 { AppError } from '../../plugins/error-handler.js';
|
||||||
|
import { formatPlan } from '../streams/plans.js';
|
||||||
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
|
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
|
||||||
|
|
||||||
const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
@@ -142,7 +143,7 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// GET /social/users/:userId — public profile
|
// GET /social/users/:userId — public profile
|
||||||
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
|
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [optionalAuth],
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
const { userId: targetId } = request.params;
|
const { userId: targetId } = request.params;
|
||||||
|
|
||||||
@@ -155,13 +156,29 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
if (!user) throw new AppError(404, 'User not found');
|
if (!user) throw new AppError(404, 'User not found');
|
||||||
|
|
||||||
const isFollowing = await fastify.prisma.follow.findUnique({
|
let isFollowing = false;
|
||||||
where: {
|
if (request.userId) {
|
||||||
followerId_followingId: {
|
const follow = await fastify.prisma.follow.findUnique({
|
||||||
followerId: request.userId,
|
where: {
|
||||||
followingId: targetId,
|
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 = {
|
const response: PublicUserResponse = {
|
||||||
@@ -171,7 +188,8 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
followerCount: user._count.followers,
|
followerCount: user._count.followers,
|
||||||
followingCount: user._count.following,
|
followingCount: user._count.following,
|
||||||
isFollowing: !!isFollowing,
|
isFollowing,
|
||||||
|
streams: publicPlans.map(formatPlan),
|
||||||
};
|
};
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { existsSync, unlinkSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
import { requireAuth } from '../../middleware/require-auth.js';
|
import { requireAuth } from '../../middleware/require-auth.js';
|
||||||
import { AppError } from '../../plugins/error-handler.js';
|
import { AppError } from '../../plugins/error-handler.js';
|
||||||
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
||||||
import { decrypt, encrypt } from '../../services/crypto.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';
|
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||||
|
|
||||||
function formatPlan(plan: any): StreamPlanResponse {
|
export function formatPlan(plan: any): StreamPlanResponse {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
status: plan.status,
|
status: plan.status,
|
||||||
executionMode: plan.executionMode ?? 'IN_GAME',
|
executionMode: plan.executionMode ?? 'IN_GAME',
|
||||||
gameId: plan.gameId ?? '',
|
gameId: plan.gameId ?? '',
|
||||||
|
isPublic: plan.isPublic ?? false,
|
||||||
createdAt: plan.createdAt.toISOString(),
|
createdAt: plan.createdAt.toISOString(),
|
||||||
updatedAt: plan.updatedAt.toISOString(),
|
updatedAt: plan.updatedAt.toISOString(),
|
||||||
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
||||||
@@ -121,6 +125,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||||
gameId: { type: 'string', maxLength: 200 },
|
gameId: { type: 'string', maxLength: 200 },
|
||||||
|
isPublic: { type: 'boolean' },
|
||||||
destinations: {
|
destinations: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
@@ -145,7 +150,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, 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
|
// Verify user has the required linked accounts
|
||||||
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
||||||
@@ -185,6 +190,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
name,
|
name,
|
||||||
executionMode: executionMode ?? 'IN_GAME',
|
executionMode: executionMode ?? 'IN_GAME',
|
||||||
gameId: gameId ?? '',
|
gameId: gameId ?? '',
|
||||||
|
isPublic: isPublic ?? true,
|
||||||
destinations: {
|
destinations: {
|
||||||
create: resolvedDestinations.map((d) => {
|
create: resolvedDestinations.map((d) => {
|
||||||
const isCustom = d.rtmpUrl && d.streamKey;
|
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');
|
request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan');
|
||||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
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 });
|
reply.status(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -295,6 +308,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||||
gameId: { type: 'string', maxLength: 200 },
|
gameId: { type: 'string', maxLength: 200 },
|
||||||
|
isPublic: { type: 'boolean' },
|
||||||
destinations: {
|
destinations: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
@@ -326,7 +340,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||||
if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited');
|
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 provided, verify accounts and replace them
|
||||||
if (destinations) {
|
if (destinations) {
|
||||||
@@ -406,12 +420,44 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
...(name !== undefined && { name }),
|
...(name !== undefined && { name }),
|
||||||
...(executionMode !== undefined && { executionMode }),
|
...(executionMode !== undefined && { executionMode }),
|
||||||
...(gameId !== undefined && { gameId }),
|
...(gameId !== undefined && { gameId }),
|
||||||
|
...(isPublic !== undefined && { isPublic }),
|
||||||
},
|
},
|
||||||
include: { destinations: true },
|
include: { destinations: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
reply.status(200).send(formatPlan(updated));
|
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;
|
export default planRoutes;
|
||||||
|
|||||||
96
src/routes/streams/preview.ts
Normal file
96
src/routes/streams/preview.ts
Normal 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 };
|
||||||
@@ -71,6 +71,7 @@ export interface CreateStreamPlanBody {
|
|||||||
name: string;
|
name: string;
|
||||||
executionMode?: string;
|
executionMode?: string;
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
destinations: CreateDestinationBody[];
|
destinations: CreateDestinationBody[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export interface UpdateStreamPlanBody {
|
|||||||
name?: string;
|
name?: string;
|
||||||
executionMode?: string;
|
executionMode?: string;
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
destinations?: CreateDestinationBody[];
|
destinations?: CreateDestinationBody[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export interface StreamPlanResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
executionMode: string;
|
executionMode: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
|
isPublic: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
destinations: StreamDestinationResponse[];
|
destinations: StreamDestinationResponse[];
|
||||||
@@ -140,10 +143,12 @@ export interface PublicUserResponse {
|
|||||||
followerCount: number;
|
followerCount: number;
|
||||||
followingCount: number;
|
followingCount: number;
|
||||||
isFollowing: boolean;
|
isFollowing: boolean;
|
||||||
|
streams?: StreamPlanResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedItemResponse {
|
export interface FeedItemResponse {
|
||||||
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
||||||
|
previewUrl: string | null;
|
||||||
likeCount: number;
|
likeCount: number;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
isLiked: boolean;
|
isLiked: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user