fix: thumbnail generation on image change

This commit is contained in:
2026-02-15 13:28:51 +01:00
parent 1059f58042
commit 5f604362df
7 changed files with 430 additions and 1 deletions

View File

@@ -611,6 +611,78 @@ export class MediaEngine extends EventEmitter {
return updated;
}
/**
* Replace the actual file content for an existing media item.
* This will:
* - Check if the new file has a different checksum
* - Replace the file if checksum differs
* - Update size, dimensions (for images), and checksum in database
* - Regenerate thumbnails for images
*
* @returns The updated MediaData if file was replaced, null if media not found or checksum unchanged
*/
async replaceMediaFile(id: string, newSourcePath: string): Promise<MediaData | null> {
const db = getDatabase().getLocal();
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
if (!dbMedia) {
return null;
}
// Read the new source file
const newBuffer = await fs.readFile(newSourcePath);
const newChecksum = this.calculateChecksum(newBuffer);
// If checksum is the same, no need to replace
if (dbMedia.checksum === newChecksum) {
return null;
}
// Copy new file to existing location
await fs.copyFile(newSourcePath, dbMedia.filePath);
// Get new dimensions for images
let width = dbMedia.width;
let height = dbMedia.height;
if (dbMedia.mimeType.startsWith('image/') && !dbMedia.mimeType.includes('svg')) {
try {
const sharp = (await import('sharp')).default;
const imageMetadata = await sharp(dbMedia.filePath).metadata();
width = imageMetadata.width ?? width;
height = imageMetadata.height ?? height;
} catch (error) {
console.error('Failed to get image dimensions:', error);
}
}
const now = new Date();
// Update database
await db.update(media)
.set({
size: newBuffer.length,
width,
height,
checksum: newChecksum,
updatedAt: now,
})
.where(eq(media.id, id));
// Regenerate thumbnails for images
if (dbMedia.mimeType.startsWith('image/') && !dbMedia.mimeType.includes('svg')) {
// Await thumbnail generation to ensure it completes before returning
await this.generateThumbnails(id, dbMedia.filePath);
}
// Get the updated media data
const updated = await this.getMedia(id);
if (updated) {
this.emit('mediaFileReplaced', updated);
}
return updated;
}
async deleteMedia(id: string): Promise<boolean> {
const db = getDatabase().getLocal();
const existing = await db.select().from(media).where(eq(media.id, id)).get();

View File

@@ -326,6 +326,43 @@ export function registerIpcHandlers(): void {
return engine.updateMedia(id, data);
});
safeHandle('media:replaceFile', async (_, id: string, newSourcePath: string) => {
const engine = getMediaEngine();
return engine.replaceMediaFile(id, newSourcePath);
});
safeHandle('media:replaceFileDialog', async (_, id: string) => {
// Get the current media to determine file type filter
const engine = getMediaEngine();
const currentMedia = await engine.getMedia(id);
if (!currentMedia) {
return null;
}
// Build filter based on current media type
let filters: { name: string; extensions: string[] }[] = [];
if (currentMedia.mimeType.startsWith('image/')) {
filters = [
{ name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'] },
{ name: 'All Files', extensions: ['*'] },
];
} else {
filters = [{ name: 'All Files', extensions: ['*'] }];
}
const result = await dialog.showOpenDialog({
title: 'Replace Media File',
filters,
properties: ['openFile'],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
return engine.replaceMediaFile(id, result.filePaths[0]);
});
safeHandle('media:delete', async (_, id: string) => {
const engine = getMediaEngine();
return engine.deleteMedia(id);
@@ -979,6 +1016,7 @@ export function registerIpcHandlers(): void {
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced'));
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));

View File

@@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
import: (sourcePath: string, metadata?: unknown) => ipcRenderer.invoke('media:import', sourcePath, metadata),
importDialog: () => ipcRenderer.invoke('media:importDialog'),
update: (id: string, data: unknown) => ipcRenderer.invoke('media:update', id, data),
replaceFile: (id: string, newSourcePath: string) => ipcRenderer.invoke('media:replaceFile', id, newSourcePath),
replaceFileDialog: (id: string) => ipcRenderer.invoke('media:replaceFileDialog', id),
delete: (id: string) => ipcRenderer.invoke('media:delete', id),
get: (id: string) => ipcRenderer.invoke('media:get', id),
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),

View File

@@ -1644,6 +1644,25 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
}
};
const handleReplaceFile = async () => {
try {
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
showToast.success('File replaced (thumbnails regenerated)');
}
// null means user cancelled or file unchanged - no action needed
} catch (error) {
console.error('Failed to replace media file:', error);
const err = error as Error;
showErrorModal({
title: 'Replace Failed',
message: err.message || 'Failed to replace media file',
stack: err.stack,
});
}
};
const handleDelete = async () => {
try {
// Fetch posts that link to this media
@@ -1735,6 +1754,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
)}
</div>
)}
<button onClick={handleReplaceFile} className="secondary">Replace File</button>
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
</div>
@@ -1745,7 +1765,7 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-image">
<img
src={`bds-media://${item.id}`}
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
alt={item.alt || item.originalName}
onError={(e) => {
// Fallback to placeholder if image fails to load

View File

@@ -302,6 +302,8 @@ export interface ElectronAPI {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
importDialog: () => Promise<MediaData[]>;
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
replaceFile: (id: string, newSourcePath: string) => Promise<MediaData | null>;
replaceFileDialog: (id: string) => Promise<MediaData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<MediaData | null>;
getUrl: (id: string) => Promise<string | null>;