patx/youtube-clone

#!/usr/bin/env node
/**
 * Populate the database and render sample clips with ffmpeg.
 * Safe to re-run: it skips channels that already exist.
 */
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { db, get, run } from '../server/db.js';
import { hashPassword } from '../server/auth.js';
import { VIDEO_DIR, THUMB_DIR } from '../server/paths.js';
import { RECIPES, TONES } from './videoclips.js';
import { CHANNELS, VIEWERS, COMMENTS, REPLIES } from './seeddata.js';

const execFileAsync = promisify(execFile);
const PASSWORD = 'password123';

for (const dir of [VIDEO_DIR, THUMB_DIR]) mkdirSync(dir, { recursive: true });

// Deterministic PRNG so a reseed produces the same plausible-looking numbers.
let seed = 1337;
const rand = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff);
const pick = (arr) => arr[Math.floor(rand() * arr.length)];
const between = (lo, hi) => lo + Math.floor(rand() * (hi - lo + 1));

function upsertUser({ username, displayName, hue, bio }) {
  const existing = get('SELECT * FROM users WHERE username = ?', username);
  if (existing) return existing;
  run(
    `INSERT INTO users (username, email, password_hash, display_name, bio, avatar_hue)
     VALUES (?, ?, ?, ?, ?, ?)`,
    username, `${username}@metube.test`, hashPassword(PASSWORD), displayName, bio ?? '', hue
  );
  return get('SELECT * FROM users WHERE username = ?', username);
}

async function renderClip(spec, storedName, variant) {
  const out = join(VIDEO_DIR, storedName);
  // Reuse a previous render, but never trust a truncated one from a failed run.
  if (existsSync(out) && statSync(out).size > 4096) return out;

  const build = RECIPES[spec.recipe] ?? RECIPES.grade;
  // Recipes compose at 720p for readable type, then downscale — these are demo
  // clips, and the fractal detail is what makes the files large.
  const chain = [...build(spec.title, spec.sub, variant), 'scale=960:540']
    .join(',')
    .replaceAll('%D', String(spec.seconds));
  const audio = (TONES[spec.tone] ?? TONES.quiet).replaceAll('%D', String(spec.seconds));

  try {
    await execFileAsync('ffmpeg', [
      '-y', '-loglevel', 'error',
      '-filter_complex', `${chain}[v];${audio}[a]`,
      '-map', '[v]', '-map', '[a]',
      '-t', String(spec.seconds),
      '-c:v', 'libx264', '-preset', 'medium', '-crf', '33',
      '-pix_fmt', 'yuv420p', '-r', '30', '-g', '60',
      '-c:a', 'aac', '-b:a', '64k',
      '-movflags', '+faststart',
      out,
    ], { maxBuffer: 1024 * 1024 * 8 });
  } catch (err) {
    rmSync(out, { force: true }); // do not leave a stub the next run would reuse
    throw err;
  }
  return out;
}

async function makeThumb(videoPath, storedName, seconds) {
  const name = `${storedName.replace(/\.mp4$/, '')}.jpg`;
  const dest = join(THUMB_DIR, name);
  if (!existsSync(dest)) {
    await execFileAsync('ffmpeg', [
      '-y', '-loglevel', 'error', '-ss', String(Math.min(seconds / 2, 3)), '-i', videoPath,
      '-frames:v', '1', '-vf', 'scale=640:-2', '-q:v', '4', dest,
    ]);
  }
  return `/media/thumbs/${name}`;
}

/** Delete leftover seed-*.mp4/jpg from earlier runs that nothing points at. */
function pruneOrphanSeedClips() {
  const referenced = new Set(
    db.prepare('SELECT src, thumb FROM videos').all()
      .flatMap((row) => [row.src, row.thumb])
      .map((path) => String(path).split('/').pop())
  );
  let removed = 0;
  for (const dir of [VIDEO_DIR, THUMB_DIR]) {
    for (const name of readdirSync(dir)) {
      if (!name.startsWith('seed-') || referenced.has(name)) continue;
      rmSync(join(dir, name), { force: true });
      removed++;
    }
  }
  if (removed) console.log(`  · removed ${removed} orphaned seed file${removed === 1 ? '' : 's'}`);
}

const START = Date.now();

