#!/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);
});