948 lines
29 KiB
TypeScript
948 lines
29 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import * as crypto from 'crypto';
|
|
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
|
import { app } from 'electron';
|
|
import { getDatabase } from '../database';
|
|
import { media, Media, NewMedia } from '../database/schema';
|
|
|
|
// Thumbnail sizes
|
|
const THUMBNAIL_SIZES = {
|
|
small: { width: 150, height: 150 },
|
|
medium: { width: 400, height: 400 },
|
|
large: { width: 800, height: 800 },
|
|
} as const;
|
|
|
|
type ThumbnailSize = keyof typeof THUMBNAIL_SIZES;
|
|
import { taskManager, Task } from './TaskManager';
|
|
|
|
export interface MediaData {
|
|
id: string;
|
|
filename: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
width?: number;
|
|
height?: number;
|
|
alt?: string;
|
|
caption?: string;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
tags: string[];
|
|
linkedPostIds?: string[]; // Posts this media is linked to
|
|
}
|
|
|
|
export interface MediaMetadata {
|
|
id: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
width?: number;
|
|
height?: number;
|
|
alt?: string;
|
|
caption?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
tags: string[];
|
|
linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar)
|
|
}
|
|
|
|
export interface MediaFilter {
|
|
tags?: string[];
|
|
startDate?: Date;
|
|
endDate?: Date;
|
|
year?: number;
|
|
month?: number;
|
|
}
|
|
|
|
export interface MediaSearchResult {
|
|
id: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export class MediaEngine extends EventEmitter {
|
|
private currentProjectId: string = 'default';
|
|
private dataDir: string | null = null; // For media files (may be external)
|
|
private internalDir: string | null = null; // For thumbnails (always local)
|
|
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
private getDefaultBaseDir(): string {
|
|
const userDataPath = app.getPath('userData');
|
|
return path.join(userDataPath, 'projects', this.currentProjectId);
|
|
}
|
|
|
|
private getDataDir(): string {
|
|
return this.dataDir || this.getDefaultBaseDir();
|
|
}
|
|
|
|
private getInternalDir(): string {
|
|
return this.internalDir || this.getDefaultBaseDir();
|
|
}
|
|
|
|
private getMediaBaseDir(): string {
|
|
return path.join(this.getDataDir(), 'media');
|
|
}
|
|
|
|
private getMediaDir(): string {
|
|
// Kept for backwards compatibility - returns base media directory
|
|
return this.getMediaBaseDir();
|
|
}
|
|
|
|
/**
|
|
* Get the date-based directory for media based on its creation date.
|
|
* Format: media/YYYY/MM/
|
|
*/
|
|
private getMediaDirForDate(date: Date): string {
|
|
const baseDir = this.getMediaBaseDir();
|
|
const year = date.getFullYear().toString();
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
return path.join(baseDir, year, month);
|
|
}
|
|
|
|
/**
|
|
* Get the full path for a media file based on id, extension, and date.
|
|
* Returns: media/YYYY/MM/{id}.{ext}
|
|
*/
|
|
getMediaPathForDate(id: string, ext: string, date: Date): string {
|
|
const dir = this.getMediaDirForDate(date);
|
|
const extension = ext.startsWith('.') ? ext : `.${ext}`;
|
|
return path.join(dir, `${id}${extension}`);
|
|
}
|
|
|
|
setProjectContext(projectId: string, dataDir?: string, internalDir?: string): void {
|
|
this.currentProjectId = projectId;
|
|
this.dataDir = dataDir || null;
|
|
this.internalDir = internalDir || null;
|
|
console.log(`[MediaEngine] setProjectContext: projectId=${projectId}, dataDir=${this.dataDir}, internalDir=${this.internalDir}`);
|
|
}
|
|
|
|
getProjectContext(): string {
|
|
return this.currentProjectId;
|
|
}
|
|
|
|
private calculateChecksum(buffer: Buffer): string {
|
|
return crypto.createHash('md5').update(buffer).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Get the thumbnails directory for the current project
|
|
*/
|
|
private getThumbnailsDir(): string {
|
|
return path.join(this.getInternalDir(), 'thumbnails');
|
|
}
|
|
|
|
/**
|
|
* Generate thumbnails for an image file
|
|
*/
|
|
async generateThumbnails(mediaId: string, sourcePath: string): Promise<Record<ThumbnailSize, string>> {
|
|
const thumbnailsDir = this.getThumbnailsDir();
|
|
await fs.mkdir(thumbnailsDir, { recursive: true });
|
|
|
|
const thumbnails: Record<ThumbnailSize, string> = {} as Record<ThumbnailSize, string>;
|
|
|
|
try {
|
|
// Dynamic import of sharp (it's a native module)
|
|
const sharp = (await import('sharp')).default;
|
|
|
|
for (const [size, dimensions] of Object.entries(THUMBNAIL_SIZES) as [ThumbnailSize, { width: number; height: number }][]) {
|
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
|
|
|
await sharp(sourcePath)
|
|
.resize(dimensions.width, dimensions.height, {
|
|
fit: 'inside',
|
|
withoutEnlargement: true,
|
|
})
|
|
.webp({ quality: 80 })
|
|
.toFile(thumbnailPath);
|
|
|
|
thumbnails[size] = thumbnailPath;
|
|
}
|
|
|
|
this.emit('thumbnailsGenerated', { mediaId, thumbnails });
|
|
} catch (error) {
|
|
console.error('Failed to generate thumbnails:', error);
|
|
// Return empty thumbnails on error - non-critical failure
|
|
}
|
|
|
|
return thumbnails;
|
|
}
|
|
|
|
/**
|
|
* Get existing thumbnail paths for a media item
|
|
*/
|
|
async getThumbnailPaths(mediaId: string): Promise<Record<ThumbnailSize, string | null>> {
|
|
const thumbnailsDir = this.getThumbnailsDir();
|
|
const result: Record<ThumbnailSize, string | null> = {
|
|
small: null,
|
|
medium: null,
|
|
large: null,
|
|
};
|
|
|
|
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
|
try {
|
|
await fs.access(thumbnailPath);
|
|
result[size] = thumbnailPath;
|
|
} catch {
|
|
// Thumbnail doesn't exist
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get thumbnail as base64 data URL for renderer
|
|
*/
|
|
async getThumbnailDataUrl(mediaId: string, size: ThumbnailSize = 'small'): Promise<string | null> {
|
|
const thumbnailsDir = this.getThumbnailsDir();
|
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
|
|
|
try {
|
|
const data = await fs.readFile(thumbnailPath);
|
|
return `data:image/webp;base64,${data.toString('base64')}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete thumbnails for a media item
|
|
*/
|
|
private async deleteThumbnails(mediaId: string): Promise<void> {
|
|
const thumbnailsDir = this.getThumbnailsDir();
|
|
|
|
for (const size of Object.keys(THUMBNAIL_SIZES) as ThumbnailSize[]) {
|
|
const thumbnailPath = path.join(thumbnailsDir, `${mediaId}-${size}.webp`);
|
|
try {
|
|
await fs.unlink(thumbnailPath);
|
|
} catch {
|
|
// Thumbnail doesn't exist, ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
private async writeSidecarFile(mediaData: MediaData, mediaPath: string): Promise<string> {
|
|
const sidecarPath = `${mediaPath}.meta`;
|
|
|
|
const metadata: MediaMetadata = {
|
|
id: mediaData.id,
|
|
originalName: mediaData.originalName,
|
|
mimeType: mediaData.mimeType,
|
|
size: mediaData.size,
|
|
width: mediaData.width,
|
|
height: mediaData.height,
|
|
alt: mediaData.alt,
|
|
caption: mediaData.caption,
|
|
createdAt: mediaData.createdAt.toISOString(),
|
|
updatedAt: mediaData.updatedAt.toISOString(),
|
|
tags: mediaData.tags,
|
|
linkedPostIds: mediaData.linkedPostIds,
|
|
};
|
|
|
|
// Write YAML-like format consistent with posts
|
|
const lines = [
|
|
'---',
|
|
`id: ${metadata.id}`,
|
|
`originalName: "${metadata.originalName}"`,
|
|
`mimeType: ${metadata.mimeType}`,
|
|
`size: ${metadata.size}`,
|
|
];
|
|
|
|
if (metadata.width) lines.push(`width: ${metadata.width}`);
|
|
if (metadata.height) lines.push(`height: ${metadata.height}`);
|
|
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
|
|
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
|
|
|
|
lines.push(`createdAt: ${metadata.createdAt}`);
|
|
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
|
lines.push(`tags: [${metadata.tags.map(t => `"${t}"`).join(', ')}]`);
|
|
if (metadata.linkedPostIds && metadata.linkedPostIds.length > 0) {
|
|
lines.push(`linkedPostIds: [${metadata.linkedPostIds.map(id => `"${id}"`).join(', ')}]`);
|
|
}
|
|
lines.push('---');
|
|
|
|
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
|
return sidecarPath;
|
|
}
|
|
|
|
private async readSidecarFile(sidecarPath: string): Promise<MediaMetadata | null> {
|
|
try {
|
|
// Check if file exists first to avoid noisy errors
|
|
try {
|
|
await fs.access(sidecarPath);
|
|
} catch {
|
|
// File doesn't exist - this is expected when DB has stale paths
|
|
return null;
|
|
}
|
|
|
|
const content = await fs.readFile(sidecarPath, 'utf-8');
|
|
const lines = content.split('\n');
|
|
|
|
const metadata: Partial<MediaMetadata> = {
|
|
tags: [],
|
|
linkedPostIds: [],
|
|
};
|
|
|
|
for (const line of lines) {
|
|
if (line === '---') continue;
|
|
|
|
const colonIndex = line.indexOf(':');
|
|
if (colonIndex === -1) continue;
|
|
|
|
const key = line.substring(0, colonIndex).trim();
|
|
let value = line.substring(colonIndex + 1).trim();
|
|
|
|
// Remove quotes
|
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
switch (key) {
|
|
case 'id':
|
|
metadata.id = value;
|
|
break;
|
|
case 'originalName':
|
|
metadata.originalName = value;
|
|
break;
|
|
case 'mimeType':
|
|
metadata.mimeType = value;
|
|
break;
|
|
case 'size':
|
|
metadata.size = parseInt(value, 10);
|
|
break;
|
|
case 'width':
|
|
metadata.width = parseInt(value, 10);
|
|
break;
|
|
case 'height':
|
|
metadata.height = parseInt(value, 10);
|
|
break;
|
|
case 'alt':
|
|
metadata.alt = value;
|
|
break;
|
|
case 'caption':
|
|
metadata.caption = value;
|
|
break;
|
|
case 'createdAt':
|
|
metadata.createdAt = value;
|
|
break;
|
|
case 'updatedAt':
|
|
metadata.updatedAt = value;
|
|
break;
|
|
case 'tags':
|
|
// Parse array format: ["tag1", "tag2"]
|
|
const tagsMatch = value.match(/\[(.*)\]/);
|
|
if (tagsMatch) {
|
|
metadata.tags = tagsMatch[1]
|
|
.split(',')
|
|
.map(t => t.trim().replace(/"/g, ''))
|
|
.filter(t => t.length > 0);
|
|
}
|
|
break;
|
|
case 'linkedPostIds':
|
|
// Parse array format: ["postId1", "postId2"]
|
|
const postIdsMatch = value.match(/\[(.*)\]/);
|
|
if (postIdsMatch) {
|
|
metadata.linkedPostIds = postIdsMatch[1]
|
|
.split(',')
|
|
.map(id => id.trim().replace(/"/g, ''))
|
|
.filter(id => id.length > 0);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!metadata.id || !metadata.originalName || !metadata.mimeType) {
|
|
return null;
|
|
}
|
|
|
|
return metadata as MediaMetadata;
|
|
} catch (error) {
|
|
console.error(`Failed to parse sidecar file: ${sidecarPath}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private getMimeType(filename: string): string {
|
|
const ext = path.extname(filename).toLowerCase();
|
|
const mimeTypes: Record<string, string> = {
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
};
|
|
return mimeTypes[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
async importMedia(sourcePath: string, metadata?: Partial<MediaData>): Promise<MediaData> {
|
|
const db = getDatabase().getLocal();
|
|
const id = uuidv4();
|
|
const now = new Date();
|
|
|
|
const sourceBuffer = await fs.readFile(sourcePath);
|
|
const originalName = path.basename(sourcePath);
|
|
const ext = path.extname(originalName);
|
|
const filename = `${id}${ext}`;
|
|
|
|
// Use date-based directory structure (media/YYYY/MM/)
|
|
const mediaDir = this.getMediaDirForDate(now);
|
|
await fs.mkdir(mediaDir, { recursive: true });
|
|
const destPath = path.join(mediaDir, filename);
|
|
|
|
// Copy file to media directory
|
|
await fs.copyFile(sourcePath, destPath);
|
|
|
|
const mimeType = metadata?.mimeType || this.getMimeType(originalName);
|
|
let width = metadata?.width;
|
|
let height = metadata?.height;
|
|
|
|
// Get image dimensions using sharp if it's an image
|
|
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
|
try {
|
|
const sharp = (await import('sharp')).default;
|
|
const imageMetadata = await sharp(destPath).metadata();
|
|
width = imageMetadata.width;
|
|
height = imageMetadata.height;
|
|
} catch (error) {
|
|
console.error('Failed to get image dimensions:', error);
|
|
}
|
|
}
|
|
|
|
const mediaData: MediaData = {
|
|
id,
|
|
filename,
|
|
originalName,
|
|
mimeType,
|
|
size: sourceBuffer.length,
|
|
width,
|
|
height,
|
|
alt: metadata?.alt,
|
|
caption: metadata?.caption,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
tags: metadata?.tags || [],
|
|
};
|
|
|
|
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
|
const checksum = this.calculateChecksum(sourceBuffer);
|
|
|
|
// Generate thumbnails for images (async, non-blocking)
|
|
if (mimeType.startsWith('image/') && !mimeType.includes('svg')) {
|
|
this.generateThumbnails(id, destPath).catch(err => {
|
|
console.error('Failed to generate thumbnails:', err);
|
|
});
|
|
}
|
|
|
|
const dbMedia: NewMedia = {
|
|
id: mediaData.id,
|
|
projectId: this.currentProjectId,
|
|
filename: mediaData.filename,
|
|
originalName: mediaData.originalName,
|
|
mimeType: mediaData.mimeType,
|
|
size: mediaData.size,
|
|
width: mediaData.width,
|
|
height: mediaData.height,
|
|
alt: mediaData.alt,
|
|
caption: mediaData.caption,
|
|
filePath: destPath,
|
|
sidecarPath,
|
|
createdAt: mediaData.createdAt,
|
|
updatedAt: mediaData.updatedAt,
|
|
syncStatus: 'pending',
|
|
checksum,
|
|
tags: JSON.stringify(mediaData.tags),
|
|
};
|
|
|
|
await db.insert(media).values(dbMedia);
|
|
|
|
this.emit('mediaImported', mediaData);
|
|
return mediaData;
|
|
}
|
|
|
|
async updateMedia(id: string, data: Partial<MediaData>): Promise<MediaData | null> {
|
|
const db = getDatabase().getLocal();
|
|
const existing = await this.getMedia(id);
|
|
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const updated: MediaData = {
|
|
...existing,
|
|
...data,
|
|
id, // Ensure ID doesn't change
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
const dbMedia = await db.select().from(media).where(eq(media.id, id)).get();
|
|
if (!dbMedia) return null;
|
|
|
|
await this.writeSidecarFile(updated, dbMedia.filePath);
|
|
|
|
await db.update(media)
|
|
.set({
|
|
alt: updated.alt,
|
|
caption: updated.caption,
|
|
updatedAt: updated.updatedAt,
|
|
syncStatus: 'pending',
|
|
tags: JSON.stringify(updated.tags),
|
|
})
|
|
.where(eq(media.id, id));
|
|
|
|
this.emit('mediaUpdated', 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();
|
|
|
|
if (!existing) {
|
|
return false;
|
|
}
|
|
|
|
// Delete media file
|
|
try {
|
|
await fs.unlink(existing.filePath);
|
|
} catch {
|
|
// File might not exist
|
|
}
|
|
|
|
// Delete sidecar file
|
|
try {
|
|
await fs.unlink(existing.sidecarPath);
|
|
} catch {
|
|
// File might not exist
|
|
}
|
|
|
|
// Delete thumbnails
|
|
await this.deleteThumbnails(id);
|
|
|
|
// Delete post-media links (cascade cleanup)
|
|
const { postMedia } = await import('../database/schema');
|
|
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
|
|
|
|
await db.delete(media).where(eq(media.id, id));
|
|
|
|
this.emit('mediaDeleted', id);
|
|
return true;
|
|
}
|
|
|
|
async getMedia(id: 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;
|
|
}
|
|
|
|
return {
|
|
id: dbMedia.id,
|
|
filename: dbMedia.filename,
|
|
originalName: dbMedia.originalName,
|
|
mimeType: dbMedia.mimeType,
|
|
size: dbMedia.size,
|
|
width: dbMedia.width || undefined,
|
|
height: dbMedia.height || undefined,
|
|
alt: dbMedia.alt || undefined,
|
|
caption: dbMedia.caption || undefined,
|
|
createdAt: dbMedia.createdAt,
|
|
updatedAt: dbMedia.updatedAt,
|
|
tags: JSON.parse(dbMedia.tags || '[]'),
|
|
};
|
|
}
|
|
|
|
async getAllMedia(): Promise<MediaData[]> {
|
|
const db = getDatabase().getLocal();
|
|
const dbMediaList = await db
|
|
.select()
|
|
.from(media)
|
|
.where(eq(media.projectId, this.currentProjectId))
|
|
.orderBy(desc(media.createdAt))
|
|
.all();
|
|
|
|
return dbMediaList.map(dbMedia => ({
|
|
id: dbMedia.id,
|
|
filename: dbMedia.filename,
|
|
originalName: dbMedia.originalName,
|
|
mimeType: dbMedia.mimeType,
|
|
size: dbMedia.size,
|
|
width: dbMedia.width || undefined,
|
|
height: dbMedia.height || undefined,
|
|
alt: dbMedia.alt || undefined,
|
|
caption: dbMedia.caption || undefined,
|
|
createdAt: dbMedia.createdAt,
|
|
updatedAt: dbMedia.updatedAt,
|
|
tags: JSON.parse(dbMedia.tags || '[]'),
|
|
}));
|
|
}
|
|
|
|
async getMediaFiltered(filter: MediaFilter): Promise<MediaData[]> {
|
|
const db = getDatabase().getLocal();
|
|
const conditions = [eq(media.projectId, this.currentProjectId)];
|
|
|
|
if (filter.startDate) {
|
|
conditions.push(gte(media.createdAt, filter.startDate));
|
|
}
|
|
|
|
if (filter.endDate) {
|
|
conditions.push(lte(media.createdAt, filter.endDate));
|
|
}
|
|
|
|
if (filter.year !== undefined) {
|
|
const startOfYear = new Date(filter.year, 0, 1);
|
|
const endOfYear = new Date(filter.year + 1, 0, 1);
|
|
conditions.push(gte(media.createdAt, startOfYear));
|
|
conditions.push(lte(media.createdAt, endOfYear));
|
|
}
|
|
|
|
if (filter.month !== undefined && filter.year !== undefined) {
|
|
const startOfMonth = new Date(filter.year, filter.month, 1);
|
|
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
|
conditions.push(gte(media.createdAt, startOfMonth));
|
|
conditions.push(lte(media.createdAt, endOfMonth));
|
|
}
|
|
|
|
const dbMediaList = await db
|
|
.select()
|
|
.from(media)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(media.createdAt))
|
|
.all();
|
|
|
|
let result: MediaData[] = [];
|
|
|
|
for (const dbMedia of dbMediaList) {
|
|
const mediaData: MediaData = {
|
|
id: dbMedia.id,
|
|
filename: dbMedia.filename,
|
|
originalName: dbMedia.originalName,
|
|
mimeType: dbMedia.mimeType,
|
|
size: dbMedia.size,
|
|
width: dbMedia.width || undefined,
|
|
height: dbMedia.height || undefined,
|
|
alt: dbMedia.alt || undefined,
|
|
caption: dbMedia.caption || undefined,
|
|
createdAt: dbMedia.createdAt,
|
|
updatedAt: dbMedia.updatedAt,
|
|
tags: JSON.parse(dbMedia.tags || '[]'),
|
|
};
|
|
|
|
// Client-side filtering for tags (JSON array)
|
|
if (filter.tags && filter.tags.length > 0) {
|
|
const hasAllTags = filter.tags.every(tag => mediaData.tags.includes(tag));
|
|
if (!hasAllTags) continue;
|
|
}
|
|
|
|
result.push(mediaData);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async searchMedia(query: string): Promise<MediaSearchResult[]> {
|
|
const db = getDatabase().getLocal();
|
|
const allMedia = await db
|
|
.select()
|
|
.from(media)
|
|
.where(eq(media.projectId, this.currentProjectId))
|
|
.orderBy(desc(media.createdAt))
|
|
.all();
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
const searchResults: MediaSearchResult[] = [];
|
|
|
|
for (const item of allMedia) {
|
|
// Search in originalName, alt, caption, and tags
|
|
const searchableText = [
|
|
item.originalName,
|
|
item.alt || '',
|
|
item.caption || '',
|
|
...(JSON.parse(item.tags || '[]') as string[]),
|
|
].join(' ').toLowerCase();
|
|
|
|
if (searchableText.includes(lowerQuery)) {
|
|
searchResults.push({
|
|
id: item.id,
|
|
originalName: item.originalName,
|
|
mimeType: item.mimeType,
|
|
createdAt: item.createdAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
return searchResults;
|
|
}
|
|
|
|
async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
|
const allMedia = await this.getAllMedia();
|
|
const counts = new Map<string, { year: number; month: number; count: number }>();
|
|
|
|
for (const item of allMedia) {
|
|
const year = item.createdAt.getFullYear();
|
|
const month = item.createdAt.getMonth();
|
|
const key = `${year}-${month}`;
|
|
const current = counts.get(key) || { year, month, count: 0 };
|
|
current.count++;
|
|
counts.set(key, current);
|
|
}
|
|
|
|
return Array.from(counts.values()).sort((a, b) => {
|
|
if (a.year !== b.year) return b.year - a.year;
|
|
return b.month - a.month;
|
|
});
|
|
}
|
|
|
|
async getAvailableTags(): Promise<string[]> {
|
|
const allMedia = await this.getAllMedia();
|
|
const tags = new Set<string>();
|
|
for (const item of allMedia) {
|
|
for (const tag of item.tags) {
|
|
tags.add(tag);
|
|
}
|
|
}
|
|
return Array.from(tags).sort();
|
|
}
|
|
|
|
async getTagsWithCounts(): Promise<{ tag: string; count: number }[]> {
|
|
const db = getDatabase().getLocal();
|
|
const dbMediaList = await db
|
|
.select({ tags: media.tags })
|
|
.from(media)
|
|
.where(eq(media.projectId, this.currentProjectId))
|
|
.all();
|
|
|
|
const tagCounts = new Map<string, number>();
|
|
for (const row of dbMediaList) {
|
|
const parsed: string[] = JSON.parse(row.tags || '[]');
|
|
for (const tag of parsed) {
|
|
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
return Array.from(tagCounts.entries())
|
|
.map(([tag, count]) => ({ tag, count }))
|
|
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
|
}
|
|
|
|
getMediaPath(id: string): string {
|
|
return path.join(this.getMediaDir(), id);
|
|
}
|
|
|
|
async rebuildDatabaseFromFiles(): Promise<void> {
|
|
const mediaBaseDir = this.getMediaBaseDir();
|
|
console.log(`[MediaEngine] rebuildDatabaseFromFiles: scanning mediaBaseDir=${mediaBaseDir}`);
|
|
const task: Task<void> = {
|
|
id: uuidv4(),
|
|
name: 'Rebuild database from media files',
|
|
execute: async (onProgress) => {
|
|
const db = getDatabase().getLocal();
|
|
|
|
onProgress(0, 'Deleting existing media for project...');
|
|
|
|
// Notify UI that rebuild is starting so it can clear the list
|
|
this.emit('rebuildStarted');
|
|
|
|
// Delete all media for the current project - clean slate rebuild
|
|
const existingMedia = await db.select({ id: media.id }).from(media).where(eq(media.projectId, this.currentProjectId)).all();
|
|
if (existingMedia.length > 0) {
|
|
await db.delete(media).where(eq(media.projectId, this.currentProjectId));
|
|
console.log(`Deleted ${existingMedia.length} existing media record(s) for project ${this.currentProjectId}`);
|
|
}
|
|
|
|
onProgress(5, 'Scanning media directory...');
|
|
|
|
// Recursively find all .meta files in the media directory tree
|
|
const metaFiles: string[] = [];
|
|
const scanDir = async (dir: string) => {
|
|
try {
|
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await scanDir(fullPath);
|
|
} else if (entry.name.endsWith('.meta')) {
|
|
metaFiles.push(fullPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// Directory might not exist
|
|
}
|
|
};
|
|
|
|
try {
|
|
await fs.mkdir(mediaBaseDir, { recursive: true });
|
|
} catch {
|
|
// Already exists
|
|
}
|
|
await scanDir(mediaBaseDir);
|
|
|
|
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
|
|
|
|
for (let i = 0; i < metaFiles.length; i++) {
|
|
const sidecarPath = metaFiles[i];
|
|
const mediaFilePath = sidecarPath.replace('.meta', '');
|
|
const metaFileName = path.basename(sidecarPath);
|
|
|
|
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${i + 1}/${metaFiles.length}: ${metaFileName}`);
|
|
|
|
const metadata = await this.readSidecarFile(sidecarPath);
|
|
|
|
if (metadata) {
|
|
try {
|
|
const stats = await fs.stat(mediaFilePath);
|
|
const buffer = await fs.readFile(mediaFilePath);
|
|
const checksum = this.calculateChecksum(buffer);
|
|
const filename = path.basename(mediaFilePath);
|
|
|
|
// Insert fresh - we deleted all records at the start
|
|
await db.insert(media).values({
|
|
id: metadata.id,
|
|
projectId: this.currentProjectId,
|
|
filename,
|
|
originalName: metadata.originalName,
|
|
mimeType: metadata.mimeType,
|
|
size: stats.size,
|
|
width: metadata.width,
|
|
height: metadata.height,
|
|
alt: metadata.alt,
|
|
caption: metadata.caption,
|
|
filePath: mediaFilePath,
|
|
sidecarPath,
|
|
createdAt: new Date(metadata.createdAt),
|
|
updatedAt: new Date(metadata.updatedAt),
|
|
syncStatus: 'pending',
|
|
checksum,
|
|
tags: JSON.stringify(metadata.tags),
|
|
});
|
|
} catch (error) {
|
|
console.error(`Media file not found for sidecar: ${sidecarPath}`, error);
|
|
}
|
|
}
|
|
|
|
// Yield to event loop periodically so the window stays responsive
|
|
if (i % 10 === 0) {
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
onProgress(100, 'Database rebuild complete');
|
|
this.emit('databaseRebuilt');
|
|
},
|
|
};
|
|
|
|
await taskManager.runTask(task);
|
|
}
|
|
|
|
/**
|
|
* Regenerate missing thumbnails for all image media.
|
|
* Useful for media imported externally without thumbnails.
|
|
*/
|
|
async regenerateMissingThumbnails(): Promise<{ processed: number; generated: number; failed: number }> {
|
|
const result = { processed: 0, generated: 0, failed: 0 };
|
|
|
|
const task: Task<{ processed: number; generated: number; failed: number }> = {
|
|
id: uuidv4(),
|
|
name: 'Generate missing thumbnails',
|
|
execute: async (onProgress) => {
|
|
const db = getDatabase().getLocal();
|
|
|
|
onProgress(0, 'Finding images without thumbnails...');
|
|
|
|
// Get all image media for current project
|
|
const allMedia = await db
|
|
.select()
|
|
.from(media)
|
|
.where(eq(media.projectId, this.currentProjectId))
|
|
.all();
|
|
|
|
// Filter to images only (not SVG - they don't need thumbnails)
|
|
const imageMedia = allMedia.filter(
|
|
m => m.mimeType.startsWith('image/') && !m.mimeType.includes('svg')
|
|
);
|
|
|
|
if (imageMedia.length === 0) {
|
|
onProgress(100, 'No images found');
|
|
return result;
|
|
}
|
|
|
|
onProgress(5, `Checking ${imageMedia.length} images...`);
|
|
|
|
// Find which ones are missing thumbnails
|
|
const missingThumbnails: typeof imageMedia = [];
|
|
for (const item of imageMedia) {
|
|
const thumbnails = await this.getThumbnailPaths(item.id);
|
|
// Consider missing if any size is missing
|
|
if (!thumbnails.small || !thumbnails.medium || !thumbnails.large) {
|
|
missingThumbnails.push(item);
|
|
}
|
|
}
|
|
|
|
if (missingThumbnails.length === 0) {
|
|
onProgress(100, 'All thumbnails exist');
|
|
return result;
|
|
}
|
|
|
|
onProgress(10, `Generating thumbnails for ${missingThumbnails.length} images...`);
|
|
|
|
for (let i = 0; i < missingThumbnails.length; i++) {
|
|
const item = missingThumbnails[i];
|
|
result.processed++;
|
|
|
|
const progress = 10 + (90 * ((i + 1) / missingThumbnails.length));
|
|
onProgress(progress, `Processing ${i + 1}/${missingThumbnails.length}: ${item.originalName}`);
|
|
|
|
try {
|
|
// Verify the source file exists
|
|
await fs.access(item.filePath);
|
|
|
|
const thumbnails = await this.generateThumbnails(item.id, item.filePath);
|
|
|
|
// Check if thumbnails were actually generated
|
|
if (Object.keys(thumbnails).length > 0) {
|
|
result.generated++;
|
|
} else {
|
|
result.failed++;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to generate thumbnails for ${item.id}:`, error);
|
|
result.failed++;
|
|
}
|
|
|
|
// Yield to event loop periodically
|
|
if (i % 5 === 0) {
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
onProgress(100, `Generated ${result.generated} thumbnails, ${result.failed} failed`);
|
|
this.emit('thumbnailsRegenerated', result);
|
|
return result;
|
|
},
|
|
};
|
|
|
|
return await taskManager.runTask(task);
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
let mediaEngine: MediaEngine | null = null;
|
|
|
|
export function getMediaEngine(): MediaEngine {
|
|
if (!mediaEngine) {
|
|
mediaEngine = new MediaEngine();
|
|
}
|
|
return mediaEngine;
|
|
}
|