bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- New video card block for GrapesJS landing pages, email templates,
  MkDocs export, and documentation editor Insert dropdown
- Shared HTML generators in admin/src/utils/videoCardHtml.ts
- MkDocs video-player.js hydrates .video-card-block elements:
  thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link
- Media API CORS: auto-add MkDocs + docs subdomain origins
- env_config_hook.py: smart Docker hostname detection, ADMIN_PORT
  resolution, pass env vars to MkDocs container
- Gallery URL uses /gallery?expanded=ID format
- VideoPickerModal: fix double /api prefix and Docker hostname thumbs
- Seed: default-video-card PageBlock
- Remove V1 legacy code (influence/, map/)

Bunker Admin
2026-02-17 15:42:32 -07:00

173 lines
5.1 KiB
TypeScript

import { FastifyInstance } from 'fastify';
import { prisma } from '../../../config/database';
import { optionalAuth, requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { Prisma } from '@prisma/client';
interface ShortsQuery {
limit?: string;
offset?: string;
sort?: 'recent' | 'popular' | 'random';
}
export async function shortsRoutes(fastify: FastifyInstance) {
/**
* GET /api/shorts - Public shorts feed
* Returns published short videos (<=60s) for the TikTok-style feed
*/
fastify.get<{ Querystring: ShortsQuery }>(
'/shorts',
{
preHandler: optionalAuth,
},
async (request, reply) => {
const limit = Math.min(parseInt(request.query.limit || '20'), 50);
const offset = parseInt(request.query.offset || '0');
const sort = request.query.sort || 'recent';
const where: Prisma.VideoWhereInput = {
isShort: true,
isPublished: true,
isLocked: false,
};
// For random sort, use raw query
if (sort === 'random') {
const total = await prisma.video.count({ where });
const shorts = await prisma.$queryRaw<any[]>`
SELECT id, title, filename, duration_seconds as "durationSeconds",
quality, orientation, thumbnail_path as "thumbnailPath",
view_count as "viewCount", upvote_count as "upvoteCount",
comment_count as "commentCount", is_locked as "isLocked",
width, height, published_at as "publishedAt",
category, created_at as "createdAt"
FROM videos
WHERE is_short = true AND is_published = true AND is_locked = false
ORDER BY RANDOM()
LIMIT ${limit} OFFSET ${offset}
`;
const shortsWithUrls = shorts.map((video: any) => ({
...video,
duration: video.durationSeconds,
thumbnailUrl: video.thumbnailPath ? `/public/${video.id}/thumbnail` : null,
videoUrl: `/public/${video.id}/stream`,
}));
return {
shorts: shortsWithUrls,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
// Standard Prisma query for recent/popular
let orderBy: Prisma.VideoOrderByWithRelationInput;
if (sort === 'popular') {
orderBy = { viewCount: 'desc' };
} else {
orderBy = { publishedAt: 'desc' };
}
const [shorts, total] = await Promise.all([
prisma.video.findMany({
where,
select: {
id: true,
title: true,
filename: true,
durationSeconds: true,
quality: true,
orientation: true,
thumbnailPath: true,
viewCount: true,
upvoteCount: true,
commentCount: true,
isLocked: true,
width: true,
height: true,
publishedAt: true,
category: true,
createdAt: true,
},
orderBy,
take: limit,
skip: offset,
}),
prisma.video.count({ where }),
]);
const shortsWithUrls = shorts.map((video) => ({
...video,
duration: video.durationSeconds,
thumbnailUrl: video.thumbnailPath ? `/public/${video.id}/thumbnail` : null,
videoUrl: `/public/${video.id}/stream`,
}));
return {
shorts: shortsWithUrls,
pagination: {
total,
limit,
offset,
hasMore: offset + limit < total,
},
};
}
);
/**
* POST /api/shorts/scan - Admin: auto-classify shorts by duration
* Sets isShort=true for videos <=60s, isShort=false for >60s or null duration
*/
fastify.post(
'/shorts/scan',
{
preHandler: requireAdminRole,
},
async (request, reply) => {
try {
const [classified, declassified] = await Promise.all([
// Mark videos <=60s as shorts
prisma.video.updateMany({
where: {
durationSeconds: { not: null, lte: 60 },
isShort: false,
},
data: { isShort: true },
}),
// Unmark videos >60s or with null duration
prisma.video.updateMany({
where: {
OR: [
{ durationSeconds: { gt: 60 } },
{ durationSeconds: null },
],
isShort: true,
},
data: { isShort: false },
}),
]);
const totalShorts = await prisma.video.count({ where: { isShort: true } });
logger.info(`Shorts scan complete: classified=${classified.count}, declassified=${declassified.count}, totalShorts=${totalShorts}`);
return {
classified: classified.count,
declassified: declassified.count,
totalShorts,
};
} catch (error) {
logger.error('Failed to scan shorts', { error });
return reply.code(500).send({ message: 'Failed to scan shorts' });
}
}
);
}