OBJ then local
Deploy / deploy (push) Successful in 2m53s

This commit is contained in:
twotalesanimation
2026-07-16 23:04:15 +02:00
parent 9375d5212c
commit e756eb9015
3 changed files with 47 additions and 14 deletions
+12 -3
View File
@@ -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
+9 -10
View File
@@ -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,22 +45,21 @@ 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);
}
stat = fs.statSync(filePath);
} catch { } catch {
return new NextResponse("Not found", { status: 404 }); 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
if (!stat.isFile()) { if (!stat.isFile()) {
+25
View File
@@ -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.