mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(player): let editors upload subtitles for a version
Subtitle tracks hang off a version rather than off a video, because re-editing a cut shifts every cue. The file always lands in our own S3-compatible storage whatever hosts the video, so a Bunny-hosted cut and an R2 one take the same path: both already play through our own video element, so a track element is all it takes. Uploads are normalised before they are stored. Whatever arrives, SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file, and anything we did not understand is dropped rather than passed through. That is what makes it safe to serve a user-supplied text file from our own origin. Files saved out of Windows editors are decoded as windows-1254 or windows-1252 when they are not valid UTF-8, rather than refused. A YouTube version cannot carry an uploaded track, so the same CC menu drives YouTube's own captions through the iframe module API. The embed hides YouTube's controls, so until now those captions were unreachable even when the video had them. Uploading and deleting take the editor permission rather than the commenter one: a subtitle is part of the delivered cut, not a comment attachment.
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
|
||||
|
||||
+20
-3
@@ -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,7 +149,8 @@ class QuotaExceededError extends Error {}
|
||||
* every upload.
|
||||
*/
|
||||
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
||||
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
|
||||
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
|
||||
@@ -162,6 +165,11 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
INNER JOIN workspaces w ON w.id = p."workspaceId"
|
||||
WHERE w."ownerId" = ${userId}
|
||||
AND vv."providerId" = 'r2'
|
||||
`,
|
||||
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 }]>`
|
||||
@@ -174,10 +182,11 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
||||
|
||||
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,272 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
|
||||
function sanitizeCueLine(line: string): string {
|
||||
return (
|
||||
line
|
||||
// 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.
|
||||
.replace(/\{\\[^}]*\}/g, '')
|
||||
.replace(/<[^<>]*>/g, (tag) => (ALLOWED_CUE_TAGS.some((re) => re.test(tag)) ? tag : ''))
|
||||
// A cue text line containing an arrow would be read back as a timing line and split
|
||||
// the cue in two. The entity is what the WebVTT parser expects for a literal `>`.
|
||||
.replace(/-->/g, '-->')
|
||||
.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;
|
||||
const text = textLines.join('\n').slice(0, MAX_CUE_TEXT_LENGTH).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,201 @@
|
||||
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('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