import type { Express, Request, Response } from "express"; import type { RowDataPacket, ResultSetHeader } from "mysql2"; import type mysql from "mysql2/promise"; import fs from "fs"; import path from "path"; import { randomUUID } from "crypto"; import multer from "multer"; const TABLE = "youwei_course_video"; const UPLOAD_SUBDIR = "videos"; function uploadsRoot(): string { return path.join(process.cwd(), "uploads"); } function videosDir(): string { return path.join(uploadsRoot(), UPLOAD_SUBDIR); } export async function ensureCourseVideoTable(pool: mysql.Pool): Promise { await pool.execute(` CREATE TABLE IF NOT EXISTS ${TABLE} ( id VARCHAR(36) NOT NULL PRIMARY KEY, title VARCHAR(512) NOT NULL, course_key VARCHAR(32) NOT NULL, course_label_zh VARCHAR(128) NOT NULL, course_label_en VARCHAR(128) NOT NULL, module_name VARCHAR(256) NOT NULL DEFAULT '', file_relpath VARCHAR(1024) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'draft', views INT NOT NULL DEFAULT 0, duration VARCHAR(32) NOT NULL DEFAULT '00:00', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); } function mapRow(r: RowDataPacket) { return { id: r.id, title: r.title, courseKey: r.course_key, courseLabelZh: r.course_label_zh, courseLabelEn: r.course_label_en, moduleName: r.module_name, videoUrl: r.file_relpath, status: r.status, views: r.views, duration: r.duration, uploadDate: r.created_at instanceof Date ? r.created_at.toISOString().split("T")[0] : String(r.created_at).split(" ")[0], }; } const storage = multer.diskStorage({ destination: (_req, _file, cb) => { const dir = videosDir(); fs.mkdirSync(dir, { recursive: true }); cb(null, dir); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname) || ".mp4"; cb(null, `${randomUUID()}${ext}`); }, }); const uploadMw = multer({ storage, limits: { fileSize: 500 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (file.mimetype.startsWith("video/")) { cb(null, true); return; } cb(new Error("Only video files are allowed")); }, }); export function registerCourseVideoRoutes(app: Express, getPool: () => mysql.Pool): void { app.get("/api/course-videos", async (req: Request, res: Response) => { try { const pool = getPool(); await ensureCourseVideoTable(pool); const courseKey = typeof req.query.courseKey === "string" ? req.query.courseKey.trim() : ""; const scope = typeof req.query.scope === "string" ? req.query.scope : ""; const isAdmin = scope === "admin"; let sql = `SELECT * FROM ${TABLE}`; const params: string[] = []; const where: string[] = []; if (!isAdmin) { where.push("status = ?"); params.push("published"); } if (courseKey) { where.push("course_key = ?"); params.push(courseKey); } if (where.length) sql += ` WHERE ${where.join(" AND ")}`; sql += " ORDER BY created_at DESC"; const [rows] = await pool.query(sql, params); res.json({ ok: true, videos: rows.map(mapRow) }); } catch (err) { console.error("[course-videos GET]", err); res.status(503).json({ ok: false, error: "Could not load videos" }); } }); app.post( "/api/course-videos", (req, res, next) => { uploadMw.single("file")(req, res, (e) => { if (e) { res.status(400).json({ ok: false, error: e instanceof Error ? e.message : "Upload failed" }); return; } next(); }); }, async (req: Request, res: Response) => { try { const pool = getPool(); await ensureCourseVideoTable(pool); const file = req.file; if (!file) { res.status(400).json({ ok: false, error: "Video file is required" }); return; } const title = String(req.body.title || "").trim(); const courseKey = String(req.body.courseKey || "").trim(); const courseLabelZh = String(req.body.courseLabelZh || "").trim(); const courseLabelEn = String(req.body.courseLabelEn || "").trim(); const moduleName = String(req.body.moduleName || "").trim(); const statusRaw = String(req.body.status || "published").trim().toLowerCase(); const status = statusRaw === "draft" ? "draft" : "published"; if (!title || !courseKey) { fs.unlink(file.path, () => {}); res.status(400).json({ ok: false, error: "Missing title or courseKey" }); return; } const id = randomUUID(); const rel = `/uploads/${UPLOAD_SUBDIR}/${file.filename}`; await pool.execute( `INSERT INTO ${TABLE} (id, title, course_key, course_label_zh, course_label_en, module_name, file_relpath, status, views, duration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, '00:00')`, [id, title, courseKey, courseLabelZh || courseKey, courseLabelEn || courseKey, moduleName, rel, status], ); const [rows] = await pool.query(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]); res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null }); } catch (err) { console.error("[course-videos POST]", err); res.status(503).json({ ok: false, error: "Could not save video" }); } }, ); app.patch("/api/course-videos/:id", async (req: Request, res: Response) => { try { const pool = getPool(); await ensureCourseVideoTable(pool); const id = req.params.id; const status = typeof req.body?.status === "string" ? req.body.status.trim().toLowerCase() : ""; if (status !== "published" && status !== "draft" && status !== "processing") { res.status(400).json({ ok: false, error: "Invalid status" }); return; } const [result] = await pool.execute( `UPDATE ${TABLE} SET status = ? WHERE id = ?`, [status, id], ); if (result.affectedRows === 0) { res.status(404).json({ ok: false, error: "Not found" }); return; } const [rows] = await pool.query(`SELECT * FROM ${TABLE} WHERE id = ?`, [id]); res.json({ ok: true, video: rows[0] ? mapRow(rows[0]) : null }); } catch (err) { console.error("[course-videos PATCH]", err); res.status(503).json({ ok: false, error: "Could not update video" }); } }); app.delete("/api/course-videos/:id", async (req: Request, res: Response) => { try { const pool = getPool(); await ensureCourseVideoTable(pool); const id = req.params.id; const [rows] = await pool.query(`SELECT file_relpath FROM ${TABLE} WHERE id = ?`, [id]); const row = rows[0]; if (!row) { res.status(404).json({ ok: false, error: "Not found" }); return; } const rel = String(row.file_relpath || ""); await pool.execute(`DELETE FROM ${TABLE} WHERE id = ?`, [id]); if (rel.startsWith("/uploads/")) { const abs = path.join(process.cwd(), rel.replace(/^\//, "")); fs.unlink(abs, () => {}); } res.json({ ok: true }); } catch (err) { console.error("[course-videos DELETE]", err); res.status(503).json({ ok: false, error: "Could not delete video" }); } }); app.post("/api/course-videos/:id/view", async (req: Request, res: Response) => { try { const pool = getPool(); await ensureCourseVideoTable(pool); const id = req.params.id; await pool.execute(`UPDATE ${TABLE} SET views = views + 1 WHERE id = ?`, [id]); res.json({ ok: true }); } catch (err) { console.error("[course-videos view]", err); res.status(503).json({ ok: false }); } }); }