mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(video-assets): implement ETag handling and polling for asset updates
This commit is contained in:
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user