feat: web video transcoding, admin playback, and viewer fixes
CI / skip-ci-check (pull_request) Successful in 1m4s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / skip-ci-check (pull_request) Successful in 1m4s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
Add on-demand H.264/AAC web playback (RQ, ffmpeg) with API routes and Next.js proxies; extend admin UI with WebPlaybackVideo and shared hooks. Store transcode cache beside pending-photos (WEB_VIDEO_CACHE_DIR / UPLOAD_DIR) and ignore data/web_videos. Centralize FastAPI URL helpers, optional Vite and Next base paths for subfolder deploy, and fix modal reopen by using router.replace when closing the home photo viewer. Include migration, install scripts, deployment doc updates, and CI admin build env tweak. Made-with: Cursor
This commit is contained in:
@@ -618,31 +618,26 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
|
||||
|
||||
// Handle closing the modal
|
||||
const handleCloseModal = () => {
|
||||
// Set flag to prevent useEffect from running
|
||||
// Skip one modal effect run (avoids racing with stale params during close)
|
||||
isClosingModalRef.current = true;
|
||||
|
||||
|
||||
// Clear modal state immediately (no reload, instant close)
|
||||
setModalPhoto(null);
|
||||
setModalPhotos([]);
|
||||
setModalIndex(0);
|
||||
|
||||
// Update URL directly using history API to avoid triggering Next.js router effects
|
||||
// This prevents any reload or re-fetch when closing
|
||||
|
||||
// Must use Next.js router so useSearchParams() updates. Raw history.replaceState
|
||||
// leaves the router thinking ?photo=… is still active, so router.push for the same
|
||||
// photo is a no-op and the modal never reopens.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('photo');
|
||||
params.delete('photos');
|
||||
params.delete('index');
|
||||
params.delete('autoplay');
|
||||
|
||||
|
||||
const newUrl = params.toString() ? `/?${params.toString()}` : '/';
|
||||
// Use window.history directly to avoid Next.js router processing
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, as: newUrl, url: newUrl },
|
||||
'',
|
||||
newUrl
|
||||
);
|
||||
|
||||
// Reset flag after a short delay to allow effects to see it
|
||||
router.replace(newUrl, { scroll: false });
|
||||
|
||||
setTimeout(() => {
|
||||
isClosingModalRef.current = false;
|
||||
}, 100);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createReadStream } from 'fs';
|
||||
@@ -167,11 +168,9 @@ export async function GET(
|
||||
|
||||
// Handle video thumbnail request
|
||||
if (thumbnail && mediaType === 'video') {
|
||||
const backendBaseUrl = process.env.BACKEND_BASE_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
try {
|
||||
const backendResponse = await fetch(
|
||||
`${backendBaseUrl}/api/v1/videos/${photoId}/thumbnail`,
|
||||
fastApiV1Url(`/videos/${photoId}/thumbnail`),
|
||||
{ headers: { Accept: 'image/*' } }
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Queue browser-safe transcoding for a video (backend RQ job, deduped per photo).
|
||||
*/
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const photoId = parseInt(id, 10);
|
||||
if (Number.isNaN(photoId)) {
|
||||
return NextResponse.json({ error: 'Invalid photo ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
fastApiV1Url(`/videos/${photoId}/web-playback/prepare`),
|
||||
{ method: 'POST', cache: 'no-store' }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { detail?: string }).detail || res.statusText },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': 'no-store, max-age=0' },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('web-playback prepare proxy error:', e);
|
||||
return NextResponse.json({ error: 'Backend unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Proxy browser-safe video stream from FastAPI (Range requests preserved).
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const photoId = parseInt(id, 10);
|
||||
if (Number.isNaN(photoId)) {
|
||||
return new NextResponse('Invalid photo ID', { status: 400 });
|
||||
}
|
||||
|
||||
const url = fastApiV1Url(`/videos/${photoId}/web-playback/stream`);
|
||||
|
||||
const headers = new Headers();
|
||||
const range = request.headers.get('range');
|
||||
if (range) {
|
||||
headers.set('Range', range);
|
||||
}
|
||||
|
||||
try {
|
||||
const backendRes = await fetch(url, { headers, cache: 'no-store' });
|
||||
const outHeaders = new Headers();
|
||||
const pass = [
|
||||
'content-type',
|
||||
'content-length',
|
||||
'content-range',
|
||||
'accept-ranges',
|
||||
'cache-control',
|
||||
];
|
||||
for (const h of pass) {
|
||||
const v = backendRes.headers.get(h);
|
||||
if (v) {
|
||||
outHeaders.set(h, v);
|
||||
}
|
||||
}
|
||||
if (!backendRes.ok && backendRes.status !== 206) {
|
||||
const text = await backendRes.text();
|
||||
return new NextResponse(text || backendRes.statusText, {
|
||||
status: backendRes.status,
|
||||
headers: outHeaders,
|
||||
});
|
||||
}
|
||||
return new NextResponse(backendRes.body, {
|
||||
status: backendRes.status,
|
||||
headers: outHeaders,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('web-playback stream proxy error:', e);
|
||||
return new NextResponse('Backend unreachable', { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { fastApiV1Url } from '@/lib/server/fastapi-backend';
|
||||
|
||||
/** Polling must never hit a cached backend response */
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const photoId = parseInt(id, 10);
|
||||
if (Number.isNaN(photoId)) {
|
||||
return NextResponse.json({ error: 'Invalid photo ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
fastApiV1Url(`/videos/${photoId}/web-playback/status`),
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: (data as { detail?: string }).detail || res.statusText },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(data, {
|
||||
headers: { 'Cache-Control': 'no-store, max-age=0' },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('web-playback status proxy error:', e);
|
||||
return NextResponse.json({ error: 'Backend unreachable' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user