keep trying

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-12 14:49:08 +08:00
parent d0a64a8043
commit e5e2580c7d
5 changed files with 116 additions and 48 deletions
+94 -33
View File
@@ -304,7 +304,7 @@ exports.getAsset = async (req, res) => {
}
};
// ─── UPLOAD ───────────────────────────────────────────────────────────────────
// ─── UPLOAD (shared core) ──────────────────────────────────────────────────────
//
// ┌─────────────────────────────────────────────────────────────────────────┐
// │ TRANSACTION STRATEGY │
@@ -318,21 +318,21 @@ exports.getAsset = async (req, res) => {
// │ Phase 2 — FAST WORK (transaction open milliseconds only): │
// │ • BEGIN → Asset.create() → COMMIT │
// │ │
// │ On Phase 2 error: │
// │ • ROLLBACK transaction │
// │ On any error: │
// │ • ROLLBACK transaction (if opened) │
// │ • rollbackUploads([{ key, provider }]) to clean orphans │
// └─────────────────────────────────────────────────────────────────────────┘
exports.uploadAsset = async (req, res) => {
//
// Shared by the single-file POST / and the multi-file POST /batch routes.
// `requireVideoThumbnail` is false for batch uploads — a bulk drop has no
// per-file thumbnail step, so videos land with thumbnail_url null and pick
// one up later via the existing "thumbnail-only" path in updateAsset().
//
async function createAssetFromUpload({ file, thumbFile, body, user, requireVideoThumbnail = true }) {
const uploadedFiles = []; // [{ key, provider }]
try {
// ── Phase 1a: Validate ────────────────────────────────────────────────────
const file = req.files?.file?.[0];
const thumbFile = req.files?.thumbnail?.[0];
if (!file) return R.error(res, "No file uploaded.", 400);
if (!file) throw Object.assign(new Error("No file uploaded."), { status: 400 });
const {
display_name,
@@ -342,22 +342,22 @@ exports.uploadAsset = async (req, res) => {
storage_bucket,
storage_key,
createdBy,
} = req.body;
} = body;
if (!createdBy) return R.error(res, "createdBy is required.", 400);
if (!createdBy) throw Object.assign(new Error("createdBy is required."), { status: 400 });
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
const mime_type = file.mimetype;
const file_type = resolveFileType(mime_type);
const extension = resolveExtension(file.originalname);
const checksum = file.buffer ? resolveChecksum(file.buffer) : null;
const usesProvider = ["chibisafe", "s3"].includes(storage_provider);
if (file_type === "video" && !thumbFile) {
return R.error(res, "A thumbnail image is required for video uploads.", 400);
if (file_type === "video" && requireVideoThumbnail && !thumbFile) {
throw Object.assign(new Error("A thumbnail image is required for video uploads."), { status: 400 });
}
if (usesProvider && !file.buffer) {
return R.error(res, "File buffer is required. Ensure multer uses memoryStorage.", 400);
throw Object.assign(new Error("File buffer is required. Ensure multer uses memoryStorage."), { status: 400 });
}
// ── Phase 1b: Upload main file ────────────────────────────────────────────
@@ -380,9 +380,9 @@ exports.uploadAsset = async (req, res) => {
} else {
file_url = storage_provider === "local"
? `/uploads/${file.filename}`
: req.body.file_url;
: body.file_url;
if (!file_url) return R.error(res, "file_url is required for non-local storage.", 400);
if (!file_url) throw Object.assign(new Error("file_url is required for non-local storage."), { status: 400 });
}
// ── Phase 1c: ffprobe + thumbnail ─────────────────────────────────────────
@@ -403,10 +403,10 @@ exports.uploadAsset = async (req, res) => {
video_codec = meta.video_codec;
audio_codec = meta.audio_codec;
if (usesProvider) {
if (!thumbFile?.buffer) {
if (usesProvider && thumbFile) {
if (!thumbFile.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 });
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
@@ -418,7 +418,7 @@ exports.uploadAsset = async (req, res) => {
});
thumbnail_url = thumbResult.url;
uploadedFiles.push({ key: thumbResult.uuid, provider: storage_provider });
} else {
} else if (!usesProvider) {
thumbnail_url = thumbFile?.filename ? `/uploads/${thumbFile.filename}` : null;
}
@@ -426,7 +426,7 @@ exports.uploadAsset = async (req, res) => {
if (usesProvider) {
if (!thumbFile.buffer) {
await rollbackUploads(uploadedFiles);
return R.error(res, "Thumbnail buffer is required.", 400);
throw Object.assign(new Error("Thumbnail buffer is required."), { status: 400 });
}
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const svc = getProvider(storage_provider);
@@ -443,8 +443,8 @@ exports.uploadAsset = async (req, res) => {
}
} else {
const parsedWidth = req.body.width ? parseInt(req.body.width) : null;
const parsedHeight = req.body.height ? parseInt(req.body.height) : null;
const parsedWidth = body.width ? parseInt(body.width) : null;
const parsedHeight = body.height ? parseInt(body.height) : null;
width = parsedWidth;
height = parsedHeight;
resolution = resolveResolution(parsedWidth, parsedHeight);
@@ -481,9 +481,8 @@ exports.uploadAsset = async (req, res) => {
}, { transaction: t });
await t.commit();
invalidateListCache();
logActivity(req.user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
return R.success(res, "Asset uploaded.", { data: asset }, 201);
logActivity(user?.user_id, 'upload_asset', { entityType: 'asset', entityId: asset.asset_id, details: { display_name: asset.display_name, file_type: asset.file_type } });
return asset;
} catch (dbErr) {
try { await t.rollback(); } catch { /* connection gone */ }
@@ -491,6 +490,21 @@ exports.uploadAsset = async (req, res) => {
throw dbErr;
}
} catch (err) {
await rollbackUploads(uploadedFiles);
throw err;
}
}
exports.uploadAsset = async (req, res) => {
try {
const file = req.files?.file?.[0];
const thumbFile = req.files?.thumbnail?.[0];
const asset = await createAssetFromUpload({ file, thumbFile, body: req.body, user: req.user });
invalidateListCache();
return R.success(res, "Asset uploaded.", { data: asset }, 201);
} catch (err) {
console.error("[ASSET][UPLOAD]", err);
if (err.status) return R.error(res, err.message, err.status, { detail: err.chibiBody });
@@ -498,6 +512,53 @@ exports.uploadAsset = async (req, res) => {
}
};
// ─── UPLOAD (batch) ─────────────────────────────────────────────────────────
//
// Accepts multiple files under the "files" field in one multipart request,
// all sharing the same is_public / storage_provider / createdBy. Each file
// is uploaded independently — one failing (bad codec, DB constraint, etc.)
// does not roll back the others. display_name defaults to the filename
// (minus extension) since there's no per-file metadata step in bulk mode.
// Videos land without a thumbnail (see createAssetFromUpload) — add one
// later via the existing "replace thumbnail" path on PATCH /:assetId.
//
exports.uploadAssetsBatch = async (req, res) => {
const files = req.files ?? [];
if (!files.length) return R.error(res, "No files uploaded.", 400);
const { display_name, description, is_public = false, storage_provider = "chibisafe", createdBy } = req.body;
if (!createdBy) return R.error(res, "createdBy is required.", 400);
const results = [];
for (const file of files) {
try {
const baseName = file.originalname.replace(/\.[^.]+$/, "");
const asset = await createAssetFromUpload({
file,
thumbFile: null,
body: {
display_name: files.length === 1 ? (display_name || baseName) : baseName,
description,
is_public,
storage_provider,
createdBy,
},
user: req.user,
requireVideoThumbnail: false,
});
results.push({ originalname: file.originalname, success: true, data: asset });
} catch (err) {
console.error("[ASSET][UPLOAD BATCH]", file.originalname, err.stack || err);
results.push({ originalname: file.originalname, success: false, message: err.status ? err.message : "Internal server error." });
}
}
invalidateListCache();
const createdCount = results.filter((r) => r.success).length;
return R.success(res, `${createdCount} of ${files.length} asset(s) uploaded.`, { results }, 201);
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {