mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Merge pull request #55 from yusufipk/feat/storage-upgrade-nudge
fix(storage): count a finished Bunny upload, and offer the upgrade when a trial is full
This commit is contained in:
@@ -77,6 +77,8 @@ interface StorageInfo {
|
|||||||
usedBytes: string;
|
usedBytes: string;
|
||||||
limitBytes: string;
|
limitBytes: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
|
/** False on the free trial, where the way out is subscribing rather than deleting. */
|
||||||
|
isPaid: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytesStr: string): string {
|
function formatBytes(bytesStr: string): string {
|
||||||
@@ -501,7 +503,8 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
Storage
|
Storage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Combined usage across video files and media attachments (200 GB limit)
|
Combined usage across video files and media attachments
|
||||||
|
{storageInfo ? ` (${formatBytes(storageInfo.limitBytes)} limit)` : ''}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{storageInfo.percentage >= 90 && (
|
{storageInfo.percentage >= 90 &&
|
||||||
<p className="text-xs text-destructive">
|
(storageInfo.isPaid ? (
|
||||||
Storage is almost full. Delete unused files or contact support.
|
<p className="text-xs text-destructive">
|
||||||
</p>
|
Storage is almost full. Delete unused files or contact support.
|
||||||
)}
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Your free trial storage is almost full. Subscribe above for more room, or
|
||||||
|
delete unused files.
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { getUserStorageInfo } from '@/lib/storage-quota';
|
import { getStorageContextForUser, getUserStorageInfo } from '@/lib/storage-quota';
|
||||||
import { hasBillingAccess } from '@/lib/billing';
|
import { hasBillingAccess } from '@/lib/billing';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
|
||||||
@@ -27,12 +27,19 @@ export async function GET() {
|
|||||||
return apiErrors.forbidden();
|
return apiErrors.forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await getUserStorageInfo(session.user.id);
|
const [info, storage] = await Promise.all([
|
||||||
|
getUserStorageInfo(session.user.id),
|
||||||
|
getStorageContextForUser(session.user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
usedBytes: info.usedBytes.toString(),
|
usedBytes: info.usedBytes.toString(),
|
||||||
limitBytes: info.limitBytes.toString(),
|
limitBytes: info.limitBytes.toString(),
|
||||||
percentage: info.percentage,
|
percentage: info.percentage,
|
||||||
|
// Which ceiling this is, so the card can name it and say what to do about it.
|
||||||
|
// A trial has 3 GB because it has not subscribed; deleting files is the wrong
|
||||||
|
// advice there, and "200 GB limit" was the wrong caption.
|
||||||
|
isPaid: storage.isPaid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache for 60s — stale data is acceptable for a usage meter
|
// Cache for 60s — stale data is acceptable for a usage meter
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ import {
|
|||||||
enforceStorageQuota,
|
enforceStorageQuota,
|
||||||
reserveStorageQuota,
|
reserveStorageQuota,
|
||||||
releaseStorageReservation,
|
releaseStorageReservation,
|
||||||
getStorageLimitForUser,
|
getStorageContextForUser,
|
||||||
|
storageExceededResponse,
|
||||||
UPLOAD_RESERVATION_PURPOSES,
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
|
type StorageContext,
|
||||||
type UploadReservationPurpose,
|
type UploadReservationPurpose,
|
||||||
} from '@/lib/storage-quota';
|
} from '@/lib/storage-quota';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
|
|
||||||
// Sentinel thrown inside a Prisma transaction when a fake reservationId is
|
// Sentinel thrown inside a Prisma transaction when a fake reservationId is
|
||||||
@@ -307,6 +309,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
// was still in flight.
|
// was still in flight.
|
||||||
let reservationPurpose: UploadReservationPurpose | null = null;
|
let reservationPurpose: UploadReservationPurpose | null = null;
|
||||||
let reservationBilledUserId: string | null = null;
|
let reservationBilledUserId: string | null = null;
|
||||||
|
// Carried out of the try so the quota refusal in the catch can be worded for
|
||||||
|
// the account it is refusing, rather than telling a trial to delete files.
|
||||||
|
let storageForRefusal: StorageContext | null = null;
|
||||||
let finalizedR2AssetSession: {
|
let finalizedR2AssetSession: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
reservationId: string | null;
|
reservationId: string | null;
|
||||||
@@ -582,14 +587,15 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
// quota check below, Bunny included. Leaving Bunny out read its own storage as
|
// quota check below, Bunny included. Leaving Bunny out read its own storage as
|
||||||
// zero, and on an account whose storage is all Bunny that made the fallback a
|
// zero, and on an account whose storage is all Bunny that made the fallback a
|
||||||
// check that could not fail.
|
// check that could not fail.
|
||||||
const preFetchedBunnyData =
|
const preFetchedBunnyBytes =
|
||||||
provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
|
provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
|
||||||
|
|
||||||
// The ceiling this account is actually held to, read for the fallback below.
|
// The ceiling this account is actually held to, read for the fallback below.
|
||||||
// It used to compare against the plan limit, which is 200 GiB whoever is
|
// It used to compare against the plan limit, which is 200 GiB whoever is
|
||||||
// asking: a caller who quoted a reservation id that no longer existed was
|
// asking: a caller who quoted a reservation id that no longer existed was
|
||||||
// measured against the paid ceiling even on a trial worth 3 GiB.
|
// measured against the paid ceiling even on a trial worth 3 GiB.
|
||||||
const storageLimitBytes = await getStorageLimitForUser(billedUserId);
|
const storage = await getStorageContextForUser(billedUserId);
|
||||||
|
storageForRefusal = storage;
|
||||||
|
|
||||||
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
// Create the VideoAsset and atomically consume the upload reservation (if any)
|
||||||
// so the spot is never double-counted.
|
// so the spot is never double-counted.
|
||||||
@@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
WHERE "billedUserId" = ${billedUserId}
|
WHERE "billedUserId" = ${billedUserId}
|
||||||
AND "expiresAt" > NOW()
|
AND "expiresAt" > NOW()
|
||||||
`;
|
`;
|
||||||
const bunnyData = preFetchedBunnyData ?? {};
|
|
||||||
const totalUsed =
|
const totalUsed =
|
||||||
(r2Row?.total ?? BigInt(0)) +
|
(r2Row?.total ?? BigInt(0)) +
|
||||||
(resRow?.total ?? BigInt(0)) +
|
(resRow?.total ?? BigInt(0)) +
|
||||||
BigInt(bunnyData[billedUserId] ?? 0);
|
BigInt(preFetchedBunnyBytes ?? 0);
|
||||||
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
|
if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
|
||||||
throw new QuotaExceededInTxError();
|
throw new QuotaExceededInTxError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
return withCacheControl(response, 'private, no-store');
|
return withCacheControl(response, 'private, no-store');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof QuotaExceededInTxError) {
|
if (error instanceof QuotaExceededInTxError) {
|
||||||
return apiErrors.storageExceeded() as NextResponse;
|
return storageForRefusal
|
||||||
|
? storageExceededResponse(storageForRefusal)
|
||||||
|
: (apiErrors.storageExceeded() as NextResponse);
|
||||||
}
|
}
|
||||||
await releaseStorageReservation(
|
await releaseStorageReservation(
|
||||||
reservationId,
|
reservationId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import * as tus from 'tus-js-client';
|
import * as tus from 'tus-js-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { toastApiError } from '@/lib/client/api-error';
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
FileVideo,
|
FileVideo,
|
||||||
@@ -56,6 +57,7 @@ function formatTime(seconds: number): string {
|
|||||||
type UploadAudioResponse = {
|
type UploadAudioResponse = {
|
||||||
data?: { url?: string; reservationId?: string | null };
|
data?: { url?: string; reservationId?: string | null };
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAudioUploadValidationError(file: Blob): string | null {
|
function getAudioUploadValidationError(file: Blob): string | null {
|
||||||
@@ -369,10 +371,11 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const uploadPayload = (await uploadRes.json().catch(() => null)) as {
|
const uploadPayload = (await uploadRes.json().catch(() => null)) as {
|
||||||
data?: { url?: string; reservationId?: string | null };
|
data?: { url?: string; reservationId?: string | null };
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: string;
|
||||||
} | null;
|
} | null;
|
||||||
const uploadedImageUrl = uploadPayload?.data?.url;
|
const uploadedImageUrl = uploadPayload?.data?.url;
|
||||||
if (!uploadRes.ok || !uploadedImageUrl) {
|
if (!uploadRes.ok || !uploadedImageUrl) {
|
||||||
toast.error(`${file.name}: ${uploadPayload?.error || 'Failed to upload image'}`);
|
toastApiError(uploadPayload, 'Failed to upload image', { prefix: file.name });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +388,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
return !!created;
|
return !!created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload image asset:', error);
|
console.error('Failed to upload image asset:', error);
|
||||||
toast.error(`${file.name}: Failed to upload image`);
|
toastApiError(error, 'Failed to upload image', { prefix: file.name });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -523,10 +526,11 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
uploadToken: string;
|
uploadToken: string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!initRes.ok || !initPayload?.data) {
|
if (!initRes.ok || !initPayload?.data) {
|
||||||
toast.error(`${file.name}: ${initPayload?.error || 'Failed to initialize Bunny upload'}`);
|
toastApiError(initPayload, 'Failed to initialize Bunny upload', { prefix: file.name });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload Bunny asset:', error);
|
console.error('Failed to upload Bunny asset:', error);
|
||||||
toast.error(`${file.name}: Failed to upload Bunny video`);
|
toastApiError(error, 'Failed to upload Bunny video', { prefix: file.name });
|
||||||
if (uploadedVideoId && uploadToken) {
|
if (uploadedVideoId && uploadToken) {
|
||||||
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -631,7 +635,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload R2 asset video:', error);
|
console.error('Failed to upload R2 asset video:', error);
|
||||||
toast.error(`${file.name}: Failed to upload video`);
|
toastApiError(error, 'Failed to upload video', { prefix: file.name });
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploadingBunny(false);
|
setIsUploadingBunny(false);
|
||||||
@@ -801,12 +805,12 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
const uploadPayload = await readUploadAudioResponse(uploadRes);
|
||||||
const uploadedUrl = uploadPayload?.data?.url;
|
const uploadedUrl = uploadPayload?.data?.url;
|
||||||
if (!uploadRes.ok || !uploadedUrl) {
|
if (!uploadRes.ok || !uploadedUrl) {
|
||||||
toast.error(
|
toastApiError(
|
||||||
`${fileName}: ${
|
uploadPayload,
|
||||||
uploadPayload?.error ||
|
uploadRes.status === 413
|
||||||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
|
? MAX_AUDIO_UPLOAD_SIZE_MESSAGE
|
||||||
'Failed to upload voice recording'
|
: 'Failed to upload voice recording',
|
||||||
}`
|
{ prefix: fileName }
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -821,7 +825,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
return !!created;
|
return !!created;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload voice asset:', error);
|
console.error('Failed to upload voice asset:', error);
|
||||||
toast.error(`${fileName}: Failed to upload voice recording`);
|
toastApiError(error, 'Failed to upload voice recording', { prefix: fileName });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '@/components/video-page/image-upload-utils';
|
} from '@/components/video-page/image-upload-utils';
|
||||||
import { validateAnnotationStrokes } from '@/lib/validation';
|
import { validateAnnotationStrokes } from '@/lib/validation';
|
||||||
import { withWebmDuration } from '@/lib/webm-duration';
|
import { withWebmDuration } from '@/lib/webm-duration';
|
||||||
|
import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||||
|
|
||||||
interface UseCommentActionsParams extends CommentActionsConfig {
|
interface UseCommentActionsParams extends CommentActionsConfig {
|
||||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||||
@@ -61,17 +62,6 @@ function getAudioUploadFilename(blob: Blob): string {
|
|||||||
return 'recording.webm';
|
return 'recording.webm';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A step of the submit that failed with something worth reading out.
|
|
||||||
*
|
|
||||||
* The attachment goes up before the comment does, so a full account fails on the
|
|
||||||
* image and never reaches the comment at all. Reporting that as "failed to add
|
|
||||||
* comment" tells the uploader to try again, which is the one thing that cannot
|
|
||||||
* work. Carried as its own error type so a network fault, which has no message
|
|
||||||
* anybody wants to see, still falls back to the generic line.
|
|
||||||
*/
|
|
||||||
class CommentSubmitError extends Error {}
|
|
||||||
|
|
||||||
export function useCommentActions({
|
export function useCommentActions({
|
||||||
videoId,
|
videoId,
|
||||||
setVideo,
|
setVideo,
|
||||||
@@ -192,7 +182,7 @@ export function useCommentActions({
|
|||||||
} | null;
|
} | null;
|
||||||
const token = payload?.data?.token;
|
const token = payload?.data?.token;
|
||||||
if (!response.ok || !token) {
|
if (!response.ok || !token) {
|
||||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
throw apiRequestError(payload, 'Failed to prepare upload');
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
@@ -274,10 +264,14 @@ export function useCommentActions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!imageRes.ok) {
|
if (!imageRes.ok) {
|
||||||
|
// The attachment goes up before the comment does, so a full account
|
||||||
|
// fails here and never reaches the comment at all. Thrown with the
|
||||||
|
// code attached so the catch below can offer the way out.
|
||||||
const imagePayload = (await imageRes.json().catch(() => null)) as {
|
const imagePayload = (await imageRes.json().catch(() => null)) as {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: string;
|
||||||
} | null;
|
} | null;
|
||||||
throw new CommentSubmitError(imagePayload?.error || 'Failed to upload image');
|
throw apiRequestError(imagePayload, 'Failed to upload image');
|
||||||
}
|
}
|
||||||
const imageDataResponse = await imageRes.json();
|
const imageDataResponse = await imageRes.json();
|
||||||
imageData = { url: imageDataResponse.data.url };
|
imageData = { url: imageDataResponse.data.url };
|
||||||
@@ -336,8 +330,11 @@ export function useCommentActions({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
const payload = (await res.json().catch(() => null)) as {
|
||||||
toast.error(payload?.error || 'Failed to add comment');
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
toastApiError(payload, 'Failed to add comment');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setVideo((prev) => {
|
setVideo((prev) => {
|
||||||
@@ -351,7 +348,9 @@ export function useCommentActions({
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
toast.error(error instanceof CommentSubmitError ? error.message : 'Failed to add comment');
|
// A network fault has no message anybody wants to see, so only an
|
||||||
|
// ApiRequestError speaks for itself; toastApiError falls back for the rest.
|
||||||
|
toastApiError(error instanceof ApiRequestError ? error : null, 'Failed to add comment');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingComment(false);
|
setIsSubmittingComment(false);
|
||||||
setIsUploadingImage(false);
|
setIsUploadingImage(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||||
|
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||||
|
|
||||||
/** What a failed version upload has to undo, depending on which provider it started on. */
|
/** What a failed version upload has to undo, depending on which provider it started on. */
|
||||||
type PendingVersionCleanup =
|
type PendingVersionCleanup =
|
||||||
@@ -113,7 +114,13 @@ export function useVersionActions({
|
|||||||
body: JSON.stringify({ title, sizeBytes: file.size.toString() }),
|
body: JSON.stringify({ title, sizeBytes: file.size.toString() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!initRes.ok) throw new Error('Failed to initialize upload');
|
if (!initRes.ok) {
|
||||||
|
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
} | null;
|
||||||
|
throw apiRequestError(initPayload, 'Failed to initialize upload');
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
|
||||||
} = await initRes.json();
|
} = await initRes.json();
|
||||||
@@ -236,7 +243,7 @@ export function useVersionActions({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
throw new Error(data?.error || 'Failed to create version');
|
throw apiRequestError(data, 'Failed to create version');
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionData = await res.json();
|
const versionData = await res.json();
|
||||||
@@ -277,7 +284,7 @@ export function useVersionActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.error('Failed to create version:', errorObj);
|
console.error('Failed to create version:', errorObj);
|
||||||
toast.error(errorObj.message || 'Failed to create version');
|
toastApiError(errorObj, 'Failed to create version');
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingVersion(false);
|
setIsCreatingVersion(false);
|
||||||
setNewVersionUploadProgress(0);
|
setNewVersionUploadProgress(0);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
import { apiRequestError, toastApiError } from '@/lib/client/api-error';
|
||||||
|
|
||||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ interface AssetsListResponse {
|
|||||||
interface AssetCreateResponse {
|
interface AssetCreateResponse {
|
||||||
data?: VideoAsset;
|
data?: VideoAsset;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASSET_PAGE_SIZE = 40;
|
const ASSET_PAGE_SIZE = 40;
|
||||||
@@ -185,7 +187,7 @@ export function useVideoAssets({
|
|||||||
});
|
});
|
||||||
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||||
if (!res.ok || !body?.data) {
|
if (!res.ok || !body?.data) {
|
||||||
toast.error(body?.error || 'Failed to create asset');
|
toastApiError(body, 'Failed to create asset');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ export function useVideoAssets({
|
|||||||
} | null;
|
} | null;
|
||||||
const token = payload?.data?.token;
|
const token = payload?.data?.token;
|
||||||
if (!response.ok || !token) {
|
if (!response.ok || !token) {
|
||||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
throw apiRequestError(payload, 'Failed to prepare upload');
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
|
|||||||
+89
-23
@@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache(
|
|||||||
{ revalidate: STORAGE_CACHE_SECONDS }
|
{ revalidate: STORAGE_CACHE_SECONDS }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this video costs us, as the larger of the two numbers we have.
|
||||||
|
*
|
||||||
|
* Bunny reports nothing for a video until it starts encoding, and what it reports
|
||||||
|
* while encoding is partial: `storageSize` counts what has been written so far and
|
||||||
|
* climbs as each rendition lands. A six minute cut uploaded at 2.5 GB read as
|
||||||
|
* 475 MB midway through and settled above 3 GB once it finished, because Bunny
|
||||||
|
* keeps the original alongside every rendition it makes.
|
||||||
|
*
|
||||||
|
* Both halves of the rule follow from that. Taking Bunny's figure whenever it is
|
||||||
|
* non-zero would hand back most of the quota in the middle of an encode, which is
|
||||||
|
* the hole the declared size exists to close. Taking the declared size forever
|
||||||
|
* would ignore the renditions, which are the actual bill and end up larger than
|
||||||
|
* the source. The larger of the two is right at every point: the declared size
|
||||||
|
* covers the encode, and Bunny's own number takes over the moment it passes it.
|
||||||
|
*/
|
||||||
|
function chargeableSize(reported: number, declared: bigint | null): number {
|
||||||
|
const declaredBytes = declared === null ? 0 : Number(declared);
|
||||||
|
return reported > declaredBytes ? reported : declaredBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bunny's reported sizes, or an empty map when the call to Bunny failed.
|
||||||
|
*
|
||||||
|
* A failed stats call is not a reason to bill an account for nothing. Bunny's own
|
||||||
|
* figure is unavailable; the sizes declared at upload are sitting in our database
|
||||||
|
* either way, and reading the whole account as empty is how a full account gets
|
||||||
|
* waved through. Used to be an early return that skipped the rows entirely.
|
||||||
|
*/
|
||||||
|
function reportedSizes(stats: BunnyStorageStats): Record<string, number> {
|
||||||
|
return stats.totalBytes < 0 ? {} : stats.byVideoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one account's Bunny videos cost, read fresh.
|
||||||
|
*
|
||||||
|
* This deliberately does not come from the cached per-user map. The declared size
|
||||||
|
* lands on the row at the moment an upload finalizes, and a map computed up to two
|
||||||
|
* minutes earlier does not have that row in it. For those two minutes the
|
||||||
|
* reservation is already gone and the row is not yet visible, so an upload that
|
||||||
|
* just succeeded reads as zero: the uploader watches their usage fall back to
|
||||||
|
* nothing, and the next upload is measured against a total that ignores the one
|
||||||
|
* before it.
|
||||||
|
*
|
||||||
|
* The call to Bunny stays cached. It is the slow half and its answer is the same
|
||||||
|
* for everybody. Only the join against our own rows has to be current.
|
||||||
|
*/
|
||||||
|
export async function getUserBunnyStorageBytes(userId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const [bunnyStats, bunnyVersions, bunnyAssets] = await Promise.all([
|
||||||
|
getCachedBunnyStorageStats(),
|
||||||
|
db.videoVersion.findMany({
|
||||||
|
where: {
|
||||||
|
providerId: 'bunny',
|
||||||
|
// The workspace owner, not the project owner: this feeds
|
||||||
|
// getUserTotalStorageBytes, which bills every other provider the same way.
|
||||||
|
video: { project: { workspace: { ownerId: userId } } },
|
||||||
|
},
|
||||||
|
select: { videoId: true, sizeBytes: true },
|
||||||
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: { provider: 'BUNNY', providerVideoId: { not: null }, billedUserId: userId },
|
||||||
|
select: { providerVideoId: true, sizeBytes: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const reported = reportedSizes(bunnyStats);
|
||||||
|
const seenVideoIds = new Set<string>();
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const row of [
|
||||||
|
...bunnyVersions.map((v) => ({ videoId: v.videoId, sizeBytes: v.sizeBytes })),
|
||||||
|
...bunnyAssets.map((a) => ({ videoId: a.providerVideoId!, sizeBytes: a.sizeBytes })),
|
||||||
|
]) {
|
||||||
|
if (!row.videoId || seenVideoIds.has(row.videoId)) continue;
|
||||||
|
seenVideoIds.add(row.videoId);
|
||||||
|
total += chargeableSize(reported[row.videoId] || 0, row.sizeBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
} catch (err) {
|
||||||
|
logError('Failed to calculate Bunny storage for user:', err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getCachedUserBunnyStorage = unstable_cache(
|
export const getCachedUserBunnyStorage = unstable_cache(
|
||||||
async () => {
|
async () => {
|
||||||
const perUserStorage: Record<string, number> = {};
|
const perUserStorage: Record<string, number> = {};
|
||||||
try {
|
try {
|
||||||
const bunnyStats = await getCachedBunnyStorageStats();
|
const bunnyStats = await getCachedBunnyStorageStats();
|
||||||
if (bunnyStats.totalBytes < 0) return perUserStorage;
|
|
||||||
|
|
||||||
const [bunnyVersions, bunnyAssets] = await Promise.all([
|
const [bunnyVersions, bunnyAssets] = await Promise.all([
|
||||||
db.videoVersion.findMany({
|
db.videoVersion.findMany({
|
||||||
@@ -262,23 +347,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
const reported = reportedSizes(bunnyStats);
|
||||||
* What this video costs us, as the larger of the two numbers we have.
|
|
||||||
*
|
|
||||||
* Bunny reports nothing for a video until it has finished encoding it,
|
|
||||||
* which on a half-hour source is most of an hour, and reading that zero
|
|
||||||
* literally meant an upload was free for as long as it was being
|
|
||||||
* processed: it did not show on the uploader's storage page and it did not
|
|
||||||
* count against the next upload's quota check. The size declared when the
|
|
||||||
* upload was admitted stands in until Bunny has a figure of its own, and
|
|
||||||
* Bunny's wins once it arrives, because the renditions it makes are the
|
|
||||||
* real bill and they are larger than the source.
|
|
||||||
*/
|
|
||||||
const chargeableSize = (reported: number, declared: bigint | null): number => {
|
|
||||||
const declaredBytes = declared === null ? 0 : Number(declared);
|
|
||||||
return reported > declaredBytes ? reported : declaredBytes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const seenVideoIds = new Set<string>();
|
const seenVideoIds = new Set<string>();
|
||||||
for (const version of bunnyVersions) {
|
for (const version of bunnyVersions) {
|
||||||
const ownerId = version.video.project.workspace.ownerId;
|
const ownerId = version.video.project.workspace.ownerId;
|
||||||
@@ -286,7 +355,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
if (seenVideoIds.has(dedupeKey)) continue;
|
if (seenVideoIds.has(dedupeKey)) continue;
|
||||||
seenVideoIds.add(dedupeKey);
|
seenVideoIds.add(dedupeKey);
|
||||||
|
|
||||||
const size = chargeableSize(bunnyStats.byVideoId[version.videoId] || 0, version.sizeBytes);
|
const size = chargeableSize(reported[version.videoId] || 0, version.sizeBytes);
|
||||||
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
perUserStorage[ownerId] = (perUserStorage[ownerId] || 0) + size;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,10 +366,7 @@ export const getCachedUserBunnyStorage = unstable_cache(
|
|||||||
if (seenVideoIds.has(dedupeKey)) continue;
|
if (seenVideoIds.has(dedupeKey)) continue;
|
||||||
seenVideoIds.add(dedupeKey);
|
seenVideoIds.add(dedupeKey);
|
||||||
|
|
||||||
const size = chargeableSize(
|
const size = chargeableSize(reported[asset.providerVideoId] || 0, asset.sizeBytes);
|
||||||
bunnyStats.byVideoId[asset.providerVideoId] || 0,
|
|
||||||
asset.sizeBytes
|
|
||||||
);
|
|
||||||
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
|
perUserStorage[billedUserId] = (perUserStorage[billedUserId] || 0) + size;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export const ErrorCode = {
|
|||||||
|
|
||||||
// Storage errors
|
// Storage errors
|
||||||
STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED',
|
STORAGE_LIMIT_EXCEEDED: 'STORAGE_LIMIT_EXCEEDED',
|
||||||
|
/**
|
||||||
|
* Out of room because the account has not paid, rather than because the plan
|
||||||
|
* is full. Its own code so the client can offer the upgrade, which is the
|
||||||
|
* actual remedy here and is no help at all on the paid ceiling.
|
||||||
|
*/
|
||||||
|
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,4 +181,13 @@ export const apiErrors = {
|
|||||||
storageExceeded: (
|
storageExceeded: (
|
||||||
message = 'Storage limit exceeded. Please delete some files to free up space.'
|
message = 'Storage limit exceeded. Please delete some files to free up space.'
|
||||||
) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
|
) => errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.STORAGE_LIMIT_EXCEEDED),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same 507, for an account that is out of room because it is on the free
|
||||||
|
* trial. Telling this caller to delete files is advice that does not apply:
|
||||||
|
* they have three gigabytes because they have not subscribed, not because they
|
||||||
|
* have filled two hundred.
|
||||||
|
*/
|
||||||
|
trialStorageExceeded: (message: string) =>
|
||||||
|
errorResponse(message, HttpStatus.INSUFFICIENT_STORAGE, ErrorCode.TRIAL_STORAGE_LIMIT_EXCEEDED),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// What a failed API call is worth showing the person who made it.
|
||||||
|
//
|
||||||
|
// Every route answers with `{ error, code? }`, and the code is the part that
|
||||||
|
// says whether there is anything the caller can do. Losing it on the way to a
|
||||||
|
// toast is how a full trial account ends up reading "Failed to upload" and
|
||||||
|
// trying the same upload again.
|
||||||
|
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/** The machine-readable half of an error response. Mirrors ErrorCode in lib/api-response.ts. */
|
||||||
|
export const API_ERROR_CODES = {
|
||||||
|
/** Out of room because the account has not subscribed, not because the plan is full. */
|
||||||
|
TRIAL_STORAGE_LIMIT_EXCEEDED: 'TRIAL_STORAGE_LIMIT_EXCEEDED',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface ApiErrorPayload {
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failure that came back from our own API, with the code still attached.
|
||||||
|
*
|
||||||
|
* The upload helpers throw rather than return, so without this the code is gone
|
||||||
|
* by the time anything is in a position to show it.
|
||||||
|
*/
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
readonly code: string | null;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string | null) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiRequestError';
|
||||||
|
this.code = code ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the error to throw from a parsed `{ error, code }` body. */
|
||||||
|
export function apiRequestError(
|
||||||
|
payload: ApiErrorPayload | null,
|
||||||
|
fallback: string
|
||||||
|
): ApiRequestError {
|
||||||
|
return new ApiRequestError(payload?.error || fallback, payload?.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeOf(source: unknown): string | null {
|
||||||
|
if (source instanceof ApiRequestError) return source.code;
|
||||||
|
if (source && typeof source === 'object' && 'code' in source) {
|
||||||
|
const code = (source as ApiErrorPayload).code;
|
||||||
|
return typeof code === 'string' ? code : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageOf(source: unknown, fallback: string): string {
|
||||||
|
if (source instanceof Error) return source.message || fallback;
|
||||||
|
if (source && typeof source === 'object' && 'error' in source) {
|
||||||
|
const message = (source as ApiErrorPayload).error;
|
||||||
|
if (typeof message === 'string' && message) return message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastApiErrorOptions {
|
||||||
|
/** Prepended as `${prefix}: ${message}`, for per-file failures in a batch. */
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows an API failure, with a way out attached when there is one.
|
||||||
|
*
|
||||||
|
* Takes either a parsed response body or a thrown error, so the same call works
|
||||||
|
* whether the caller checked `res.ok` itself or caught what an upload helper
|
||||||
|
* threw. On the trial storage ceiling it adds a link to the billing settings,
|
||||||
|
* because subscribing is the only thing that makes the upload possible and the
|
||||||
|
* uploader has no way to know that from "storage limit exceeded".
|
||||||
|
*/
|
||||||
|
export function toastApiError(
|
||||||
|
source: unknown,
|
||||||
|
fallback: string,
|
||||||
|
options: ToastApiErrorOptions = {}
|
||||||
|
): void {
|
||||||
|
const message = messageOf(source, fallback);
|
||||||
|
const text = options.prefix ? `${options.prefix}: ${message}` : message;
|
||||||
|
|
||||||
|
if (codeOf(source) === API_ERROR_CODES.TRIAL_STORAGE_LIMIT_EXCEEDED) {
|
||||||
|
toast.error(text, {
|
||||||
|
// Longer than a plain error: this one is asking for a decision rather than
|
||||||
|
// just reporting, and it disappears under the cursor at the usual timing.
|
||||||
|
duration: 12000,
|
||||||
|
action: {
|
||||||
|
label: 'See plans',
|
||||||
|
onClick: () => {
|
||||||
|
window.location.href = '/settings';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(text);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
import { cleanupPendingR2VideoUpload, uploadVideoToR2 } from '@/lib/client/r2-video-upload';
|
||||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||||
|
import { apiRequestError } from '@/lib/client/api-error';
|
||||||
|
|
||||||
export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
export const VIDEO_FILE_EXTENSIONS = ['mp4', 'webm', 'ogg', 'mov', 'm4v', 'mkv'];
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ export async function uploadProjectVideo(
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
throw new Error(createPayload?.error || 'Failed to create video');
|
throw apiRequestError(createPayload, 'Failed to create video');
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingCleanup = null;
|
pendingCleanup = null;
|
||||||
@@ -171,7 +172,7 @@ export async function uploadProjectVideo(
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!initResponse.ok || !initPayload?.data) {
|
if (!initResponse.ok || !initPayload?.data) {
|
||||||
throw new Error(initPayload?.error || 'Failed to initialize upload');
|
throw apiRequestError(initPayload, 'Failed to initialize upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data;
|
const { videoId, libraryId, signature, expirationTime, uploadToken } = initPayload.data;
|
||||||
@@ -243,7 +244,7 @@ export async function uploadProjectVideo(
|
|||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!createResponse.ok) {
|
if (!createResponse.ok) {
|
||||||
throw new Error(createPayload?.error || 'Failed to create video');
|
throw apiRequestError(createPayload, 'Failed to create video');
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingCleanup = null;
|
pendingCleanup = null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||||
|
import { apiRequestError } from '@/lib/client/api-error';
|
||||||
import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload';
|
import { uploadBytesWithProgress, type UploadProgressHandler } from '@/lib/client/r2-video-upload';
|
||||||
|
|
||||||
export type R2AssetVideoInitResponse = {
|
export type R2AssetVideoInitResponse = {
|
||||||
@@ -39,7 +40,7 @@ export async function initR2AssetVideoUpload(
|
|||||||
error?: string;
|
error?: string;
|
||||||
} | null;
|
} | null;
|
||||||
if (!initRes.ok || !initPayload?.data) {
|
if (!initRes.ok || !initPayload?.data) {
|
||||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
throw apiRequestError(initPayload, 'Failed to initialize video upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
return initPayload.data;
|
return initPayload.data;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
import { captureVideoThumbnail } from '@/lib/client/video-thumbnail';
|
||||||
|
import { apiRequestError } from '@/lib/client/api-error';
|
||||||
import {
|
import {
|
||||||
getMultipartProgressPercent,
|
getMultipartProgressPercent,
|
||||||
getPartByteRange,
|
getPartByteRange,
|
||||||
@@ -151,7 +152,7 @@ async function completeMultipartUpload(
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
throw new Error(payload?.error || 'Failed to complete multipart upload');
|
throw apiRequestError(payload, 'Failed to complete multipart upload');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +244,7 @@ export async function initR2VideoUpload(
|
|||||||
error?: string;
|
error?: string;
|
||||||
} | null;
|
} | null;
|
||||||
if (!initRes.ok || !initPayload?.data) {
|
if (!initRes.ok || !initPayload?.data) {
|
||||||
throw new Error(initPayload?.error || 'Failed to initialize video upload');
|
throw apiRequestError(initPayload, 'Failed to initialize video upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
return initPayload.data;
|
return initPayload.data;
|
||||||
|
|||||||
+62
-15
@@ -2,7 +2,7 @@ import type { NextResponse } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { apiErrors } from '@/lib/api-response';
|
import { apiErrors } from '@/lib/api-response';
|
||||||
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||||
import { isPaidTier } from '@/lib/billing';
|
import { isPaidTier } from '@/lib/billing';
|
||||||
import { getStorageLimitBytes } from '@/lib/trial-limits';
|
import { getStorageLimitBytes } from '@/lib/trial-limits';
|
||||||
|
|
||||||
@@ -18,12 +18,59 @@ export const PLAN_STORAGE_LIMIT_BYTES = BigInt(200) * BigInt(1024) * BigInt(1024
|
|||||||
* forget to pass it.
|
* forget to pass it.
|
||||||
*/
|
*/
|
||||||
export async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
export async function getStorageLimitForUser(userId: string): Promise<bigint> {
|
||||||
|
return (await getStorageContextForUser(userId)).limitBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageContext {
|
||||||
|
/** The ceiling this account is held to. */
|
||||||
|
limitBytes: bigint;
|
||||||
|
/** Whether that ceiling is the plan's or the trial's. */
|
||||||
|
isPaid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceiling and the reason for it, read together.
|
||||||
|
*
|
||||||
|
* The two travel as a pair because a refusal has to say which one it is: an
|
||||||
|
* unpaid account is out of room because it has not subscribed, and telling it to
|
||||||
|
* delete files is advice that does not apply.
|
||||||
|
*/
|
||||||
|
export async function getStorageContextForUser(userId: string): Promise<StorageContext> {
|
||||||
const user = await db.user.findUnique({
|
const user = await db.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
select: { subscriptionStatus: true, stripeCurrentPeriodEnd: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return getStorageLimitBytes(user ? isPaidTier(user) : false, PLAN_STORAGE_LIMIT_BYTES);
|
const isPaid = user ? isPaidTier(user) : false;
|
||||||
|
return { limitBytes: getStorageLimitBytes(isPaid, PLAN_STORAGE_LIMIT_BYTES), isPaid };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole gigabytes where the number is whole, which every ceiling we ship is.
|
||||||
|
* A host that sets an odd one gets a decimal rather than a rounded lie.
|
||||||
|
*/
|
||||||
|
function formatStorageLimit(bytes: bigint): string {
|
||||||
|
const gigabytes = Number(bytes) / 1024 ** 3;
|
||||||
|
return `${Number.isInteger(gigabytes) ? gigabytes : gigabytes.toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The refusal, in the words that fit the account it is being given to.
|
||||||
|
*
|
||||||
|
* A paying account that has filled 200 GB has to delete something. An unpaid one
|
||||||
|
* has three gigabytes because it has not subscribed, so the way out is the
|
||||||
|
* subscription, and the response says so under its own error code rather than
|
||||||
|
* leaving the client to guess from the number.
|
||||||
|
*/
|
||||||
|
export function storageExceededResponse(context: StorageContext): NextResponse {
|
||||||
|
if (context.isPaid) {
|
||||||
|
return apiErrors.storageExceeded() as NextResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiErrors.trialStorageExceeded(
|
||||||
|
`Your free trial includes ${formatStorageLimit(context.limitBytes)} of storage. ` +
|
||||||
|
`Upgrade to get ${formatStorageLimit(PLAN_STORAGE_LIMIT_BYTES)}.`
|
||||||
|
) as NextResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
// TTL for upload reservations: 30 minutes is enough for R2 image/audio uploads
|
||||||
@@ -66,7 +113,7 @@ 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, bunnyByUser, reservationRows] = await Promise.all([
|
const [r2AssetRows, r2VideoRows, bunnyUserBytes, reservationRows] = await Promise.all([
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
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
|
||||||
@@ -82,7 +129,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
|||||||
WHERE w."ownerId" = ${userId}
|
WHERE w."ownerId" = ${userId}
|
||||||
AND vv."providerId" = 'r2'
|
AND vv."providerId" = 'r2'
|
||||||
`,
|
`,
|
||||||
getCachedUserBunnyStorage(),
|
getUserBunnyStorageBytes(userId),
|
||||||
db.$queryRaw<[{ total: bigint }]>`
|
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
|
||||||
@@ -93,7 +140,7 @@ export async function getUserTotalStorageBytes(userId: string): Promise<bigint>
|
|||||||
|
|
||||||
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 bunnyBytes = BigInt(bunnyByUser[userId] ?? 0);
|
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 + bunnyBytes + reservedBytes;
|
||||||
@@ -136,13 +183,13 @@ export async function enforceStorageQuota(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [usedBytes, limitBytes] = await Promise.all([
|
const [usedBytes, storage] = await Promise.all([
|
||||||
getUserTotalStorageBytes(userId),
|
getUserTotalStorageBytes(userId),
|
||||||
getStorageLimitForUser(userId),
|
getStorageContextForUser(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (usedBytes + incomingSizeBytes >= limitBytes) {
|
if (usedBytes + incomingSizeBytes >= storage.limitBytes) {
|
||||||
return apiErrors.storageExceeded() as NextResponse;
|
return storageExceededResponse(storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -176,11 +223,11 @@ export async function reserveStorageQuota(
|
|||||||
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
|
// Fetch Bunny storage and the account's ceiling BEFORE entering the transaction,
|
||||||
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
|
// to avoid holding the advisory lock during a potentially slow/failing HTTP call
|
||||||
// on cache miss or an extra round trip to Postgres.
|
// on cache miss or an extra round trip to Postgres.
|
||||||
const [bunnyData, limitBytes] = await Promise.all([
|
const [bunnyUserBytes, storage] = await Promise.all([
|
||||||
getCachedUserBunnyStorage(),
|
getUserBunnyStorageBytes(userId),
|
||||||
getStorageLimitForUser(userId),
|
getStorageContextForUser(userId),
|
||||||
]);
|
]);
|
||||||
const bunnyBytes = BigInt(bunnyData[userId] ?? 0);
|
const bunnyBytes = BigInt(bunnyUserBytes);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reservationId = await db.$transaction(async (tx) => {
|
const reservationId = await db.$transaction(async (tx) => {
|
||||||
@@ -222,7 +269,7 @@ export async function reserveStorageQuota(
|
|||||||
const reservedBytes = resRow?.total ?? BigInt(0);
|
const reservedBytes = resRow?.total ?? BigInt(0);
|
||||||
|
|
||||||
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
const totalUsed = r2Bytes + reservedBytes + bunnyBytes;
|
||||||
if (totalUsed + incomingSizeBytes >= limitBytes) {
|
if (totalUsed + incomingSizeBytes >= storage.limitBytes) {
|
||||||
throw new QuotaExceededError();
|
throw new QuotaExceededError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +284,7 @@ export async function reserveStorageQuota(
|
|||||||
return { reservationId };
|
return { reservationId };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof QuotaExceededError) {
|
if (e instanceof QuotaExceededError) {
|
||||||
return { error: apiErrors.storageExceeded() as NextResponse };
|
return { error: storageExceededResponse(storage) };
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,15 @@ import {
|
|||||||
} from '@/app/api/projects/[projectId]/videos/bunny-init/route';
|
} from '@/app/api/projects/[projectId]/videos/bunny-init/route';
|
||||||
import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
|
import { POST as createAsset } from '@/app/api/videos/[videoId]/assets/route';
|
||||||
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
import { UPLOAD_RESERVATION_PURPOSES } from '@/lib/storage-quota';
|
||||||
|
import { PLAN_STORAGE_LIMIT_BYTES } from '@/lib/storage-quota';
|
||||||
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
||||||
import { signedInAs } from '../helpers/session';
|
import { signedInAs } from '../helpers/session';
|
||||||
import { createVideo, seedProject } from '../factories';
|
import {
|
||||||
|
createSubscribedUser,
|
||||||
|
createUploadReservation,
|
||||||
|
createVideo,
|
||||||
|
seedProject,
|
||||||
|
} from '../factories';
|
||||||
|
|
||||||
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
@@ -261,3 +267,65 @@ describe('a Bunny hold cannot be consumed by another flow', () => {
|
|||||||
expect(second.status).toBe(507);
|
expect(second.status).toBe(507);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// What a refusal says, and to whom.
|
||||||
|
//
|
||||||
|
// A trial account is out of room because it has not subscribed, so "delete some
|
||||||
|
// files" is advice that does not apply and the upgrade is the only way through.
|
||||||
|
// The two cases carry different codes because the client offers a link on one of
|
||||||
|
// them and must not on the other.
|
||||||
|
describe('what the storage refusal says', () => {
|
||||||
|
it('names the trial ceiling and its own code for an unpaid account', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
const response = await initUpload(scenario.project.id, BigInt(4) * GIB);
|
||||||
|
|
||||||
|
expect(response.status).toBe(507);
|
||||||
|
const body = (await response.json()) as { error: string; code: string };
|
||||||
|
expect(body.code).toBe('TRIAL_STORAGE_LIMIT_EXCEEDED');
|
||||||
|
expect(body.error).toContain('3 GB');
|
||||||
|
expect(body.error).toContain('Upgrade');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a paying account to free up space instead', async () => {
|
||||||
|
const scenario = await seedProject({ ownerUser: await createSubscribedUser() });
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
// Full at the paid ceiling rather than the trial one.
|
||||||
|
await createUploadReservation({
|
||||||
|
billedUserId: scenario.owner.id,
|
||||||
|
sizeBytes: PLAN_STORAGE_LIMIT_BYTES - BigInt(1024),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await initUpload(scenario.project.id, BigInt(1) * GIB);
|
||||||
|
|
||||||
|
expect(response.status).toBe(507);
|
||||||
|
const body = (await response.json()) as { error: string; code: string };
|
||||||
|
expect(body.code).toBe('STORAGE_LIMIT_EXCEEDED');
|
||||||
|
expect(body.error).toContain('delete some files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asked directly: five one-gigabyte uploads started at once against a three
|
||||||
|
// gigabyte trial. Whether they go up in one piece or in parts makes no
|
||||||
|
// difference, because r2-init takes the reservation before it decides on
|
||||||
|
// multipart, and bunny-init holds one for the size the client declared.
|
||||||
|
describe('several uploads started at once', () => {
|
||||||
|
it('grants only the ones that fit and refuses the rest', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
signedInAs(scenario.owner);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () => initUpload(scenario.project.id, BigInt(1) * GIB))
|
||||||
|
);
|
||||||
|
|
||||||
|
const granted = results.filter((response) => response.status === 200);
|
||||||
|
const refused = results.filter((response) => response.status === 507);
|
||||||
|
|
||||||
|
// Two fit: the check is >=, so the third would land exactly on 3 GiB.
|
||||||
|
expect(granted).toHaveLength(2);
|
||||||
|
expect(refused).toHaveLength(3);
|
||||||
|
expect(await db.uploadReservation.count()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
getCachedUserBunnyStorage,
|
getCachedUserBunnyStorage,
|
||||||
getCachedUserDownloadEgress,
|
getCachedUserDownloadEgress,
|
||||||
getCachedUserMediaStorage,
|
getCachedUserMediaStorage,
|
||||||
|
getUserBunnyStorageBytes,
|
||||||
refreshR2StorageSnapshot,
|
refreshR2StorageSnapshot,
|
||||||
} from '@/lib/admin-stats';
|
} from '@/lib/admin-stats';
|
||||||
import {
|
import {
|
||||||
@@ -603,21 +604,23 @@ describe('getCachedUserBunnyStorage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The -1 sentinel means "Bunny did not answer", and the module must not turn
|
// The -1 sentinel means "Bunny did not answer", and the module must not turn
|
||||||
// that into "this user stores nothing", because lib/storage-quota.ts would
|
// that into "this user stores nothing", because lib/storage-quota.ts would then
|
||||||
// then hand out headroom the user does not have. An empty map at least leaves
|
// hand out headroom the user does not have. Bunny's figure is gone; the size
|
||||||
// the R2 figures intact.
|
// declared when the upload was admitted is still in our own rows, so that is
|
||||||
it('answers an empty map when the Bunny library could not be read', async () => {
|
// what the account is charged until Bunny can be reached again.
|
||||||
|
it('falls back to the declared sizes when the Bunny library could not be read', async () => {
|
||||||
const scenario = await seedProject();
|
const scenario = await seedProject();
|
||||||
const video = await createVideo({ projectId: scenario.project.id });
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
await createVersion({
|
await createVersion({
|
||||||
videoParentId: video.id,
|
videoParentId: video.id,
|
||||||
providerId: 'bunny',
|
providerId: 'bunny',
|
||||||
providerVideoId: 'bunny-first',
|
providerVideoId: 'bunny-first',
|
||||||
|
sizeBytes: BigInt(2048),
|
||||||
});
|
});
|
||||||
bunnyCredentials();
|
bunnyCredentials();
|
||||||
stubBunnyPages([{ status: 503, body: {} }]);
|
stubBunnyPages([{ status: 503, body: {} }]);
|
||||||
|
|
||||||
expect(await getCachedUserBunnyStorage()).toEqual({});
|
expect(await getCachedUserBunnyStorage()).toEqual({ [scenario.owner.id]: 2048 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is empty on a database with no videos at all', async () => {
|
it('is empty on a database with no videos at all', async () => {
|
||||||
@@ -987,3 +990,126 @@ describe('getCachedStripeStats', () => {
|
|||||||
expect(await getCachedStripeStats()).toBeNull();
|
expect(await getCachedStripeStats()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The per-user figure the quota checks read, which deliberately does not come
|
||||||
|
// from the cached map above.
|
||||||
|
//
|
||||||
|
// The bug this exists for: the declared size lands on the row at the moment an
|
||||||
|
// upload finalizes and the reservation is deleted in the same transaction. A map
|
||||||
|
// computed up to two minutes earlier does not have that row in it, so for those
|
||||||
|
// two minutes the upload that just succeeded counted as nothing. The uploader
|
||||||
|
// watched their usage fall back to zero and the next upload was measured against
|
||||||
|
// a total that ignored the one before it.
|
||||||
|
describe('getUserBunnyStorageBytes', () => {
|
||||||
|
it('counts a finished upload straight away, without waiting for Bunny', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
// Bunny has the video but reports nothing for it yet, which is what an
|
||||||
|
// encode in progress looks like.
|
||||||
|
stubBunnyLibrary({ 'bunny-encoding': 0 });
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: video.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-encoding',
|
||||||
|
sizeBytes: BigInt(2_500_000_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// What Bunny reports mid-encode is partial: it counts what has been written so
|
||||||
|
// far and climbs as each rendition lands. A 2.5 GB source read as 475 MB
|
||||||
|
// halfway through and settled above 3 GB once it finished. Letting the partial
|
||||||
|
// figure displace the declared size would hand most of the quota back in the
|
||||||
|
// middle of an encode, which is exactly what the declared size is there to stop.
|
||||||
|
it('keeps the declared size while Bunny figure is still climbing', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: video.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-encoding',
|
||||||
|
sizeBytes: BigInt(2_500_000_000),
|
||||||
|
});
|
||||||
|
stubBunnyLibrary({ 'bunny-encoding': 474_900_000 });
|
||||||
|
|
||||||
|
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(2_500_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// And gets out of the way once the renditions are all there, because they are
|
||||||
|
// the actual bill and they add up to more than the source.
|
||||||
|
it('takes Bunny own figure once it passes the declared size', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: video.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-encoded',
|
||||||
|
sizeBytes: BigInt(2_500_000_000),
|
||||||
|
});
|
||||||
|
stubBunnyLibrary({ 'bunny-encoded': 3_600_000_000 });
|
||||||
|
|
||||||
|
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(3_600_000_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only the videos billed to the user asked about', async () => {
|
||||||
|
const mine = await seedProject();
|
||||||
|
const theirs = await seedProject();
|
||||||
|
const myVideo = await createVideo({ projectId: mine.project.id });
|
||||||
|
const theirVideo = await createVideo({ projectId: theirs.project.id });
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: myVideo.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-mine',
|
||||||
|
sizeBytes: BigInt(700),
|
||||||
|
});
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: theirVideo.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-theirs',
|
||||||
|
sizeBytes: BigInt(900),
|
||||||
|
});
|
||||||
|
stubBunnyLibrary({ 'bunny-mine': 0, 'bunny-theirs': 0 });
|
||||||
|
|
||||||
|
expect(await getUserBunnyStorageBytes(mine.owner.id)).toBe(700);
|
||||||
|
expect(await getUserBunnyStorageBytes(theirs.owner.id)).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds Bunny assets to the user they are billed to, deduped against versions', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
const video = await createVideo({ projectId: scenario.project.id });
|
||||||
|
await createVersion({
|
||||||
|
videoParentId: video.id,
|
||||||
|
providerId: 'bunny',
|
||||||
|
providerVideoId: 'bunny-shared',
|
||||||
|
sizeBytes: BigInt(500),
|
||||||
|
});
|
||||||
|
await createVideoAsset({
|
||||||
|
videoId: video.id,
|
||||||
|
billedUserId: scenario.owner.id,
|
||||||
|
kind: VideoAssetKind.VIDEO,
|
||||||
|
provider: VideoAssetProvider.BUNNY,
|
||||||
|
providerVideoId: 'bunny-shared',
|
||||||
|
sizeBytes: BigInt(500),
|
||||||
|
});
|
||||||
|
await createVideoAsset({
|
||||||
|
videoId: video.id,
|
||||||
|
billedUserId: scenario.owner.id,
|
||||||
|
kind: VideoAssetKind.VIDEO,
|
||||||
|
provider: VideoAssetProvider.BUNNY,
|
||||||
|
providerVideoId: 'bunny-asset-only',
|
||||||
|
sizeBytes: BigInt(300),
|
||||||
|
});
|
||||||
|
stubBunnyLibrary({ 'bunny-shared': 0, 'bunny-asset-only': 0 });
|
||||||
|
|
||||||
|
// The shared id is one video however many rows point at it.
|
||||||
|
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is zero for a user with no Bunny videos', async () => {
|
||||||
|
const scenario = await seedProject();
|
||||||
|
stubBunnyLibrary({ 'bunny-someone-else': 999 });
|
||||||
|
|
||||||
|
expect(await getUserBunnyStorageBytes(scenario.owner.id)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
|
import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
|
||||||
import {
|
import {
|
||||||
PLAN_STORAGE_LIMIT_BYTES,
|
PLAN_STORAGE_LIMIT_BYTES,
|
||||||
UPLOAD_RESERVATION_PURPOSES,
|
UPLOAD_RESERVATION_PURPOSES,
|
||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
const GIB = BigInt(1024) * BigInt(1024) * BigInt(1024);
|
||||||
|
|
||||||
function bunnyStorage(map: Record<string, number>): void {
|
function bunnyStorage(map: Record<string, number>): void {
|
||||||
vi.mocked(getCachedUserBunnyStorage).mockResolvedValue(map);
|
vi.mocked(getUserBunnyStorageBytes).mockImplementation(async (userId) => map[userId] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mock implementation is module state, so it survives afterEach. Reset it so
|
// The mock implementation is module state, so it survives afterEach. Reset it so
|
||||||
|
|||||||
+4
-3
@@ -146,14 +146,15 @@ vi.mock('@/lib/stripe', async (importOriginal) => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bunny storage stats
|
// Bunny storage stats
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// getCachedUserBunnyStorage() is an HTTP call to the Bunny API, and it sits in
|
// Both of these reach the Bunny API, and getUserBunnyStorageBytes() sits in the
|
||||||
// the middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota
|
// middle of reserveStorageQuota(). Default to "no Bunny bytes"; the quota suite
|
||||||
// suite overrides it to prove Bunny usage counts against the limit.
|
// overrides them to prove Bunny usage counts against the limit.
|
||||||
vi.mock('@/lib/admin-stats', async (importOriginal) => {
|
vi.mock('@/lib/admin-stats', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/admin-stats')>();
|
const actual = await importOriginal<typeof import('@/lib/admin-stats')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>),
|
getCachedUserBunnyStorage: vi.fn(async () => ({}) as Record<string, number>),
|
||||||
|
getUserBunnyStorageBytes: vi.fn(async () => 0),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user