- 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
173 lines
5.1 KiB
TypeScript
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' });
|
|
}
|
|
}
|
|
);
|
|
}
|