From 8d59095e724258957061573d207ec5eb4fb46671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Thu, 26 Feb 2026 12:51:58 +0300 Subject: [PATCH] feat(video-assets): implement ETag handling and polling for asset updates --- app/api/videos/[videoId]/assets/route.ts | 26 ++++++++- .../video-page/hooks/use-video-assets.ts | 56 ++++++++++++++++--- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/app/api/videos/[videoId]/assets/route.ts b/app/api/videos/[videoId]/assets/route.ts index 67974b6..ac509c9 100644 --- a/app/api/videos/[videoId]/assets/route.ts +++ b/app/api/videos/[videoId]/assets/route.ts @@ -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); diff --git a/components/video-page/hooks/use-video-assets.ts b/components/video-page/hooks/use-video-assets.ts index 5c6733d..dc29164 100644 --- a/components/video-page/hooks/use-video-assets.ts +++ b/components/video-page/hooks/use-video-assets.ts @@ -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(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 | 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 => { 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]);