added and fix some of things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-08-03 12:03:02 +08:00
parent 6765c1faba
commit d246c09cdd
31 changed files with 724 additions and 510 deletions
+27 -96
View File
@@ -1,16 +1,15 @@
// controllers/admin/assets.controller.js
const path = require("path");
const fs = require("fs");
const sequelize = require("../../config/db.config");
const Asset = require("../../models/assets/assets.mdl");
const chibi = require("../../services/chibisafe.service");
const s3 = require("../../services/s3.service");
const mediaToken = require("../../services/mediaToken.service");
const uploadProgress = require("../../services/uploadProgress.service");
const { extractVideoMeta } = require("../../services/ffprobe.service");
const ffmpegSvc = require("../../services/ffmpeg.service");
const assetTranscode = require("../../services/assetTranscode.service");
const documentConversion = require("../../services/documentConversion.service");
const R = require('../../utils/response.util');
const { paginate } = require("../../utils/paginate.util");
const { adminExclude, jsonbSchemas, computedAttributes } = require("../../models/assets/assets.attributes");
@@ -58,15 +57,6 @@ function resolveExtension(originalName = "") {
return path.extname(originalName).replace(".", "").toLowerCase() || null;
}
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
function resolveResolution(width, height) {
if (!width || !height) return null;
const h = Math.min(width, height);
@@ -398,7 +388,31 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec = videoMeta.video_codec;
audio_codec = videoMeta.audio_codec;
if (thumbnail_storage_key) thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
if (thumbnail_storage_key) {
thumbnail_url = await svc.buildPublicUrl(thumbnail_storage_key);
} else if (file_type === "video") {
// No client-provided thumbnail — grab a frame from the video itself so
// the asset doesn't sit with no preview at all in every picker/library
// grid. Best-effort: a failure here must not fail the whole upload.
let framePath = null;
try {
framePath = await ffmpegSvc.extractFrameThumbnail(probeUrl, duration);
const uploaded = await svc.uploadStream({
stream: fs.createReadStream(framePath),
originalname: `${(original_name || "thumb").replace(/\.[^.]+$/, "")}.jpg`,
mimetype: "image/jpeg",
ownerType: "thumbnail", // → thumbnails/ prefix, same as manually-uploaded thumbnails
});
thumbnail_storage_key = uploaded.uuid;
thumbnail_url = uploaded.url;
uploadedFiles.push({ key: thumbnail_storage_key, provider: storage_provider }); // rollback cleanup on later failure
} catch (err) {
console.warn(`[ASSET][THUMBNAIL] Auto-generate failed for "${storage_key}":`, err.message);
// leave thumbnail_url null — same fallback as before, admin can add one manually later
} finally {
if (framePath) fs.promises.unlink(framePath).catch(() => {});
}
}
} else {
const parsedWidth = body.width ? parseInt(body.width) : null;
@@ -435,6 +449,7 @@ async function finalizeAssetFromStorage({ storage_key, thumbnail_storage_key, or
video_codec,
audio_codec,
thumbnail_url,
thumbnail_storage_key,
description,
storage_provider,
storage_bucket: storage_bucket || (storage_provider === "s3" ? process.env.S3_BUCKET : null) || null,
@@ -572,90 +587,6 @@ exports.uploadAsset = async (req, res) => {
}
};
// Same broadcaster, reused as-is for document-conversion job progress — it's
// just a generic string-keyed SSE channel, nothing upload-specific about it.
exports.streamConvertProgress = (req, res) => {
uploadProgress.subscribe(req.params.jobId, res);
};
// ─── CONVERT TO MARKDOWN ────────────────────────────────────────────────────
//
// PDF/PPTX -> Markdown, text only (see services/documentConversion.service.js
// for the compile/validate/automate stages and why OCR/images are out of
// scope). Nothing here is written to the database — the caller (the Lesson
// block builder) treats the response as a draft and only persists it if/when
// the admin explicitly inserts it into a block and saves the lesson page.
//
// Mirrors the upload flow's SSE progress pattern exactly: the actual work
// runs synchronously inside this request (uploadProgress.service.js is
// reused as-is, keyed by a client-generated jobId) while stage transitions
// are published for a live "Compiling / Validating / Generating" UI, same as
// AddAsset.jsx already renders for uploads.
//
const CONVERTIBLE_EXTENSIONS = new Set(["pdf", "pptx"]);
const MAX_CONVERT_SIZE_BYTES = 25 * 1024 * 1024; // 25MB — keeps this comfortably synchronous
// compile() calls the MarkItDown sidecar over HTTP now instead of running
// officeparser in-process, so this leaves a bit more room than the original
// 45s for network/queueing overhead — MarkItDown's own parsing is plain
// CPU-bound work, not ML inference, so it doesn't need much more than that.
const CONVERT_TIMEOUT_MS = 60_000;
exports.convertAssetToMarkdown = async (req, res) => {
const { jobId } = req.body;
try {
const { assetId } = req.params;
if (!assetId || assetId === "undefined") return R.error(res, "Invalid asset ID.", 400);
const asset = await Asset.findOne({ where: { asset_id: assetId, ...notDeleted } });
if (!asset) return R.error(res, "Asset not found.", 404);
const extension = (asset.extension || "").toLowerCase();
if (!CONVERTIBLE_EXTENSIONS.has(extension)) {
return R.error(res, "Only PDF and PPTX documents can be converted to Markdown.", 400);
}
if (asset.storage_provider !== "s3" || !asset.storage_key) {
return R.error(res, "This asset has no stored file to convert.", 400);
}
if (Number(asset.file_size) > MAX_CONVERT_SIZE_BYTES) {
return R.error(res, "File is too large to convert (25MB max).", 400);
}
const publish = (phase) => { if (jobId) uploadProgress.publish(jobId, { phase }); };
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), CONVERT_TIMEOUT_MS);
let markdown, warnings, stats;
try {
publish("compiling");
const { stream } = await s3.getObjectStream(asset.storage_key);
const buffer = await streamToBuffer(stream);
const ast = await documentConversion.compile(buffer, extension, { signal: abortController.signal });
publish("validating");
const validated = documentConversion.validate(ast);
publish("generating");
const generated = await documentConversion.automate(ast, { signal: abortController.signal });
markdown = generated.markdown;
warnings = [...validated.warnings, ...generated.messages];
stats = validated.stats;
} finally {
clearTimeout(timeout);
}
if (jobId) uploadProgress.complete(jobId, { phase: "done" });
return R.success(res, "Document converted.", { markdown, warnings, stats });
} catch (err) {
if (jobId) uploadProgress.complete(jobId, { phase: "error", message: err.message });
console.error("[ASSET][CONVERT TO MARKDOWN]", err);
if (err.status) return R.error(res, err.message, err.status);
return R.error(res, "Internal server error.", 500);
}
};
// ─── UPDATE ───────────────────────────────────────────────────────────────────
exports.updateAsset = async (req, res) => {