Mantap. Sekarang memang sudah berubah dari “cuma galeri sederhana” jadi fondasi platform yang serius. Itu justru bagus — nanti artikel kamu juga jadi lebih berbobot karena bukan teori kosong, tapi hasil bangun beneran.

Biar rapi, saya kasih generate.js full version v2 yang sudah digabung semua, jadi kamu tinggal replace sekali.

Fitur yang sudah masuk

  • support mp4, m3u8, jpg, jpeg, png, webp
  • baca .env untuk R2_PUBLIC_BASE_URL
  • fallback ke media/... kalau env belum ada
  • ambil:
    • size
    • duration
    • width
    • height
    • orientation
  • parse filename jadi:
    • category
    • categorySlug
    • title
    • slug
    • tags
  • thumbnail pairing otomatis jika nama sama
  • image yang dipakai sebagai thumbnail video/stream tidak diduplikasi jadi item gallery
  • output di-sort stabil

Replace seluruh isi generate.js dengan ini

import "dotenv/config";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";

const publicBase = process.env.R2_PUBLIC_BASE_URL || "";
const mediaDir = "./media";
const output = "./data.json";

const files = fs.readdirSync(mediaDir);
const data = [];

function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/\s+/g, "-")
    .replace(/[^a-z0-9\-]/g, "")
    .replace(/\-+/g, "-");
}

function makeMediaUrl(fileName) {
  return publicBase ? `${publicBase}/${fileName}` : `media/${fileName}`;
}

function getFileSize(filePath) {
  try {
    return fs.statSync(filePath).size;
  } catch {
    return null;
  }
}

function getVideoDuration(filePath) {
  try {
    const result = execSync(
      `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`
    )
      .toString()
      .trim();

    return Math.round(Number(result));
  } catch {
    return null;
  }
}

function getVideoDimensions(filePath) {
  try {
    const result = execSync(
      `ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0:s=x "${filePath}"`
    )
      .toString()
      .trim();

    const [width, height] = result.split("x").map(Number);

    if (!width || !height) {
      return {
        width: null,
        height: null,
        orientation: "unknown"
      };
    }

    let orientation = "square";

    if (height > width) {
      orientation = "portrait";
    } else if (width > height) {
      orientation = "landscape";
    }

    return {
      width,
      height,
      orientation
    };
  } catch {
    return {
      width: null,
      height: null,
      orientation: "unknown"
    };
  }
}

function parseFileMeta(fileName) {
  const ext = path.extname(fileName);
  const base = path.basename(fileName, ext);

  const parts = base.split("__");

  let category = "Uncategorized";
  let title = base;
  let tags = [];

  if (parts.length >= 2) {
    category = parts[0].trim() || "Uncategorized";
    title = parts[1].trim() || base;
  } else if (parts.length === 1) {
    title = base;
  }

  if (parts.length >= 3) {
    tags = parts[2]
      .split("-")
      .map(tag => tag.trim())
      .filter(Boolean);
  }

  return {
    category,
    categorySlug: slugify(category),
    title,
    slug: slugify(title),
    tags
  };
}

function findMatchingThumb(name) {
  return files.find(f => {
    const thumbExt = path.extname(f).toLowerCase();
    const thumbName = path.basename(f, thumbExt);

    return (
      thumbName === name &&
      [".jpg", ".jpeg", ".png", ".webp"].includes(thumbExt)
    );
  });
}

