@@ -541,11 +544,17 @@ export default function SettingsPage({ billingOnly = false }: { billingOnly?: bo
: ''
}
/>
- {storageInfo.percentage >= 90 && (
-
- Storage is almost full. Delete unused files or contact support.
-
- )}
+ {storageInfo.percentage >= 90 &&
+ (storageInfo.isPaid ? (
+
+ Storage is almost full. Delete unused files or contact support.
+
+ ) : (
+
+ Your free trial storage is almost full. Subscribe above for more room, or
+ delete unused files.
+
+ ))}
>
)}
diff --git a/app/api/settings/storage/route.ts b/app/api/settings/storage/route.ts
index c057a27..2edbed6 100644
--- a/app/api/settings/storage/route.ts
+++ b/app/api/settings/storage/route.ts
@@ -1,6 +1,6 @@
import { auth } from '@/lib/auth';
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 { db } from '@/lib/db';
@@ -27,12 +27,19 @@ export async function GET() {
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({
usedBytes: info.usedBytes.toString(),
limitBytes: info.limitBytes.toString(),
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
diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts
index 4573b54..956c5fb 100644
--- a/app/api/videos/[videoId]/assets/route.ts
+++ b/app/api/videos/[videoId]/assets/route.ts
@@ -32,11 +32,13 @@ import {
enforceStorageQuota,
reserveStorageQuota,
releaseStorageReservation,
- getStorageLimitForUser,
+ getStorageContextForUser,
+ storageExceededResponse,
UPLOAD_RESERVATION_PURPOSES,
+ type StorageContext,
type UploadReservationPurpose,
} from '@/lib/storage-quota';
-import { getCachedUserBunnyStorage } from '@/lib/admin-stats';
+import { getUserBunnyStorageBytes } from '@/lib/admin-stats';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
// 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.
let reservationPurpose: UploadReservationPurpose | 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: {
sessionId: string;
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
// zero, and on an account whose storage is all Bunny that made the fallback a
// check that could not fail.
- const preFetchedBunnyData =
- provider === VideoAssetProvider.YOUTUBE ? null : await getCachedUserBunnyStorage();
+ const preFetchedBunnyBytes =
+ provider === VideoAssetProvider.YOUTUBE ? null : await getUserBunnyStorageBytes(billedUserId);
// 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
// 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.
- const storageLimitBytes = await getStorageLimitForUser(billedUserId);
+ const storage = await getStorageContextForUser(billedUserId);
+ storageForRefusal = storage;
// Create the VideoAsset and atomically consume the upload reservation (if any)
// so the spot is never double-counted.
@@ -631,12 +637,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
WHERE "billedUserId" = ${billedUserId}
AND "expiresAt" > NOW()
`;
- const bunnyData = preFetchedBunnyData ?? {};
const totalUsed =
(r2Row?.total ?? BigInt(0)) +
(resRow?.total ?? BigInt(0)) +
- BigInt(bunnyData[billedUserId] ?? 0);
- if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storageLimitBytes) {
+ BigInt(preFetchedBunnyBytes ?? 0);
+ if (isStripeFeatureEnabled() && totalUsed + assetSizeBytes >= storage.limitBytes) {
throw new QuotaExceededInTxError();
}
}
@@ -712,7 +717,9 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return withCacheControl(response, 'private, no-store');
} catch (error) {
if (error instanceof QuotaExceededInTxError) {
- return apiErrors.storageExceeded() as NextResponse;
+ return storageForRefusal
+ ? storageExceededResponse(storageForRefusal)
+ : (apiErrors.storageExceeded() as NextResponse);
}
await releaseStorageReservation(
reservationId,
diff --git a/components/video-page/assets-pane.tsx b/components/video-page/assets-pane.tsx
index c2b50c0..58545cb 100644
--- a/components/video-page/assets-pane.tsx
+++ b/components/video-page/assets-pane.tsx
@@ -3,6 +3,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
import { toast } from 'sonner';
+import { toastApiError } from '@/lib/client/api-error';
import {
Download,
FileVideo,
@@ -56,6 +57,7 @@ function formatTime(seconds: number): string {
type UploadAudioResponse = {
data?: { url?: string; reservationId?: string | null };
error?: string;
+ code?: string;
};
function getAudioUploadValidationError(file: Blob): string | null {
@@ -369,10 +371,11 @@ export const AssetsPane = memo(function AssetsPane({
const uploadPayload = (await uploadRes.json().catch(() => null)) as {
data?: { url?: string; reservationId?: string | null };
error?: string;
+ code?: string;
} | null;
const uploadedImageUrl = uploadPayload?.data?.url;
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;
}
@@ -385,7 +388,7 @@ export const AssetsPane = memo(function AssetsPane({
return !!created;
} catch (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;
}
},
@@ -523,10 +526,11 @@ export const AssetsPane = memo(function AssetsPane({
uploadToken: string;
};
error?: string;
+ code?: string;
} | null;
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;
}
@@ -578,7 +582,7 @@ export const AssetsPane = memo(function AssetsPane({
return true;
} catch (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) {
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'DELETE',
@@ -631,7 +635,7 @@ export const AssetsPane = memo(function AssetsPane({
return true;
} catch (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;
} finally {
setIsUploadingBunny(false);
@@ -801,12 +805,12 @@ export const AssetsPane = memo(function AssetsPane({
const uploadPayload = await readUploadAudioResponse(uploadRes);
const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) {
- toast.error(
- `${fileName}: ${
- uploadPayload?.error ||
- (uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
- 'Failed to upload voice recording'
- }`
+ toastApiError(
+ uploadPayload,
+ uploadRes.status === 413
+ ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE
+ : 'Failed to upload voice recording',
+ { prefix: fileName }
);
return false;
}
@@ -821,7 +825,7 @@ export const AssetsPane = memo(function AssetsPane({
return !!created;
} catch (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;
}
},
diff --git a/components/video-page/hooks/use-comment-actions.ts b/components/video-page/hooks/use-comment-actions.ts
index 02ce4e4..097e125 100644
--- a/components/video-page/hooks/use-comment-actions.ts
+++ b/components/video-page/hooks/use-comment-actions.ts
@@ -28,6 +28,7 @@ import {
} from '@/components/video-page/image-upload-utils';
import { validateAnnotationStrokes } from '@/lib/validation';
import { withWebmDuration } from '@/lib/webm-duration';
+import { ApiRequestError, apiRequestError, toastApiError } from '@/lib/client/api-error';
interface UseCommentActionsParams extends CommentActionsConfig {
setVideo: Dispatch