feat: backfill thumbnails for existing image attachments on first startup
One-time async migration runs after server is listening — generates 800px WebP thumbnails for all existing image attachments that lack one. Gated by a persistent flag in instance_settings so it never re-runs.
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
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';
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
console.log('Checking for database migrations...');
|
||||
@@ -679,3 +682,59 @@ function migrateReplicatedUsernames(db: Database.Database): void {
|
||||
console.log(`Migrating: Renamed replicated user "${row.username}" → "${newUsername}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async backfill: generate thumbnails for all existing image attachments that
|
||||
* don't have one yet. Runs once after server startup, gated by a persistent
|
||||
* flag in instance_settings so it never re-runs.
|
||||
*
|
||||
* Call this AFTER the server is listening — it's fire-and-forget and doesn't
|
||||
* block startup.
|
||||
*/
|
||||
export async function backfillThumbnails(db: Database.Database, uploadDir: string): Promise<void> {
|
||||
// Ensure the flag column exists
|
||||
const cols = db.pragma('table_info(instance_settings)') as { name: string }[];
|
||||
if (!cols.some(c => c.name === 'thumbnails_backfilled')) {
|
||||
db.exec('ALTER TABLE instance_settings ADD COLUMN thumbnails_backfilled INTEGER DEFAULT 0');
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT thumbnails_backfilled FROM instance_settings WHERE id = 1').get() as
|
||||
{ thumbnails_backfilled: number } | undefined;
|
||||
if (row && row.thumbnails_backfilled === 1) return;
|
||||
|
||||
// Find all image attachments without a thumbnail
|
||||
const rows = db.prepare(
|
||||
"SELECT id, filename, mimetype FROM attachments WHERE thumbnail_filename IS NULL"
|
||||
).all() as { id: string; filename: string; mimetype: string }[];
|
||||
|
||||
const candidates = rows.filter(r => isResizableImage(r.mimetype));
|
||||
if (candidates.length === 0) {
|
||||
db.prepare('UPDATE instance_settings SET thumbnails_backfilled = 1 WHERE id = 1').run();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Backfill: Generating thumbnails for ${candidates.length} existing image(s)...`);
|
||||
|
||||
const update = db.prepare('UPDATE attachments SET thumbnail_filename = ? WHERE id = ?');
|
||||
let generated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const att of candidates) {
|
||||
const originalPath = path.join(uploadDir, path.basename(att.filename));
|
||||
if (!fs.existsSync(originalPath)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const thumbName = await generateThumbnail(originalPath, att.mimetype, uploadDir);
|
||||
if (thumbName) {
|
||||
update.run(thumbName, att.id);
|
||||
generated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import websocket from '@fastify/websocket';
|
||||
import multipart from '@fastify/multipart';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { config } from './config.js';
|
||||
import { getDb } from './db/index.js';
|
||||
import { getDb, getRawDb } from './db/index.js';
|
||||
import { seedDatabase } from './db/seed.js';
|
||||
import { backfillThumbnails } from './db/migrate.js';
|
||||
import { authRoutes } from './routes/auth.js';
|
||||
import { userRoutes } from './routes/users.js';
|
||||
import { spaceRoutes } from './routes/spaces.js';
|
||||
@@ -111,6 +112,11 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Fire-and-forget: backfill thumbnails for existing images (runs once)
|
||||
backfillThumbnails(getRawDb(), config.uploadDir).catch(err => {
|
||||
console.error('Thumbnail backfill failed (non-fatal):', err);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...');
|
||||
await app.close();
|
||||
|
||||
Reference in New Issue
Block a user