Backend changes: - Add ffmpeg to Docker image for video processing - Create gifGenerator utility with high-quality palette-based GIF generation - Update Prisma schema: Add GIF to MediaKind enum - Auto-generate 300px GIF preview (10fps, 3sec) on video upload - Add POST /api/terms/:id/media/:mediaId/regenerate-gif endpoint for admins - Update delete endpoint to cascade delete GIFs when video is deleted - GIF generation uses ffmpeg with palette optimization for quality Frontend changes: - Update MediaKind enum to include GIF - Display animated GIF previews in dictionary word cards - Show 'No preview' icon for videos without GIF - Add 'Regenerate GIF' button in admin video upload UI - Display both video and GIF side-by-side in admin panel - Visual indicator when GIF preview is available Features: - Automatic GIF generation on video upload (non-blocking) - Manual GIF regeneration from admin panel - GIF preview in dictionary listing for better UX - Fallback to video icon when no GIF available - Proper cleanup: GIFs deleted when parent video is deleted Technical details: - GIFs stored in /uploads/gifs/ - Uses two-pass ffmpeg encoding with palette for quality - Configurable fps, width, duration, start time - Error handling: video upload succeeds even if GIF fails Co-Authored-By: Auggie
602 lines
17 KiB
TypeScript
602 lines
17 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { prisma } from '../lib/prisma.js';
|
|
import { WordType, CefrLevel, MediaKind } from '@prisma/client';
|
|
import { isAuthenticated, isAdmin } from '../middleware/auth.js';
|
|
import { videoUpload, handleUploadError } from '../middleware/upload.js';
|
|
import { generateGifFromVideo } from '../utils/gifGenerator.js';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const router = Router();
|
|
|
|
/**
|
|
* GET /api/terms
|
|
* Get all terms with optional filtering, search, and pagination
|
|
* Query params:
|
|
* - query: search text (searches wordText and normalizedText)
|
|
* - wordType: filter by word type (NOUN, VERB, etc.)
|
|
* - cefrLevel: filter by CEFR level (A1, A2, B1, B2, C1, C2)
|
|
* - page: page number (default: 1)
|
|
* - limit: items per page (default: 20)
|
|
*/
|
|
router.get('/', async (req: Request, res: Response) => {
|
|
try {
|
|
const {
|
|
query = '',
|
|
wordType,
|
|
cefrLevel,
|
|
page = '1',
|
|
limit = '20',
|
|
} = req.query;
|
|
|
|
const pageNum = parseInt(page as string, 10);
|
|
const limitNum = parseInt(limit as string, 10);
|
|
const skip = (pageNum - 1) * limitNum;
|
|
|
|
// Build where clause
|
|
const where: any = {};
|
|
|
|
// Add search filter
|
|
if (query && typeof query === 'string' && query.trim()) {
|
|
where.OR = [
|
|
{ wordText: { contains: query.trim() } },
|
|
{ normalizedText: { contains: query.trim() } },
|
|
];
|
|
}
|
|
|
|
// Add wordType filter
|
|
if (wordType && typeof wordType === 'string') {
|
|
const validWordTypes = Object.values(WordType);
|
|
if (validWordTypes.includes(wordType as WordType)) {
|
|
where.wordType = wordType as WordType;
|
|
}
|
|
}
|
|
|
|
// Add cefrLevel filter
|
|
if (cefrLevel && typeof cefrLevel === 'string') {
|
|
const validCefrLevels = Object.values(CefrLevel);
|
|
if (validCefrLevels.includes(cefrLevel as CefrLevel)) {
|
|
where.cefrLevel = cefrLevel as CefrLevel;
|
|
}
|
|
}
|
|
|
|
// Get total count for pagination
|
|
const total = await prisma.term.count({ where });
|
|
|
|
// Get terms with media
|
|
const terms = await prisma.term.findMany({
|
|
where,
|
|
include: {
|
|
media: {
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
},
|
|
examples: {
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
wordText: 'asc',
|
|
},
|
|
skip,
|
|
take: limitNum,
|
|
});
|
|
|
|
res.json({
|
|
terms,
|
|
pagination: {
|
|
page: pageNum,
|
|
limit: limitNum,
|
|
total,
|
|
totalPages: Math.ceil(total / limitNum),
|
|
},
|
|
});
|
|
} catch (error: any) {
|
|
console.error('Error fetching terms:', error);
|
|
res.status(500).json({ error: 'Failed to fetch terms', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/terms/:id
|
|
* Get a single term by ID with all related data
|
|
*/
|
|
router.get('/:id', async (req: Request, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const term = await prisma.term.findUnique({
|
|
where: { id },
|
|
include: {
|
|
media: {
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
},
|
|
examples: {
|
|
orderBy: {
|
|
createdAt: 'asc',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!term) {
|
|
return res.status(404).json({ error: 'Term not found' });
|
|
}
|
|
|
|
res.json({ term });
|
|
} catch (error: any) {
|
|
console.error('Error fetching term:', error);
|
|
res.status(500).json({ error: 'Failed to fetch term', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Helper function to normalize text (lowercase, remove diacritics)
|
|
*/
|
|
function normalizeText(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, ''); // Remove diacritics
|
|
}
|
|
|
|
/**
|
|
* POST /api/terms
|
|
* Create a new term (Admin only)
|
|
*/
|
|
router.post('/', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body;
|
|
|
|
// Validate required fields
|
|
if (!wordText || !wordType || !cefrLevel) {
|
|
return res.status(400).json({
|
|
error: 'Validation error',
|
|
message: 'wordText, wordType, and cefrLevel are required',
|
|
});
|
|
}
|
|
|
|
// Validate wordType
|
|
const validWordTypes = Object.values(WordType);
|
|
if (!validWordTypes.includes(wordType as WordType)) {
|
|
return res.status(400).json({
|
|
error: 'Validation error',
|
|
message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`,
|
|
});
|
|
}
|
|
|
|
// Validate cefrLevel
|
|
const validCefrLevels = Object.values(CefrLevel);
|
|
if (!validCefrLevels.includes(cefrLevel as CefrLevel)) {
|
|
return res.status(400).json({
|
|
error: 'Validation error',
|
|
message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`,
|
|
});
|
|
}
|
|
|
|
// Create term with auto-generated normalizedText
|
|
const term = await prisma.term.create({
|
|
data: {
|
|
wordText,
|
|
normalizedText: normalizeText(wordText),
|
|
wordType: wordType as WordType,
|
|
cefrLevel: cefrLevel as CefrLevel,
|
|
shortDescription: shortDescription || null,
|
|
tags: tags || null,
|
|
iconAssetId: iconAssetId || null,
|
|
},
|
|
include: {
|
|
media: true,
|
|
examples: true,
|
|
},
|
|
});
|
|
|
|
res.status(201).json({ term });
|
|
} catch (error: any) {
|
|
console.error('Error creating term:', error);
|
|
res.status(500).json({ error: 'Failed to create term', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/terms/:id
|
|
* Update an existing term (Admin only)
|
|
*/
|
|
router.put('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { wordText, wordType, cefrLevel, shortDescription, tags, iconAssetId } = req.body;
|
|
|
|
// Check if term exists
|
|
const existingTerm = await prisma.term.findUnique({ where: { id } });
|
|
if (!existingTerm) {
|
|
return res.status(404).json({ error: 'Term not found' });
|
|
}
|
|
|
|
// Build update data
|
|
const updateData: any = {};
|
|
|
|
if (wordText !== undefined) {
|
|
updateData.wordText = wordText;
|
|
updateData.normalizedText = normalizeText(wordText);
|
|
}
|
|
|
|
if (wordType !== undefined) {
|
|
const validWordTypes = Object.values(WordType);
|
|
if (!validWordTypes.includes(wordType as WordType)) {
|
|
return res.status(400).json({
|
|
error: 'Validation error',
|
|
message: `Invalid wordType. Must be one of: ${validWordTypes.join(', ')}`,
|
|
});
|
|
}
|
|
updateData.wordType = wordType as WordType;
|
|
}
|
|
|
|
if (cefrLevel !== undefined) {
|
|
const validCefrLevels = Object.values(CefrLevel);
|
|
if (!validCefrLevels.includes(cefrLevel as CefrLevel)) {
|
|
return res.status(400).json({
|
|
error: 'Validation error',
|
|
message: `Invalid cefrLevel. Must be one of: ${validCefrLevels.join(', ')}`,
|
|
});
|
|
}
|
|
updateData.cefrLevel = cefrLevel as CefrLevel;
|
|
}
|
|
|
|
if (shortDescription !== undefined) updateData.shortDescription = shortDescription || null;
|
|
if (tags !== undefined) updateData.tags = tags || null;
|
|
if (iconAssetId !== undefined) updateData.iconAssetId = iconAssetId || null;
|
|
|
|
// Update term
|
|
const term = await prisma.term.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
media: true,
|
|
examples: true,
|
|
},
|
|
});
|
|
|
|
res.json({ term });
|
|
} catch (error: any) {
|
|
console.error('Error updating term:', error);
|
|
res.status(500).json({ error: 'Failed to update term', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/terms/:id
|
|
* Delete a term and its associated media (Admin only)
|
|
*/
|
|
router.delete('/:id', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check if term exists and get associated media
|
|
const term = await prisma.term.findUnique({
|
|
where: { id },
|
|
include: { media: true },
|
|
});
|
|
|
|
if (!term) {
|
|
return res.status(404).json({ error: 'Term not found' });
|
|
}
|
|
|
|
// Delete associated video files from disk
|
|
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos');
|
|
for (const media of term.media) {
|
|
if (media.kind === MediaKind.VIDEO && media.url) {
|
|
const filename = path.basename(media.url);
|
|
const filePath = path.join(uploadsDir, filename);
|
|
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
console.log(`Deleted video file: ${filename}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to delete video file ${filename}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete term (cascade will delete TermMedia records)
|
|
await prisma.term.delete({ where: { id } });
|
|
|
|
res.json({ message: 'Term deleted successfully' });
|
|
} catch (error: any) {
|
|
console.error('Error deleting term:', error);
|
|
res.status(500).json({ error: 'Failed to delete term', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/terms/:id/media
|
|
* Upload a video for a term (Admin only)
|
|
*/
|
|
router.post(
|
|
'/:id/media',
|
|
isAuthenticated,
|
|
isAdmin,
|
|
videoUpload.single('video'),
|
|
handleUploadError,
|
|
async (req: Request, res: Response) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check if term exists
|
|
const term = await prisma.term.findUnique({ where: { id } });
|
|
if (!term) {
|
|
// Clean up uploaded file if term doesn't exist
|
|
if (req.file) {
|
|
fs.unlinkSync(req.file.path);
|
|
}
|
|
return res.status(404).json({ error: 'Term not found' });
|
|
}
|
|
|
|
// Check if file was uploaded
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: 'No video file uploaded' });
|
|
}
|
|
|
|
// Create relative URL path
|
|
const relativeUrl = `/uploads/videos/${req.file.filename}`;
|
|
|
|
// Create TermMedia record for video
|
|
const media = await prisma.termMedia.create({
|
|
data: {
|
|
termId: id,
|
|
kind: MediaKind.VIDEO,
|
|
url: relativeUrl,
|
|
// TODO: Extract video metadata (duration, dimensions) using ffprobe if available
|
|
// For now, these fields will be null
|
|
},
|
|
});
|
|
|
|
// Generate GIF preview from video
|
|
try {
|
|
const videoPath = req.file.path;
|
|
const gifFilename = req.file.filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
|
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
|
|
|
// Ensure gifs directory exists
|
|
if (!fs.existsSync(gifsDir)) {
|
|
fs.mkdirSync(gifsDir, { recursive: true });
|
|
}
|
|
|
|
const gifPath = path.join(gifsDir, gifFilename);
|
|
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
|
|
|
// Generate GIF (300px width, 10fps, first 3 seconds)
|
|
await generateGifFromVideo(videoPath, gifPath, {
|
|
width: 300,
|
|
fps: 10,
|
|
duration: 3,
|
|
startTime: 0,
|
|
});
|
|
|
|
// Create TermMedia record for GIF
|
|
await prisma.termMedia.create({
|
|
data: {
|
|
termId: id,
|
|
kind: MediaKind.GIF,
|
|
url: gifRelativeUrl,
|
|
},
|
|
});
|
|
|
|
console.log(`Generated GIF preview: ${gifRelativeUrl}`);
|
|
} catch (gifError: any) {
|
|
// Log error but don't fail the upload if GIF generation fails
|
|
console.error('Failed to generate GIF preview:', gifError.message);
|
|
}
|
|
|
|
res.status(201).json({ media });
|
|
} catch (error: any) {
|
|
console.error('Error uploading video:', error);
|
|
|
|
// Clean up uploaded file on error
|
|
if (req.file) {
|
|
try {
|
|
fs.unlinkSync(req.file.path);
|
|
} catch (err) {
|
|
console.error('Failed to clean up file:', err);
|
|
}
|
|
}
|
|
|
|
res.status(500).json({ error: 'Failed to upload video', message: error.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* DELETE /api/terms/:id/media/:mediaId
|
|
* Delete a video from a term (Admin only)
|
|
*/
|
|
router.delete('/:id/media/:mediaId', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const { id, mediaId } = req.params;
|
|
|
|
// Check if media exists and belongs to the term
|
|
const media = await prisma.termMedia.findUnique({
|
|
where: { id: mediaId },
|
|
});
|
|
|
|
if (!media) {
|
|
return res.status(404).json({ error: 'Media not found' });
|
|
}
|
|
|
|
if (media.termId !== id) {
|
|
return res.status(400).json({ error: 'Media does not belong to this term' });
|
|
}
|
|
|
|
// Delete video file from disk if it's a video
|
|
if (media.kind === MediaKind.VIDEO && media.url) {
|
|
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'videos');
|
|
const filename = path.basename(media.url);
|
|
const filePath = path.join(uploadsDir, filename);
|
|
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
console.log(`Deleted video file: ${filename}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to delete video file ${filename}:`, err);
|
|
}
|
|
|
|
// Also delete corresponding GIF if it exists
|
|
const gifFilename = filename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
|
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
|
const gifPath = path.join(gifsDir, gifFilename);
|
|
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
|
|
|
try {
|
|
if (fs.existsSync(gifPath)) {
|
|
fs.unlinkSync(gifPath);
|
|
console.log(`Deleted GIF file: ${gifFilename}`);
|
|
}
|
|
|
|
// Delete GIF media record
|
|
const gifMedia = await prisma.termMedia.findFirst({
|
|
where: {
|
|
termId: id,
|
|
kind: MediaKind.GIF,
|
|
url: gifRelativeUrl,
|
|
},
|
|
});
|
|
|
|
if (gifMedia) {
|
|
await prisma.termMedia.delete({ where: { id: gifMedia.id } });
|
|
console.log(`Deleted GIF media record: ${gifMedia.id}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to delete GIF ${gifFilename}:`, err);
|
|
}
|
|
}
|
|
|
|
// Delete GIF file from disk if it's a GIF
|
|
if (media.kind === MediaKind.GIF && media.url) {
|
|
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
|
const filename = path.basename(media.url);
|
|
const filePath = path.join(gifsDir, filename);
|
|
|
|
try {
|
|
if (fs.existsSync(filePath)) {
|
|
fs.unlinkSync(filePath);
|
|
console.log(`Deleted GIF file: ${filename}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`Failed to delete GIF file ${filename}:`, err);
|
|
}
|
|
}
|
|
|
|
// Delete TermMedia record
|
|
await prisma.termMedia.delete({ where: { id: mediaId } });
|
|
|
|
res.json({ message: 'Media deleted successfully' });
|
|
} catch (error: any) {
|
|
console.error('Error deleting media:', error);
|
|
res.status(500).json({ error: 'Failed to delete media', message: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/terms/:id/media/:mediaId/regenerate-gif
|
|
* Regenerate GIF preview for a video (Admin only)
|
|
*/
|
|
router.post('/:id/media/:mediaId/regenerate-gif', isAuthenticated, isAdmin, async (req: Request, res: Response) => {
|
|
try {
|
|
const { id, mediaId } = req.params;
|
|
|
|
// Check if media exists and belongs to the term
|
|
const media = await prisma.termMedia.findUnique({
|
|
where: { id: mediaId },
|
|
});
|
|
|
|
if (!media) {
|
|
return res.status(404).json({ error: 'Media not found' });
|
|
}
|
|
|
|
if (media.termId !== id) {
|
|
return res.status(400).json({ error: 'Media does not belong to this term' });
|
|
}
|
|
|
|
if (media.kind !== MediaKind.VIDEO) {
|
|
return res.status(400).json({ error: 'Can only regenerate GIF from video media' });
|
|
}
|
|
|
|
// Get video file path
|
|
const videoFilename = path.basename(media.url);
|
|
const videoPath = path.join(__dirname, '..', '..', 'uploads', 'videos', videoFilename);
|
|
|
|
if (!fs.existsSync(videoPath)) {
|
|
return res.status(404).json({ error: 'Video file not found on disk' });
|
|
}
|
|
|
|
// Generate GIF filename and path
|
|
const gifFilename = videoFilename.replace(/\.(mp4|webm|mov)$/i, '.gif');
|
|
const gifsDir = path.join(__dirname, '..', '..', 'uploads', 'gifs');
|
|
|
|
// Ensure gifs directory exists
|
|
if (!fs.existsSync(gifsDir)) {
|
|
fs.mkdirSync(gifsDir, { recursive: true });
|
|
}
|
|
|
|
const gifPath = path.join(gifsDir, gifFilename);
|
|
const gifRelativeUrl = `/uploads/gifs/${gifFilename}`;
|
|
|
|
// Delete old GIF if it exists
|
|
if (fs.existsSync(gifPath)) {
|
|
fs.unlinkSync(gifPath);
|
|
}
|
|
|
|
// Delete old GIF media record if it exists
|
|
const existingGif = await prisma.termMedia.findFirst({
|
|
where: {
|
|
termId: id,
|
|
kind: MediaKind.GIF,
|
|
url: gifRelativeUrl,
|
|
},
|
|
});
|
|
|
|
if (existingGif) {
|
|
await prisma.termMedia.delete({ where: { id: existingGif.id } });
|
|
}
|
|
|
|
// Generate new GIF (300px width, 10fps, first 3 seconds)
|
|
await generateGifFromVideo(videoPath, gifPath, {
|
|
width: 300,
|
|
fps: 10,
|
|
duration: 3,
|
|
startTime: 0,
|
|
});
|
|
|
|
// Create new TermMedia record for GIF
|
|
const gifMedia = await prisma.termMedia.create({
|
|
data: {
|
|
termId: id,
|
|
kind: MediaKind.GIF,
|
|
url: gifRelativeUrl,
|
|
},
|
|
});
|
|
|
|
console.log(`Regenerated GIF preview: ${gifRelativeUrl}`);
|
|
res.status(201).json({ media: gifMedia });
|
|
} catch (error: any) {
|
|
console.error('Error regenerating GIF:', error);
|
|
res.status(500).json({ error: 'Failed to regenerate GIF', message: error.message });
|
|
}
|
|
});
|
|
|
|
export default router;
|
|
|