From e756eb9015bd2c9f375ed7df4ea614a632bef15c Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:04:15 +0200 Subject: [PATCH] OBJ then local --- app/api/batch-upload/upload/route.ts | 15 ++++++++++++--- app/api/files/[...key]/route.ts | 21 ++++++++++----------- lib/storage.ts | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/app/api/batch-upload/upload/route.ts b/app/api/batch-upload/upload/route.ts index 947c1f5..f9ddb23 100644 --- a/app/api/batch-upload/upload/route.ts +++ b/app/api/batch-upload/upload/route.ts @@ -4,7 +4,7 @@ import { db } from "@/lib/db"; import { uploadToHetzner, deleteFromHetzner } from "@/lib/storage"; 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 @@ -46,6 +46,15 @@ export async function POST( 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) ─────────────────────────────────────────────────────── if (action === "update-highres") { const shot = await db.shot.findUnique({ @@ -61,7 +70,7 @@ export async function POST( 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({ where: { id: shotId }, @@ -134,7 +143,7 @@ export async function POST( } // 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 }; // Mark all existing versions for this task as no longer latest diff --git a/app/api/files/[...key]/route.ts b/app/api/files/[...key]/route.ts index 809b207..bb43c7b 100644 --- a/app/api/files/[...key]/route.ts +++ b/app/api/files/[...key]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import fs from "fs"; import path from "path"; -import { generateHetznerStreamUrl } from "@/lib/storage"; +import { generateHetznerStreamUrl, hetznerKeyExists } from "@/lib/storage"; // ── Tuning constants ────────────────────────────────────────────────────────── // @@ -45,21 +45,20 @@ export async function GET( return new NextResponse("Forbidden", { status: 403 }); } - // Single stat() replaces the previous existsSync() + statSync() (2 → 1 - // syscall). The try/catch also catches ENOENT, EACCES, ENAMETOOLONG, etc. + // ── Hetzner first, local disk fallback ─────────────────────────────────── + // 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; try { - stat = fs.statSync(filePath); - } 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 { + if (await hetznerKeyExists(relativePath)) { const hetznerUrl = await generateHetznerStreamUrl(relativePath, 3600); 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 diff --git a/lib/storage.ts b/lib/storage.ts index 53cddd3..5cad181 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -16,6 +16,7 @@ import { DeleteObjectCommand, GetObjectCommand, ListObjectsV2Command, + HeadObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import fs from "fs"; @@ -278,6 +279,30 @@ export async function uploadToHetzner( 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(); + +/** + * 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 { + 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. * Paginates automatically. Used by the migration tool.