mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
This commit is contained in:
@@ -14,7 +14,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
|
||||
|
||||
- Timestamped comments directly on the video timeline
|
||||
- Voice notes, image attachments, and frame annotations
|
||||
- Version history with side-by-side compare
|
||||
- Version history with side-by-side compare and per-version subtitle tracks
|
||||
- Approval requests and sign-off tracking
|
||||
- Share links for client review with optional guest commenting
|
||||
- Workspaces, projects, member roles, and invitation flows
|
||||
@@ -41,7 +41,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
|
||||
|
||||
### Versioning And Comparison
|
||||
|
||||
- Videos support multiple versions inside the same review thread.
|
||||
- Videos support multiple versions inside the same review thread, each with its own subtitle tracks uploaded as SRT or WebVTT.
|
||||
- Teams can switch between versions without losing review context.
|
||||
- Compare mode lets reviewers inspect two versions side by side.
|
||||
|
||||
|
||||
@@ -130,6 +130,14 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
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 } });
|
||||
@@ -149,15 +157,16 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
}
|
||||
});
|
||||
|
||||
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]),
|
||||
result.version.providerId === 'r2'
|
||||
? deleteMediaFilesBestEffort(
|
||||
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
|
||||
Boolean(url)
|
||||
)
|
||||
)
|
||||
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
|
||||
deleteMediaFilesBestEffort(versionMediaUrls),
|
||||
]);
|
||||
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { validateShareLinkAccess } from '@/lib/share-links';
|
||||
import { getShareSessionFromRequest } from '@/lib/share-session';
|
||||
import { apiErrors } from '@/lib/api-response';
|
||||
import { proxyR2MediaObject } from '@/lib/r2-media-proxy';
|
||||
import { logError } from '@/lib/logger';
|
||||
import {
|
||||
SAFE_SUBTITLE_FILENAME,
|
||||
SUBTITLE_CONTENT_TYPE,
|
||||
SUBTITLE_OBJECT_KEY_PREFIX,
|
||||
subtitleFileNameToProxyUrl,
|
||||
} from '@/lib/subtitle-validation';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
try {
|
||||
const { filename } = await params;
|
||||
|
||||
// Validate filename to prevent path traversal
|
||||
if (!SAFE_SUBTITLE_FILENAME.test(filename)) {
|
||||
return apiErrors.badRequest('Invalid filename');
|
||||
}
|
||||
|
||||
// Parallelize the DB lookup and session check to narrow the timing delta
|
||||
// between "subtitle not found" and "subtitle found, access denied" responses.
|
||||
const [subtitle, session] = await Promise.all([
|
||||
db.videoSubtitle.findUnique({
|
||||
where: { sourceUrl: subtitleFileNameToProxyUrl(filename) },
|
||||
select: {
|
||||
version: {
|
||||
select: {
|
||||
video: {
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const video = subtitle?.version?.video ?? null;
|
||||
if (!video) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const access = await checkProjectAccess(video.project, session?.user?.id);
|
||||
|
||||
if (!access.hasAccess) {
|
||||
const shareSession = getShareSessionFromRequest(request, video.id);
|
||||
const shareAccess = shareSession
|
||||
? await validateShareLinkAccess({
|
||||
token: shareSession.token,
|
||||
projectId: video.projectId,
|
||||
videoId: video.id,
|
||||
requiredPermission: 'VIEW',
|
||||
passwordVerified: shareSession.passwordVerified,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!shareAccess?.hasAccess) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
}
|
||||
|
||||
return proxyR2MediaObject({
|
||||
request,
|
||||
key: `${SUBTITLE_OBJECT_KEY_PREFIX}${filename}`,
|
||||
fallbackContentType: SUBTITLE_CONTENT_TYPE,
|
||||
cacheControl: 'private, no-store',
|
||||
extraHeaders: {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'; sandbox",
|
||||
},
|
||||
internalErrorMessage: 'Failed to retrieve subtitle',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logError('Error serving subtitle:', error);
|
||||
return apiErrors.internalError('Failed to retrieve subtitle');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
|
||||
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string; subtitleId: string }> };
|
||||
|
||||
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
|
||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'subtitle-delete');
|
||||
if (limited) return limited;
|
||||
|
||||
const { videoId, subtitleId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
if (!context.viewerUserId || !context.canManageAssets) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const subtitle = await db.videoSubtitle.findFirst({
|
||||
where: { id: subtitleId, version: { videoParentId: videoId } },
|
||||
select: { id: true, sourceUrl: true },
|
||||
});
|
||||
if (!subtitle) return apiErrors.notFound('Subtitle');
|
||||
|
||||
// Storage first, row second, for the same reason video deletion does it in that
|
||||
// order: a refused delete leaves the row in place so the operation can be retried,
|
||||
// rather than orphaning an object nothing points at any more.
|
||||
const objectKey = subtitleProxyPathToObjectKey(subtitle.sourceUrl);
|
||||
if (objectKey) {
|
||||
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: objectKey }));
|
||||
}
|
||||
|
||||
await db.videoSubtitle.delete({ where: { id: subtitle.id } });
|
||||
|
||||
const response = successResponse({ id: subtitle.id, deleted: true });
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error deleting subtitle:', error);
|
||||
return apiErrors.internalError('Failed to delete subtitle');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||
import { db } from '@/lib/db';
|
||||
import { logError } from '@/lib/logger';
|
||||
import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { rateLimit } from '@/lib/rate-limit';
|
||||
import {
|
||||
releaseStorageReservation,
|
||||
reserveStorageQuota,
|
||||
UPLOAD_RESERVATION_PURPOSES,
|
||||
} from '@/lib/storage-quota';
|
||||
import {
|
||||
getSubtitleExtension,
|
||||
MAX_SUBTITLE_FILE_SIZE,
|
||||
normalizeSubtitleFile,
|
||||
normalizeSubtitleLanguage,
|
||||
sanitizeSubtitleLabel,
|
||||
subtitleFileNameToProxyUrl,
|
||||
SUBTITLE_CONTENT_TYPE,
|
||||
SUBTITLE_OBJECT_KEY_PREFIX,
|
||||
subtitleProxyPathToObjectKey,
|
||||
} from '@/lib/subtitle-validation';
|
||||
import { getVideoAssetAccessContext } from '@/lib/video-assets';
|
||||
|
||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||
|
||||
const MAX_MULTIPART_BODY_SIZE = MAX_SUBTITLE_FILE_SIZE + 64 * 1024;
|
||||
|
||||
/** A cut with more tracks than this is not being subtitled, it is being used as storage. */
|
||||
const MAX_SUBTITLES_PER_VERSION = 20;
|
||||
|
||||
type SubtitleRow = {
|
||||
id: string;
|
||||
versionId: string;
|
||||
language: string;
|
||||
label: string;
|
||||
sourceUrl: string;
|
||||
sizeBytes: bigint;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
|
||||
};
|
||||
|
||||
function shapeSubtitle(subtitle: SubtitleRow, canManage: boolean) {
|
||||
return {
|
||||
id: subtitle.id,
|
||||
versionId: subtitle.versionId,
|
||||
language: subtitle.language,
|
||||
label: subtitle.label,
|
||||
url: subtitle.sourceUrl,
|
||||
sizeBytes: Number(subtitle.sizeBytes),
|
||||
createdAt: subtitle.createdAt,
|
||||
updatedAt: subtitle.updatedAt,
|
||||
uploadedByUser: subtitle.uploadedByUser,
|
||||
canDelete: canManage,
|
||||
};
|
||||
}
|
||||
|
||||
const SUBTITLE_SELECT = {
|
||||
id: true,
|
||||
versionId: true,
|
||||
language: true,
|
||||
label: true,
|
||||
sourceUrl: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
uploadedByUser: { select: { id: true, name: true, image: true } },
|
||||
} as const;
|
||||
|
||||
// GET /api/videos/[videoId]/subtitles?versionId=...
|
||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||
try {
|
||||
const limited = await rateLimit(request, 'subtitle-list');
|
||||
if (limited) return limited;
|
||||
|
||||
const { videoId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
if (!context.hasViewAccess) return apiErrors.forbidden('Access denied');
|
||||
|
||||
const versionId = request.nextUrl.searchParams.get('versionId')?.trim() || null;
|
||||
|
||||
const subtitles = await db.videoSubtitle.findMany({
|
||||
where: {
|
||||
version: {
|
||||
videoParentId: videoId,
|
||||
...(versionId ? { id: versionId } : {}),
|
||||
},
|
||||
},
|
||||
orderBy: [{ language: 'asc' }],
|
||||
select: SUBTITLE_SELECT,
|
||||
});
|
||||
|
||||
const response = successResponse({
|
||||
subtitles: subtitles.map((subtitle) => shapeSubtitle(subtitle, context.canManageAssets)),
|
||||
canManageSubtitles: context.canManageAssets,
|
||||
});
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
logError('Error listing subtitles:', error);
|
||||
return apiErrors.internalError('Failed to load subtitles');
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/videos/[videoId]/subtitles
|
||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||
let reservationId: string | null = null;
|
||||
let billedUserId: string | null = null;
|
||||
let storedObjectKey: string | null = null;
|
||||
|
||||
try {
|
||||
const contentLength = request.headers.get('content-length');
|
||||
if (!contentLength) {
|
||||
return apiErrors.badRequest('Missing Content-Length header');
|
||||
}
|
||||
const bodySize = Number.parseInt(contentLength, 10);
|
||||
if (!Number.isFinite(bodySize) || bodySize <= 0) {
|
||||
return apiErrors.badRequest('Invalid Content-Length header');
|
||||
}
|
||||
if (bodySize > MAX_MULTIPART_BODY_SIZE) {
|
||||
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
|
||||
}
|
||||
|
||||
const limited = await rateLimit(request, 'subtitle-create');
|
||||
if (limited) return limited;
|
||||
|
||||
const { videoId } = await params;
|
||||
const context = await getVideoAssetAccessContext(request, videoId, 'VIEW');
|
||||
if (!context) return apiErrors.notFound('Video');
|
||||
// A subtitle is part of the delivered cut rather than a comment attachment, so it
|
||||
// takes the editor permission and never the commenter one. Guests and share-link
|
||||
// viewers can read the tracks but cannot add them.
|
||||
if (!context.viewerUserId || !context.canManageAssets) {
|
||||
return apiErrors.forbidden('Access denied');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const files = formData.getAll('subtitle');
|
||||
if (files.length !== 1 || !(files[0] instanceof File)) {
|
||||
return apiErrors.badRequest('No subtitle file provided');
|
||||
}
|
||||
const file = files[0];
|
||||
|
||||
if (file.size > MAX_SUBTITLE_FILE_SIZE) {
|
||||
return apiErrors.badRequest('Subtitle file is too large. Maximum size is 2MB.');
|
||||
}
|
||||
if (!getSubtitleExtension(file.name)) {
|
||||
return apiErrors.badRequest('Subtitle must be a .srt or .vtt file');
|
||||
}
|
||||
|
||||
const versionIdValue = formData.get('versionId');
|
||||
if (typeof versionIdValue !== 'string' || !versionIdValue.trim()) {
|
||||
return apiErrors.badRequest('versionId is required');
|
||||
}
|
||||
const versionId = versionIdValue.trim();
|
||||
|
||||
const language = normalizeSubtitleLanguage(formData.get('language'));
|
||||
if (!language) {
|
||||
return apiErrors.badRequest('language must be a BCP-47 tag such as "tr" or "en-US"');
|
||||
}
|
||||
const label = sanitizeSubtitleLabel(formData.get('label'), language.toUpperCase());
|
||||
|
||||
const version = await db.videoVersion.findFirst({
|
||||
where: { id: versionId, videoParentId: videoId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!version) return apiErrors.notFound('Version');
|
||||
|
||||
const existing = await db.videoSubtitle.findUnique({
|
||||
where: { versionId_language: { versionId, language } },
|
||||
select: { id: true, sourceUrl: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const trackCount = await db.videoSubtitle.count({ where: { versionId } });
|
||||
if (trackCount >= MAX_SUBTITLES_PER_VERSION) {
|
||||
return apiErrors.badRequest(
|
||||
`A version can hold at most ${MAX_SUBTITLES_PER_VERSION} subtitle tracks`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitleFile(new Uint8Array(await file.arrayBuffer()));
|
||||
if (!normalized.ok) {
|
||||
return apiErrors.badRequest(normalized.error);
|
||||
}
|
||||
|
||||
const body = Buffer.from(normalized.vtt, 'utf8');
|
||||
const sizeBytes = BigInt(body.byteLength);
|
||||
|
||||
billedUserId = context.video.project.workspace.ownerId;
|
||||
const reserveResult = await reserveStorageQuota(
|
||||
billedUserId,
|
||||
sizeBytes,
|
||||
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
|
||||
);
|
||||
if ('error' in reserveResult) return reserveResult.error;
|
||||
reservationId = reserveResult.reservationId;
|
||||
|
||||
const fileName = `${randomUUID()}.vtt`;
|
||||
const objectKey = `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
|
||||
await r2Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: R2_BUCKET_NAME,
|
||||
Key: objectKey,
|
||||
Body: body,
|
||||
ContentType: SUBTITLE_CONTENT_TYPE,
|
||||
})
|
||||
);
|
||||
storedObjectKey = objectKey;
|
||||
|
||||
const created = await db.$transaction(async (tx) => {
|
||||
if (existing) {
|
||||
await tx.videoSubtitle.delete({ where: { id: existing.id } });
|
||||
}
|
||||
return tx.videoSubtitle.create({
|
||||
data: {
|
||||
versionId,
|
||||
language,
|
||||
label,
|
||||
sourceUrl: subtitleFileNameToProxyUrl(fileName),
|
||||
sizeBytes,
|
||||
billedUserId: billedUserId as string,
|
||||
uploadedByUserId: context.viewerUserId,
|
||||
},
|
||||
select: SUBTITLE_SELECT,
|
||||
});
|
||||
});
|
||||
|
||||
// The row is committed, so the bytes are counted by the usage sum and the hold that
|
||||
// stood in for them until now is no longer needed.
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
|
||||
);
|
||||
reservationId = null;
|
||||
storedObjectKey = null;
|
||||
|
||||
if (existing) {
|
||||
// Best effort: the replaced track is already unreachable, and a stranded object is
|
||||
// a cleanup problem rather than a reason to fail an upload that succeeded.
|
||||
const staleKey = subtitleProxyPathToObjectKey(existing.sourceUrl);
|
||||
if (staleKey) {
|
||||
try {
|
||||
await r2Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: staleKey }));
|
||||
} catch (deleteError) {
|
||||
logError('Failed to delete replaced subtitle object:', deleteError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = successResponse(shapeSubtitle(created, true), 201);
|
||||
return withCacheControl(response, 'private, no-store');
|
||||
} catch (error) {
|
||||
if (storedObjectKey) {
|
||||
try {
|
||||
await r2Client.send(
|
||||
new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: storedObjectKey })
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
logError('Failed to clean up subtitle object after a failed upload:', cleanupError);
|
||||
}
|
||||
}
|
||||
await releaseStorageReservation(
|
||||
reservationId,
|
||||
billedUserId,
|
||||
UPLOAD_RESERVATION_PURPOSES.SUBTITLE
|
||||
);
|
||||
logError('Error uploading subtitle:', error);
|
||||
return apiErrors.internalError('Failed to upload subtitle');
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
} from '@/components/video-page/types';
|
||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||
import { useSubtitles } from '@/components/video-page/hooks/use-subtitles';
|
||||
import { useYoutubeCaptions } from '@/components/video-page/hooks/use-youtube-captions';
|
||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
|
||||
|
||||
@@ -275,6 +277,24 @@ export function VideoPageContent({
|
||||
}, [video?.versions, activeVersionId]);
|
||||
const activeProviderId = activeVersion?.providerId;
|
||||
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
|
||||
// Only the providers that play through our own <video> element can carry a <track>.
|
||||
// A YouTube version is an iframe we do not control, and it brings its own captions.
|
||||
const supportsSubtitles = activeProviderId === 'bunny' || activeProviderId === 'r2';
|
||||
const {
|
||||
subtitles,
|
||||
subtitleTrackKey,
|
||||
canManageSubtitles,
|
||||
activeSubtitleLanguage,
|
||||
selectSubtitleLanguage,
|
||||
uploadSubtitle,
|
||||
deleteSubtitle,
|
||||
isUploadingSubtitle,
|
||||
} = useSubtitles({
|
||||
videoId,
|
||||
versionId: activeVersionId,
|
||||
videoRef,
|
||||
supportsSubtitles,
|
||||
});
|
||||
const activeVersionDuration = activeVersion?.duration;
|
||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||
const embedUrl = useMemo(() => {
|
||||
@@ -305,6 +325,7 @@ export function VideoPageContent({
|
||||
|
||||
const {
|
||||
isReady,
|
||||
youtubeModuleRevision,
|
||||
bunnyPlaybackState,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
@@ -359,6 +380,27 @@ export function VideoPageContent({
|
||||
setViewingAnnotation,
|
||||
});
|
||||
|
||||
const { youtubeCaptionTracks, activeYoutubeCaptionLanguage, selectYoutubeCaptionLanguage } =
|
||||
useYoutubeCaptions({
|
||||
videoId,
|
||||
versionId: activeVersionId,
|
||||
playerRef,
|
||||
enabled: activeProviderId === 'youtube',
|
||||
isReady,
|
||||
moduleRevision: youtubeModuleRevision,
|
||||
});
|
||||
|
||||
// One CC menu, two sources behind it. A YouTube version can only offer the captions the
|
||||
// video already carries, so nothing there is ours to manage.
|
||||
const isYoutubeVersion = activeProviderId === 'youtube';
|
||||
const subtitleTracks = isYoutubeVersion ? youtubeCaptionTracks : subtitles;
|
||||
const activeCaptionLanguage = isYoutubeVersion
|
||||
? activeYoutubeCaptionLanguage
|
||||
: activeSubtitleLanguage;
|
||||
const selectCaptionLanguage = isYoutubeVersion
|
||||
? selectYoutubeCaptionLanguage
|
||||
: selectSubtitleLanguage;
|
||||
|
||||
const {
|
||||
savedProgress,
|
||||
showResumePrompt,
|
||||
@@ -823,6 +865,15 @@ export function VideoPageContent({
|
||||
selectedQualityLevel={selectedQualityLevel}
|
||||
qualityOptions={qualityOptions}
|
||||
handleQualityChange={handleQualityChange}
|
||||
subtitles={subtitles}
|
||||
subtitleTracks={subtitleTracks}
|
||||
subtitleTrackKey={subtitleTrackKey}
|
||||
activeSubtitleLanguage={activeCaptionLanguage}
|
||||
onSelectSubtitleLanguage={selectCaptionLanguage}
|
||||
canManageSubtitles={canManageSubtitles}
|
||||
onUploadSubtitle={uploadSubtitle}
|
||||
onDeleteSubtitle={deleteSubtitle}
|
||||
isUploadingSubtitle={isUploadingSubtitle}
|
||||
playbackSpeed={playbackSpeed}
|
||||
speedOptions={speedOptions}
|
||||
handleSpeedChange={handleSpeedChange}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The chosen subtitle language, remembered per video the way a player is expected to.
|
||||
*
|
||||
* Shared by both caption paths, so a viewer who turned Turkish on for a Bunny-hosted cut
|
||||
* gets Turkish again on the YouTube version of the same video.
|
||||
*/
|
||||
|
||||
function preferenceKey(videoId: string): string {
|
||||
return `openframe:subtitle-language:${videoId}`;
|
||||
}
|
||||
|
||||
export function readStoredSubtitleLanguage(videoId: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
return window.localStorage.getItem(preferenceKey(videoId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredSubtitleLanguage(videoId: string, language: string | null): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
if (language) {
|
||||
window.localStorage.setItem(preferenceKey(videoId), language);
|
||||
} else {
|
||||
window.localStorage.removeItem(preferenceKey(videoId));
|
||||
}
|
||||
} catch {
|
||||
// A browser with storage disabled still gets subtitles, just not a remembered choice.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import {
|
||||
readStoredSubtitleLanguage,
|
||||
writeStoredSubtitleLanguage,
|
||||
} from '@/components/video-page/hooks/subtitle-preference';
|
||||
import type { Subtitle } from '@/components/video-page/types';
|
||||
|
||||
interface UseSubtitlesParams {
|
||||
videoId: string;
|
||||
versionId: string | null;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
/** Only the providers we play through our own element can carry a track. */
|
||||
supportsSubtitles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wiped track is remounted at most this many times per version. A file that really is
|
||||
* empty cannot reach storage (the upload route refuses one), so the cap only exists so a
|
||||
* surprise can never turn into a fetch loop.
|
||||
*/
|
||||
const MAX_TRACK_REPAIRS = 3;
|
||||
|
||||
export function useSubtitles({
|
||||
videoId,
|
||||
versionId,
|
||||
videoRef,
|
||||
supportsSubtitles,
|
||||
}: UseSubtitlesParams) {
|
||||
const [subtitles, setSubtitles] = useState<Subtitle[]>([]);
|
||||
const [canManageSubtitles, setCanManageSubtitles] = useState(false);
|
||||
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
|
||||
const [isUploadingSubtitle, setIsUploadingSubtitle] = useState(false);
|
||||
|
||||
// Bumped to remount the <track> elements when something empties them. See the effect
|
||||
// below for what does that and why remounting is the fix.
|
||||
const [trackEpoch, setTrackEpoch] = useState(0);
|
||||
|
||||
// The stored preference is applied once per version, not on every list refresh: turning
|
||||
// subtitles off and then deleting an unrelated track must not switch them back on.
|
||||
const appliedPreferenceForVersionRef = useRef<string | null>(null);
|
||||
const loadedLanguagesRef = useRef<Set<string>>(new Set());
|
||||
const repairCountRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadedLanguagesRef.current.clear();
|
||||
repairCountRef.current = 0;
|
||||
}, [versionId]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!versionId || !supportsSubtitles) {
|
||||
setSubtitles([]);
|
||||
setCanManageSubtitles(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/videos/${videoId}/subtitles?versionId=${encodeURIComponent(versionId)}`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const payload = await res.json();
|
||||
const list: Subtitle[] = Array.isArray(payload?.data?.subtitles)
|
||||
? payload.data.subtitles
|
||||
: [];
|
||||
setSubtitles(list);
|
||||
setCanManageSubtitles(Boolean(payload?.data?.canManageSubtitles));
|
||||
} catch {
|
||||
// A failed list leaves the player without tracks, which is the same as having none.
|
||||
}
|
||||
}, [supportsSubtitles, versionId, videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!versionId) return;
|
||||
if (appliedPreferenceForVersionRef.current === versionId) return;
|
||||
if (subtitles.length === 0) return;
|
||||
|
||||
appliedPreferenceForVersionRef.current = versionId;
|
||||
const stored = readStoredSubtitleLanguage(videoId);
|
||||
if (stored && subtitles.some((subtitle) => subtitle.language === stored)) {
|
||||
setActiveLanguage(stored);
|
||||
}
|
||||
}, [subtitles, versionId, videoId]);
|
||||
|
||||
// A track that is no longer in the list cannot stay selected.
|
||||
useEffect(() => {
|
||||
if (!activeLanguage) return;
|
||||
if (subtitles.some((subtitle) => subtitle.language === activeLanguage)) return;
|
||||
setActiveLanguage(null);
|
||||
}, [activeLanguage, subtitles]);
|
||||
|
||||
/**
|
||||
* React renders the <track> elements; their display mode is set here rather than through
|
||||
* the `default` attribute, which the browser only honours on first load and which would
|
||||
* fight the user's choice on every re-render.
|
||||
*
|
||||
* The second job here is repair. hls.js empties every text track on the media element,
|
||||
* ours included, each time it loads a manifest (`_cleanTracks()` in its timeline
|
||||
* controller). That fires on the initial load and again on every source switch, so a
|
||||
* viewer who flips quality would watch the subtitles vanish for good: the file has
|
||||
* already been fetched, so the browser never parses it a second time. Remounting the
|
||||
* track element under a new key is what makes it fetch again.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
if (!videoEl) return;
|
||||
|
||||
const findActiveTrack = (): TextTrack | null => {
|
||||
if (!activeLanguage) return null;
|
||||
const tracks = videoEl.textTracks;
|
||||
for (let index = 0; index < tracks.length; index += 1) {
|
||||
if (tracks[index].language === activeLanguage) return tracks[index];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyModes = () => {
|
||||
const tracks = videoEl.textTracks;
|
||||
for (let index = 0; index < tracks.length; index += 1) {
|
||||
const track = tracks[index];
|
||||
const shouldShow = Boolean(activeLanguage) && track.language === activeLanguage;
|
||||
track.mode = shouldShow ? 'showing' : 'disabled';
|
||||
if (!shouldShow) continue;
|
||||
|
||||
// The control bar sits over the bottom of the frame in fullscreen, so cues are
|
||||
// lifted clear of it instead of landing underneath.
|
||||
const cues = track.cues;
|
||||
if (!cues) continue;
|
||||
for (let cueIndex = 0; cueIndex < cues.length; cueIndex += 1) {
|
||||
const cue = cues[cueIndex] as VTTCue;
|
||||
if (typeof cue.line !== 'undefined') {
|
||||
cue.snapToLines = true;
|
||||
cue.line = -3;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const markLoaded = (event: Event) => {
|
||||
const element = event.currentTarget as HTMLTrackElement;
|
||||
loadedLanguagesRef.current.add(element.srclang);
|
||||
applyModes();
|
||||
};
|
||||
|
||||
const repairIfEmptied = () => {
|
||||
if (!activeLanguage) return;
|
||||
// Before the file has loaded a track legitimately has no cues, so only a track we
|
||||
// have seen load and that is now empty counts as wiped.
|
||||
if (!loadedLanguagesRef.current.has(activeLanguage)) return;
|
||||
const track = findActiveTrack();
|
||||
if (!track || (track.cues?.length ?? 0) > 0) return;
|
||||
if (repairCountRef.current >= MAX_TRACK_REPAIRS) return;
|
||||
|
||||
repairCountRef.current += 1;
|
||||
loadedLanguagesRef.current.delete(activeLanguage);
|
||||
setTrackEpoch((epoch) => epoch + 1);
|
||||
};
|
||||
|
||||
applyModes();
|
||||
|
||||
// A track's cues are null until the browser has fetched the file, which it only does
|
||||
// once the track is not disabled. The lift above therefore has to run again on load.
|
||||
const trackElements = Array.from(videoEl.querySelectorAll('track'));
|
||||
trackElements.forEach((element) => element.addEventListener('load', markLoaded));
|
||||
videoEl.textTracks.addEventListener('addtrack', applyModes);
|
||||
// `loadeddata` catches a source switch while paused; `timeupdate` catches everything
|
||||
// else within a quarter of a second of playback.
|
||||
videoEl.addEventListener('loadeddata', repairIfEmptied);
|
||||
videoEl.addEventListener('timeupdate', repairIfEmptied);
|
||||
|
||||
return () => {
|
||||
trackElements.forEach((element) => element.removeEventListener('load', markLoaded));
|
||||
videoEl.textTracks.removeEventListener('addtrack', applyModes);
|
||||
videoEl.removeEventListener('loadeddata', repairIfEmptied);
|
||||
videoEl.removeEventListener('timeupdate', repairIfEmptied);
|
||||
};
|
||||
}, [activeLanguage, subtitles, trackEpoch, videoRef, versionId]);
|
||||
|
||||
const selectSubtitleLanguage = useCallback(
|
||||
(language: string | null) => {
|
||||
setActiveLanguage(language);
|
||||
writeStoredSubtitleLanguage(videoId, language);
|
||||
appliedPreferenceForVersionRef.current = versionId;
|
||||
},
|
||||
[versionId, videoId]
|
||||
);
|
||||
|
||||
const uploadSubtitle = useCallback(
|
||||
async (file: File, language: string, label: string): Promise<string | null> => {
|
||||
if (!versionId) return 'No version selected';
|
||||
|
||||
setIsUploadingSubtitle(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('subtitle', file);
|
||||
formData.append('versionId', versionId);
|
||||
formData.append('language', language);
|
||||
formData.append('label', label);
|
||||
|
||||
const res = await fetch(`/api/videos/${videoId}/subtitles`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
return payload?.error?.message || payload?.error || 'Failed to upload subtitle';
|
||||
}
|
||||
|
||||
await refresh();
|
||||
selectSubtitleLanguage(language.toLowerCase());
|
||||
return null;
|
||||
} catch {
|
||||
return 'Failed to upload subtitle';
|
||||
} finally {
|
||||
setIsUploadingSubtitle(false);
|
||||
}
|
||||
},
|
||||
[refresh, selectSubtitleLanguage, versionId, videoId]
|
||||
);
|
||||
|
||||
const deleteSubtitle = useCallback(
|
||||
async (subtitleId: string): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/subtitles/${subtitleId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
return payload?.error?.message || payload?.error || 'Failed to delete subtitle';
|
||||
}
|
||||
await refresh();
|
||||
return null;
|
||||
} catch {
|
||||
return 'Failed to delete subtitle';
|
||||
}
|
||||
},
|
||||
[refresh, videoId]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
subtitles,
|
||||
canManageSubtitles,
|
||||
activeSubtitleLanguage: activeLanguage,
|
||||
subtitleTrackKey: String(trackEpoch),
|
||||
selectSubtitleLanguage,
|
||||
uploadSubtitle,
|
||||
deleteSubtitle,
|
||||
isUploadingSubtitle,
|
||||
refreshSubtitles: refresh,
|
||||
}),
|
||||
[
|
||||
activeLanguage,
|
||||
canManageSubtitles,
|
||||
deleteSubtitle,
|
||||
isUploadingSubtitle,
|
||||
refresh,
|
||||
selectSubtitleLanguage,
|
||||
subtitles,
|
||||
trackEpoch,
|
||||
uploadSubtitle,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -81,6 +81,10 @@ export function useVideoPlayer({
|
||||
}: UseVideoPlayerParams) {
|
||||
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
// Bumped every time the YouTube player loads or unloads a module. It is the only
|
||||
// signal that `getOption('captions', ...)` will answer, so the captions hook waits
|
||||
// on it rather than polling.
|
||||
const [youtubeModuleRevision, setYoutubeModuleRevision] = useState(0);
|
||||
const [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
@@ -315,6 +319,9 @@ export function useVideoPlayer({
|
||||
const dur = event.target.getDuration();
|
||||
if (dur > 0) setVideoDuration(dur);
|
||||
},
|
||||
onApiChange: () => {
|
||||
setYoutubeModuleRevision((revision) => revision + 1);
|
||||
},
|
||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||
|
||||
@@ -1344,6 +1351,7 @@ export function useVideoPlayer({
|
||||
|
||||
return {
|
||||
isReady,
|
||||
youtubeModuleRevision,
|
||||
bunnyPlaybackState,
|
||||
currentTime,
|
||||
setCurrentTime,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
// Same exemption as the players themselves: this hook mirrors an external player's
|
||||
// caption state into React, which is the case the rule cannot distinguish.
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import {
|
||||
readStoredSubtitleLanguage,
|
||||
writeStoredSubtitleLanguage,
|
||||
} from '@/components/video-page/hooks/subtitle-preference';
|
||||
import type { PlayerAdapter, SubtitleTrackOption } from '@/components/video-page/types';
|
||||
|
||||
interface UseYoutubeCaptionsParams {
|
||||
videoId: string;
|
||||
versionId: string | null;
|
||||
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
|
||||
/** The active version is a YouTube one. */
|
||||
enabled: boolean;
|
||||
isReady: boolean;
|
||||
/** Incremented by the player on every onApiChange. */
|
||||
moduleRevision: number;
|
||||
}
|
||||
|
||||
/** One entry of `getOption('captions', 'tracklist')`. Only these fields are relied on. */
|
||||
type YoutubeCaptionTrack = {
|
||||
languageCode?: string;
|
||||
languageName?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
const CAPTIONS_MODULE = 'captions';
|
||||
|
||||
function asYoutubePlayer(
|
||||
player: YT.Player | PlayerAdapter | null
|
||||
): (YT.Player & { loadModule?: unknown }) | null {
|
||||
if (!player) return null;
|
||||
const candidate = player as YT.Player;
|
||||
return typeof candidate.loadModule === 'function' ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives YouTube's own captions from our control bar.
|
||||
*
|
||||
* A YouTube version plays inside an iframe we do not own, so a <track> element is not an
|
||||
* option and neither is an uploaded file: the only captions that exist for it are the ones
|
||||
* the video already carries. The player is embedded with controls=0, which hides
|
||||
* YouTube's CC button along with the rest of its chrome, so without this the captions
|
||||
* would be unreachable even when they exist.
|
||||
*/
|
||||
export function useYoutubeCaptions({
|
||||
videoId,
|
||||
versionId,
|
||||
playerRef,
|
||||
enabled,
|
||||
isReady,
|
||||
moduleRevision,
|
||||
}: UseYoutubeCaptionsParams) {
|
||||
const [tracks, setTracks] = useState<SubtitleTrackOption[]>([]);
|
||||
const [activeLanguage, setActiveLanguage] = useState<string | null>(null);
|
||||
|
||||
// Read inside effects that must not re-run when the selection changes.
|
||||
const activeLanguageRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
activeLanguageRef.current = activeLanguage;
|
||||
}, [activeLanguage]);
|
||||
const appliedPreferenceForVersionRef = useRef<string | null>(null);
|
||||
/**
|
||||
* The caption state we last pushed into the player, or `undefined` before the first
|
||||
* push. Loading and unloading a module both fire onApiChange, so an effect that reacted
|
||||
* to every revision by unloading again would answer its own event forever.
|
||||
*/
|
||||
const appliedLanguageRef = useRef<string | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setTracks([]);
|
||||
setActiveLanguage(null);
|
||||
appliedLanguageRef.current = undefined;
|
||||
}, [versionId]);
|
||||
|
||||
// Loading the module is what makes the track list readable, and it also switches
|
||||
// captions on. The probe below turns them straight back off for a viewer who has not
|
||||
// asked for them: at this point the video is at its first frame with no cue to draw,
|
||||
// so there is nothing to flash.
|
||||
useEffect(() => {
|
||||
if (!enabled || !isReady) return;
|
||||
const player = asYoutubePlayer(playerRef.current);
|
||||
if (!player) return;
|
||||
try {
|
||||
player.loadModule(CAPTIONS_MODULE);
|
||||
} catch {
|
||||
// An older or restricted player without the module API simply has no captions.
|
||||
}
|
||||
}, [enabled, isReady, playerRef, versionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isReady || moduleRevision === 0) return;
|
||||
const player = asYoutubePlayer(playerRef.current);
|
||||
if (!player) return;
|
||||
|
||||
let rawTracks: YoutubeCaptionTrack[] = [];
|
||||
try {
|
||||
rawTracks = player.getOption<YoutubeCaptionTrack[]>(CAPTIONS_MODULE, 'tracklist') ?? [];
|
||||
} catch {
|
||||
rawTracks = [];
|
||||
}
|
||||
|
||||
const mapped: SubtitleTrackOption[] = rawTracks
|
||||
.filter((track): track is YoutubeCaptionTrack & { languageCode: string } =>
|
||||
Boolean(track?.languageCode)
|
||||
)
|
||||
.map((track) => ({
|
||||
id: `youtube:${track.languageCode}`,
|
||||
language: track.languageCode.toLowerCase(),
|
||||
label: track.displayName || track.languageName || track.languageCode.toUpperCase(),
|
||||
canDelete: false,
|
||||
}));
|
||||
|
||||
setTracks(mapped);
|
||||
|
||||
const stored =
|
||||
versionId && appliedPreferenceForVersionRef.current !== versionId
|
||||
? readStoredSubtitleLanguage(videoId)
|
||||
: null;
|
||||
if (versionId) appliedPreferenceForVersionRef.current = versionId;
|
||||
|
||||
const wanted =
|
||||
activeLanguageRef.current ??
|
||||
(stored && mapped.some((track) => track.language === stored) ? stored : null);
|
||||
|
||||
if (appliedLanguageRef.current === wanted) return;
|
||||
appliedLanguageRef.current = wanted;
|
||||
|
||||
try {
|
||||
if (wanted) {
|
||||
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: wanted });
|
||||
setActiveLanguage(wanted);
|
||||
} else {
|
||||
player.unloadModule(CAPTIONS_MODULE);
|
||||
}
|
||||
} catch {
|
||||
// Same as above: a player that will not take the option has no captions to give.
|
||||
}
|
||||
}, [enabled, isReady, moduleRevision, playerRef, versionId, videoId]);
|
||||
|
||||
const selectCaptionLanguage = useCallback(
|
||||
(language: string | null) => {
|
||||
setActiveLanguage(language);
|
||||
writeStoredSubtitleLanguage(videoId, language);
|
||||
appliedPreferenceForVersionRef.current = versionId;
|
||||
appliedLanguageRef.current = language;
|
||||
|
||||
const player = asYoutubePlayer(playerRef.current);
|
||||
if (!player) return;
|
||||
try {
|
||||
if (language) {
|
||||
player.loadModule(CAPTIONS_MODULE);
|
||||
player.setOption(CAPTIONS_MODULE, 'track', { languageCode: language });
|
||||
} else {
|
||||
player.unloadModule(CAPTIONS_MODULE);
|
||||
}
|
||||
} catch {
|
||||
// Nothing to recover: the menu already reflects the choice, and a player that
|
||||
// refuses the module was never going to show captions.
|
||||
}
|
||||
},
|
||||
[playerRef, versionId, videoId]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
youtubeCaptionTracks: enabled ? tracks : [],
|
||||
activeYoutubeCaptionLanguage: enabled ? activeLanguage : null,
|
||||
selectYoutubeCaptionLanguage: selectCaptionLanguage,
|
||||
}),
|
||||
[activeLanguage, enabled, selectCaptionLanguage, tracks]
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,13 @@ import {
|
||||
type AnnotationStroke,
|
||||
} from '@/components/annotation-canvas';
|
||||
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
|
||||
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
|
||||
import { SubtitleControls } from '@/components/video-page/subtitle-controls';
|
||||
import type {
|
||||
BunnyQualityOption,
|
||||
CommentMarker,
|
||||
Subtitle,
|
||||
SubtitleTrackOption,
|
||||
} from '@/components/video-page/types';
|
||||
|
||||
interface PlayerCoreProps {
|
||||
activeVersionId: string | null;
|
||||
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
|
||||
selectedQualityLevel: number;
|
||||
qualityOptions: BunnyQualityOption[];
|
||||
handleQualityChange: (level: number) => void;
|
||||
/** Uploaded tracks, rendered as <track> elements. Empty for a YouTube version. */
|
||||
subtitles: Subtitle[];
|
||||
/**
|
||||
* What the CC menu offers, which is the list above for our own player and YouTube's
|
||||
* own caption list for an embedded YouTube version.
|
||||
*/
|
||||
subtitleTracks: SubtitleTrackOption[];
|
||||
/**
|
||||
* Changes when a track has to be re-fetched. It is part of each <track> key because
|
||||
* remounting the element is the only way to make the browser parse the file again.
|
||||
*/
|
||||
subtitleTrackKey: string;
|
||||
activeSubtitleLanguage: string | null;
|
||||
onSelectSubtitleLanguage: (language: string | null) => void;
|
||||
canManageSubtitles: boolean;
|
||||
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
|
||||
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
|
||||
isUploadingSubtitle: boolean;
|
||||
playbackSpeed: number;
|
||||
speedOptions: number[];
|
||||
handleSpeedChange: (speed: number) => void;
|
||||
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
selectedQualityLevel,
|
||||
qualityOptions,
|
||||
handleQualityChange,
|
||||
subtitles,
|
||||
subtitleTracks,
|
||||
subtitleTrackKey,
|
||||
activeSubtitleLanguage,
|
||||
onSelectSubtitleLanguage,
|
||||
canManageSubtitles,
|
||||
onUploadSubtitle,
|
||||
onDeleteSubtitle,
|
||||
isUploadingSubtitle,
|
||||
playbackSpeed,
|
||||
speedOptions,
|
||||
handleSpeedChange,
|
||||
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
}}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
>
|
||||
{subtitles.map((subtitle) => (
|
||||
<track
|
||||
key={`${subtitle.id}:${subtitleTrackKey}`}
|
||||
kind="subtitles"
|
||||
src={subtitle.url}
|
||||
srcLang={subtitle.language}
|
||||
label={subtitle.label}
|
||||
/>
|
||||
))}
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{activeProviderId && activeProviderId !== 'direct' && (
|
||||
<SubtitleControls
|
||||
subtitles={subtitleTracks}
|
||||
activeSubtitleLanguage={activeSubtitleLanguage}
|
||||
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
|
||||
canManageSubtitles={canManageSubtitles}
|
||||
onUploadSubtitle={onUploadSubtitle}
|
||||
onDeleteSubtitle={onDeleteSubtitle}
|
||||
isUploadingSubtitle={isUploadingSubtitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Captions, Loader2, Trash2, Upload } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SubtitleTrackOption } from '@/components/video-page/types';
|
||||
|
||||
const COMMON_LANGUAGES = [
|
||||
'tr',
|
||||
'en',
|
||||
'de',
|
||||
'fr',
|
||||
'es',
|
||||
'it',
|
||||
'pt',
|
||||
'nl',
|
||||
'pl',
|
||||
'ru',
|
||||
'ar',
|
||||
'ja',
|
||||
'ko',
|
||||
'zh',
|
||||
'hi',
|
||||
] as const;
|
||||
|
||||
const OTHER_LANGUAGE = '__other__';
|
||||
|
||||
/** A file named `cut-v3.tr.srt` already says which language it is. */
|
||||
const LANGUAGE_FROM_FILENAME = /\.([a-z]{2,3}(?:-[a-z0-9]{2,8})?)\.(?:srt|vtt)$/i;
|
||||
|
||||
function describeLanguage(tag: string): string {
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(undefined, { type: 'language' });
|
||||
return displayNames.of(tag) || tag.toUpperCase();
|
||||
} catch {
|
||||
return tag.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
function guessLanguageFromFileName(fileName: string): string | null {
|
||||
const match = LANGUAGE_FROM_FILENAME.exec(fileName);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
interface SubtitleControlsProps {
|
||||
/**
|
||||
* What the menu lists. For a Bunny or R2 version these are the tracks uploaded to this
|
||||
* cut; for a YouTube version they are the captions the video already carries, which is
|
||||
* why the shape is narrower than a stored subtitle.
|
||||
*/
|
||||
subtitles: SubtitleTrackOption[];
|
||||
activeSubtitleLanguage: string | null;
|
||||
onSelectSubtitleLanguage: (language: string | null) => void;
|
||||
canManageSubtitles: boolean;
|
||||
onUploadSubtitle: (file: File, language: string, label: string) => Promise<string | null>;
|
||||
onDeleteSubtitle: (subtitleId: string) => Promise<string | null>;
|
||||
isUploadingSubtitle: boolean;
|
||||
}
|
||||
|
||||
export const SubtitleControls = memo(function SubtitleControls({
|
||||
subtitles,
|
||||
activeSubtitleLanguage,
|
||||
onSelectSubtitleLanguage,
|
||||
canManageSubtitles,
|
||||
onUploadSubtitle,
|
||||
onDeleteSubtitle,
|
||||
isUploadingSubtitle,
|
||||
}: SubtitleControlsProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [languageChoice, setLanguageChoice] = useState<string>('tr');
|
||||
const [customLanguage, setCustomLanguage] = useState('');
|
||||
const [label, setLabel] = useState('');
|
||||
|
||||
const activeSubtitle = useMemo(
|
||||
() => subtitles.find((subtitle) => subtitle.language === activeSubtitleLanguage) ?? null,
|
||||
[activeSubtitleLanguage, subtitles]
|
||||
);
|
||||
|
||||
const resolvedLanguage = (
|
||||
languageChoice === OTHER_LANGUAGE ? customLanguage : languageChoice
|
||||
).trim();
|
||||
|
||||
const handleFileChosen = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
// Clearing the input lets the same file be picked again after a failed upload.
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const guessed = guessLanguageFromFileName(file.name);
|
||||
const known = guessed && (COMMON_LANGUAGES as readonly string[]).includes(guessed);
|
||||
setLanguageChoice(known ? (guessed as string) : guessed ? OTHER_LANGUAGE : 'tr');
|
||||
setCustomLanguage(known ? '' : (guessed ?? ''));
|
||||
setLabel('');
|
||||
setPendingFile(file);
|
||||
}, []);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (!pendingFile || !resolvedLanguage) return;
|
||||
const finalLabel = label.trim() || describeLanguage(resolvedLanguage);
|
||||
const error = await onUploadSubtitle(pendingFile, resolvedLanguage, finalLabel);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
toast.success('Subtitle added');
|
||||
setPendingFile(null);
|
||||
}, [label, onUploadSubtitle, pendingFile, resolvedLanguage]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (subtitle: SubtitleTrackOption) => {
|
||||
const error = await onDeleteSubtitle(subtitle.id);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
toast.success(`${subtitle.label} removed`);
|
||||
},
|
||||
[onDeleteSubtitle]
|
||||
);
|
||||
|
||||
if (subtitles.length === 0 && !canManageSubtitles) return null;
|
||||
|
||||
const replacesExisting = subtitles.some(
|
||||
(subtitle) => subtitle.language === resolvedLanguage.toLowerCase()
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={activeSubtitle ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
title="Subtitles"
|
||||
>
|
||||
<Captions className="h-3.5 w-3.5" />
|
||||
{activeSubtitle ? activeSubtitle.label : 'CC'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSelectSubtitleLanguage(null)}
|
||||
className={cn(!activeSubtitleLanguage && 'font-bold text-primary')}
|
||||
>
|
||||
Off
|
||||
</DropdownMenuItem>
|
||||
{subtitles.map((subtitle) => (
|
||||
<DropdownMenuItem
|
||||
key={subtitle.id}
|
||||
onClick={() => onSelectSubtitleLanguage(subtitle.language)}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2',
|
||||
subtitle.language === activeSubtitleLanguage && 'font-bold text-primary'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{subtitle.label}</span>
|
||||
{subtitle.canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Delete ${subtitle.label} subtitle`}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void handleDelete(subtitle);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{canManageSubtitles && (
|
||||
<>
|
||||
{subtitles.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingSubtitle}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-2" />
|
||||
Add subtitle
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".srt,.vtt,text/vtt,application/x-subrip"
|
||||
className="hidden"
|
||||
onChange={handleFileChosen}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={!!pendingFile}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isUploadingSubtitle) setPendingFile(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add subtitle</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingFile?.name} is attached to this version only, because cue timings belong to
|
||||
one cut. SRT files are converted to WebVTT on upload.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle-language">Language</Label>
|
||||
<Select value={languageChoice} onValueChange={setLanguageChoice}>
|
||||
<SelectTrigger id="subtitle-language">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_LANGUAGES.map((tag) => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{describeLanguage(tag)}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={OTHER_LANGUAGE}>Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{languageChoice === OTHER_LANGUAGE && (
|
||||
<Input
|
||||
value={customLanguage}
|
||||
onChange={(event) => setCustomLanguage(event.target.value)}
|
||||
placeholder="Language tag, e.g. en-US"
|
||||
maxLength={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subtitle-label">Label</Label>
|
||||
<Input
|
||||
id="subtitle-label"
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={resolvedLanguage ? describeLanguage(resolvedLanguage) : 'Türkçe'}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{replacesExisting && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This version already has a track in that language. Uploading replaces it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setPendingFile(null)}
|
||||
disabled={isUploadingSubtitle}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleUpload()}
|
||||
disabled={isUploadingSubtitle || !resolvedLanguage}
|
||||
>
|
||||
{isUploadingSubtitle && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,27 @@ export interface VideoAsset {
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the player's CC menu needs to know about one track. Our own uploaded tracks and
|
||||
* the ones a YouTube video brings with it are different things underneath, and the menu
|
||||
* is the one place that does not have to care.
|
||||
*/
|
||||
export interface SubtitleTrackOption {
|
||||
id: string;
|
||||
language: string;
|
||||
label: string;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export interface Subtitle extends SubtitleTrackOption {
|
||||
versionId: string;
|
||||
url: string;
|
||||
sizeBytes: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
|
||||
}
|
||||
|
||||
export interface ApprovalDecision {
|
||||
id: string;
|
||||
approverId: string;
|
||||
|
||||
@@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = {
|
||||
includeAssets?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subtitle tracks are deliberately not in the manifest. They belong to a version rather
|
||||
* than to a video, and a zip that carried them would need a naming scheme that pairs each
|
||||
* .vtt with the cut it was timed against. Add them the day that pairing is designed, not
|
||||
* as a loose file next to the videos.
|
||||
*/
|
||||
export function buildProjectDownloadManifest(
|
||||
projectName: string,
|
||||
videos: VideoRow[],
|
||||
|
||||
+26
-4
@@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
||||
import { db } from '@/lib/db';
|
||||
import { runWithConcurrency } from '@/lib/async-pool';
|
||||
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
|
||||
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
|
||||
import { logError } from '@/lib/logger';
|
||||
|
||||
/** The path prefix for images served by the upload API. */
|
||||
@@ -34,7 +35,7 @@ export function mediaUrlToKey(url: string): string | null {
|
||||
return filename ? `images/${filename}` : null;
|
||||
}
|
||||
|
||||
return videoProxyPathToObjectKey(url);
|
||||
return subtitleProxyPathToObjectKey(url) ?? videoProxyPathToObjectKey(url);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ export async function deleteMediaFilesBestEffort(mediaUrls: string[]): Promise<R
|
||||
* Collect all media URLs from comments under a given video (all versions).
|
||||
*/
|
||||
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
const [comments, assets, versions, subtitles] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
@@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
where: { videoParentId: videoId, providerId: 'r2' },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
db.videoSubtitle.findMany({
|
||||
where: { version: { videoParentId: videoId } },
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
subtitles.forEach((subtitle) => {
|
||||
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -121,7 +129,7 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
||||
* Collect all media URLs from comments under all videos in a project.
|
||||
*/
|
||||
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
const [comments, assets, versions, subtitles] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
@@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
where: { providerId: 'r2', video: { projectId } },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
db.videoSubtitle.findMany({
|
||||
where: { version: { video: { projectId } } },
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
subtitles.forEach((subtitle) => {
|
||||
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -160,7 +175,7 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
||||
* Collect all media URLs from comments under all projects in a workspace.
|
||||
*/
|
||||
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
||||
const [comments, assets, versions] = await Promise.all([
|
||||
const [comments, assets, versions, subtitles] = await Promise.all([
|
||||
db.comment.findMany({
|
||||
where: {
|
||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||
@@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
where: { providerId: 'r2', video: { project: { workspaceId } } },
|
||||
select: { originalUrl: true, thumbnailUrl: true },
|
||||
}),
|
||||
db.videoSubtitle.findMany({
|
||||
where: { version: { video: { project: { workspaceId } } } },
|
||||
select: { sourceUrl: true },
|
||||
}),
|
||||
]);
|
||||
const urls: string[] = [];
|
||||
comments.forEach((c) => {
|
||||
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
||||
if (version.originalUrl) urls.push(version.originalUrl);
|
||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||
});
|
||||
subtitles.forEach((subtitle) => {
|
||||
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ type ProxyR2MediaOptions = {
|
||||
// call sites gate the file name on a strict pattern first, and a fourth that forgot would
|
||||
// otherwise hand a traversal straight to GetObject.
|
||||
const SAFE_MEDIA_OBJECT_KEY =
|
||||
/^(?:images|voice|videos)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
/^(?:images|voice|videos|subtitles)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||
|
||||
export function isSafeR2MediaKey(key: string): boolean {
|
||||
return SAFE_MEDIA_OBJECT_KEY.test(key);
|
||||
|
||||
@@ -71,6 +71,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
||||
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||
'subtitle-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
|
||||
'subtitle-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
'subtitle-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||
|
||||
// Search — debounced on client but protect against scripted callers
|
||||
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
||||
|
||||
+25
-8
@@ -132,6 +132,8 @@ export const UPLOAD_RESERVATION_PURPOSES = {
|
||||
R2_VIDEO: 'R2_VIDEO',
|
||||
/** A direct upload to Bunny, where the bytes never pass through us. */
|
||||
BUNNY: 'BUNNY',
|
||||
/** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */
|
||||
SUBTITLE: 'SUBTITLE',
|
||||
} as const;
|
||||
|
||||
export type UploadReservationPurpose =
|
||||
@@ -147,14 +149,15 @@ class QuotaExceededError extends Error {}
|
||||
* every upload.
|
||||
*/
|
||||
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
||||
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] =
|
||||
await Promise.all([
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_assets
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||
`,
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||
FROM video_versions vv
|
||||
INNER JOIN videos v ON v.id = vv."videoParentId"
|
||||
@@ -163,21 +166,27 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`,
|
||||
getUserBunnyStorageBytes(userId),
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_subtitles
|
||||
WHERE "billedUserId" = ${userId}
|
||||
`,
|
||||
getUserBunnyStorageBytes(userId),
|
||||
db.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||
FROM upload_reservations
|
||||
WHERE "billedUserId" = ${userId}
|
||||
AND "expiresAt" > NOW()
|
||||
`,
|
||||
]);
|
||||
]);
|
||||
|
||||
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
|
||||
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
|
||||
const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0);
|
||||
const bunnyBytes = BigInt(bunnyUserBytes);
|
||||
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
|
||||
|
||||
return r2AssetBytes + r2VideoBytes + bunnyBytes + reservedBytes;
|
||||
return r2AssetBytes + r2VideoBytes + subtitleBytes + bunnyBytes + reservedBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,7 +300,15 @@ export async function reserveStorageQuota(
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`;
|
||||
const r2Bytes = (r2AssetRow?.total ?? BigInt(0)) + (r2VideoRow?.total ?? BigInt(0));
|
||||
const [subtitleRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||
FROM video_subtitles
|
||||
WHERE "billedUserId" = ${userId}
|
||||
`;
|
||||
const r2Bytes =
|
||||
(r2AssetRow?.total ?? BigInt(0)) +
|
||||
(r2VideoRow?.total ?? BigInt(0)) +
|
||||
(subtitleRow?.total ?? BigInt(0));
|
||||
|
||||
// Read active (non-expired) reservations under the same lock
|
||||
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Subtitle uploads are normalised before they are stored: whatever the user hands us,
|
||||
* SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file.
|
||||
* Anything we did not understand is dropped rather than passed through, so the file the
|
||||
* player fetches contains cues and nothing else. That is what makes it safe to serve a
|
||||
* user-supplied text file from our own origin.
|
||||
*/
|
||||
|
||||
/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */
|
||||
export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
/** Ceiling on the normalised output, so a pathological input cannot be stored. */
|
||||
export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024;
|
||||
|
||||
export const MAX_SUBTITLE_CUES = 5000;
|
||||
|
||||
/** Longer than this and it is not a subtitle, it is a document being smuggled in. */
|
||||
const MAX_CUE_TEXT_LENGTH = 500;
|
||||
|
||||
export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const;
|
||||
|
||||
export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/';
|
||||
|
||||
export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/';
|
||||
|
||||
/** The only shape a subtitle URL may take once it has been through our upload API. */
|
||||
export const SAFE_SUBTITLE_PROXY_PATH =
|
||||
/^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
|
||||
|
||||
export const SAFE_SUBTITLE_FILENAME =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
|
||||
|
||||
export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8';
|
||||
|
||||
/** Room for a label a human typed, not for a paragraph. */
|
||||
const MAX_SUBTITLE_LABEL_LENGTH = 60;
|
||||
|
||||
/**
|
||||
* BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`,
|
||||
* `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute
|
||||
* and in a unique index.
|
||||
*/
|
||||
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i;
|
||||
|
||||
export type SubtitleCue = {
|
||||
/** Seconds from the start of the video. */
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SubtitleNormalizeResult =
|
||||
| { ok: true; vtt: string; cueCount: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Cue text may carry a small amount of WebVTT markup. Everything outside this list is
|
||||
* removed: the browser's VTT parser does not execute scripts, but a file that only ever
|
||||
* contains tags we recognise is one less thing to reason about.
|
||||
*/
|
||||
const ALLOWED_CUE_TAGS = [
|
||||
/^<\/?[biu]>$/i,
|
||||
/^<\/?ruby>$/i,
|
||||
/^<\/?rt>$/i,
|
||||
/^<\/?c(?:\.[\w-]+)*>$/i,
|
||||
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{1,80})?>$/i,
|
||||
/^<\/v>$/i,
|
||||
/^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/,
|
||||
];
|
||||
|
||||
export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'vtt' || ext === 'srt') return ext;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a language tag for storage. Kept lowercase so the unique index on
|
||||
* (version, language) treats `TR` and `tr` as the same track.
|
||||
*/
|
||||
export function normalizeSubtitleLanguage(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null;
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
export function sanitizeSubtitleLabel(value: unknown, fallback: string): string {
|
||||
const raw = typeof value === 'string' ? value : '';
|
||||
const normalized = raw
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized) return fallback;
|
||||
return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved
|
||||
* out of a Windows tool is usually windows-1254, and rejecting it outright would send the
|
||||
* user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a
|
||||
* valid file is never mangled by a legacy codepage.
|
||||
*/
|
||||
export function decodeSubtitleBuffer(buffer: Uint8Array): string | null {
|
||||
for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) {
|
||||
try {
|
||||
const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer);
|
||||
return decoded.replace(/^\uFEFF/, '');
|
||||
} catch {
|
||||
// Wrong encoding, or one this runtime's ICU build does not carry. Try the next.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): number | null {
|
||||
const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const hours = match[1] ? Number(match[1]) : 0;
|
||||
const minutes = Number(match[2]);
|
||||
const seconds = Number(match[3]);
|
||||
const millis = Number(match[4].padEnd(3, '0'));
|
||||
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
|
||||
}
|
||||
|
||||
function formatTimestamp(seconds: number): string {
|
||||
const clamped = Math.max(0, seconds);
|
||||
const totalMillis = Math.round(clamped * 1000);
|
||||
const hours = Math.floor(totalMillis / 3_600_000);
|
||||
const minutes = Math.floor((totalMillis % 3_600_000) / 60_000);
|
||||
const secs = Math.floor((totalMillis % 60_000) / 1000);
|
||||
const millis = totalMillis % 1000;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function parseTimingLine(line: string): { start: number; end: number } | null {
|
||||
const separatorIndex = line.indexOf('-->');
|
||||
if (separatorIndex === -1) return null;
|
||||
const start = parseTimestamp(line.slice(0, separatorIndex));
|
||||
// Anything after the end timestamp is a cue setting (position, align, line). They are
|
||||
// dropped: the player positions cues itself so its own control bar does not cover them.
|
||||
const rest = line.slice(separatorIndex + 3).trim();
|
||||
const end = parseTimestamp(rest.split(/\s+/)[0] ?? '');
|
||||
if (start === null || end === null) return null;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const CUE_TAG = /<[^<>]*>/g;
|
||||
|
||||
/**
|
||||
* Angle brackets outside a recognised tag are escaped one character at a time rather than
|
||||
* the offending tag being deleted whole. Deleting is what lets a filter like this be
|
||||
* reassembled around: strip the `<b>` out of `<scr<b>ipt>` and the two halves close up
|
||||
* into a tag that was never written. Nothing closes up when the leftovers are escaped
|
||||
* instead, and the same escaping takes care of `-->`, which would otherwise be read back
|
||||
* as a timing line and split the cue in two. `&` is left alone so a file that already
|
||||
* spells its entities properly keeps them.
|
||||
*/
|
||||
function escapeCueText(text: string): string {
|
||||
return text.replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function sanitizeCueLine(line: string): string {
|
||||
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser
|
||||
// renders them as literal text, which is never what the author meant.
|
||||
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
|
||||
|
||||
let sanitized = '';
|
||||
let cursor = 0;
|
||||
CUE_TAG.lastIndex = 0;
|
||||
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
|
||||
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
|
||||
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
|
||||
sanitized += match[0];
|
||||
}
|
||||
cursor = match.index + match[0].length;
|
||||
}
|
||||
sanitized += escapeCueText(withoutOverrides.slice(cursor));
|
||||
|
||||
return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
|
||||
* SRT sequence numbers) are skipped rather than carried over.
|
||||
*/
|
||||
export function parseSubtitleCues(input: string): SubtitleCue[] {
|
||||
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
||||
const cues: SubtitleCue[] = [];
|
||||
|
||||
let index = 0;
|
||||
// A STYLE or REGION block runs until the next blank line and may itself contain no
|
||||
// timing, so it is skipped wholesale rather than line by line.
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
|
||||
index += 1;
|
||||
while (index < lines.length && lines[index].trim()) index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
|
||||
// id). The timing is then on the following line.
|
||||
let timing = parseTimingLine(trimmed);
|
||||
if (!timing) {
|
||||
const next = lines[index + 1]?.trim();
|
||||
if (!next) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
timing = parseTimingLine(next);
|
||||
if (!timing) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
|
||||
const textLines: string[] = [];
|
||||
while (index < lines.length && lines[index].trim()) {
|
||||
const sanitized = sanitizeCueLine(lines[index]);
|
||||
if (sanitized.trim()) textLines.push(sanitized);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (timing.end <= timing.start) continue;
|
||||
// The cap can land inside an escape the sanitiser wrote, so a dangling `<` tail is
|
||||
// trimmed rather than left for the parser to render as text.
|
||||
const text = textLines
|
||||
.join('\n')
|
||||
.slice(0, MAX_CUE_TEXT_LENGTH)
|
||||
.replace(/&[a-z]{0,5}$/i, '')
|
||||
.trim();
|
||||
if (!text) continue;
|
||||
|
||||
cues.push({ start: timing.start, end: timing.end, text });
|
||||
if (cues.length >= MAX_SUBTITLE_CUES) break;
|
||||
}
|
||||
|
||||
return cues;
|
||||
}
|
||||
|
||||
export function serializeWebVtt(cues: SubtitleCue[]): string {
|
||||
const body = cues
|
||||
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
|
||||
.join('\n\n');
|
||||
return `WEBVTT\n\n${body}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
|
||||
* what is wrong with the file in terms the person who uploaded it can act on.
|
||||
*/
|
||||
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
|
||||
if (buffer.byteLength === 0) {
|
||||
return { ok: false, error: 'Subtitle file is empty' };
|
||||
}
|
||||
|
||||
const decoded = decodeSubtitleBuffer(buffer);
|
||||
if (decoded === null) {
|
||||
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
|
||||
}
|
||||
|
||||
const cues = parseSubtitleCues(decoded);
|
||||
if (cues.length === 0) {
|
||||
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
|
||||
}
|
||||
|
||||
const vtt = serializeWebVtt(cues);
|
||||
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
|
||||
return { ok: false, error: 'Subtitle file is too large after conversion' };
|
||||
}
|
||||
|
||||
return { ok: true, vtt, cueCount: cues.length };
|
||||
}
|
||||
|
||||
export function subtitleFileNameToProxyUrl(fileName: string): string {
|
||||
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
|
||||
}
|
||||
|
||||
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
|
||||
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
|
||||
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
|
||||
}
|
||||
|
||||
export function subtitleProxyPathToObjectKey(url: string): string | null {
|
||||
const fileName = extractSubtitleFileNameFromProxyUrl(url);
|
||||
if (!fileName) return null;
|
||||
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Subtitle tracks hang off a version, not off the video: re-editing a cut shifts
|
||||
-- every cue, so a track attached to the parent would be wrong for every version
|
||||
-- but the one it was written against.
|
||||
CREATE TABLE "video_subtitles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"versionId" TEXT NOT NULL,
|
||||
"language" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"sourceUrl" TEXT NOT NULL,
|
||||
"size_bytes" BIGINT NOT NULL DEFAULT 0,
|
||||
"billedUserId" TEXT NOT NULL,
|
||||
"uploadedByUserId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "video_subtitles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- One stored object belongs to exactly one row, so the reference check that runs
|
||||
-- before an object delete cannot be fooled by a second row pointing at the file.
|
||||
CREATE UNIQUE INDEX "video_subtitles_sourceUrl_key" ON "video_subtitles"("sourceUrl");
|
||||
|
||||
-- Re-uploading a language replaces the track rather than stacking a second one,
|
||||
-- which would leave the player with two tracks labelled the same.
|
||||
CREATE UNIQUE INDEX "video_subtitles_versionId_language_key" ON "video_subtitles"("versionId", "language");
|
||||
|
||||
CREATE INDEX "video_subtitles_versionId_idx" ON "video_subtitles"("versionId");
|
||||
|
||||
-- The storage quota sums this column per billed user on every upload.
|
||||
CREATE INDEX "video_subtitles_billedUserId_idx" ON "video_subtitles"("billedUserId");
|
||||
|
||||
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_versionId_fkey"
|
||||
FOREIGN KEY ("versionId") REFERENCES "video_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_billedUserId_fkey"
|
||||
FOREIGN KEY ("billedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_uploadedByUserId_fkey"
|
||||
FOREIGN KEY ("uploadedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -42,6 +42,8 @@ model User {
|
||||
comments Comment[]
|
||||
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
|
||||
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
|
||||
uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy")
|
||||
billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo")
|
||||
projectMemberships ProjectMember[]
|
||||
notificationSetting NotificationSetting?
|
||||
watchProgress WatchProgress[]
|
||||
@@ -383,6 +385,7 @@ model VideoVersion {
|
||||
comments Comment[]
|
||||
watchProgress WatchProgress[]
|
||||
approvalRequests ApprovalRequest[]
|
||||
subtitles VideoSubtitle[]
|
||||
|
||||
@@unique([videoParentId, versionNumber])
|
||||
@@index([videoParentId])
|
||||
@@ -418,6 +421,34 @@ model VideoAsset {
|
||||
@@map("video_assets")
|
||||
}
|
||||
|
||||
/// A subtitle track for one cut. Timings belong to a version rather than to the
|
||||
/// video: re-editing shifts every cue, so a track attached to the parent would be
|
||||
/// wrong for every version but the one it was written against.
|
||||
model VideoSubtitle {
|
||||
id String @id @default(cuid())
|
||||
versionId String
|
||||
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||
/// BCP-47 tag, lowercased primary subtag, e.g. `tr`, `en-US`.
|
||||
language String
|
||||
label String
|
||||
/// Always an /api/upload/subtitle/<uuid>.vtt path. The file itself lives in
|
||||
/// S3-compatible storage whatever the video's own provider is, so a Bunny-hosted
|
||||
/// video and an R2-hosted one take the same path through the player.
|
||||
sourceUrl String @unique
|
||||
sizeBytes BigInt @default(0) @map("size_bytes")
|
||||
billedUserId String
|
||||
billedUser User @relation("VideoSubtitleBilledTo", fields: [billedUserId], references: [id], onDelete: Cascade)
|
||||
uploadedByUserId String?
|
||||
uploadedByUser User? @relation("VideoSubtitleUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([versionId, language])
|
||||
@@index([versionId])
|
||||
@@index([billedUserId])
|
||||
@@map("video_subtitles")
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ import * as uploadAudioFileRoute from '@/app/api/upload/audio/[filename]/route';
|
||||
import * as uploadAudioRoute from '@/app/api/upload/audio/route';
|
||||
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
|
||||
import * as uploadImageRoute from '@/app/api/upload/image/route';
|
||||
import * as uploadSubtitleFileRoute from '@/app/api/upload/subtitle/[filename]/route';
|
||||
import * as uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
|
||||
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
|
||||
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/route';
|
||||
@@ -92,6 +93,8 @@ import * as assetRoute from '@/app/api/videos/[videoId]/assets/[assetId]/route';
|
||||
import * as assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
|
||||
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-init/route';
|
||||
import * as assetsRoute from '@/app/api/videos/[videoId]/assets/route';
|
||||
import * as subtitleRoute from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
|
||||
import * as subtitlesRoute from '@/app/api/videos/[videoId]/subtitles/route';
|
||||
import * as watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
|
||||
import * as watchRoute from '@/app/api/watch/[videoId]/route';
|
||||
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/route';
|
||||
@@ -145,7 +148,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
// The count guard
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 63;
|
||||
const EXPECTED_ROUTE_MODULE_COUNT = 66;
|
||||
|
||||
/**
|
||||
* Routes that are public by design, and why. Everything else must reject an
|
||||
@@ -203,6 +206,7 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
|
||||
const IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
|
||||
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
|
||||
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
|
||||
const SUBTITLE_FILENAME = '44444444-4444-4444-8444-444444444444.vtt';
|
||||
|
||||
interface Fixtures {
|
||||
userId: string;
|
||||
@@ -217,6 +221,7 @@ interface Fixtures {
|
||||
versionId: string;
|
||||
commentId: string;
|
||||
assetId: string;
|
||||
subtitleId: string;
|
||||
approvalRequestId: string;
|
||||
feedbackId: string;
|
||||
}
|
||||
@@ -280,6 +285,20 @@ async function seedFixtures(): Promise<Fixtures> {
|
||||
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
|
||||
});
|
||||
|
||||
// A real track, so /api/upload/subtitle/[filename] resolves to a row and its
|
||||
// refusal comes from the access check rather than from the reverse lookup.
|
||||
const subtitle = await db.videoSubtitle.create({
|
||||
data: {
|
||||
versionId: version.id,
|
||||
language: 'tr',
|
||||
label: 'Türkçe',
|
||||
sourceUrl: `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
|
||||
sizeBytes: BigInt(64),
|
||||
billedUserId: owner.id,
|
||||
uploadedByUserId: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
|
||||
|
||||
const approvalRequest = await createApprovalRequest({
|
||||
@@ -310,6 +329,7 @@ async function seedFixtures(): Promise<Fixtures> {
|
||||
versionId: version.id,
|
||||
commentId: comment.id,
|
||||
assetId: asset.id,
|
||||
subtitleId: subtitle.id,
|
||||
approvalRequestId: approvalRequest.id,
|
||||
feedbackId: feedback.id,
|
||||
};
|
||||
@@ -595,6 +615,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
||||
// and constructing a Request from a FormData does not set one.
|
||||
headers: { 'content-length': '2048' },
|
||||
},
|
||||
{
|
||||
file: 'upload/subtitle/[filename]/route.ts',
|
||||
module: uploadSubtitleFileRoute,
|
||||
url: () => `/api/upload/subtitle/${SUBTITLE_FILENAME}`,
|
||||
params: () => ({ filename: SUBTITLE_FILENAME }),
|
||||
},
|
||||
{
|
||||
file: 'upload/video/[filename]/route.ts',
|
||||
module: uploadVideoFileRoute,
|
||||
@@ -669,6 +695,27 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
||||
// exact-status coverage lives in tests/api/assets-authz.test.ts.
|
||||
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
|
||||
},
|
||||
{
|
||||
file: 'videos/[videoId]/subtitles/[subtitleId]/route.ts',
|
||||
module: subtitleRoute,
|
||||
url: (f) => `/api/videos/${f.videoId}/subtitles/${f.subtitleId}`,
|
||||
params: (f) => ({ videoId: f.videoId, subtitleId: f.subtitleId }),
|
||||
},
|
||||
{
|
||||
file: 'videos/[videoId]/subtitles/route.ts',
|
||||
module: subtitlesRoute,
|
||||
url: (f) => `/api/videos/${f.videoId}/subtitles`,
|
||||
params: (f) => ({ videoId: f.videoId }),
|
||||
// POST sizes the body before it does anything else, and a Request built from
|
||||
// a FormData carries no Content-Length, so without this the anonymous call
|
||||
// would stop on a 400 above the guard rather than on the guard.
|
||||
headers: { 'content-length': '4096' },
|
||||
rawBody: () => {
|
||||
const form = new FormData();
|
||||
form.append('subtitle', new File(['WEBVTT'], 'anon.vtt', { type: 'text/vtt' }));
|
||||
return form;
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'watch/[videoId]/progress/route.ts',
|
||||
module: watchProgressRoute,
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
// The subtitle family: list, upload, delete, and the proxy that serves the stored
|
||||
// WebVTT back to the player.
|
||||
//
|
||||
// Two properties are worth pinning down here rather than in the unit suite.
|
||||
//
|
||||
// - The upload path is editor-only. Every other write under /api/videos/[videoId]
|
||||
// is open to anyone who may comment, guests included, so a subtitle route that
|
||||
// reached for `canUploadAssets` instead of `canManageAssets` would look correct
|
||||
// next to its neighbours and would let a share-link viewer rewrite the captions
|
||||
// on a delivered cut.
|
||||
//
|
||||
// - What lands in storage is the normalised file, never the bytes that were
|
||||
// uploaded. The assertions below read the PutObject command rather than trusting
|
||||
// the 201.
|
||||
//
|
||||
// tests/setup/api.ts stubs the named helpers in `@/lib/r2` but leaves `r2Client`
|
||||
// real, and the real one throws on first use, so it is replaced with a recorder.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '@/lib/db';
|
||||
import {
|
||||
GET as listSubtitles,
|
||||
POST as uploadSubtitle,
|
||||
} from '@/app/api/videos/[videoId]/subtitles/route';
|
||||
import { DELETE as deleteSubtitle } from '@/app/api/videos/[videoId]/subtitles/[subtitleId]/route';
|
||||
import { GET as serveSubtitle } from '@/app/api/upload/subtitle/[filename]/route';
|
||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||
import { signedInAs, signedOut } from '../helpers/session';
|
||||
import { addProjectMember, createUser, seedVersion } from '../factories';
|
||||
|
||||
const r2 = vi.hoisted(() => ({
|
||||
bucket: 'openframe-subtitle-test-bucket',
|
||||
puts: [] as Array<{ key: string; body: string; contentType: string }>,
|
||||
deletedKeys: [] as string[],
|
||||
gets: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/r2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/r2')>();
|
||||
return {
|
||||
...actual,
|
||||
R2_BUCKET_NAME: r2.bucket,
|
||||
r2Client: {
|
||||
send: async (command: {
|
||||
constructor: { name: string };
|
||||
input?: { Key?: string; Body?: Buffer; ContentType?: string };
|
||||
}) => {
|
||||
const key = command.input?.Key ?? '';
|
||||
switch (command.constructor.name) {
|
||||
case 'PutObjectCommand':
|
||||
r2.puts.push({
|
||||
key,
|
||||
body: Buffer.from(command.input?.Body ?? Buffer.alloc(0)).toString('utf8'),
|
||||
contentType: command.input?.ContentType ?? '',
|
||||
});
|
||||
return {};
|
||||
case 'DeleteObjectCommand':
|
||||
r2.deletedKeys.push(key);
|
||||
return {};
|
||||
case 'GetObjectCommand': {
|
||||
r2.gets.push(key);
|
||||
const stored = r2.puts.find((put) => put.key === key);
|
||||
if (!stored) {
|
||||
const error = new Error('NoSuchKey');
|
||||
error.name = 'NoSuchKey';
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
Body: new Response(stored.body).body,
|
||||
ContentType: stored.contentType,
|
||||
ContentLength: Buffer.byteLength(stored.body),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const SRT_FILE = ['1', '00:00:01,000 --> 00:00:02,500', 'Merhaba', '', ''].join('\n');
|
||||
const NORMALIZED_VTT = 'WEBVTT\n\n00:00:01.000 --> 00:00:02.500\nMerhaba\n';
|
||||
const SUBTITLE_KEY = /^subtitles\/[0-9a-f-]{36}\.vtt$/;
|
||||
|
||||
beforeEach(() => {
|
||||
r2.puts.length = 0;
|
||||
r2.deletedKeys.length = 0;
|
||||
r2.gets.length = 0;
|
||||
});
|
||||
|
||||
function subtitlesUrl(videoId: string): string {
|
||||
return `/api/videos/${videoId}/subtitles`;
|
||||
}
|
||||
|
||||
function subtitleForm(input: {
|
||||
content?: string;
|
||||
fileName?: string;
|
||||
versionId: string;
|
||||
language?: string;
|
||||
label?: string;
|
||||
}): FormData {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'subtitle',
|
||||
new File([input.content ?? SRT_FILE], input.fileName ?? 'cut.tr.srt', { type: 'text/plain' })
|
||||
);
|
||||
form.append('versionId', input.versionId);
|
||||
if (input.language !== undefined) form.append('language', input.language);
|
||||
if (input.label !== undefined) form.append('label', input.label);
|
||||
return form;
|
||||
}
|
||||
|
||||
function uploadRequest(videoId: string, form: FormData) {
|
||||
return apiRequest(subtitlesUrl(videoId), {
|
||||
rawBody: form,
|
||||
// Constructing a Request from a FormData sets no Content-Length, and the route
|
||||
// refuses a body it cannot size before it reads one.
|
||||
headers: { 'content-length': '4096' },
|
||||
});
|
||||
}
|
||||
|
||||
/** An editor-owned bunny version with one Turkish track already uploaded. */
|
||||
async function seedSubtitledVersion() {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'tr', label: 'Türkçe' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
expect(response.status).toBe(201);
|
||||
const subtitle = await readData<{ id: string; url: string }>(response);
|
||||
return { ...scenario, subtitle };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/videos/[videoId]/subtitles
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('POST /api/videos/[videoId]/subtitles', () => {
|
||||
it('stores the normalised WebVTT rather than the uploaded SRT', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'TR', label: ' Türkçe ' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const created = await readData<{ language: string; label: string; url: string }>(response);
|
||||
expect(created.language).toBe('tr');
|
||||
expect(created.label).toBe('Türkçe');
|
||||
expect(created.url).toMatch(/^\/api\/upload\/subtitle\/[0-9a-f-]{36}\.vtt$/);
|
||||
|
||||
expect(r2.puts).toHaveLength(1);
|
||||
expect(r2.puts[0].key).toMatch(SUBTITLE_KEY);
|
||||
expect(r2.puts[0].body).toBe(NORMALIZED_VTT);
|
||||
expect(r2.puts[0].contentType).toBe('text/vtt; charset=utf-8');
|
||||
|
||||
const row = await db.videoSubtitle.findFirstOrThrow({
|
||||
where: { versionId: scenario.version.id },
|
||||
});
|
||||
expect(row.billedUserId).toBe(scenario.owner.id);
|
||||
expect(row.uploadedByUserId).toBe(scenario.owner.id);
|
||||
expect(Number(row.sizeBytes)).toBe(Buffer.byteLength(NORMALIZED_VTT));
|
||||
});
|
||||
|
||||
it('leaves no upload reservation behind once the row is committed', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(await db.uploadReservation.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('replaces the track for a language instead of stacking a second one', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const firstKey = r2.puts[0].key;
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({
|
||||
versionId: scenario.version.id,
|
||||
language: 'tr',
|
||||
label: 'Türkçe düzeltme',
|
||||
content: ['1', '00:00:04,000 --> 00:00:05,000', 'Düzeltildi', '', ''].join('\n'),
|
||||
})
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const rows = await db.videoSubtitle.findMany({ where: { versionId: scenario.version.id } });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].label).toBe('Türkçe düzeltme');
|
||||
// The object the replaced row pointed at is gone, so it cannot outlive its row.
|
||||
expect(r2.deletedKeys).toEqual([firstKey]);
|
||||
});
|
||||
|
||||
it('keeps a second language alongside the first', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
|
||||
await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'en', fileName: 'cut.en.srt' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
const rows = await db.videoSubtitle.findMany({
|
||||
where: { versionId: scenario.version.id },
|
||||
orderBy: { language: 'asc' },
|
||||
});
|
||||
expect(rows.map((row) => row.language)).toEqual(['en', 'tr']);
|
||||
});
|
||||
|
||||
it('refuses a file with no cues and stores nothing', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({
|
||||
versionId: scenario.version.id,
|
||||
language: 'tr',
|
||||
content: 'just some prose\nand more of it\n',
|
||||
})
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(r2.puts).toHaveLength(0);
|
||||
expect(await db.videoSubtitle.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('refuses a file that is not a subtitle by extension', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'tr', fileName: 'payload.html' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await readError(response)).toBe('Subtitle must be a .srt or .vtt file');
|
||||
});
|
||||
|
||||
it('refuses a language that is not a tag', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: '<script>' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('refuses a version that belongs to another video', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: other.version.id, language: 'tr' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(r2.puts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a project COMMENTATOR, who may comment but not edit the cut', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
const commentator = await createUser();
|
||||
await addProjectMember({
|
||||
projectId: scenario.project.id,
|
||||
userId: commentator.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
signedInAs(commentator);
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(r2.puts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses an anonymous caller', async () => {
|
||||
const scenario = await seedVersion({ providerId: 'bunny' });
|
||||
signedOut();
|
||||
|
||||
const response = await callRoute(
|
||||
uploadSubtitle,
|
||||
uploadRequest(
|
||||
scenario.video.id,
|
||||
subtitleForm({ versionId: scenario.version.id, language: 'tr' })
|
||||
),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/videos/[videoId]/subtitles
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('GET /api/videos/[videoId]/subtitles', () => {
|
||||
it('lists the tracks of one version and tells an editor they may manage them', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
|
||||
const response = await callRoute(
|
||||
listSubtitles,
|
||||
apiRequest(subtitlesUrl(scenario.video.id), {
|
||||
searchParams: { versionId: scenario.version.id },
|
||||
}),
|
||||
{ videoId: scenario.video.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await readData<{
|
||||
subtitles: Array<{ language: string; canDelete: boolean }>;
|
||||
canManageSubtitles: boolean;
|
||||
}>(response);
|
||||
expect(data.subtitles.map((subtitle) => subtitle.language)).toEqual(['tr']);
|
||||
expect(data.canManageSubtitles).toBe(true);
|
||||
expect(data.subtitles[0].canDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a COMMENTATOR the tracks without the ability to manage them', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const commentator = await createUser();
|
||||
await addProjectMember({
|
||||
projectId: scenario.project.id,
|
||||
userId: commentator.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
signedInAs(commentator);
|
||||
|
||||
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
|
||||
videoId: scenario.video.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await readData<{
|
||||
subtitles: Array<{ canDelete: boolean }>;
|
||||
canManageSubtitles: boolean;
|
||||
}>(response);
|
||||
expect(data.subtitles).toHaveLength(1);
|
||||
expect(data.canManageSubtitles).toBe(false);
|
||||
expect(data.subtitles[0].canDelete).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a signed-in stranger', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const stranger = await createUser();
|
||||
signedInAs(stranger);
|
||||
|
||||
const response = await callRoute(listSubtitles, apiRequest(subtitlesUrl(scenario.video.id)), {
|
||||
videoId: scenario.video.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/videos/[videoId]/subtitles/[subtitleId]
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('DELETE /api/videos/[videoId]/subtitles/[subtitleId]', () => {
|
||||
it('removes the row and the stored object', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const storedKey = r2.puts[0].key;
|
||||
|
||||
const response = await callRoute(
|
||||
deleteSubtitle,
|
||||
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await db.videoSubtitle.count()).toBe(0);
|
||||
expect(r2.deletedKeys).toEqual([storedKey]);
|
||||
});
|
||||
|
||||
it('refuses a COMMENTATOR and leaves the track in place', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const commentator = await createUser();
|
||||
await addProjectMember({
|
||||
projectId: scenario.project.id,
|
||||
userId: commentator.id,
|
||||
role: 'COMMENTATOR',
|
||||
});
|
||||
signedInAs(commentator);
|
||||
|
||||
const response = await callRoute(
|
||||
deleteSubtitle,
|
||||
apiRequest(`${subtitlesUrl(scenario.video.id)}/${scenario.subtitle.id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
{ videoId: scenario.video.id, subtitleId: scenario.subtitle.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await db.videoSubtitle.count()).toBe(1);
|
||||
expect(r2.deletedKeys).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('answers 404 for a subtitle that belongs to another video', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const other = await seedVersion({ providerId: 'bunny', ownerUser: scenario.owner });
|
||||
signedInAs(scenario.owner);
|
||||
|
||||
const response = await callRoute(
|
||||
deleteSubtitle,
|
||||
apiRequest(`${subtitlesUrl(other.video.id)}/${scenario.subtitle.id}`, { method: 'DELETE' }),
|
||||
{ videoId: other.video.id, subtitleId: scenario.subtitle.id }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await db.videoSubtitle.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/upload/subtitle/[filename]
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('GET /api/upload/subtitle/[filename]', () => {
|
||||
function fileNameOf(url: string): string {
|
||||
return url.slice('/api/upload/subtitle/'.length);
|
||||
}
|
||||
|
||||
it('serves the stored WebVTT to a viewer', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const filename = fileNameOf(scenario.subtitle.url);
|
||||
|
||||
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
|
||||
filename,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toBe('text/vtt; charset=utf-8');
|
||||
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(await response.text()).toBe(NORMALIZED_VTT);
|
||||
});
|
||||
|
||||
it('refuses a signed-in stranger without reading the object', async () => {
|
||||
const scenario = await seedSubtitledVersion();
|
||||
const stranger = await createUser();
|
||||
signedInAs(stranger);
|
||||
|
||||
const response = await callRoute(serveSubtitle, apiRequest(scenario.subtitle.url), {
|
||||
filename: fileNameOf(scenario.subtitle.url),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(r2.gets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a filename that is not a stored subtitle', async () => {
|
||||
const response = await callRoute(serveSubtitle, apiRequest('/api/upload/subtitle/x'), {
|
||||
filename: '../../etc/passwd',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@ const REVIEWED_MIGRATIONS = [
|
||||
'20260801120000_add_acquisition_analytics',
|
||||
'20260818120000_add_upload_reservation_purpose',
|
||||
'20260820120000_add_comment_images',
|
||||
'20260822120000_add_video_subtitles',
|
||||
];
|
||||
|
||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeSubtitleBuffer,
|
||||
getSubtitleExtension,
|
||||
MAX_SUBTITLE_CUES,
|
||||
normalizeSubtitleFile,
|
||||
normalizeSubtitleLanguage,
|
||||
parseSubtitleCues,
|
||||
sanitizeSubtitleLabel,
|
||||
SAFE_SUBTITLE_PROXY_PATH,
|
||||
serializeWebVtt,
|
||||
subtitleProxyPathToObjectKey,
|
||||
} from '@/lib/subtitle-validation';
|
||||
|
||||
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
function encode(text: string): Uint8Array {
|
||||
return new TextEncoder().encode(text);
|
||||
}
|
||||
|
||||
describe('getSubtitleExtension', () => {
|
||||
it('accepts the two subtitle formats and nothing else', () => {
|
||||
expect(getSubtitleExtension('cut.srt')).toBe('srt');
|
||||
expect(getSubtitleExtension('cut.VTT')).toBe('vtt');
|
||||
expect(getSubtitleExtension('cut.ass')).toBeNull();
|
||||
expect(getSubtitleExtension('cut.srt.exe')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSubtitleLanguage', () => {
|
||||
it('lowercases so a re-upload replaces the track it means to', () => {
|
||||
expect(normalizeSubtitleLanguage('TR')).toBe('tr');
|
||||
expect(normalizeSubtitleLanguage(' en-US ')).toBe('en-us');
|
||||
expect(normalizeSubtitleLanguage('zh-Hant-TW')).toBe('zh-hant-tw');
|
||||
});
|
||||
|
||||
it('rejects anything that is not a language tag', () => {
|
||||
expect(normalizeSubtitleLanguage('')).toBeNull();
|
||||
expect(normalizeSubtitleLanguage('t')).toBeNull();
|
||||
expect(normalizeSubtitleLanguage('tr; drop table')).toBeNull();
|
||||
expect(normalizeSubtitleLanguage('<script>')).toBeNull();
|
||||
expect(normalizeSubtitleLanguage(42)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSubtitleLabel', () => {
|
||||
it('falls back when the label is empty after cleaning', () => {
|
||||
expect(sanitizeSubtitleLabel(' ', 'TR')).toBe('TR');
|
||||
expect(sanitizeSubtitleLabel(undefined, 'TR')).toBe('TR');
|
||||
});
|
||||
|
||||
it('strips control characters and collapses whitespace', () => {
|
||||
expect(sanitizeSubtitleLabel('Türk\u0000\n çe ', 'TR')).toBe('Türk çe');
|
||||
});
|
||||
|
||||
it('caps the length', () => {
|
||||
expect(sanitizeSubtitleLabel('a'.repeat(200), 'TR')).toHaveLength(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeSubtitleBuffer', () => {
|
||||
it('reads UTF-8 and drops the byte order mark', () => {
|
||||
expect(decodeSubtitleBuffer(encode('\uFEFFmerhaba'))).toBe('merhaba');
|
||||
});
|
||||
|
||||
it('falls back to a legacy codepage rather than rejecting the file', () => {
|
||||
// 0xFD is "ı" in windows-1254 and not valid UTF-8 on its own.
|
||||
const decoded = decodeSubtitleBuffer(new Uint8Array([0x61, 0xfd, 0x62]));
|
||||
expect(decoded).not.toBeNull();
|
||||
expect(decoded).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSubtitleCues', () => {
|
||||
it('parses SRT, comma decimals and sequence numbers included', () => {
|
||||
const cues = parseSubtitleCues(
|
||||
[
|
||||
'1',
|
||||
'00:00:01,000 --> 00:00:02,500',
|
||||
'Merhaba',
|
||||
'',
|
||||
'2',
|
||||
'00:00:03,000 --> 00:00:04,000',
|
||||
'Dünya',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(cues).toEqual([
|
||||
{ start: 1, end: 2.5, text: 'Merhaba' },
|
||||
{ start: 3, end: 4, text: 'Dünya' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses WebVTT with cue ids, settings and short timestamps', () => {
|
||||
const cues = parseSubtitleCues(
|
||||
['WEBVTT', '', 'intro', '00:01.000 --> 00:02.000 align:start position:10%', 'Hello', ''].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
expect(cues).toEqual([{ start: 1, end: 2, text: 'Hello' }]);
|
||||
});
|
||||
|
||||
it('skips NOTE, STYLE and REGION blocks', () => {
|
||||
const cues = parseSubtitleCues(
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'NOTE this is a comment',
|
||||
'still the comment',
|
||||
'',
|
||||
'STYLE',
|
||||
'::cue { color: red }',
|
||||
'',
|
||||
'00:00:01.000 --> 00:00:02.000',
|
||||
'Kept',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(cues).toEqual([{ start: 1, end: 2, text: 'Kept' }]);
|
||||
});
|
||||
|
||||
it('drops cues that end before they start and cues with no text', () => {
|
||||
const cues = parseSubtitleCues(
|
||||
[
|
||||
'00:00:05,000 --> 00:00:02,000',
|
||||
'Backwards',
|
||||
'',
|
||||
'00:00:06,000 --> 00:00:07,000',
|
||||
'',
|
||||
'00:00:08,000 --> 00:00:09,000',
|
||||
'Good',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
expect(cues).toEqual([{ start: 8, end: 9, text: 'Good' }]);
|
||||
});
|
||||
|
||||
it('keeps known cue markup and removes everything else', () => {
|
||||
const cues = parseSubtitleCues(
|
||||
['00:00:01,000 --> 00:00:02,000', '<i>tilt</i><script>alert(1)</script>{\\an8}', ''].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
|
||||
});
|
||||
|
||||
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
|
||||
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
|
||||
// was never written. Escaping what is left over is what stops that.
|
||||
const cues = parseSubtitleCues(
|
||||
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
|
||||
);
|
||||
expect(cues[0].text).toBe('<scr<b>ipt>alert(1)');
|
||||
expect(cues[0].text).not.toContain('<script');
|
||||
});
|
||||
|
||||
it('neutralises an arrow in cue text so the file cannot be re-split', () => {
|
||||
const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n'));
|
||||
expect(cues[0].text).toBe('a --> b');
|
||||
expect(parseSubtitleCues(serializeWebVtt(cues))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops at the cue ceiling', () => {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < MAX_SUBTITLE_CUES + 10; index += 1) {
|
||||
lines.push(`00:00:0${index % 9}.000 --> 00:00:0${(index % 9) + 1}.000`, `line ${index}`, '');
|
||||
}
|
||||
expect(parseSubtitleCues(lines.join('\n'))).toHaveLength(MAX_SUBTITLE_CUES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeSubtitleFile', () => {
|
||||
it('converts SRT to a canonical WebVTT document', () => {
|
||||
const result = normalizeSubtitleFile(
|
||||
encode('1\r\n00:00:01,500 --> 00:00:02,000\r\nMerhaba\r\n\r\n')
|
||||
);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
cueCount: 1,
|
||||
vtt: 'WEBVTT\n\n00:00:01.500 --> 00:00:02.000\nMerhaba\n',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an empty file', () => {
|
||||
const result = normalizeSubtitleFile(new Uint8Array());
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a file with no cues rather than storing an empty track', () => {
|
||||
const result = normalizeSubtitleFile(encode('this is just prose\nand more prose\n'));
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: 'No subtitle cues found. Upload a valid .srt or .vtt file.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtitle proxy paths', () => {
|
||||
it('only recognises a uuid .vtt path', () => {
|
||||
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.vtt`)).toBe(true);
|
||||
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.srt`)).toBe(false);
|
||||
expect(SAFE_SUBTITLE_PROXY_PATH.test('/api/upload/subtitle/../../etc/passwd')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a proxy path to its object key and refuses anything else', () => {
|
||||
expect(subtitleProxyPathToObjectKey(`/api/upload/subtitle/${UUID}.vtt`)).toBe(
|
||||
`subtitles/${UUID}.vtt`
|
||||
);
|
||||
expect(subtitleProxyPathToObjectKey(`/api/upload/image/${UUID}.png`)).toBeNull();
|
||||
});
|
||||
});
|
||||
Vendored
+10
@@ -25,6 +25,16 @@ declare namespace YT {
|
||||
getPlaybackRate(): number;
|
||||
getAvailablePlaybackRates(): number[];
|
||||
destroy(): void;
|
||||
|
||||
// The module API is undocumented but is the only way to drive captions on a
|
||||
// player embedded with controls=0, where YouTube's own CC button is hidden.
|
||||
// `loadModule('captions')` turns them on, `unloadModule` turns them off, and
|
||||
// `getOption('captions', 'tracklist')` answers only once the module has loaded
|
||||
// and announced itself through onApiChange.
|
||||
loadModule(moduleName: string): void;
|
||||
unloadModule(moduleName: string): void;
|
||||
setOption(module: string, option: string, value: unknown): void;
|
||||
getOption<T = unknown>(module: string, option: string): T | undefined;
|
||||
}
|
||||
|
||||
interface PlayerOptions {
|
||||
|
||||
Reference in New Issue
Block a user