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 { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import { VideoAssetProvider } from '@prisma/client';
|
import { VideoAssetProvider } from '@prisma/client';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers';
|
import { parseVideoUrl, getThumbnailUrl } from '@/lib/video-providers';
|
||||||
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
import { rateLimit } from '@/lib/rate-limit';
|
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 {
|
function parsePaginationParam(value: string | null, fallback: number): number {
|
||||||
const parsed = Number.parseInt(value ?? '', 10);
|
const parsed = Number.parseInt(value ?? '', 10);
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
|
||||||
@@ -172,6 +176,25 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const offset = requestedOffset;
|
const offset = requestedOffset;
|
||||||
const includeDeleteMetadata = context.canUploadAssets;
|
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({
|
const assets = await db.videoAsset.findMany({
|
||||||
where: { videoId },
|
where: { videoId },
|
||||||
skip: offset,
|
skip: offset,
|
||||||
@@ -214,6 +237,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
canUploadAssets: context.canUploadAssets,
|
canUploadAssets: context.canUploadAssets,
|
||||||
canDownloadAssets: context.canDownloadAssets,
|
canDownloadAssets: context.canDownloadAssets,
|
||||||
});
|
});
|
||||||
|
response.headers.set('ETag', etag);
|
||||||
return withCacheControl(response, 'private, no-cache');
|
return withCacheControl(response, 'private, no-cache');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching video assets:', error);
|
console.error('Error fetching video assets:', error);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
@@ -60,25 +60,40 @@ export function useVideoAssets({
|
|||||||
const [hasMoreAssets, setHasMoreAssets] = useState(false);
|
const [hasMoreAssets, setHasMoreAssets] = useState(false);
|
||||||
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
|
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
|
||||||
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
|
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
|
||||||
|
const assetsEtagRef = useRef<string | null>(null);
|
||||||
|
const isMutatingRef = useRef(false);
|
||||||
|
|
||||||
const fetchAssets = useCallback(async () => {
|
const fetchAssets = useCallback(async (options?: { useEtag?: boolean; silent?: boolean }) => {
|
||||||
setIsLoadingAssets(true);
|
const useEtag = options?.useEtag ?? false;
|
||||||
|
const silent = options?.silent ?? false;
|
||||||
|
if (!silent) setIsLoadingAssets(true);
|
||||||
try {
|
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;
|
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast.error(payload?.error || 'Failed to fetch assets');
|
if (!silent) {
|
||||||
|
toast.error(payload?.error || 'Failed to fetch assets');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const etag = res.headers.get('etag');
|
||||||
|
if (etag) assetsEtagRef.current = etag;
|
||||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||||
const pagination = payload?.data?.pagination;
|
const pagination = payload?.data?.pagination;
|
||||||
setAssets(list);
|
setAssets(list);
|
||||||
setHasMoreAssets(!!pagination?.hasMore);
|
setHasMoreAssets(!!pagination?.hasMore);
|
||||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to fetch assets');
|
if (!silent) {
|
||||||
|
toast.error('Failed to fetch assets');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingAssets(false);
|
if (!silent) setIsLoadingAssets(false);
|
||||||
}
|
}
|
||||||
}, [videoId]);
|
}, [videoId]);
|
||||||
|
|
||||||
@@ -105,9 +120,30 @@ export function useVideoAssets({
|
|||||||
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
|
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchAssets();
|
void fetchAssets({ useEtag: true });
|
||||||
}, [fetchAssets]);
|
}, [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> => {
|
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
|
||||||
if (!canUploadAssets) {
|
if (!canUploadAssets) {
|
||||||
toast.error('You do not have permission to upload assets');
|
toast.error('You do not have permission to upload assets');
|
||||||
@@ -115,6 +151,7 @@ export function useVideoAssets({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsCreatingAsset(true);
|
setIsCreatingAsset(true);
|
||||||
|
isMutatingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -138,11 +175,13 @@ export function useVideoAssets({
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreatingAsset(false);
|
setIsCreatingAsset(false);
|
||||||
|
isMutatingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
|
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
|
||||||
|
|
||||||
const deleteAsset = useCallback(async (assetId: string) => {
|
const deleteAsset = useCallback(async (assetId: string) => {
|
||||||
setActiveDeleteAssetId(assetId);
|
setActiveDeleteAssetId(assetId);
|
||||||
|
isMutatingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -161,6 +200,7 @@ export function useVideoAssets({
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setActiveDeleteAssetId(null);
|
setActiveDeleteAssetId(null);
|
||||||
|
isMutatingRef.current = false;
|
||||||
}
|
}
|
||||||
}, [videoId]);
|
}, [videoId]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user