import { unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { get, run, all } from './db.js';
import {
CATEGORIES, publicUser, findVideo, listVideos, countVideos, likedVideos, historyVideos,
watchLaterVideos, relatedVideos, videoComments, findComment, recordView, isSubscribed,
} from './model.js';
import {
hashPassword, verifyPassword, createSession, destroySession, sessionCookie, clearCookie,
} from './auth.js';
import { HttpError, bad, notFound, forbidden, sendJson, readJson, field, intParam } from './http.js';
import { parseMultipart } from './multipart.js';
import { VIDEO_DIR, THUMB_DIR } from './paths.js';
const execFileAsync = promisify(execFile);
const VIDEO_TYPES = /^video\/(mp4|webm|quicktime|x-m4v|ogg)$/i;
const MAX_UPLOAD = 512 * 1024 * 1024;
/* ------------------------------------------------------------------ auth -- */
const authRoutes = {
'POST /api/auth/signup': async (ctx) => {
const body = await readJson(ctx.req);
const username = field(body, 'username', { max: 24, label: 'Username' }).replace(/^@/, '');
const email = field(body, 'email', { max: 160, label: 'Email' });
const password = field(body, 'password', { min: 8, max: 200, label: 'Password' });
const displayName = (typeof body.displayName === 'string' && body.displayName.trim()) || username;
if (!/^[a-zA-Z0-9_.]{3,24}$/.test(username)) {
bad('Username must be 3–24 characters: letters, numbers, underscore or dot.');
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) bad('Enter a valid email address.');
if (get('SELECT 1 AS x FROM users WHERE username = ?', username)) bad('That username is already taken.');
if (get('SELECT 1 AS x FROM users WHERE email = ?', email)) bad('An account already uses that email.');
const hue = Math.floor(Math.random() * 360);
const { lastInsertRowid } = run(
`INSERT INTO users (username, email, password_hash, display_name, avatar_hue)
VALUES (?, ?, ?, ?, ?)`,
username, email, hashPassword(password), displayName.slice(0, 40), hue
);
const token = createSession(Number(lastInsertRowid));
const user = get('SELECT * FROM users WHERE id = ?', Number(lastInsertRowid));
sendJson(ctx.res, 201, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
},
'POST /api/auth/login': async (ctx) => {
const body = await readJson(ctx.req);
const identifier = field(body, 'identifier', { max: 160, label: 'Username or email' }).replace(/^@/, '');
const password = field(body, 'password', { max: 200, label: 'Password' });
const user = get('SELECT * FROM users WHERE username = ? OR email = ?', identifier, identifier);
if (!user || !verifyPassword(password, user.password_hash)) {
throw new HttpError(401, 'That username/email and password combination did not match.');
}
const token = createSession(user.id);
sendJson(ctx.res, 200, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
},
'POST /api/auth/logout': (ctx) => {
destroySession(ctx.token);
sendJson(ctx.res, 200, { ok: true }, { 'Set-Cookie': clearCookie() });
},
'GET /api/auth/me': (ctx) => {
sendJson(ctx.res, 200, { user: ctx.user ? publicUser(ctx.user, ctx.user.id) : null });
},
'PATCH /api/me': async (ctx) => {
const me = ctx.requireUser();
const body = await readJson(ctx.req);
if (typeof body.displayName === 'string') {
const displayName = field(body, 'displayName', { max: 40, label: 'Display name' });
run('UPDATE users SET display_name = ? WHERE id = ?', displayName, me.id);
}
if (typeof body.bio === 'string') {
run('UPDATE users SET bio = ? WHERE id = ?', body.bio.trim().slice(0, 600), me.id);
}
if (Number.isInteger(body.avatarHue)) {
run('UPDATE users SET avatar_hue = ? WHERE id = ?', ((body.avatarHue % 360) + 360) % 360, me.id);
}
const fresh = get('SELECT * FROM users WHERE id = ?', me.id);
sendJson(ctx.res, 200, { user: publicUser(fresh, me.id) });
},
};
/* ---------------------------------------------------------------- videos -- */
const videoRoutes = {
'GET /api/categories': (ctx) => sendJson(ctx.res, 200, { categories: CATEGORIES }),
'GET /api/videos': (ctx) => {
const viewerId = ctx.user?.id ?? null;
const q = ctx.query.get('q')?.trim() || null;
const category = ctx.query.get('category') || null;
const sort = ctx.query.get('sort') || (q ? 'popular' : 'new');
const limit = intParam(ctx.query.get('limit'), 24, { min: 1, max: 60 });
const offset = intParam(ctx.query.get('offset'), 0);
const username = ctx.query.get('channel');
const channelId = username
? get('SELECT id FROM users WHERE username = ?', username.replace(/^@/, ''))?.id ?? -1
: null;
const subscribedBy = ctx.query.get('feed') === 'subscriptions' ? ctx.requireUser().id : null;
const filters = { channelId, category, q, subscribedBy };
sendJson(ctx.res, 200, {
videos: listVideos({ ...filters, viewerId, sort, limit, offset }),
total: countVideos(filters),
});
},
'GET /api/videos/:id': (ctx) => {
const viewerId = ctx.user?.id ?? null;
const video = findVideo(ctx.params.id, viewerId);
if (!video) notFound('That video does not exist.');
sendJson(ctx.res, 200, { video, related: relatedVideos(video, viewerId) });
},
'POST /api/videos/:id/view': (ctx) => {
const video = findVideo(ctx.params.id, ctx.user?.id ?? null);
if (!video) notFound('That video does not exist.');
recordView(video.id, ctx.user?.id ?? null);
sendJson(ctx.res, 200, { views: video.views + 1 });
},
'POST /api/videos/:id/like': async (ctx) => {
const me = ctx.requireUser();
const body = await readJson(ctx.req);
const value = Number(body.value);
if (![1, -1, 0].includes(value)) bad('Vote must be 1, -1 or 0.');
const video = findVideo(ctx.params.id, me.id);
if (!video) notFound('That video does not exist.');
if (value === 0) {
run('DELETE FROM video_likes WHERE video_id = ? AND user_id = ?', video.id, me.id);
} else {
run(
`INSERT INTO video_likes (video_id, user_id, value) VALUES (?, ?, ?)
ON CONFLICT(video_id, user_id) DO UPDATE SET value = excluded.value, created_at = datetime('now')`,
video.id, me.id, value
);
}
const fresh = findVideo(video.id, me.id);
sendJson(ctx.res, 200, { likes: fresh.likes, dislikes: fresh.dislikes, myVote: fresh.myVote });
},
'POST /api/videos/:id/save': (ctx) => {
const me = ctx.requireUser();
const video = findVideo(ctx.params.id, me.id);
if (!video) notFound('That video does not exist.');
if (video.saved) {
run('DELETE FROM watch_later WHERE user_id = ? AND video_id = ?', me.id, video.id);
sendJson(ctx.res, 200, { saved: false });
} else {
run('INSERT OR IGNORE INTO watch_later (user_id, video_id) VALUES (?, ?)', me.id, video.id);
sendJson(ctx.res, 200, { saved: true });
}
},
'PATCH /api/videos/:id': async (ctx) => {
const me = ctx.requireUser();
const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
if (!row) notFound('That video does not exist.');
if (row.user_id !== me.id) forbidden('You can only edit your own videos.');
const body = await readJson(ctx.req);
if (typeof body.title === 'string') {
run('UPDATE videos SET title = ? WHERE id = ?', field(body, 'title', { max: 120, label: 'Title' }), row.id);
}
if (typeof body.description === 'string') {
run('UPDATE videos SET description = ? WHERE id = ?', body.description.trim().slice(0, 5000), row.id);
}
if (typeof body.category === 'string' && CATEGORIES.includes(body.category)) {
run('UPDATE videos SET category = ? WHERE id = ?', body.category, row.id);
}
sendJson(ctx.res, 200, { video: findVideo(row.id, me.id) });
},
'DELETE /api/videos/:id': async (ctx) => {
const me = ctx.requireUser();
const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
if (!row) notFound('That video does not exist.');
if (row.user_id !== me.id) forbidden('You can only delete your own videos.');
run('DELETE FROM videos WHERE id = ?', row.id);
for (const [dir, name] of [[VIDEO_DIR, row.src], [THUMB_DIR, row.thumb]]) {
const file = name?.split('/').pop();
if (file) await unlink(join(dir, file)).catch(() => {});
}
sendJson(ctx.res, 200, { ok: true });
},
'POST /api/videos': async (ctx) => {
const me = ctx.requireUser();
const { fields, files, cleanup } = await parseMultipart(ctx.req, {
dir: VIDEO_DIR,
maxFileBytes: MAX_UPLOAD,
});
try {
const video = files.find((f) => f.field === 'video');
if (!video) bad('Choose a video file to upload.');
if (!VIDEO_TYPES.test(video.contentType) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(video.filename)) {
bad('Unsupported format — upload an MP4, WebM, MOV or OGG file.');
}
if (!video.size) bad('That file was empty.');
const title = field(fields, 'title', { max: 120, label: 'Title' });
const description = (fields.description ?? '').trim().slice(0, 5000);
const category = CATEGORIES.includes(fields.category) && fields.category !== 'All'
? fields.category
: 'General';
const src = `/media/videos/${video.storedName}`;
const duration = await probeDuration(video.path);
const thumb = await makeThumbnail(video.path, video.storedName, duration);
const { lastInsertRowid } = run(
`INSERT INTO videos (user_id, title, description, category, src, thumb, duration)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
me.id, title, description, category, src, thumb, duration
);
sendJson(ctx.res, 201, { video: findVideo(Number(lastInsertRowid), me.id) });
} catch (err) {
await cleanup();
throw err;
}
},
};
/* -------------------------------------------------------------- comments -- */
const commentRoutes = {
'GET /api/videos/:id/comments': (ctx) => {
if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
const sort = ctx.query.get('sort') === 'top' ? 'top' : 'new';
sendJson(ctx.res, 200, { comments: videoComments(Number(ctx.params.id), ctx.user?.id ?? null, sort) });
},
'POST /api/videos/:id/comments': async (ctx) => {
const me = ctx.requireUser();
if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
const body = await readJson(ctx.req);
const text = field(body, 'body', { max: 2000, label: 'Comment' });
let parentId = null;
if (body.parentId != null) {
const parent = get('SELECT * FROM comments WHERE id = ?', body.parentId);
if (!parent || parent.video_id !== Number(ctx.params.id)) bad('That comment no longer exists.');
parentId = parent.parent_id ?? parent.id; // keep replies one level deep
}
const { lastInsertRowid } = run(
'INSERT INTO comments (video_id, user_id, parent_id, body) VALUES (?, ?, ?, ?)',
Number(ctx.params.id), me.id, parentId, text
);
sendJson(ctx.res, 201, { comment: findComment(Number(lastInsertRowid), me.id) });
},
'POST /api/comments/:id/like': async (ctx) => {
const me = ctx.requireUser();
const body = await readJson(ctx.req);
const value = Number(body.value);
if (![1, 0].includes(value)) bad('Vote must be 1 or 0.');
if (!get('SELECT 1 AS x FROM comments WHERE id = ?', ctx.params.id)) notFound('That comment no longer exists.');
if (value === 0) {
run('DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?', ctx.params.id, me.id);
} else {
run(
`INSERT INTO comment_likes (comment_id, user_id, value) VALUES (?, ?, 1)
ON CONFLICT(comment_id, user_id) DO UPDATE SET value = 1`,
ctx.params.id, me.id
);
}
const fresh = findComment(Number(ctx.params.id), me.id);
sendJson(ctx.res, 200, { likes: fresh.likes, myVote: fresh.myVote });
},
'DELETE /api/comments/:id': (ctx) => {
const me = ctx.requireUser();
const row = get('SELECT * FROM comments WHERE id = ?', ctx.params.id);
if (!row) notFound('That comment no longer exists.');
const owner = get('SELECT user_id FROM videos WHERE id = ?', row.video_id)?.user_id;
if (row.user_id !== me.id && owner !== me.id) forbidden('You can only delete your own comments.');
run('DELETE FROM comments WHERE id = ?', row.id);
sendJson(ctx.res, 200, { ok: true });
},
};
/* -------------------------------------------------------------- channels -- */
const channelRoutes = {
'GET /api/channels/:username': (ctx) => {
const viewerId = ctx.user?.id ?? null;
const username = String(ctx.params.username).replace(/^@/, '');
const row = get('SELECT * FROM users WHERE username = ?', username);
if (!row) notFound('That channel does not exist.');
const sort = ctx.query.get('sort') || 'new';
sendJson(ctx.res, 200, {
channel: publicUser(row, viewerId),
videos: listVideos({ viewerId, channelId: row.id, sort, limit: 60 }),
totalViews: get('SELECT COALESCE(SUM(views), 0) AS n FROM videos WHERE user_id = ?', row.id).n,
});
},
'POST /api/channels/:username/subscribe': (ctx) => {
const me = ctx.requireUser();
const username = String(ctx.params.username).replace(/^@/, '');
const channel = get('SELECT * FROM users WHERE username = ?', username);
if (!channel) notFound('That channel does not exist.');
if (channel.id === me.id) bad('You cannot subscribe to your own channel.');
const already = isSubscribed(channel.id, me.id);
if (already) {
run('DELETE FROM subscriptions WHERE channel_id = ? AND subscriber_id = ?', channel.id, me.id);
} else {
run('INSERT OR IGNORE INTO subscriptions (channel_id, subscriber_id) VALUES (?, ?)', channel.id, me.id);
}
sendJson(ctx.res, 200, {
isSubscribed: !already,
subscriberCount: get('SELECT COUNT(*) AS n FROM subscriptions WHERE channel_id = ?', channel.id).n,
});
},
'GET /api/subscriptions': (ctx) => {
const me = ctx.requireUser();
const rows = all(
`SELECT u.* FROM subscriptions s JOIN users u ON u.id = s.channel_id
WHERE s.subscriber_id = ? ORDER BY s.created_at DESC`,
me.id
);
sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, me.id)) });
},
'GET /api/channels': (ctx) => {
const viewerId = ctx.user?.id ?? null;
const rows = all(
`SELECT u.*, (SELECT COUNT(*) FROM subscriptions s WHERE s.channel_id = u.id) AS subs
FROM users u
WHERE (SELECT COUNT(*) FROM videos v WHERE v.user_id = u.id) > 0
ORDER BY subs DESC, u.display_name ASC LIMIT 40`
);
sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, viewerId)) });
},
};
/* ------------------------------------------------------------- libraries -- */
const libraryRoutes = {
'GET /api/library/liked': (ctx) => sendJson(ctx.res, 200, { videos: likedVideos(ctx.requireUser().id) }),
'GET /api/library/history': (ctx) => sendJson(ctx.res, 200, { videos: historyVideos(ctx.requireUser().id) }),
'GET /api/library/saved': (ctx) => sendJson(ctx.res, 200, { videos: watchLaterVideos(ctx.requireUser().id) }),
'DELETE /api/library/history': (ctx) => {
run('DELETE FROM watch_history WHERE user_id = ?', ctx.requireUser().id);
sendJson(ctx.res, 200, { ok: true });
},
};
/* ----------------------------------------------------------------- ffmpeg -- */
async function probeDuration(path) {
try {
const { stdout } = await execFileAsync('ffprobe', [
'-v', 'error', '-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1', path,
]);
const seconds = Number.parseFloat(stdout.trim());
return Number.isFinite(seconds) ? Math.round(seconds * 10) / 10 : 0;
} catch {
return 0;
}
}
async function makeThumbnail(videoPath, storedName, duration) {
const name = `${storedName.replace(/\.[^.]+$/, '')}.jpg`;
const at = duration > 2 ? Math.min(duration / 3, 10) : 0;
try {
await execFileAsync('ffmpeg', [
'-y', '-loglevel', 'error', '-ss', String(at), '-i', videoPath,
'-frames:v', '1', '-vf', 'scale=640:-2', '-q:v', '4',
join(THUMB_DIR, name),
]);
return `/media/thumbs/${name}`;
} catch {
return '';
}
}
export const routes = {
...authRoutes, ...videoRoutes, ...commentRoutes, ...channelRoutes, ...libraryRoutes,
};