From 80d97d72c9d1d9c78b5919f8c2bf783b522636f0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:22:44 +0100 Subject: [PATCH] feat: add backfill migration for media dimensions and video thumbnails --- packages/server/src/db/migrate.ts | 76 ++++++++++++++++++++++++++++++- packages/server/src/index.ts | 11 ++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index e1c737cc..4866fb9a 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -3,7 +3,7 @@ import crypto from 'crypto'; import path from 'path'; import fs from 'fs'; import { DEFAULT_EVERYONE_PERMISSIONS, PermissionBits, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; -import { generateThumbnail, isResizableImage } from '../utils/thumbnail.js'; +import { generateThumbnail, isResizableImage, probeImageDimensions, probeMediaMeta, generateVideoThumbnail } from '../utils/thumbnail.js'; export function runMigrations(db: Database.Database): void { console.log('Checking for database migrations...'); @@ -1058,3 +1058,77 @@ export async function backfillThumbnails(db: Database.Database, uploadDir: strin console.log(`Backfill: Generated ${generated} thumbnail(s), skipped ${skipped} (small or missing)`); db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run(); } + +/** + * Async backfill: extract dimensions and generate thumbnails for existing + * video and image attachments that don't have width/height yet. + * Runs once after server startup, gated by a persistent flag. + */ +export async function backfillMediaDimensions(db: Database.Database, uploadDir: string): Promise { + // Ensure the flag column exists + const cols = db.pragma('table_info(instance_settings)') as { name: string }[]; + if (!cols.some(c => c.name === 'media_dimensions_backfilled')) { + db.exec('ALTER TABLE instance_settings ADD COLUMN media_dimensions_backfilled INTEGER DEFAULT 0'); + } + + const row = db.prepare('SELECT media_dimensions_backfilled FROM instance_settings WHERE id = 1').get() as + { media_dimensions_backfilled: number } | undefined; + if (row && row.media_dimensions_backfilled === 1) return; + + // Find all image/video attachments without dimensions + const rows = db.prepare( + "SELECT id, filename, mimetype FROM attachments WHERE width IS NULL AND (mimetype LIKE 'video/%' OR mimetype LIKE 'image/%')" + ).all() as { id: string; filename: string; mimetype: string }[]; + + if (rows.length === 0) { + db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run(); + return; + } + + console.log(`Backfill: Extracting dimensions for ${rows.length} existing media attachment(s)...`); + + const update = db.prepare( + 'UPDATE attachments SET width = ?, height = ?, duration = ?, thumbnail_filename = COALESCE(?, thumbnail_filename) WHERE id = ?' + ); + let processed = 0; + let skipped = 0; + + for (const att of rows) { + const originalPath = path.join(uploadDir, path.basename(att.filename)); + if (!fs.existsSync(originalPath)) { + skipped++; + continue; + } + + try { + if (att.mimetype.startsWith('video/')) { + // Video: thumbnail + dimensions + duration + const videoThumb = await generateVideoThumbnail(originalPath, uploadDir); + const meta = await probeMediaMeta(originalPath, att.mimetype); + + const width = videoThumb?.width ?? meta?.width ?? null; + const height = videoThumb?.height ?? meta?.height ?? null; + const duration = meta?.duration ?? null; + const thumbName = videoThumb?.thumbnailFilename ?? null; + + update.run(width, height, duration, thumbName, att.id); + processed++; + } else { + // Image: dimensions only + const dims = await probeImageDimensions(originalPath); + if (dims) { + update.run(dims.width, dims.height, null, null, att.id); + processed++; + } else { + skipped++; + } + } + } catch (err) { + console.error(`Backfill: Failed to process ${att.filename} (non-fatal):`, err); + skipped++; + } + } + + console.log(`Backfill: Processed ${processed} media attachment(s), skipped ${skipped}`); + db.prepare('UPDATE instance_settings SET media_dimensions_backfilled = 1 WHERE id = 1').run(); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ace695d6..9ead5f43 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,7 +7,8 @@ import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getDb, getRawDb } from './db/index.js'; import { seedDatabase } from './db/seed.js'; -import { backfillThumbnails } from './db/migrate.js'; +import { backfillThumbnails, backfillMediaDimensions } from './db/migrate.js'; +import { checkFfmpeg } from './utils/thumbnail.js'; import { authRoutes } from './routes/auth.js'; import { userRoutes } from './routes/users.js'; import { spaceRoutes } from './routes/spaces.js'; @@ -123,6 +124,14 @@ async function main(): Promise { console.error('Thumbnail backfill failed (non-fatal):', err); }); + // Log ffmpeg availability at startup (so admins see the warning immediately) + checkFfmpeg(); + + // Fire-and-forget: backfill dimensions for existing media (runs once) + backfillMediaDimensions(getRawDb(), config.uploadDir).catch(err => { + console.error('Media dimensions backfill failed (non-fatal):', err); + }); + const shutdown = async () => { console.log('Shutting down...'); await app.close();