@@ -4,7 +4,7 @@ import { db } from "@/lib/db";
|
|||||||
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage";
|
||||||
import { recalcShotStatus } from "@/lib/shot-status";
|
import { recalcShotStatus } from "@/lib/shot-status";
|
||||||
|
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 300; // 5 min — large .mov high-res files
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/batch-upload/upload
|
* POST /api/batch-upload/upload
|
||||||
@@ -46,6 +46,15 @@ export async function POST(
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
// Browsers (especially on Windows) often send an empty MIME type for .mov
|
||||||
|
// files. Fall back to a safe content type based on the file extension.
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
file.type ||
|
||||||
|
(ext === "mov" ? "video/quicktime" :
|
||||||
|
ext === "mp4" ? "video/mp4" :
|
||||||
|
"application/octet-stream");
|
||||||
|
|
||||||
// ── High-res (.mov) ───────────────────────────────────────────────────────
|
// ── High-res (.mov) ───────────────────────────────────────────────────────
|
||||||
if (action === "update-highres") {
|
if (action === "update-highres") {
|
||||||
const shot = await db.shot.findUnique({
|
const shot = await db.shot.findUnique({
|
||||||
@@ -61,7 +70,7 @@ export async function POST(
|
|||||||
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
await deleteFromHetzner(shot.highResKey).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { key } = await uploadToHetzner(buffer, file.name, file.type, "highres");
|
const { key } = await uploadToHetzner(buffer, file.name, contentType, "highres");
|
||||||
|
|
||||||
await db.shot.update({
|
await db.shot.update({
|
||||||
where: { id: shotId },
|
where: { id: shotId },
|
||||||
@@ -134,7 +143,7 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload the video to Hetzner object storage
|
// Upload the video to Hetzner object storage
|
||||||
const { key: videoKey } = await uploadToHetzner(buffer, file.name, file.type, "videos");
|
const { key: videoKey } = await uploadToHetzner(buffer, file.name, contentType, "videos");
|
||||||
const result = { url: `/api/files/${videoKey}`, key: videoKey };
|
const result = { url: `/api/files/${videoKey}`, key: videoKey };
|
||||||
|
|
||||||
// Mark all existing versions for this task as no longer latest
|
// Mark all existing versions for this task as no longer latest
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { generateHetznerStreamUrl } from "@/lib/storage";
|
import { generateHetznerStreamUrl, hetznerKeyExists } from "@/lib/storage";
|
||||||
|
|
||||||
// ── Tuning constants ──────────────────────────────────────────────────────────
|
// ── Tuning constants ──────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -45,21 +45,20 @@ export async function GET(
|
|||||||
return new NextResponse("Forbidden", { status: 403 });
|
return new NextResponse("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single stat() replaces the previous existsSync() + statSync() (2 → 1
|
// ── Hetzner first, local disk fallback ───────────────────────────────────
|
||||||
// syscall). The try/catch also catches ENOENT, EACCES, ENAMETOOLONG, etc.
|
// Check object storage first (all new uploads go there directly; migrated
|
||||||
|
// files live there too). Results are cached in process memory after the
|
||||||
|
// first check so subsequent range requests pay no extra latency.
|
||||||
|
// Fall back to local disk only for files that haven't been migrated yet.
|
||||||
let stat: fs.Stats;
|
let stat: fs.Stats;
|
||||||
try {
|
try {
|
||||||
stat = fs.statSync(filePath);
|
if (await hetznerKeyExists(relativePath)) {
|
||||||
} catch {
|
|
||||||
// File not on local disk — attempt a redirect to Hetzner object storage.
|
|
||||||
// This covers files uploaded after the migration and any migrated files
|
|
||||||
// whose local copies have been removed.
|
|
||||||
try {
|
|
||||||
const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600);
|
const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600);
|
||||||
return NextResponse.redirect(hetznerUrl, 302);
|
return NextResponse.redirect(hetznerUrl, 302);
|
||||||
} catch {
|
|
||||||
return new NextResponse("Not found", { status: 404 });
|
|
||||||
}
|
}
|
||||||
|
stat = fs.statSync(filePath);
|
||||||
|
} catch {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against directory traversal that resolves to a directory
|
// Guard against directory traversal that resolves to a directory
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
|
HeadObjectCommand,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -278,6 +279,30 @@ export async function uploadToHetzner(
|
|||||||
return { key };
|
return { key };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process cache for Hetzner key existence checks.
|
||||||
|
* Positive hits (key exists) are cached indefinitely for the process lifetime
|
||||||
|
* since uploaded files are immutable UUID-named objects that never disappear.
|
||||||
|
* Negative hits are not cached so newly-migrated files are picked up immediately.
|
||||||
|
*/
|
||||||
|
const hetznerExistsCache = new Map<string, true>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given key exists in the Hetzner bucket.
|
||||||
|
* Results are cached in process memory after the first check.
|
||||||
|
*/
|
||||||
|
export async function hetznerKeyExists(key: string): Promise<boolean> {
|
||||||
|
if (hetznerExistsCache.has(key)) return true;
|
||||||
|
try {
|
||||||
|
const { client, bucket } = await buildHetznerClient();
|
||||||
|
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
|
||||||
|
hetznerExistsCache.set(key, true);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all object keys currently stored in the Hetzner bucket.
|
* List all object keys currently stored in the Hetzner bucket.
|
||||||
* Paginates automatically. Used by the migration tool.
|
* Paginates automatically. Used by the migration tool.
|
||||||
|
|||||||
Reference in New Issue
Block a user