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` 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' }); } } ); }