fix: thumbnail generation on image change
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
2
src/renderer/types/electron.d.ts
vendored
2
src/renderer/types/electron.d.ts
vendored
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user