Custom RTMP saved accounts, CUSTOM destination prepare, debug logging
- Add POST /providers/accounts/custom-rtmp endpoint for saved RTMP servers - Encrypt rtmpUrl/streamKey in accessTokenEnc/refreshTokenEnc fields - Decrypt and return rtmpUrl/streamKey in GET /providers/accounts for CUSTOM_RTMP - Skip token revocation on DELETE for CUSTOM_RTMP accounts - Decrypt CUSTOM_RTMP credentials into CUSTOM destinations on plan create/update - Handle CUSTOM destinations in prepare lifecycle (already READY, skip provider auth) - Add debug logging for plan operations and user upsert
This commit is contained in:
@@ -57,6 +57,8 @@ const metaAuthRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upsert user
|
// Upsert user
|
||||||
|
const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } });
|
||||||
|
request.log.info({ metaId, userId: existingUser?.id, isNew: !existingUser }, 'User upsert');
|
||||||
const user = await fastify.prisma.user.upsert({
|
const user = await fastify.prisma.user.upsert({
|
||||||
where: { metaId },
|
where: { metaId },
|
||||||
update: { displayName },
|
update: { displayName },
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ const healthRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
return { status: 'ok', timestamp: new Date().toISOString(), version };
|
return { status: 'ok', timestamp: new Date().toISOString(), version };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Temporary debug endpoint — remove after diagnosing plan 404 issue
|
||||||
|
fastify.get('/debug/db-state', async () => {
|
||||||
|
const users = await fastify.prisma.user.findMany({
|
||||||
|
select: { id: true, metaId: true, displayName: true },
|
||||||
|
});
|
||||||
|
const plans = await fastify.prisma.streamPlan.findMany({
|
||||||
|
select: { id: true, userId: true, name: true, status: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
const sessions = await fastify.prisma.session.findMany({
|
||||||
|
select: { id: true, userId: true, expiresAt: true, deviceInfo: true },
|
||||||
|
});
|
||||||
|
return { users, plans, sessions };
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export default healthRoutes;
|
export default healthRoutes;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { requireAuth } from '../../middleware/require-auth.js';
|
import { requireAuth } from '../../middleware/require-auth.js';
|
||||||
import { decrypt } from '../../services/crypto.service.js';
|
import { decrypt, encrypt } from '../../services/crypto.service.js';
|
||||||
import { revokeYouTubeToken } from '../../services/youtube.service.js';
|
import { revokeYouTubeToken } from '../../services/youtube.service.js';
|
||||||
import { revokeTwitchToken } from '../../services/twitch.service.js';
|
import { revokeTwitchToken } from '../../services/twitch.service.js';
|
||||||
import { AppError } from '../../plugins/error-handler.js';
|
import { AppError } from '../../plugins/error-handler.js';
|
||||||
import type { LinkedAccountResponse } from '../../types/api.js';
|
import type { LinkedAccountResponse, CreateCustomRtmpBody } from '../../types/api.js';
|
||||||
|
|
||||||
const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
// GET /providers/accounts — list linked accounts (no tokens)
|
// GET /providers/accounts — list linked accounts (no tokens, except CUSTOM_RTMP)
|
||||||
fastify.get('/providers/accounts', {
|
fastify.get('/providers/accounts', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
@@ -15,17 +16,73 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
where: { userId: request.userId },
|
where: { userId: request.userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: LinkedAccountResponse[] = accounts.map((a) => ({
|
const response: LinkedAccountResponse[] = accounts.map((a) => {
|
||||||
id: a.id,
|
const base: LinkedAccountResponse = {
|
||||||
serviceId: a.serviceId,
|
id: a.id,
|
||||||
displayName: a.displayName,
|
serviceId: a.serviceId,
|
||||||
accountId: a.accountId,
|
displayName: a.displayName,
|
||||||
avatarUrl: a.avatarUrl,
|
accountId: a.accountId,
|
||||||
}));
|
avatarUrl: a.avatarUrl,
|
||||||
|
};
|
||||||
|
if (a.serviceId === 'CUSTOM_RTMP') {
|
||||||
|
base.rtmpUrl = decrypt(a.accessTokenEnc, a.accessTokenIv);
|
||||||
|
base.streamKey = decrypt(a.refreshTokenEnc, a.refreshTokenIv);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /providers/accounts/custom-rtmp — create a custom RTMP account
|
||||||
|
fastify.post<{ Body: CreateCustomRtmpBody }>('/providers/accounts/custom-rtmp', {
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['displayName', 'rtmpUrl', 'streamKey'],
|
||||||
|
properties: {
|
||||||
|
displayName: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
rtmpUrl: { type: 'string', minLength: 1, maxLength: 500 },
|
||||||
|
streamKey: { type: 'string', minLength: 1, maxLength: 500 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { displayName, rtmpUrl, streamKey } = request.body;
|
||||||
|
|
||||||
|
const urlEnc = encrypt(rtmpUrl);
|
||||||
|
const keyEnc = encrypt(streamKey);
|
||||||
|
|
||||||
|
const account = await fastify.prisma.linkedAccount.create({
|
||||||
|
data: {
|
||||||
|
userId: request.userId,
|
||||||
|
serviceId: 'CUSTOM_RTMP',
|
||||||
|
accountId: randomUUID(),
|
||||||
|
displayName,
|
||||||
|
avatarUrl: null,
|
||||||
|
accessTokenEnc: urlEnc.ciphertext,
|
||||||
|
accessTokenIv: urlEnc.iv,
|
||||||
|
refreshTokenEnc: keyEnc.ciphertext,
|
||||||
|
refreshTokenIv: keyEnc.iv,
|
||||||
|
tokenExpiresAt: new Date('2099-12-31T00:00:00Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: LinkedAccountResponse = {
|
||||||
|
id: account.id,
|
||||||
|
serviceId: account.serviceId,
|
||||||
|
displayName: account.displayName,
|
||||||
|
accountId: account.accountId,
|
||||||
|
avatarUrl: account.avatarUrl,
|
||||||
|
rtmpUrl,
|
||||||
|
streamKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
reply.status(201).send(response);
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /providers/accounts/:id — revoke tokens and unlink by account ID
|
// DELETE /providers/accounts/:id — revoke tokens and unlink by account ID
|
||||||
fastify.delete<{ Params: { id: string } }>('/providers/accounts/:id', {
|
fastify.delete<{ Params: { id: string } }>('/providers/accounts/:id', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
@@ -50,16 +107,18 @@ const accountRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
throw new AppError(404, 'Account not linked');
|
throw new AppError(404, 'Account not linked');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort revoke tokens at the provider
|
// Best-effort revoke tokens at the provider (skip for CUSTOM_RTMP)
|
||||||
try {
|
if (account.serviceId !== 'CUSTOM_RTMP') {
|
||||||
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
try {
|
||||||
if (account.serviceId === 'YOUTUBE') {
|
const accessToken = decrypt(account.accessTokenEnc, account.accessTokenIv);
|
||||||
await revokeYouTubeToken(accessToken);
|
if (account.serviceId === 'YOUTUBE') {
|
||||||
} else {
|
await revokeYouTubeToken(accessToken);
|
||||||
await revokeTwitchToken(accessToken);
|
} else {
|
||||||
|
await revokeTwitchToken(accessToken);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Revocation failure is non-fatal
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Revocation failure is non-fatal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fastify.prisma.linkedAccount.delete({
|
await fastify.prisma.linkedAccount.delete({
|
||||||
|
|||||||
@@ -82,11 +82,17 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
|
request.log.info({ planId: request.params.id, userId: request.userId }, 'Prepare plan request');
|
||||||
const plan = await fastify.prisma.streamPlan.findFirst({
|
const plan = await fastify.prisma.streamPlan.findFirst({
|
||||||
where: { id: request.params.id, userId: request.userId },
|
where: { id: request.params.id, userId: request.userId },
|
||||||
include: { destinations: true },
|
include: { destinations: true },
|
||||||
});
|
});
|
||||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
if (!plan) {
|
||||||
|
// Debug: check if plan exists under any user
|
||||||
|
const anyPlan = await fastify.prisma.streamPlan.findUnique({ where: { id: request.params.id } });
|
||||||
|
request.log.warn({ planId: request.params.id, userId: request.userId, existsUnderOtherUser: !!anyPlan, otherUserId: anyPlan?.userId }, 'Plan not found for user');
|
||||||
|
throw new AppError(404, 'Stream plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
// If already READY, return the existing prepared data
|
// If already READY, return the existing prepared data
|
||||||
if (plan.status === 'READY') {
|
if (plan.status === 'READY') {
|
||||||
@@ -94,7 +100,7 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
planId: plan.id,
|
planId: plan.id,
|
||||||
destinations: plan.destinations.map((dest) => ({
|
destinations: plan.destinations.map((dest) => ({
|
||||||
id: dest.id,
|
id: dest.id,
|
||||||
serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH',
|
serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH' | 'CUSTOM',
|
||||||
rtmpUrl: dest.rtmpUrl || '',
|
rtmpUrl: dest.rtmpUrl || '',
|
||||||
streamKey: dest.streamKey || '',
|
streamKey: dest.streamKey || '',
|
||||||
broadcastId: dest.broadcastId || '',
|
broadcastId: dest.broadcastId || '',
|
||||||
@@ -110,6 +116,24 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const prepared: PreparedDestination[] = [];
|
const prepared: PreparedDestination[] = [];
|
||||||
|
|
||||||
for (const dest of plan.destinations) {
|
for (const dest of plan.destinations) {
|
||||||
|
// CUSTOM destinations are already READY with rtmpUrl/streamKey set at creation
|
||||||
|
if (dest.serviceId === 'CUSTOM') {
|
||||||
|
if (dest.status !== 'READY') {
|
||||||
|
await fastify.prisma.streamDestination.update({
|
||||||
|
where: { id: dest.id },
|
||||||
|
data: { status: 'READY' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
prepared.push({
|
||||||
|
id: dest.id,
|
||||||
|
serviceId: 'CUSTOM',
|
||||||
|
rtmpUrl: dest.rtmpUrl || '',
|
||||||
|
streamKey: dest.streamKey || '',
|
||||||
|
broadcastId: '',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { account, accessToken } = await getDecryptedTokenByAccountId(
|
const { account, accessToken } = await getDecryptedTokenByAccountId(
|
||||||
fastify.prisma,
|
fastify.prisma,
|
||||||
request.userId,
|
request.userId,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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 type { CreateStreamPlanBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
|
||||||
|
|
||||||
function formatPlan(plan: any): StreamPlanResponse {
|
function formatPlan(plan: any): StreamPlanResponse {
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +101,10 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Debug: log total plans in DB vs plans for this user
|
||||||
|
const totalPlans = await fastify.prisma.streamPlan.count();
|
||||||
|
request.log.info({ userId: request.userId, userPlans: plans.length, totalPlans }, 'List plans');
|
||||||
|
|
||||||
await autoDetectEndedPlans(plans, request.userId);
|
await autoDetectEndedPlans(plans, request.userId);
|
||||||
|
|
||||||
return plans.map(formatPlan);
|
return plans.map(formatPlan);
|
||||||
@@ -122,14 +126,16 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['linkedAccountId', 'title'],
|
required: ['title'],
|
||||||
properties: {
|
properties: {
|
||||||
linkedAccountId: { type: 'string', minLength: 1 },
|
linkedAccountId: { type: 'string' },
|
||||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
description: { type: 'string', maxLength: 5000 },
|
description: { type: 'string', maxLength: 5000 },
|
||||||
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
||||||
gameId: { type: 'string', maxLength: 100 },
|
gameId: { type: 'string', maxLength: 100 },
|
||||||
tags: { type: 'string', maxLength: 500 },
|
tags: { type: 'string', maxLength: 500 },
|
||||||
|
rtmpUrl: { type: 'string', maxLength: 500 },
|
||||||
|
streamKey: { type: 'string', maxLength: 500 },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
@@ -148,7 +154,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
||||||
|
|
||||||
// If no destinations provided, auto-create one per linked account
|
// If no destinations provided, auto-create one per linked account
|
||||||
const resolvedDestinations = destinations.length > 0
|
const resolvedDestinations: CreateDestinationBody[] = destinations.length > 0
|
||||||
? destinations
|
? destinations
|
||||||
: linkedAccounts.map((a) => ({
|
: linkedAccounts.map((a) => ({
|
||||||
linkedAccountId: a.id,
|
linkedAccountId: a.id,
|
||||||
@@ -164,9 +170,12 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dest of resolvedDestinations) {
|
for (const dest of resolvedDestinations) {
|
||||||
const account = linkedAccountMap.get(dest.linkedAccountId);
|
const isCustom = dest.rtmpUrl && dest.streamKey;
|
||||||
if (!account) {
|
if (!isCustom) {
|
||||||
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
const account = linkedAccountMap.get(dest.linkedAccountId ?? '');
|
||||||
|
if (!account) {
|
||||||
|
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +187,40 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
gameId: gameId ?? '',
|
gameId: gameId ?? '',
|
||||||
destinations: {
|
destinations: {
|
||||||
create: resolvedDestinations.map((d) => {
|
create: resolvedDestinations.map((d) => {
|
||||||
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
const isCustom = d.rtmpUrl && d.streamKey;
|
||||||
|
if (isCustom) {
|
||||||
|
return {
|
||||||
|
serviceId: 'CUSTOM',
|
||||||
|
linkedAccountId: '',
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ?? '',
|
||||||
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
|
gameId: d.gameId ?? '',
|
||||||
|
tags: d.tags ?? '',
|
||||||
|
rtmpUrl: d.rtmpUrl!,
|
||||||
|
streamKey: d.streamKey!,
|
||||||
|
status: 'READY',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const account = linkedAccountMap.get(d.linkedAccountId ?? '')!;
|
||||||
|
// CUSTOM_RTMP: decrypt stored credentials into destination
|
||||||
|
if (account.serviceId === 'CUSTOM_RTMP') {
|
||||||
|
return {
|
||||||
|
serviceId: 'CUSTOM',
|
||||||
|
linkedAccountId: d.linkedAccountId ?? '',
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ?? '',
|
||||||
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
|
gameId: d.gameId ?? '',
|
||||||
|
tags: d.tags ?? '',
|
||||||
|
rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||||
|
streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv),
|
||||||
|
status: 'READY',
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
serviceId: account.serviceId,
|
serviceId: account.serviceId,
|
||||||
linkedAccountId: d.linkedAccountId,
|
linkedAccountId: d.linkedAccountId ?? '',
|
||||||
title: d.title,
|
title: d.title,
|
||||||
description: d.description ?? '',
|
description: d.description ?? '',
|
||||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
@@ -194,6 +233,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
include: { destinations: true },
|
include: { destinations: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
request.log.info({ planId: plan.id, userId: request.userId, name, executionMode: executionMode ?? 'IN_GAME' }, 'Plan created');
|
||||||
reply.status(201).send(formatPlan(plan));
|
reply.status(201).send(formatPlan(plan));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +275,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');
|
||||||
|
|
||||||
|
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 } });
|
||||||
reply.status(200).send({ success: true });
|
reply.status(200).send({ success: true });
|
||||||
});
|
});
|
||||||
@@ -259,14 +300,16 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['linkedAccountId', 'title'],
|
required: ['title'],
|
||||||
properties: {
|
properties: {
|
||||||
linkedAccountId: { type: 'string', minLength: 1 },
|
linkedAccountId: { type: 'string' },
|
||||||
title: { type: 'string', minLength: 1, maxLength: 200 },
|
title: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
description: { type: 'string', maxLength: 5000 },
|
description: { type: 'string', maxLength: 5000 },
|
||||||
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] },
|
||||||
gameId: { type: 'string', maxLength: 100 },
|
gameId: { type: 'string', maxLength: 100 },
|
||||||
tags: { type: 'string', maxLength: 500 },
|
tags: { type: 'string', maxLength: 500 },
|
||||||
|
rtmpUrl: { type: 'string', maxLength: 500 },
|
||||||
|
streamKey: { type: 'string', maxLength: 500 },
|
||||||
},
|
},
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
@@ -293,7 +336,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a]));
|
||||||
|
|
||||||
for (const dest of destinations) {
|
for (const dest of destinations) {
|
||||||
if (!linkedAccountMap.has(dest.linkedAccountId)) {
|
const isCustom = dest.rtmpUrl && dest.streamKey;
|
||||||
|
if (!isCustom && !linkedAccountMap.has(dest.linkedAccountId ?? '')) {
|
||||||
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,19 +345,57 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
// Delete old destinations and create new ones
|
// Delete old destinations and create new ones
|
||||||
await fastify.prisma.streamDestination.deleteMany({ where: { planId: plan.id } });
|
await fastify.prisma.streamDestination.deleteMany({ where: { planId: plan.id } });
|
||||||
for (const d of destinations) {
|
for (const d of destinations) {
|
||||||
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
const isCustom = d.rtmpUrl && d.streamKey;
|
||||||
await fastify.prisma.streamDestination.create({
|
if (isCustom) {
|
||||||
data: {
|
await fastify.prisma.streamDestination.create({
|
||||||
planId: plan.id,
|
data: {
|
||||||
serviceId: account.serviceId,
|
planId: plan.id,
|
||||||
linkedAccountId: d.linkedAccountId,
|
serviceId: 'CUSTOM',
|
||||||
title: d.title,
|
linkedAccountId: '',
|
||||||
description: d.description ?? '',
|
title: d.title,
|
||||||
privacyStatus: d.privacyStatus ?? 'unlisted',
|
description: d.description ?? '',
|
||||||
gameId: d.gameId ?? '',
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
tags: d.tags ?? '',
|
gameId: d.gameId ?? '',
|
||||||
},
|
tags: d.tags ?? '',
|
||||||
});
|
rtmpUrl: d.rtmpUrl!,
|
||||||
|
streamKey: d.streamKey!,
|
||||||
|
status: 'READY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const account = linkedAccountMap.get(d.linkedAccountId ?? '')!;
|
||||||
|
// CUSTOM_RTMP: decrypt stored credentials into destination
|
||||||
|
if (account.serviceId === 'CUSTOM_RTMP') {
|
||||||
|
await fastify.prisma.streamDestination.create({
|
||||||
|
data: {
|
||||||
|
planId: plan.id,
|
||||||
|
serviceId: 'CUSTOM',
|
||||||
|
linkedAccountId: d.linkedAccountId ?? '',
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ?? '',
|
||||||
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
|
gameId: d.gameId ?? '',
|
||||||
|
tags: d.tags ?? '',
|
||||||
|
rtmpUrl: decrypt(account.accessTokenEnc, account.accessTokenIv),
|
||||||
|
streamKey: decrypt(account.refreshTokenEnc, account.refreshTokenIv),
|
||||||
|
status: 'READY',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fastify.prisma.streamDestination.create({
|
||||||
|
data: {
|
||||||
|
planId: plan.id,
|
||||||
|
serviceId: account.serviceId,
|
||||||
|
linkedAccountId: d.linkedAccountId ?? '',
|
||||||
|
title: d.title,
|
||||||
|
description: d.description ?? '',
|
||||||
|
privacyStatus: d.privacyStatus ?? 'unlisted',
|
||||||
|
gameId: d.gameId ?? '',
|
||||||
|
tags: d.tags ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ export interface LinkedAccountResponse {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
|
rtmpUrl?: string;
|
||||||
|
streamKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomRtmpBody {
|
||||||
|
displayName: string;
|
||||||
|
rtmpUrl: string;
|
||||||
|
streamKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Streams ──────────────────────────────────────────────
|
// ── Streams ──────────────────────────────────────────────
|
||||||
@@ -63,6 +71,8 @@ export interface CreateDestinationBody {
|
|||||||
privacyStatus?: string;
|
privacyStatus?: string;
|
||||||
gameId?: string;
|
gameId?: string;
|
||||||
tags?: string;
|
tags?: string;
|
||||||
|
rtmpUrl?: string;
|
||||||
|
streamKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamPlanResponse {
|
export interface StreamPlanResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user