@@ -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/lib/admin-stats.ts b/lib/admin-stats.ts
index 39955f0..8c57937 100644
--- a/lib/admin-stats.ts
+++ b/lib/admin-stats.ts
@@ -221,12 +221,97 @@ export const getCachedBunnyStorageStats = unstable_cache(
{ 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