mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the same path: both already play through our own video element, so a track element is all it takes. Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through. That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused. A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the video had them. Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the delivered cut, not a comment attachment.
187 lines
6.1 KiB
TypeScript
187 lines
6.1 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
|
import { rateLimit } from '@/lib/rate-limit';
|
|
import { cleanupBunnyStreamVideosBestEffort } from '@/lib/bunny-stream-cleanup';
|
|
import { deleteMediaFilesBestEffort } from '@/lib/r2-cleanup';
|
|
import { buildCleanupWarnings, logCleanupWarnings } from '@/lib/cleanup-warnings';
|
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
|
import { logError } from '@/lib/logger';
|
|
|
|
type RouteParams = { params: Promise<{ projectId: string; videoId: string; versionId: string }> };
|
|
|
|
async function getVersionWithAccess(
|
|
projectId: string,
|
|
videoId: string,
|
|
versionId: string,
|
|
userId: string
|
|
) {
|
|
const version = await db.videoVersion.findFirst({
|
|
where: { id: versionId, videoParentId: videoId },
|
|
include: {
|
|
video: {
|
|
include: {
|
|
project: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!version || version.video.projectId !== projectId) {
|
|
return null;
|
|
}
|
|
|
|
const project = version.video.project;
|
|
const access = await checkProjectAccess(project, userId);
|
|
|
|
return { version, canEdit: access.canEdit, isOwner: access.isOwner };
|
|
}
|
|
|
|
// PATCH /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
|
export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'mutate');
|
|
if (limited) return limited;
|
|
|
|
const session = await auth();
|
|
const { projectId, videoId, versionId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
|
if (!result) {
|
|
return apiErrors.notFound('Version');
|
|
}
|
|
if (!result.canEdit) {
|
|
return apiErrors.forbidden('Access denied');
|
|
}
|
|
|
|
const body = await request.json();
|
|
const { duration, versionLabel, isActive } = body;
|
|
|
|
if (
|
|
duration !== undefined &&
|
|
(typeof duration !== 'number' || !isFinite(duration) || duration < 0)
|
|
) {
|
|
return apiErrors.badRequest('Invalid duration value');
|
|
}
|
|
|
|
const updateData: Record<string, unknown> = {};
|
|
if (duration !== undefined) updateData.duration = duration;
|
|
if (versionLabel !== undefined) updateData.versionLabel = versionLabel?.trim() || null;
|
|
|
|
if (isActive === true) {
|
|
// Deactivate all other versions, then activate this one
|
|
await db.videoVersion.updateMany({
|
|
where: { videoParentId: videoId },
|
|
data: { isActive: false },
|
|
});
|
|
updateData.isActive = true;
|
|
}
|
|
|
|
const updated = await db.videoVersion.update({
|
|
where: { id: versionId },
|
|
data: updateData,
|
|
});
|
|
|
|
const response = successResponse(updated);
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error updating version:', error);
|
|
return apiErrors.internalError('Failed to update version');
|
|
}
|
|
}
|
|
|
|
// DELETE /api/projects/[projectId]/videos/[videoId]/versions/[versionId]
|
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
try {
|
|
const limited = await rateLimit(request, 'mutate');
|
|
if (limited) return limited;
|
|
|
|
const session = await auth();
|
|
const { projectId, videoId, versionId } = await params;
|
|
|
|
if (!session?.user?.id) {
|
|
return apiErrors.unauthorized();
|
|
}
|
|
|
|
const result = await getVersionWithAccess(projectId, videoId, versionId, session.user.id);
|
|
if (!result) {
|
|
return apiErrors.notFound('Version');
|
|
}
|
|
if (!result.canEdit) {
|
|
return apiErrors.forbidden('Access denied');
|
|
}
|
|
|
|
// Check there's more than one version — can't delete the last one
|
|
const versionCount = await db.videoVersion.count({
|
|
where: { videoParentId: videoId },
|
|
});
|
|
|
|
if (versionCount <= 1) {
|
|
return apiErrors.badRequest('Cannot delete the only version. Delete the video instead.');
|
|
}
|
|
|
|
const wasActive = result.version.isActive;
|
|
const bunnyRef = {
|
|
providerId: result.version.providerId,
|
|
videoId: result.version.videoId,
|
|
};
|
|
|
|
// Read before the delete: the rows cascade away with the version, and their stored
|
|
// objects would then have nothing pointing at them. Subtitles live in our own storage
|
|
// whatever hosts the video, so this runs for a Bunny-hosted cut too.
|
|
const subtitles = await db.videoSubtitle.findMany({
|
|
where: { versionId },
|
|
select: { sourceUrl: true },
|
|
});
|
|
|
|
await db.$transaction(async (tx) => {
|
|
// Delete the version (cascades to comments).
|
|
await tx.videoVersion.delete({ where: { id: versionId } });
|
|
|
|
// If the deleted version was active, activate the latest remaining one.
|
|
if (wasActive) {
|
|
const latestVersion = await tx.videoVersion.findFirst({
|
|
where: { videoParentId: videoId },
|
|
orderBy: { versionNumber: 'desc' },
|
|
});
|
|
if (latestVersion) {
|
|
await tx.videoVersion.update({
|
|
where: { id: latestVersion.id },
|
|
data: { isActive: true },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const versionMediaUrls = [
|
|
...subtitles.map((subtitle) => subtitle.sourceUrl),
|
|
...(result.version.providerId === 'r2'
|
|
? [result.version.originalUrl, result.version.thumbnailUrl]
|
|
: []),
|
|
].filter((url): url is string => Boolean(url));
|
|
|
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
|
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
|
deleteMediaFilesBestEffort(versionMediaUrls),
|
|
]);
|
|
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
|
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
|
if (cleanupWarnings) {
|
|
logCleanupWarnings({ entityType: 'video-version', entityId: versionId }, cleanupInput);
|
|
}
|
|
|
|
const response = successResponse({
|
|
message: 'Version deleted',
|
|
...(cleanupWarnings ? { cleanupWarnings } : {}),
|
|
});
|
|
return withCacheControl(response, 'private, no-store');
|
|
} catch (error) {
|
|
logError('Error deleting version:', error);
|
|
return apiErrors.internalError('Failed to delete version');
|
|
}
|
|
}
|