files.forEach(file => {
  const ext = path.extname(file).toLowerCase();
  const name = path.basename(file, ext);
  const fullPath = path.join(mediaDir, file);
  const meta = parseFileMeta(file);

  // MP4 VIDEO
  if (ext === ".mp4") {
    const thumb = findMatchingThumb(name);
    const videoMeta = getVideoDimensions(fullPath);

    data.push({
      title: meta.title,
      category: meta.category,
      categorySlug: meta.categorySlug,
      tags: meta.tags,
      slug: meta.slug,
      type: "mp4",
      kind: "video",
      src: makeMediaUrl(file),
      thumb: thumb
        ? makeMediaUrl(thumb)
        : "https://dummyimage.com/300x200/000/fff&text=No+Thumb",
      duration: getVideoDuration(fullPath),
      size: getFileSize(fullPath),
      width: videoMeta.width,
      height: videoMeta.height,
      orientation: videoMeta.orientation
    });
  }

  // HLS / M3U8 STREAM
  else if (ext === ".m3u8") {
    const thumb = findMatchingThumb(name);

    data.push({
      title: meta.title,
      category: meta.category,
      categorySlug: meta.categorySlug,
      tags: meta.tags,
      slug: meta.slug,
      type: "m3u8",
      kind: "stream",
      src: makeMediaUrl(file),
      thumb: thumb
        ? makeMediaUrl(thumb)
        : "https://dummyimage.com/300x200/000/fff&text=Stream",
      size: getFileSize(fullPath),
      width: null,
      height: null,
      orientation: "unknown"
    });
  }

  // IMAGE
  else if ([".jpg", ".jpeg", ".png", ".webp"].includes(ext)) {
    const isUsedAsThumb = files.some(f => {
      const mediaExt = path.extname(f).toLowerCase();
      const mediaName = path.basename(f, mediaExt);

      return mediaName === name && [".mp4", ".m3u8"].includes(mediaExt);
    });

    if (!isUsedAsThumb) {
      data.push({
        title: meta.title,
        category: meta.category,
        categorySlug: meta.categorySlug,
        tags: meta.tags,
        slug: meta.slug,
        type: "image",
        kind: "image",
        src: makeMediaUrl(file),
        thumb: makeMediaUrl(file),
        size: getFileSize(fullPath),
        width: null,
        height: null,
        orientation: "unknown"
      });
    }
  }
});

// sort stabil: category lalu title
data.sort((a, b) => {
  const catCompare = (a.category || "").localeCompare(b.category || "");
  if (catCompare !== 0) return catCompare;
  return (a.title || "").localeCompare(b.title || "");
});

fs.writeFileSync(output, JSON.stringify(data, null, 2));
console.log("data.json generated with full metadata v2!");

Format nama file yang disarankan

Pakai pola ini:

Category Name__Judul Video__tag1-tag2-tag3.mp4

Contoh:

Social Media__cewek manis dance__viral-fyp.mp4
Social Media__cewek manis dance__viral-fyp.jpg
TikTok__kucing lucu tidur__funny-hewan.mp4
Gallery__poster promo terbaru.webp

Hasil parsing

File ini:

Social Media__cewek manis dance__viral-fyp.mp4

akan jadi:

{
  "title": "cewek manis dance",
  "category": "Social Media",
  "categorySlug": "social-media",
  "tags": ["viral", "fyp"],
  "slug": "cewek-manis-dance",
  "type": "mp4",
  "kind": "video",
  "orientation": "portrait"
}

Cara pakai

Jalankan:

npm run generate

atau langsung full flow kamu:

npm run release -- "upgrade metadata v2"

Catatan penting

Untuk thumbnail pairing otomatis, nama file thumbnail harus sama persis dengan video, hanya beda ekstensi.

Benar:

Social Media__cewek manis dance__viral-fyp.mp4
Social Media__cewek manis dance__viral-fyp.jpg

Tidak akan otomatis pairing:

Social Media__cewek manis dance__viral-fyp.mp4
thumb-cewek.jpg

Next paling masuk akal setelah ini

Setelah generate.js ini beres, langkah berikut yang paling kuat adalah upgrade app.js supaya badge orientasi (PORTRAIT, LANDSCAPE) muncul otomatis dan category/card layout makin rapi.

Kalau mau, saya lanjutkan dengan app.js full version final supaya sinkron penuh dengan metadata v2 ini.