feat(video-assets): implement ETag handling and polling for asset updates

This commit is contained in:
Yusuf İpek
2026-02-26 12:51:58 +03:00
parent 60add2a1c4
commit 8d59095e72
2 changed files with 73 additions and 9 deletions
+25 -1
View File
@@ -1,6 +1,6 @@
import { HeadObjectCommand } from '@aws-sdk/client-s3';
import { VideoAssetProvider } from '@prisma/client';
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } from '@/lib/rate-limit';
@@ -95,6 +95,10 @@ function shapeAssetForViewer(asset: AssetWithViewerFields, canExposeSource: bool
};
}
function normalizeEtag(value: string): string {
return value.trim().replace(/^W\//, '');
}
function parsePaginationParam(value: string | null, fallback: number): number {
const parsed = Number.parseInt(value ?? '', 10);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
@@ -172,6 +176,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const offset = requestedOffset;
const includeDeleteMetadata = context.canUploadAssets;
const assetsRevision = await db.videoAsset.aggregate({
where: { videoId },
_count: { id: true },
_max: { updatedAt: true },
});
const etag = `"assets:${videoId}:${limit}:${offset}:${includeDeleteMetadata ? 1 : 0}:${context.canDownloadAssets ? 1 : 0}:${assetsRevision._count.id}:${assetsRevision._max.updatedAt?.getTime() ?? 0}"`;
const ifNoneMatch = request.headers.get('if-none-match');
if (ifNoneMatch) {
const matches = ifNoneMatch
.split(',')
.map(normalizeEtag)
.includes(normalizeEtag(etag));
if (matches) {
const notModified = new NextResponse(null, { status: 304 });
notModified.headers.set('ETag', etag);
return withCacheControl(notModified, 'private, no-cache');
}
}
const assets = await db.videoAsset.findMany({
where: { videoId },
skip: offset,
@@ -214,6 +237,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
canUploadAssets: context.canUploadAssets,
canDownloadAssets: context.canDownloadAssets,
});
response.headers.set('ETag', etag);
return withCacheControl(response, 'private, no-cache');
} catch (error) {
console.error('Error fetching video assets:', error);
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import type { VideoAsset } from '@/components/video-page/types';
@@ -60,25 +60,40 @@ export function useVideoAssets({
const [hasMoreAssets, setHasMoreAssets] = useState(false);
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
const assetsEtagRef = useRef<string | null>(null);
const isMutatingRef = useRef(false);
const fetchAssets = useCallback(async () => {
setIsLoadingAssets(true);
const fetchAssets = useCallback(async (options?: { useEtag?: boolean; silent?: boolean }) => {
const useEtag = options?.useEtag ?? false;
const silent = options?.silent ?? false;
if (!silent) setIsLoadingAssets(true);
try {
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store' });
const headers: HeadersInit = {};
if (useEtag && assetsEtagRef.current) {
headers['If-None-Match'] = assetsEtagRef.current;
}
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store', headers });
if (res.status === 304) return;
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
toast.error(payload?.error || 'Failed to fetch assets');
if (!silent) {
toast.error(payload?.error || 'Failed to fetch assets');
}
return;
}
const etag = res.headers.get('etag');
if (etag) assetsEtagRef.current = etag;
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
const pagination = payload?.data?.pagination;
setAssets(list);
setHasMoreAssets(!!pagination?.hasMore);
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
} catch {
toast.error('Failed to fetch assets');
if (!silent) {
toast.error('Failed to fetch assets');
}
} finally {
setIsLoadingAssets(false);
if (!silent) setIsLoadingAssets(false);
}
}, [videoId]);
@@ -105,9 +120,30 @@ export function useVideoAssets({
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
useEffect(() => {
void fetchAssets();
void fetchAssets({ useEtag: true });
}, [fetchAssets]);
useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | null = null;
let isPageVisible = true;
const poll = async () => {
if (!isPageVisible || isMutatingRef.current || isLoadingMoreAssets) return;
await fetchAssets({ useEtag: true, silent: true });
};
intervalId = setInterval(poll, 10000);
const handleVisibilityChange = () => {
isPageVisible = document.visibilityState === 'visible';
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
if (intervalId) clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [fetchAssets, isLoadingMoreAssets]);
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
@@ -115,6 +151,7 @@ export function useVideoAssets({
}
setIsCreatingAsset(true);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets`, {
method: 'POST',
@@ -138,11 +175,13 @@ export function useVideoAssets({
return null;
} finally {
setIsCreatingAsset(false);
isMutatingRef.current = false;
}
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
const deleteAsset = useCallback(async (assetId: string) => {
setActiveDeleteAssetId(assetId);
isMutatingRef.current = true;
try {
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
method: 'DELETE',
@@ -161,6 +200,7 @@ export function useVideoAssets({
return false;
} finally {
setActiveDeleteAssetId(null);
isMutatingRef.current = false;
}
}, [videoId]);