Multi-account support and streaming fixes
- Change LinkedAccount unique constraint to (userId, serviceId, accountId) - Add linkedAccountId to StreamDestination for per-account targeting - OAuth callbacks upsert by accountId so different accounts create new rows - Delete endpoint changed to /providers/accounts/:id - getDecryptedToken resolves tokens by linkedAccountId instead of serviceId - /start transition wrapped in try-catch (enableAutoStart compatibility) - /end always attempts YouTube complete transition regardless of plan status - autoDetectEndedPlans loads tokens per-destination
This commit is contained in:
@@ -16,15 +16,15 @@ import type { PrepareResponse, PreparedDestination } from '../../types/api.js';
|
||||
|
||||
const TWITCH_RTMP_URL = 'rtmp://live.twitch.tv/app';
|
||||
|
||||
async function getDecryptedToken(
|
||||
async function getDecryptedTokenByAccountId(
|
||||
prisma: any,
|
||||
userId: string,
|
||||
serviceId: string,
|
||||
linkedAccountId: string,
|
||||
): Promise<{ account: any; accessToken: string }> {
|
||||
const account = await prisma.linkedAccount.findUnique({
|
||||
where: { userId_serviceId: { userId, serviceId } },
|
||||
const account = await prisma.linkedAccount.findFirst({
|
||||
where: { id: linkedAccountId, userId },
|
||||
});
|
||||
if (!account) throw new AppError(400, `No ${serviceId} account linked`);
|
||||
if (!account) throw new AppError(400, `Linked account ${linkedAccountId} not found`);
|
||||
|
||||
// Lazy refresh if token is expired or about to expire
|
||||
if (account.tokenExpiresAt < new Date(Date.now() + 60 * 1000)) {
|
||||
@@ -33,7 +33,7 @@ async function getDecryptedToken(
|
||||
let newRefresh: string | undefined;
|
||||
let expiresIn: number;
|
||||
|
||||
if (serviceId === 'YOUTUBE') {
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
const result = await refreshYouTubeToken(refreshToken);
|
||||
newAccess = result.accessToken;
|
||||
expiresIn = result.expiresIn;
|
||||
@@ -87,6 +87,22 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
include: { destinations: true },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
// If already READY, return the existing prepared data
|
||||
if (plan.status === 'READY') {
|
||||
const response: PrepareResponse = {
|
||||
planId: plan.id,
|
||||
destinations: plan.destinations.map((dest) => ({
|
||||
id: dest.id,
|
||||
serviceId: dest.serviceId as 'YOUTUBE' | 'TWITCH',
|
||||
rtmpUrl: dest.rtmpUrl || '',
|
||||
streamKey: dest.streamKey || '',
|
||||
broadcastId: dest.broadcastId || '',
|
||||
})),
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
if (plan.status !== 'DRAFT') {
|
||||
throw new AppError(400, `Plan is already ${plan.status}`);
|
||||
}
|
||||
@@ -94,10 +110,10 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const prepared: PreparedDestination[] = [];
|
||||
|
||||
for (const dest of plan.destinations) {
|
||||
const { account, accessToken } = await getDecryptedToken(
|
||||
const { account, accessToken } = await getDecryptedTokenByAccountId(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
dest.serviceId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
|
||||
if (dest.serviceId === 'YOUTUBE') {
|
||||
@@ -119,6 +135,7 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'YOUTUBE',
|
||||
rtmpUrl: broadcast.rtmpUrl,
|
||||
streamKey: broadcast.streamKey,
|
||||
@@ -149,6 +166,7 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
});
|
||||
|
||||
prepared.push({
|
||||
id: dest.id,
|
||||
serviceId: 'TWITCH',
|
||||
rtmpUrl: TWITCH_RTMP_URL,
|
||||
streamKey,
|
||||
@@ -185,18 +203,34 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
include: { destinations: true },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
|
||||
// Idempotent: already LIVE is fine
|
||||
if (plan.status === 'LIVE') {
|
||||
return { success: true, status: 'LIVE' };
|
||||
}
|
||||
|
||||
if (plan.status !== 'READY') {
|
||||
throw new AppError(400, `Plan must be READY to start, currently ${plan.status}`);
|
||||
}
|
||||
|
||||
for (const dest of plan.destinations) {
|
||||
if (dest.serviceId === 'YOUTUBE' && dest.broadcastId) {
|
||||
const { accessToken } = await getDecryptedToken(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
'YOUTUBE',
|
||||
);
|
||||
await transitionYouTubeBroadcast(accessToken, dest.broadcastId, 'live');
|
||||
// Broadcast was created with enableAutoStart: true, so YouTube
|
||||
// auto-transitions to 'live' when RTMP data arrives. A manual
|
||||
// transition can fail if the broadcast is still transitioning
|
||||
// (e.g. in 'testing' state). Wrap in try-catch so the plan
|
||||
// status always gets updated to LIVE.
|
||||
try {
|
||||
const { accessToken } = await getDecryptedTokenByAccountId(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
await transitionYouTubeBroadcast(accessToken, dest.broadcastId, 'live');
|
||||
} catch (err) {
|
||||
fastify.log.warn({ err, broadcastId: dest.broadcastId },
|
||||
'YouTube live transition failed (autoStart may have handled it)');
|
||||
}
|
||||
}
|
||||
// Twitch goes live automatically when RTMP stream is received
|
||||
|
||||
@@ -230,21 +264,33 @@ const lifecycleRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
include: { destinations: true },
|
||||
});
|
||||
if (!plan) throw new AppError(404, 'Stream plan not found');
|
||||
if (plan.status !== 'LIVE') {
|
||||
throw new AppError(400, `Plan must be LIVE to end, currently ${plan.status}`);
|
||||
|
||||
// Idempotent: already ENDED is fine
|
||||
if (plan.status === 'ENDED') {
|
||||
return { success: true, status: 'ENDED' };
|
||||
}
|
||||
|
||||
if (plan.status !== 'LIVE' && plan.status !== 'READY') {
|
||||
throw new AppError(400, `Plan must be LIVE or READY to end, currently ${plan.status}`);
|
||||
}
|
||||
|
||||
for (const dest of plan.destinations) {
|
||||
if (dest.serviceId === 'YOUTUBE' && dest.broadcastId) {
|
||||
const { accessToken } = await getDecryptedToken(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
'YOUTUBE',
|
||||
);
|
||||
// Always try to end the YouTube broadcast regardless of plan status.
|
||||
// The broadcast may be live via enableAutoStart even if our DB status
|
||||
// is still READY (e.g. if the /start transition failed).
|
||||
try {
|
||||
const { accessToken } = await getDecryptedTokenByAccountId(
|
||||
fastify.prisma,
|
||||
request.userId,
|
||||
dest.linkedAccountId,
|
||||
);
|
||||
await transitionYouTubeBroadcast(accessToken, dest.broadcastId, 'complete');
|
||||
} catch {
|
||||
// Non-fatal — stream may already be ended
|
||||
} catch (err) {
|
||||
// Non-fatal — broadcast may already be complete or still transitioning.
|
||||
// enableAutoStop will handle it when RTMP disconnects.
|
||||
fastify.log.warn({ err, broadcastId: dest.broadcastId },
|
||||
'YouTube complete transition failed');
|
||||
}
|
||||
}
|
||||
// Twitch ends when RTMP stream stops
|
||||
|
||||
Reference in New Issue
Block a user