async function main() {
  console.log('Seeding MeTube…');

  const viewers = VIEWERS.map(upsertUser);
  const channels = [];

  for (const [channelIndex, channel] of CHANNELS.entries()) {
    const user = upsertUser(channel);
    channels.push(user);
    const alreadyHasVideos = get('SELECT COUNT(*) AS n FROM videos WHERE user_id = ?', user.id).n > 0;
    if (alreadyHasVideos) {
      console.log(`  · @${user.username} already has videos, skipping render`);
      continue;
    }

    for (const [videoIndex, spec] of channel.videos.entries()) {
      // Names and variants are derived from position, not a running counter, so
      // re-running after a partial seed reuses the same files instead of new ones.
      const variant = channelIndex * 7 + videoIndex;
      const storedName = `seed-${channel.username}-${videoIndex}.mp4`;
      process.stdout.write(`  · rendering ${spec.title.slice(0, 46)}… `);
      const path = await renderClip(spec, storedName, variant);
      const thumb = await makeThumb(path, storedName, spec.seconds);
      const { stdout } = await execFileAsync('ffprobe', [
        '-v', 'error', '-show_entries', 'format=duration',
        '-of', 'default=noprint_wrappers=1:nokey=1', path,
      ]);
      const duration = Math.round(Number.parseFloat(stdout.trim()) * 10) / 10 || spec.seconds;
      const ageDays = between(0, 240);

      run(
        `INSERT INTO videos (user_id, title, description, category, src, thumb, duration, views, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', ?))`,
        user.id, spec.title, spec.description, spec.category,
        `/media/videos/${storedName}`, thumb, duration,
        between(180, 480000), `-${ageDays} days`
      );
      console.log('done');
    }
  }

  // Engagement is only generated once; re-running must not double the comments.
  if (get('SELECT COUNT(*) AS n FROM comments').n > 0) {
    console.log('  · engagement already seeded, leaving likes and comments alone');
    pruneOrphanSeedClips();
    return report();
  }

  // Subscriptions: every account follows a few channels.
  const everyone = [...channels, ...viewers];
  for (const person of everyone) {
    for (const channel of channels) {
      if (channel.id === person.id || rand() > 0.55) continue;
      run(
        `INSERT OR IGNORE INTO subscriptions (channel_id, subscriber_id, created_at)
         VALUES (?, ?, datetime('now', ?))`,
        channel.id, person.id, `-${between(1, 200)} days`
      );
    }
  }

  // Likes, comments and replies across the catalogue.
  const videos = db.prepare('SELECT * FROM videos').all();
  for (const video of videos) {
    for (const person of everyone) {
      if (person.id === video.user_id || rand() > 0.62) continue;
      run(
        `INSERT OR IGNORE INTO video_likes (video_id, user_id, value, created_at)
         VALUES (?, ?, ?, datetime('now', ?))`,
        video.id, person.id, rand() > 0.12 ? 1 : -1, `-${between(0, 90)} days`
      );
    }

    const commentCount = between(1, 4);
    for (let i = 0; i < commentCount; i++) {
      const author = pick(everyone.filter((p) => p.id !== video.user_id));
      const { lastInsertRowid } = run(
        `INSERT INTO comments (video_id, user_id, parent_id, body, created_at)
         VALUES (?, ?, NULL, ?, datetime('now', ?))`,
        video.id, author.id, pick(COMMENTS), `-${between(0, 60)} days`
      );
      const commentId = Number(lastInsertRowid);

      for (const person of everyone) {
        if (rand() > 0.3) continue;
        run('INSERT OR IGNORE INTO comment_likes (comment_id, user_id, value) VALUES (?, ?, 1)', commentId, person.id);
      }
      if (rand() > 0.55) {
        const replier = rand() > 0.5
          ? get('SELECT * FROM users WHERE id = ?', video.user_id)
          : pick(everyone);
        run(
          `INSERT INTO comments (video_id, user_id, parent_id, body, created_at)
           VALUES (?, ?, ?, ?, datetime('now', ?))`,
          video.id, replier.id, commentId, pick(REPLIES), `-${between(0, 20)} days`
        );
      }
    }
  }

  pruneOrphanSeedClips();
  report();
}

function report() {
  const counts = get(`SELECT
      (SELECT COUNT(*) FROM users) AS users,
      (SELECT COUNT(*) FROM videos) AS videos,
      (SELECT COUNT(*) FROM comments) AS comments,
      (SELECT COUNT(*) FROM subscriptions) AS subs,
      (SELECT COUNT(*) FROM video_likes) AS likes`);

  console.log(`\nSeeded in ${((Date.now() - START) / 1000).toFixed(1)}s`);
  console.log(`  ${counts.users} accounts · ${counts.videos} videos · ${counts.comments} comments · ${counts.subs} subscriptions · ${counts.likes} votes`);
  console.log(`\nSign in with any username below and the password "${PASSWORD}":`);
  console.log(`  ${[...CHANNELS, ...VIEWERS].map((c) => c.username).join(', ')}`);
}

main().catch((err) => {
  console.error('\nSeed failed:', err.message);
  process.exit(1);
});