mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
Merge pull request #68 from yusufipk/feat/version-subtitles
feat(player): let editors upload subtitles for a version
This commit is contained in:
@@ -14,7 +14,7 @@ OpenFrame is built for video teams that want one system for review, revision, ap
|
|||||||
|
|
||||||
- Timestamped comments directly on the video timeline
|
- Timestamped comments directly on the video timeline
|
||||||
- Voice notes, image attachments, and frame annotations
|
- 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
|
- Approval requests and sign-off tracking
|
||||||
- Share links for client review with optional guest commenting
|
- Share links for client review with optional guest commenting
|
||||||
- Workspaces, projects, member roles, and invitation flows
|
- 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
|
### 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.
|
- Teams can switch between versions without losing review context.
|
||||||
- Compare mode lets reviewers inspect two versions side by side.
|
- 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,
|
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) => {
|
await db.$transaction(async (tx) => {
|
||||||
// Delete the version (cascades to comments).
|
// Delete the version (cascades to comments).
|
||||||
await tx.videoVersion.delete({ where: { id: versionId } });
|
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([
|
const [bunnyCleanupResult, r2CleanupResult] = await Promise.all([
|
||||||
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
cleanupBunnyStreamVideosBestEffort([bunnyRef]),
|
||||||
result.version.providerId === 'r2'
|
deleteMediaFilesBestEffort(versionMediaUrls),
|
||||||
? deleteMediaFilesBestEffort(
|
|
||||||
[result.version.originalUrl, result.version.thumbnailUrl].filter((url): url is string =>
|
|
||||||
Boolean(url)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: Promise.resolve({ attempted: 0, failed: 0, failedKeys: [] }),
|
|
||||||
]);
|
]);
|
||||||
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
const cleanupInput = { bunny: bunnyCleanupResult, r2: r2CleanupResult };
|
||||||
const cleanupWarnings = buildCleanupWarnings(cleanupInput);
|
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';
|
} from '@/components/video-page/types';
|
||||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
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 { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
|
import { getSpeedOptionsForProvider } from '@/components/video-page/hooks/video-player-utils';
|
||||||
|
|
||||||
@@ -275,6 +277,24 @@ export function VideoPageContent({
|
|||||||
}, [video?.versions, activeVersionId]);
|
}, [video?.versions, activeVersionId]);
|
||||||
const activeProviderId = activeVersion?.providerId;
|
const activeProviderId = activeVersion?.providerId;
|
||||||
const speedOptions = getSpeedOptionsForProvider(activeProviderId);
|
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 activeVersionDuration = activeVersion?.duration;
|
||||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const embedUrl = useMemo(() => {
|
const embedUrl = useMemo(() => {
|
||||||
@@ -305,6 +325,7 @@ export function VideoPageContent({
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
isReady,
|
isReady,
|
||||||
|
youtubeModuleRevision,
|
||||||
bunnyPlaybackState,
|
bunnyPlaybackState,
|
||||||
currentTime,
|
currentTime,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
@@ -359,6 +380,27 @@ export function VideoPageContent({
|
|||||||
setViewingAnnotation,
|
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 {
|
const {
|
||||||
savedProgress,
|
savedProgress,
|
||||||
showResumePrompt,
|
showResumePrompt,
|
||||||
@@ -823,6 +865,15 @@ export function VideoPageContent({
|
|||||||
selectedQualityLevel={selectedQualityLevel}
|
selectedQualityLevel={selectedQualityLevel}
|
||||||
qualityOptions={qualityOptions}
|
qualityOptions={qualityOptions}
|
||||||
handleQualityChange={handleQualityChange}
|
handleQualityChange={handleQualityChange}
|
||||||
|
subtitles={subtitles}
|
||||||
|
subtitleTracks={subtitleTracks}
|
||||||
|
subtitleTrackKey={subtitleTrackKey}
|
||||||
|
activeSubtitleLanguage={activeCaptionLanguage}
|
||||||
|
onSelectSubtitleLanguage={selectCaptionLanguage}
|
||||||
|
canManageSubtitles={canManageSubtitles}
|
||||||
|
onUploadSubtitle={uploadSubtitle}
|
||||||
|
onDeleteSubtitle={deleteSubtitle}
|
||||||
|
isUploadingSubtitle={isUploadingSubtitle}
|
||||||
playbackSpeed={playbackSpeed}
|
playbackSpeed={playbackSpeed}
|
||||||
speedOptions={speedOptions}
|
speedOptions={speedOptions}
|
||||||
handleSpeedChange={handleSpeedChange}
|
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) {
|
}: UseVideoPlayerParams) {
|
||||||
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
const [isApiLoaded, setIsApiLoaded] = useState(false);
|
||||||
const [isReady, setIsReady] = 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 [bunnyPlaybackState, setBunnyPlaybackState] = useState<BunnyPlaybackState>('none');
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [videoDuration, setVideoDuration] = useState(0);
|
const [videoDuration, setVideoDuration] = useState(0);
|
||||||
@@ -315,6 +319,9 @@ export function useVideoPlayer({
|
|||||||
const dur = event.target.getDuration();
|
const dur = event.target.getDuration();
|
||||||
if (dur > 0) setVideoDuration(dur);
|
if (dur > 0) setVideoDuration(dur);
|
||||||
},
|
},
|
||||||
|
onApiChange: () => {
|
||||||
|
setYoutubeModuleRevision((revision) => revision + 1);
|
||||||
|
},
|
||||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||||
|
|
||||||
@@ -1344,6 +1351,7 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isReady,
|
isReady,
|
||||||
|
youtubeModuleRevision,
|
||||||
bunnyPlaybackState,
|
bunnyPlaybackState,
|
||||||
currentTime,
|
currentTime,
|
||||||
setCurrentTime,
|
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,
|
type AnnotationStroke,
|
||||||
} from '@/components/annotation-canvas';
|
} from '@/components/annotation-canvas';
|
||||||
import { SILENT_ABOVE_SPEED } from '@/components/video-page/hooks/video-player-utils';
|
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 {
|
interface PlayerCoreProps {
|
||||||
activeVersionId: string | null;
|
activeVersionId: string | null;
|
||||||
@@ -85,6 +91,24 @@ interface PlayerCoreProps {
|
|||||||
selectedQualityLevel: number;
|
selectedQualityLevel: number;
|
||||||
qualityOptions: BunnyQualityOption[];
|
qualityOptions: BunnyQualityOption[];
|
||||||
handleQualityChange: (level: number) => void;
|
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;
|
playbackSpeed: number;
|
||||||
speedOptions: number[];
|
speedOptions: number[];
|
||||||
handleSpeedChange: (speed: number) => void;
|
handleSpeedChange: (speed: number) => void;
|
||||||
@@ -153,6 +177,15 @@ export const PlayerCore = memo(function PlayerCore({
|
|||||||
selectedQualityLevel,
|
selectedQualityLevel,
|
||||||
qualityOptions,
|
qualityOptions,
|
||||||
handleQualityChange,
|
handleQualityChange,
|
||||||
|
subtitles,
|
||||||
|
subtitleTracks,
|
||||||
|
subtitleTrackKey,
|
||||||
|
activeSubtitleLanguage,
|
||||||
|
onSelectSubtitleLanguage,
|
||||||
|
canManageSubtitles,
|
||||||
|
onUploadSubtitle,
|
||||||
|
onDeleteSubtitle,
|
||||||
|
isUploadingSubtitle,
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
speedOptions,
|
speedOptions,
|
||||||
handleSpeedChange,
|
handleSpeedChange,
|
||||||
@@ -208,7 +241,17 @@ export const PlayerCore = memo(function PlayerCore({
|
|||||||
}}
|
}}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
>
|
||||||
|
{subtitles.map((subtitle) => (
|
||||||
|
<track
|
||||||
|
key={`${subtitle.id}:${subtitleTrackKey}`}
|
||||||
|
kind="subtitles"
|
||||||
|
src={subtitle.url}
|
||||||
|
srcLang={subtitle.language}
|
||||||
|
label={subtitle.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -442,6 +485,18 @@ export const PlayerCore = memo(function PlayerCore({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeProviderId && activeProviderId !== 'direct' && (
|
||||||
|
<SubtitleControls
|
||||||
|
subtitles={subtitleTracks}
|
||||||
|
activeSubtitleLanguage={activeSubtitleLanguage}
|
||||||
|
onSelectSubtitleLanguage={onSelectSubtitleLanguage}
|
||||||
|
canManageSubtitles={canManageSubtitles}
|
||||||
|
onUploadSubtitle={onUploadSubtitle}
|
||||||
|
onDeleteSubtitle={onDeleteSubtitle}
|
||||||
|
isUploadingSubtitle={isUploadingSubtitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs">
|
<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;
|
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 {
|
export interface ApprovalDecision {
|
||||||
id: string;
|
id: string;
|
||||||
approverId: string;
|
approverId: string;
|
||||||
|
|||||||
@@ -235,6 +235,12 @@ export type BuildProjectDownloadManifestOptions = {
|
|||||||
includeAssets?: boolean;
|
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(
|
export function buildProjectDownloadManifest(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
videos: VideoRow[],
|
videos: VideoRow[],
|
||||||
|
|||||||
+26
-4
@@ -3,6 +3,7 @@ import { r2Client, R2_BUCKET_NAME } from '@/lib/r2';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { runWithConcurrency } from '@/lib/async-pool';
|
import { runWithConcurrency } from '@/lib/async-pool';
|
||||||
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
|
import { videoProxyPathToObjectKey } from '@/lib/video-upload-validation';
|
||||||
|
import { subtitleProxyPathToObjectKey } from '@/lib/subtitle-validation';
|
||||||
import { logError } from '@/lib/logger';
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
/** The path prefix for images served by the upload API. */
|
/** 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 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).
|
* Collect all media URLs from comments under a given video (all versions).
|
||||||
*/
|
*/
|
||||||
export async function collectVideoMediaUrls(videoId: string): Promise<string[]> {
|
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({
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||||
@@ -101,6 +102,10 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
|||||||
where: { videoParentId: videoId, providerId: 'r2' },
|
where: { videoParentId: videoId, providerId: 'r2' },
|
||||||
select: { originalUrl: true, thumbnailUrl: true },
|
select: { originalUrl: true, thumbnailUrl: true },
|
||||||
}),
|
}),
|
||||||
|
db.videoSubtitle.findMany({
|
||||||
|
where: { version: { videoParentId: videoId } },
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach((c) => {
|
comments.forEach((c) => {
|
||||||
@@ -114,6 +119,9 @@ export async function collectVideoMediaUrls(videoId: string): Promise<string[]>
|
|||||||
if (version.originalUrl) urls.push(version.originalUrl);
|
if (version.originalUrl) urls.push(version.originalUrl);
|
||||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||||
});
|
});
|
||||||
|
subtitles.forEach((subtitle) => {
|
||||||
|
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
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.
|
* Collect all media URLs from comments under all videos in a project.
|
||||||
*/
|
*/
|
||||||
export async function collectProjectMediaUrls(projectId: string): Promise<string[]> {
|
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({
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||||
@@ -140,6 +148,10 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
|||||||
where: { providerId: 'r2', video: { projectId } },
|
where: { providerId: 'r2', video: { projectId } },
|
||||||
select: { originalUrl: true, thumbnailUrl: true },
|
select: { originalUrl: true, thumbnailUrl: true },
|
||||||
}),
|
}),
|
||||||
|
db.videoSubtitle.findMany({
|
||||||
|
where: { version: { video: { projectId } } },
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach((c) => {
|
comments.forEach((c) => {
|
||||||
@@ -153,6 +165,9 @@ export async function collectProjectMediaUrls(projectId: string): Promise<string
|
|||||||
if (version.originalUrl) urls.push(version.originalUrl);
|
if (version.originalUrl) urls.push(version.originalUrl);
|
||||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||||
});
|
});
|
||||||
|
subtitles.forEach((subtitle) => {
|
||||||
|
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
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.
|
* Collect all media URLs from comments under all projects in a workspace.
|
||||||
*/
|
*/
|
||||||
export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<string[]> {
|
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({
|
db.comment.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
OR: [{ voiceUrl: { not: null } }, { images: { some: {} } }],
|
||||||
@@ -179,6 +194,10 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
|||||||
where: { providerId: 'r2', video: { project: { workspaceId } } },
|
where: { providerId: 'r2', video: { project: { workspaceId } } },
|
||||||
select: { originalUrl: true, thumbnailUrl: true },
|
select: { originalUrl: true, thumbnailUrl: true },
|
||||||
}),
|
}),
|
||||||
|
db.videoSubtitle.findMany({
|
||||||
|
where: { version: { video: { project: { workspaceId } } } },
|
||||||
|
select: { sourceUrl: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
comments.forEach((c) => {
|
comments.forEach((c) => {
|
||||||
@@ -192,6 +211,9 @@ export async function collectWorkspaceMediaUrls(workspaceId: string): Promise<st
|
|||||||
if (version.originalUrl) urls.push(version.originalUrl);
|
if (version.originalUrl) urls.push(version.originalUrl);
|
||||||
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
if (version.thumbnailUrl) urls.push(version.thumbnailUrl);
|
||||||
});
|
});
|
||||||
|
subtitles.forEach((subtitle) => {
|
||||||
|
if (subtitle.sourceUrl) urls.push(subtitle.sourceUrl);
|
||||||
|
});
|
||||||
return urls;
|
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
|
// call sites gate the file name on a strict pattern first, and a fourth that forgot would
|
||||||
// otherwise hand a traversal straight to GetObject.
|
// otherwise hand a traversal straight to GetObject.
|
||||||
const SAFE_MEDIA_OBJECT_KEY =
|
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 {
|
export function isSafeR2MediaKey(key: string): boolean {
|
||||||
return SAFE_MEDIA_OBJECT_KEY.test(key);
|
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-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
'asset-bunny-init': { 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
|
'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 — debounced on client but protect against scripted callers
|
||||||
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
||||||
|
|||||||
+25
-8
@@ -132,6 +132,8 @@ export const UPLOAD_RESERVATION_PURPOSES = {
|
|||||||
R2_VIDEO: 'R2_VIDEO',
|
R2_VIDEO: 'R2_VIDEO',
|
||||||
/** A direct upload to Bunny, where the bytes never pass through us. */
|
/** A direct upload to Bunny, where the bytes never pass through us. */
|
||||||
BUNNY: 'BUNNY',
|
BUNNY: 'BUNNY',
|
||||||
|
/** A subtitle track, which lands in our own S3-compatible storage whatever hosts the video. */
|
||||||
|
SUBTITLE: 'SUBTITLE',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type UploadReservationPurpose =
|
export type UploadReservationPurpose =
|
||||||
@@ -147,14 +149,15 @@ class QuotaExceededError extends Error {}
|
|||||||
* every upload.
|
* every upload.
|
||||||
*/
|
*/
|
||||||
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
export async function getUserTotalStorageBytes(userId: string): Promise<bigint> {
|
||||||
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
|
const [r2AssetRows, r2VideoRows, subtitleRows, bunnyUserBytes, reservationRows] =
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
await Promise.all([
|
||||||
|
db.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||||
FROM video_assets
|
FROM video_assets
|
||||||
WHERE "billedUserId" = ${userId}
|
WHERE "billedUserId" = ${userId}
|
||||||
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
AND provider IN ('R2_IMAGE', 'R2_AUDIO', 'R2_VIDEO')
|
||||||
`,
|
`,
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
db.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
SELECT COALESCE(SUM(vv.size_bytes), 0)::bigint AS total
|
||||||
FROM video_versions vv
|
FROM video_versions vv
|
||||||
INNER JOIN videos v ON v.id = vv."videoParentId"
|
INNER JOIN videos v ON v.id = vv."videoParentId"
|
||||||
@@ -163,21 +166,27 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
|||||||
WHERE w."ownerId" = ${userId}
|
WHERE w."ownerId" = ${userId}
|
||||||
AND vv."providerId" = 'r2'
|
AND vv."providerId" = 'r2'
|
||||||
`,
|
`,
|
||||||
getUserBunnyStorageBytes(userId),
|
db.$queryRaw<[{ total: bigint }]>`
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
SELECT COALESCE(SUM(size_bytes), 0)::bigint AS total
|
||||||
|
FROM video_subtitles
|
||||||
|
WHERE "billedUserId" = ${userId}
|
||||||
|
`,
|
||||||
|
getUserBunnyStorageBytes(userId),
|
||||||
|
db.$queryRaw<[{ total: bigint }]>`
|
||||||
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
SELECT COALESCE(SUM("sizeBytes"), 0)::bigint AS total
|
||||||
FROM upload_reservations
|
FROM upload_reservations
|
||||||
WHERE "billedUserId" = ${userId}
|
WHERE "billedUserId" = ${userId}
|
||||||
AND "expiresAt" > NOW()
|
AND "expiresAt" > NOW()
|
||||||
`,
|
`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
|
const r2AssetBytes = r2AssetRows[0]?.total ?? BigInt(0);
|
||||||
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
|
const r2VideoBytes = r2VideoRows[0]?.total ?? BigInt(0);
|
||||||
|
const subtitleBytes = subtitleRows[0]?.total ?? BigInt(0);
|
||||||
const bunnyBytes = BigInt(bunnyUserBytes);
|
const bunnyBytes = BigInt(bunnyUserBytes);
|
||||||
const reservedBytes = reservationRows[0]?.total ?? BigInt(0);
|
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}
|
WHERE w."ownerId" = ${userId}
|
||||||
AND vv."providerId" = 'r2'
|
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
|
// Read active (non-expired) reservations under the same lock
|
||||||
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
const [resRow] = await tx.$queryRaw<[{ total: bigint }]>`
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* Subtitle uploads are normalised before they are stored: whatever the user hands us,
|
||||||
|
* SRT or WebVTT, is parsed into cues and re-serialised as a canonical WebVTT file.
|
||||||
|
* Anything we did not understand is dropped rather than passed through, so the file the
|
||||||
|
* player fetches contains cues and nothing else. That is what makes it safe to serve a
|
||||||
|
* user-supplied text file from our own origin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Uploaded subtitle files are text. Two megabytes is a feature-length film with room to spare. */
|
||||||
|
export const MAX_SUBTITLE_FILE_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Ceiling on the normalised output, so a pathological input cannot be stored. */
|
||||||
|
export const MAX_NORMALIZED_SUBTITLE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
export const MAX_SUBTITLE_CUES = 5000;
|
||||||
|
|
||||||
|
/** Longer than this and it is not a subtitle, it is a document being smuggled in. */
|
||||||
|
const MAX_CUE_TEXT_LENGTH = 500;
|
||||||
|
|
||||||
|
export const ALLOWED_SUBTITLE_EXTENSIONS = ['vtt', 'srt'] as const;
|
||||||
|
|
||||||
|
export const SUBTITLE_OBJECT_KEY_PREFIX = 'subtitles/';
|
||||||
|
|
||||||
|
export const SUBTITLE_PROXY_PREFIX = '/api/upload/subtitle/';
|
||||||
|
|
||||||
|
/** The only shape a subtitle URL may take once it has been through our upload API. */
|
||||||
|
export const SAFE_SUBTITLE_PROXY_PATH =
|
||||||
|
/^\/api\/upload\/subtitle\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
|
||||||
|
|
||||||
|
export const SAFE_SUBTITLE_FILENAME =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.vtt$/i;
|
||||||
|
|
||||||
|
export const SUBTITLE_CONTENT_TYPE = 'text/vtt; charset=utf-8';
|
||||||
|
|
||||||
|
/** Room for a label a human typed, not for a paragraph. */
|
||||||
|
const MAX_SUBTITLE_LABEL_LENGTH = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BCP-47, narrowed: a primary subtag plus optional subtags. Wide enough for `tr`,
|
||||||
|
* `en-US` and `zh-Hant-TW`, narrow enough that the value is safe in an HTML attribute
|
||||||
|
* and in a unique index.
|
||||||
|
*/
|
||||||
|
const LANGUAGE_TAG = /^[a-z]{2,3}(?:-[a-z0-9]{2,8}){0,3}$/i;
|
||||||
|
|
||||||
|
export type SubtitleCue = {
|
||||||
|
/** Seconds from the start of the video. */
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubtitleNormalizeResult =
|
||||||
|
| { ok: true; vtt: string; cueCount: number }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cue text may carry a small amount of WebVTT markup. Everything outside this list is
|
||||||
|
* removed: the browser's VTT parser does not execute scripts, but a file that only ever
|
||||||
|
* contains tags we recognise is one less thing to reason about.
|
||||||
|
*/
|
||||||
|
const ALLOWED_CUE_TAGS = [
|
||||||
|
/^<\/?[biu]>$/i,
|
||||||
|
/^<\/?ruby>$/i,
|
||||||
|
/^<\/?rt>$/i,
|
||||||
|
/^<\/?c(?:\.[\w-]+)*>$/i,
|
||||||
|
/^<v(?:\.[\w-]+)*(?:\s+[^<>]{1,80})?>$/i,
|
||||||
|
/^<\/v>$/i,
|
||||||
|
/^<\d{1,3}:\d{2}(?::\d{2})?\.\d{3}>$/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getSubtitleExtension(fileName: string): 'vtt' | 'srt' | null {
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
|
if (ext === 'vtt' || ext === 'srt') return ext;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise a language tag for storage. Kept lowercase so the unique index on
|
||||||
|
* (version, language) treats `TR` and `tr` as the same track.
|
||||||
|
*/
|
||||||
|
export function normalizeSubtitleLanguage(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || !LANGUAGE_TAG.test(trimmed)) return null;
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeSubtitleLabel(value: unknown, fallback: string): string {
|
||||||
|
const raw = typeof value === 'string' ? value : '';
|
||||||
|
const normalized = raw
|
||||||
|
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!normalized) return fallback;
|
||||||
|
return normalized.slice(0, MAX_SUBTITLE_LABEL_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitle files written by desktop editors are routinely not UTF-8. A Turkish SRT saved
|
||||||
|
* out of a Windows tool is usually windows-1254, and rejecting it outright would send the
|
||||||
|
* user off to convert a file we can decode ourselves. UTF-8 is tried strictly first so a
|
||||||
|
* valid file is never mangled by a legacy codepage.
|
||||||
|
*/
|
||||||
|
export function decodeSubtitleBuffer(buffer: Uint8Array): string | null {
|
||||||
|
for (const encoding of ['utf-8', 'windows-1254', 'windows-1252']) {
|
||||||
|
try {
|
||||||
|
const decoded = new TextDecoder(encoding, { fatal: true }).decode(buffer);
|
||||||
|
return decoded.replace(/^\uFEFF/, '');
|
||||||
|
} catch {
|
||||||
|
// Wrong encoding, or one this runtime's ICU build does not carry. Try the next.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(value: string): number | null {
|
||||||
|
const match = /^(?:(\d{1,3}):)?([0-5]?\d):([0-5]?\d)[.,](\d{1,3})$/.exec(value.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = match[1] ? Number(match[1]) : 0;
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
const seconds = Number(match[3]);
|
||||||
|
const millis = Number(match[4].padEnd(3, '0'));
|
||||||
|
return hours * 3600 + minutes * 60 + seconds + millis / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(seconds: number): string {
|
||||||
|
const clamped = Math.max(0, seconds);
|
||||||
|
const totalMillis = Math.round(clamped * 1000);
|
||||||
|
const hours = Math.floor(totalMillis / 3_600_000);
|
||||||
|
const minutes = Math.floor((totalMillis % 3_600_000) / 60_000);
|
||||||
|
const secs = Math.floor((totalMillis % 60_000) / 1000);
|
||||||
|
const millis = totalMillis % 1000;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimingLine(line: string): { start: number; end: number } | null {
|
||||||
|
const separatorIndex = line.indexOf('-->');
|
||||||
|
if (separatorIndex === -1) return null;
|
||||||
|
const start = parseTimestamp(line.slice(0, separatorIndex));
|
||||||
|
// Anything after the end timestamp is a cue setting (position, align, line). They are
|
||||||
|
// dropped: the player positions cues itself so its own control bar does not cover them.
|
||||||
|
const rest = line.slice(separatorIndex + 3).trim();
|
||||||
|
const end = parseTimestamp(rest.split(/\s+/)[0] ?? '');
|
||||||
|
if (start === null || end === null) return null;
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUE_TAG = /<[^<>]*>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angle brackets outside a recognised tag are escaped one character at a time rather than
|
||||||
|
* the offending tag being deleted whole. Deleting is what lets a filter like this be
|
||||||
|
* reassembled around: strip the `<b>` out of `<scr<b>ipt>` and the two halves close up
|
||||||
|
* into a tag that was never written. Nothing closes up when the leftovers are escaped
|
||||||
|
* instead, and the same escaping takes care of `-->`, which would otherwise be read back
|
||||||
|
* as a timing line and split the cue in two. `&` is left alone so a file that already
|
||||||
|
* spells its entities properly keeps them.
|
||||||
|
*/
|
||||||
|
function escapeCueText(text: string): string {
|
||||||
|
return text.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCueLine(line: string): string {
|
||||||
|
// ASS/SSA override blocks travel in SRT files ripped from other formats. The VTT parser
|
||||||
|
// renders them as literal text, which is never what the author meant.
|
||||||
|
const withoutOverrides = line.replace(/\{\\[^}]*\}/g, '');
|
||||||
|
|
||||||
|
let sanitized = '';
|
||||||
|
let cursor = 0;
|
||||||
|
CUE_TAG.lastIndex = 0;
|
||||||
|
for (let match = CUE_TAG.exec(withoutOverrides); match; match = CUE_TAG.exec(withoutOverrides)) {
|
||||||
|
sanitized += escapeCueText(withoutOverrides.slice(cursor, match.index));
|
||||||
|
if (ALLOWED_CUE_TAGS.some((allowed) => allowed.test(match[0]))) {
|
||||||
|
sanitized += match[0];
|
||||||
|
}
|
||||||
|
cursor = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
sanitized += escapeCueText(withoutOverrides.slice(cursor));
|
||||||
|
|
||||||
|
return sanitized.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '').trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SRT or WebVTT into cues. Unknown blocks (NOTE, STYLE, REGION, cue identifiers,
|
||||||
|
* SRT sequence numbers) are skipped rather than carried over.
|
||||||
|
*/
|
||||||
|
export function parseSubtitleCues(input: string): SubtitleCue[] {
|
||||||
|
const lines = input.replace(/\r\n?/g, '\n').split('\n');
|
||||||
|
const cues: SubtitleCue[] = [];
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
// A STYLE or REGION block runs until the next blank line and may itself contain no
|
||||||
|
// timing, so it is skipped wholesale rather than line by line.
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(?:WEBVTT|NOTE|STYLE|REGION)\b/.test(trimmed)) {
|
||||||
|
index += 1;
|
||||||
|
while (index < lines.length && lines[index].trim()) index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cue may be preceded by an identifier line (an SRT sequence number, or a VTT cue
|
||||||
|
// id). The timing is then on the following line.
|
||||||
|
let timing = parseTimingLine(trimmed);
|
||||||
|
if (!timing) {
|
||||||
|
const next = lines[index + 1]?.trim();
|
||||||
|
if (!next) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
timing = parseTimingLine(next);
|
||||||
|
if (!timing) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
|
||||||
|
const textLines: string[] = [];
|
||||||
|
while (index < lines.length && lines[index].trim()) {
|
||||||
|
const sanitized = sanitizeCueLine(lines[index]);
|
||||||
|
if (sanitized.trim()) textLines.push(sanitized);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timing.end <= timing.start) continue;
|
||||||
|
// The cap can land inside an escape the sanitiser wrote, so a dangling `<` tail is
|
||||||
|
// trimmed rather than left for the parser to render as text.
|
||||||
|
const text = textLines
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, MAX_CUE_TEXT_LENGTH)
|
||||||
|
.replace(/&[a-z]{0,5}$/i, '')
|
||||||
|
.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
cues.push({ start: timing.start, end: timing.end, text });
|
||||||
|
if (cues.length >= MAX_SUBTITLE_CUES) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeWebVtt(cues: SubtitleCue[]): string {
|
||||||
|
const body = cues
|
||||||
|
.map((cue) => `${formatTimestamp(cue.start)} --> ${formatTimestamp(cue.end)}\n${cue.text}`)
|
||||||
|
.join('\n\n');
|
||||||
|
return `WEBVTT\n\n${body}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole pipeline: bytes in, a canonical WebVTT string out, or a message explaining
|
||||||
|
* what is wrong with the file in terms the person who uploaded it can act on.
|
||||||
|
*/
|
||||||
|
export function normalizeSubtitleFile(buffer: Uint8Array): SubtitleNormalizeResult {
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
return { ok: false, error: 'Subtitle file is empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = decodeSubtitleBuffer(buffer);
|
||||||
|
if (decoded === null) {
|
||||||
|
return { ok: false, error: 'Could not read the subtitle file. Save it as UTF-8 and retry.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(decoded);
|
||||||
|
if (cues.length === 0) {
|
||||||
|
return { ok: false, error: 'No subtitle cues found. Upload a valid .srt or .vtt file.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const vtt = serializeWebVtt(cues);
|
||||||
|
if (Buffer.byteLength(vtt, 'utf8') > MAX_NORMALIZED_SUBTITLE_SIZE) {
|
||||||
|
return { ok: false, error: 'Subtitle file is too large after conversion' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, vtt, cueCount: cues.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtitleFileNameToProxyUrl(fileName: string): string {
|
||||||
|
return `${SUBTITLE_PROXY_PREFIX}${fileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSubtitleFileNameFromProxyUrl(url: string): string | null {
|
||||||
|
if (!SAFE_SUBTITLE_PROXY_PATH.test(url)) return null;
|
||||||
|
return url.slice(SUBTITLE_PROXY_PREFIX.length) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtitleProxyPathToObjectKey(url: string): string | null {
|
||||||
|
const fileName = extractSubtitleFileNameFromProxyUrl(url);
|
||||||
|
if (!fileName) return null;
|
||||||
|
return `${SUBTITLE_OBJECT_KEY_PREFIX}${fileName}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Subtitle tracks hang off a version, not off the video: re-editing a cut shifts
|
||||||
|
-- every cue, so a track attached to the parent would be wrong for every version
|
||||||
|
-- but the one it was written against.
|
||||||
|
CREATE TABLE "video_subtitles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"versionId" TEXT NOT NULL,
|
||||||
|
"language" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"sourceUrl" TEXT NOT NULL,
|
||||||
|
"size_bytes" BIGINT NOT NULL DEFAULT 0,
|
||||||
|
"billedUserId" TEXT NOT NULL,
|
||||||
|
"uploadedByUserId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "video_subtitles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One stored object belongs to exactly one row, so the reference check that runs
|
||||||
|
-- before an object delete cannot be fooled by a second row pointing at the file.
|
||||||
|
CREATE UNIQUE INDEX "video_subtitles_sourceUrl_key" ON "video_subtitles"("sourceUrl");
|
||||||
|
|
||||||
|
-- Re-uploading a language replaces the track rather than stacking a second one,
|
||||||
|
-- which would leave the player with two tracks labelled the same.
|
||||||
|
CREATE UNIQUE INDEX "video_subtitles_versionId_language_key" ON "video_subtitles"("versionId", "language");
|
||||||
|
|
||||||
|
CREATE INDEX "video_subtitles_versionId_idx" ON "video_subtitles"("versionId");
|
||||||
|
|
||||||
|
-- The storage quota sums this column per billed user on every upload.
|
||||||
|
CREATE INDEX "video_subtitles_billedUserId_idx" ON "video_subtitles"("billedUserId");
|
||||||
|
|
||||||
|
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_versionId_fkey"
|
||||||
|
FOREIGN KEY ("versionId") REFERENCES "video_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_billedUserId_fkey"
|
||||||
|
FOREIGN KEY ("billedUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "video_subtitles" ADD CONSTRAINT "video_subtitles_uploadedByUserId_fkey"
|
||||||
|
FOREIGN KEY ("uploadedByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -42,6 +42,8 @@ model User {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
|
uploadedVideoAssets VideoAsset[] @relation("VideoAssetUploadedBy")
|
||||||
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
|
billedVideoAssets VideoAsset[] @relation("VideoAssetBilledTo")
|
||||||
|
uploadedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleUploadedBy")
|
||||||
|
billedVideoSubtitles VideoSubtitle[] @relation("VideoSubtitleBilledTo")
|
||||||
projectMemberships ProjectMember[]
|
projectMemberships ProjectMember[]
|
||||||
notificationSetting NotificationSetting?
|
notificationSetting NotificationSetting?
|
||||||
watchProgress WatchProgress[]
|
watchProgress WatchProgress[]
|
||||||
@@ -383,6 +385,7 @@ model VideoVersion {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
watchProgress WatchProgress[]
|
watchProgress WatchProgress[]
|
||||||
approvalRequests ApprovalRequest[]
|
approvalRequests ApprovalRequest[]
|
||||||
|
subtitles VideoSubtitle[]
|
||||||
|
|
||||||
@@unique([videoParentId, versionNumber])
|
@@unique([videoParentId, versionNumber])
|
||||||
@@index([videoParentId])
|
@@index([videoParentId])
|
||||||
@@ -418,6 +421,34 @@ model VideoAsset {
|
|||||||
@@map("video_assets")
|
@@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 {
|
model Comment {
|
||||||
id String @id @default(cuid())
|
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 uploadAudioRoute from '@/app/api/upload/audio/route';
|
||||||
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
|
import * as uploadImageFileRoute from '@/app/api/upload/image/[filename]/route';
|
||||||
import * as uploadImageRoute from '@/app/api/upload/image/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 uploadVideoFileRoute from '@/app/api/upload/video/[filename]/route';
|
||||||
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
|
import * as versionApprovalsRoute from '@/app/api/versions/[versionId]/approvals/route';
|
||||||
import * as commentsExportRoute from '@/app/api/versions/[versionId]/comments/export/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 assetsBunnyInitRoute from '@/app/api/videos/[videoId]/assets/bunny-init/route';
|
||||||
import * as assetsR2InitRoute from '@/app/api/videos/[videoId]/assets/r2-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 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 watchProgressRoute from '@/app/api/watch/[videoId]/progress/route';
|
||||||
import * as watchRoute from '@/app/api/watch/[videoId]/route';
|
import * as watchRoute from '@/app/api/watch/[videoId]/route';
|
||||||
import * as watchUploadTokenRoute from '@/app/api/watch/[videoId]/upload-token/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
|
// The count guard
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
|
// 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
|
* 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 IMAGE_FILENAME = '11111111-1111-4111-8111-111111111111.png';
|
||||||
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
|
const AUDIO_FILENAME = '22222222-2222-4222-8222-222222222222.webm';
|
||||||
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
|
const VIDEO_FILENAME = '33333333-3333-4333-8333-333333333333.mp4';
|
||||||
|
const SUBTITLE_FILENAME = '44444444-4444-4444-8444-444444444444.vtt';
|
||||||
|
|
||||||
interface Fixtures {
|
interface Fixtures {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -217,6 +221,7 @@ interface Fixtures {
|
|||||||
versionId: string;
|
versionId: string;
|
||||||
commentId: string;
|
commentId: string;
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
subtitleId: string;
|
||||||
approvalRequestId: string;
|
approvalRequestId: string;
|
||||||
feedbackId: string;
|
feedbackId: string;
|
||||||
}
|
}
|
||||||
@@ -280,6 +285,20 @@ async function seedFixtures(): Promise<Fixtures> {
|
|||||||
sourceUrl: `/api/upload/audio/${AUDIO_FILENAME}`,
|
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' });
|
await createShareLink({ projectId: project.id, videoId: video.id, permission: 'COMMENT' });
|
||||||
|
|
||||||
const approvalRequest = await createApprovalRequest({
|
const approvalRequest = await createApprovalRequest({
|
||||||
@@ -310,6 +329,7 @@ async function seedFixtures(): Promise<Fixtures> {
|
|||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
|
subtitleId: subtitle.id,
|
||||||
approvalRequestId: approvalRequest.id,
|
approvalRequestId: approvalRequest.id,
|
||||||
feedbackId: feedback.id,
|
feedbackId: feedback.id,
|
||||||
};
|
};
|
||||||
@@ -595,6 +615,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
|||||||
// and constructing a Request from a FormData does not set one.
|
// and constructing a Request from a FormData does not set one.
|
||||||
headers: { 'content-length': '2048' },
|
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',
|
file: 'upload/video/[filename]/route.ts',
|
||||||
module: uploadVideoFileRoute,
|
module: uploadVideoFileRoute,
|
||||||
@@ -669,6 +695,27 @@ const ROUTE_CASES: readonly RouteCase[] = [
|
|||||||
// exact-status coverage lives in tests/api/assets-authz.test.ts.
|
// exact-status coverage lives in tests/api/assets-authz.test.ts.
|
||||||
body: { kind: 'IMAGE', sourceUrl: `/api/upload/image/${IMAGE_FILENAME}` },
|
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',
|
file: 'watch/[videoId]/progress/route.ts',
|
||||||
module: watchProgressRoute,
|
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',
|
'20260801120000_add_acquisition_analytics',
|
||||||
'20260818120000_add_upload_reservation_purpose',
|
'20260818120000_add_upload_reservation_purpose',
|
||||||
'20260820120000_add_comment_images',
|
'20260820120000_add_comment_images',
|
||||||
|
'20260822120000_add_video_subtitles',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
decodeSubtitleBuffer,
|
||||||
|
getSubtitleExtension,
|
||||||
|
MAX_SUBTITLE_CUES,
|
||||||
|
normalizeSubtitleFile,
|
||||||
|
normalizeSubtitleLanguage,
|
||||||
|
parseSubtitleCues,
|
||||||
|
sanitizeSubtitleLabel,
|
||||||
|
SAFE_SUBTITLE_PROXY_PATH,
|
||||||
|
serializeWebVtt,
|
||||||
|
subtitleProxyPathToObjectKey,
|
||||||
|
} from '@/lib/subtitle-validation';
|
||||||
|
|
||||||
|
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
|
||||||
|
function encode(text: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getSubtitleExtension', () => {
|
||||||
|
it('accepts the two subtitle formats and nothing else', () => {
|
||||||
|
expect(getSubtitleExtension('cut.srt')).toBe('srt');
|
||||||
|
expect(getSubtitleExtension('cut.VTT')).toBe('vtt');
|
||||||
|
expect(getSubtitleExtension('cut.ass')).toBeNull();
|
||||||
|
expect(getSubtitleExtension('cut.srt.exe')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSubtitleLanguage', () => {
|
||||||
|
it('lowercases so a re-upload replaces the track it means to', () => {
|
||||||
|
expect(normalizeSubtitleLanguage('TR')).toBe('tr');
|
||||||
|
expect(normalizeSubtitleLanguage(' en-US ')).toBe('en-us');
|
||||||
|
expect(normalizeSubtitleLanguage('zh-Hant-TW')).toBe('zh-hant-tw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects anything that is not a language tag', () => {
|
||||||
|
expect(normalizeSubtitleLanguage('')).toBeNull();
|
||||||
|
expect(normalizeSubtitleLanguage('t')).toBeNull();
|
||||||
|
expect(normalizeSubtitleLanguage('tr; drop table')).toBeNull();
|
||||||
|
expect(normalizeSubtitleLanguage('<script>')).toBeNull();
|
||||||
|
expect(normalizeSubtitleLanguage(42)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeSubtitleLabel', () => {
|
||||||
|
it('falls back when the label is empty after cleaning', () => {
|
||||||
|
expect(sanitizeSubtitleLabel(' ', 'TR')).toBe('TR');
|
||||||
|
expect(sanitizeSubtitleLabel(undefined, 'TR')).toBe('TR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips control characters and collapses whitespace', () => {
|
||||||
|
expect(sanitizeSubtitleLabel('Türk\u0000\n çe ', 'TR')).toBe('Türk çe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the length', () => {
|
||||||
|
expect(sanitizeSubtitleLabel('a'.repeat(200), 'TR')).toHaveLength(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeSubtitleBuffer', () => {
|
||||||
|
it('reads UTF-8 and drops the byte order mark', () => {
|
||||||
|
expect(decodeSubtitleBuffer(encode('\uFEFFmerhaba'))).toBe('merhaba');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a legacy codepage rather than rejecting the file', () => {
|
||||||
|
// 0xFD is "ı" in windows-1254 and not valid UTF-8 on its own.
|
||||||
|
const decoded = decodeSubtitleBuffer(new Uint8Array([0x61, 0xfd, 0x62]));
|
||||||
|
expect(decoded).not.toBeNull();
|
||||||
|
expect(decoded).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseSubtitleCues', () => {
|
||||||
|
it('parses SRT, comma decimals and sequence numbers included', () => {
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
[
|
||||||
|
'1',
|
||||||
|
'00:00:01,000 --> 00:00:02,500',
|
||||||
|
'Merhaba',
|
||||||
|
'',
|
||||||
|
'2',
|
||||||
|
'00:00:03,000 --> 00:00:04,000',
|
||||||
|
'Dünya',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
expect(cues).toEqual([
|
||||||
|
{ start: 1, end: 2.5, text: 'Merhaba' },
|
||||||
|
{ start: 3, end: 4, text: 'Dünya' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses WebVTT with cue ids, settings and short timestamps', () => {
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
['WEBVTT', '', 'intro', '00:01.000 --> 00:02.000 align:start position:10%', 'Hello', ''].join(
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(cues).toEqual([{ start: 1, end: 2, text: 'Hello' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips NOTE, STYLE and REGION blocks', () => {
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
[
|
||||||
|
'WEBVTT',
|
||||||
|
'',
|
||||||
|
'NOTE this is a comment',
|
||||||
|
'still the comment',
|
||||||
|
'',
|
||||||
|
'STYLE',
|
||||||
|
'::cue { color: red }',
|
||||||
|
'',
|
||||||
|
'00:00:01.000 --> 00:00:02.000',
|
||||||
|
'Kept',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
expect(cues).toEqual([{ start: 1, end: 2, text: 'Kept' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops cues that end before they start and cues with no text', () => {
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
[
|
||||||
|
'00:00:05,000 --> 00:00:02,000',
|
||||||
|
'Backwards',
|
||||||
|
'',
|
||||||
|
'00:00:06,000 --> 00:00:07,000',
|
||||||
|
'',
|
||||||
|
'00:00:08,000 --> 00:00:09,000',
|
||||||
|
'Good',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
);
|
||||||
|
expect(cues).toEqual([{ start: 8, end: 9, text: 'Good' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps known cue markup and removes everything else', () => {
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
['00:00:01,000 --> 00:00:02,000', '<i>tilt</i><script>alert(1)</script>{\\an8}', ''].join(
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(cues[0].text).toBe('<i>tilt</i>alert(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the leftovers of a rejected tag so it cannot be reassembled', () => {
|
||||||
|
// Deleting `<b>` out of the middle would close the two halves into a `<script>` that
|
||||||
|
// was never written. Escaping what is left over is what stops that.
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
['00:00:01,000 --> 00:00:02,000', '<scr<b>ipt>alert(1)', ''].join('\n')
|
||||||
|
);
|
||||||
|
expect(cues[0].text).toBe('<scr<b>ipt>alert(1)');
|
||||||
|
expect(cues[0].text).not.toContain('<script');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises an arrow in cue text so the file cannot be re-split', () => {
|
||||||
|
const cues = parseSubtitleCues(['00:00:01,000 --> 00:00:02,000', 'a --> b', ''].join('\n'));
|
||||||
|
expect(cues[0].text).toBe('a --> b');
|
||||||
|
expect(parseSubtitleCues(serializeWebVtt(cues))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the cue ceiling', () => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let index = 0; index < MAX_SUBTITLE_CUES + 10; index += 1) {
|
||||||
|
lines.push(`00:00:0${index % 9}.000 --> 00:00:0${(index % 9) + 1}.000`, `line ${index}`, '');
|
||||||
|
}
|
||||||
|
expect(parseSubtitleCues(lines.join('\n'))).toHaveLength(MAX_SUBTITLE_CUES);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSubtitleFile', () => {
|
||||||
|
it('converts SRT to a canonical WebVTT document', () => {
|
||||||
|
const result = normalizeSubtitleFile(
|
||||||
|
encode('1\r\n00:00:01,500 --> 00:00:02,000\r\nMerhaba\r\n\r\n')
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
cueCount: 1,
|
||||||
|
vtt: 'WEBVTT\n\n00:00:01.500 --> 00:00:02.000\nMerhaba\n',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an empty file', () => {
|
||||||
|
const result = normalizeSubtitleFile(new Uint8Array());
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a file with no cues rather than storing an empty track', () => {
|
||||||
|
const result = normalizeSubtitleFile(encode('this is just prose\nand more prose\n'));
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'No subtitle cues found. Upload a valid .srt or .vtt file.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('subtitle proxy paths', () => {
|
||||||
|
it('only recognises a uuid .vtt path', () => {
|
||||||
|
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.vtt`)).toBe(true);
|
||||||
|
expect(SAFE_SUBTITLE_PROXY_PATH.test(`/api/upload/subtitle/${UUID}.srt`)).toBe(false);
|
||||||
|
expect(SAFE_SUBTITLE_PROXY_PATH.test('/api/upload/subtitle/../../etc/passwd')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a proxy path to its object key and refuses anything else', () => {
|
||||||
|
expect(subtitleProxyPathToObjectKey(`/api/upload/subtitle/${UUID}.vtt`)).toBe(
|
||||||
|
`subtitles/${UUID}.vtt`
|
||||||
|
);
|
||||||
|
expect(subtitleProxyPathToObjectKey(`/api/upload/image/${UUID}.png`)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Vendored
+10
@@ -25,6 +25,16 @@ declare namespace YT {
|
|||||||
getPlaybackRate(): number;
|
getPlaybackRate(): number;
|
||||||
getAvailablePlaybackRates(): number[];
|
getAvailablePlaybackRates(): number[];
|
||||||
destroy(): void;
|
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 {
|
interface PlayerOptions {
|
||||||
|
|||||||
Reference in New Issue
Block a user