mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 15:26:42 +02:00
feat(mana-media): add centralized media storage with NutriPhi integration
- Implement mana-media service with PostgreSQL/Drizzle ORM persistence - Add content-addressable storage (SHA-256) for automatic deduplication - Add Matrix MXC URL import endpoint to copy images from Matrix - Create @manacore/media-client package for service consumption - Integrate mana-media into NutriPhi bot for persistent image storage - Update pnpm-workspace.yaml to include nested service packages - Add mana-media to docker-compose with port 3015 Images sent to NutriPhi bot are now stored in mana-media after analysis, providing persistent storage with deduplication across all apps. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
171cf7a854
commit
d4663b5643
31 changed files with 2114 additions and 4419 deletions
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MatrixService } from './matrix.service';
|
||||
|
||||
@Module({
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
})
|
||||
export class MatrixModule {}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface MatrixMediaInfo {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for downloading media from Matrix homeservers
|
||||
* Handles MXC URLs like mxc://matrix.mana.how/abc123
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatrixService {
|
||||
private readonly logger = new Logger(MatrixService.name);
|
||||
private readonly homeserverUrl: string;
|
||||
|
||||
constructor(private config: ConfigService) {
|
||||
this.homeserverUrl = this.config.get('MATRIX_HOMESERVER_URL', 'https://matrix.mana.how');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an MXC URL into server and media ID
|
||||
* @param mxcUrl - URL in format mxc://server/media_id
|
||||
*/
|
||||
parseMxcUrl(mxcUrl: string): { server: string; mediaId: string } | null {
|
||||
const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { server: match[1], mediaId: match[2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MXC URL to HTTP download URL
|
||||
*/
|
||||
getDownloadUrl(mxcUrl: string): string | null {
|
||||
const parsed = this.parseMxcUrl(mxcUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the Matrix Content Repository API
|
||||
// Format: /_matrix/media/v3/download/{serverName}/{mediaId}
|
||||
return `${this.homeserverUrl}/_matrix/media/v3/download/${parsed.server}/${parsed.mediaId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media from a Matrix MXC URL
|
||||
*/
|
||||
async downloadFromMxc(mxcUrl: string): Promise<MatrixMediaInfo | null> {
|
||||
const downloadUrl = this.getDownloadUrl(mxcUrl);
|
||||
if (!downloadUrl) {
|
||||
this.logger.error(`Invalid MXC URL: ${mxcUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug(`Downloading from Matrix: ${downloadUrl}`);
|
||||
|
||||
const response = await fetch(downloadUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.error(
|
||||
`Failed to download from Matrix: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
|
||||
// Extract filename from Content-Disposition if available
|
||||
let filename: string | undefined;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(
|
||||
/filename[*]?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i
|
||||
);
|
||||
if (match) {
|
||||
filename = decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
mimeType: contentType,
|
||||
size: buffer.length,
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error downloading from Matrix: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a thumbnail from Matrix
|
||||
* Matrix can generate thumbnails on-the-fly with specified dimensions
|
||||
*/
|
||||
async downloadThumbnailFromMxc(
|
||||
mxcUrl: string,
|
||||
options?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
method?: 'crop' | 'scale';
|
||||
}
|
||||
): Promise<MatrixMediaInfo | null> {
|
||||
const parsed = this.parseMxcUrl(mxcUrl);
|
||||
if (!parsed) {
|
||||
this.logger.error(`Invalid MXC URL: ${mxcUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = options?.width || 320;
|
||||
const height = options?.height || 240;
|
||||
const method = options?.method || 'scale';
|
||||
|
||||
// Use the Matrix thumbnail API
|
||||
// Format: /_matrix/media/v3/thumbnail/{serverName}/{mediaId}?width=X&height=Y&method=crop|scale
|
||||
const thumbnailUrl = `${this.homeserverUrl}/_matrix/media/v3/thumbnail/${parsed.server}/${parsed.mediaId}?width=${width}&height=${height}&method=${method}`;
|
||||
|
||||
try {
|
||||
this.logger.debug(`Downloading thumbnail from Matrix: ${thumbnailUrl}`);
|
||||
|
||||
const response = await fetch(thumbnailUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Failed to get thumbnail from Matrix: ${response.status}, falling back to full download`
|
||||
);
|
||||
return this.downloadFromMxc(mxcUrl);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/png';
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
mimeType: contentType,
|
||||
size: buffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error downloading thumbnail from Matrix: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a valid MXC URL
|
||||
*/
|
||||
isValidMxcUrl(url: string): boolean {
|
||||
return this.parseMxcUrl(url) !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { ProcessService } from './process.service';
|
||||
import { UploadService } from '../upload/upload.service';
|
||||
|
|
@ -12,6 +13,8 @@ interface ProcessJobData {
|
|||
|
||||
@Processor(PROCESS_QUEUE)
|
||||
export class ProcessWorker extends WorkerHost {
|
||||
private readonly logger = new Logger(ProcessWorker.name);
|
||||
|
||||
constructor(
|
||||
private processService: ProcessService,
|
||||
private uploadService: UploadService
|
||||
|
|
@ -22,7 +25,7 @@ export class ProcessWorker extends WorkerHost {
|
|||
async process(job: Job<ProcessJobData>): Promise<void> {
|
||||
const { mediaId, mimeType, originalKey } = job.data;
|
||||
|
||||
console.log(`Processing media ${mediaId} (${mimeType})`);
|
||||
this.logger.log(`Processing media ${mediaId} (${mimeType})`);
|
||||
|
||||
try {
|
||||
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
|
||||
|
|
@ -32,7 +35,7 @@ export class ProcessWorker extends WorkerHost {
|
|||
await this.uploadService.update(mediaId, { status: 'ready' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to process media ${mediaId}:`, error);
|
||||
this.logger.error(`Failed to process media ${mediaId}:`, error);
|
||||
await this.uploadService.update(mediaId, { status: 'failed' });
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -47,16 +50,16 @@ export class ProcessWorker extends WorkerHost {
|
|||
|
||||
await this.uploadService.update(mediaId, {
|
||||
status: 'ready',
|
||||
keys: {
|
||||
original: originalKey,
|
||||
thumbnail: result.thumbnail,
|
||||
medium: result.medium,
|
||||
large: result.large,
|
||||
},
|
||||
metadata: result.metadata,
|
||||
thumbnailKey: result.thumbnail,
|
||||
mediumKey: result.medium,
|
||||
largeKey: result.large,
|
||||
width: result.metadata?.width,
|
||||
height: result.metadata?.height,
|
||||
format: result.metadata?.format,
|
||||
hasAlpha: result.metadata?.hasAlpha,
|
||||
});
|
||||
|
||||
console.log(
|
||||
this.logger.log(
|
||||
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ import { UploadService, MediaRecord } from './upload.service';
|
|||
interface UploadResponse {
|
||||
id: string;
|
||||
status: MediaRecord['status'];
|
||||
originalName: string;
|
||||
originalName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
urls: {
|
||||
original: string;
|
||||
thumbnail?: string;
|
||||
|
|
@ -29,6 +30,13 @@ interface UploadResponse {
|
|||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ImportFromMatrixDto {
|
||||
mxcUrl: string;
|
||||
app: string;
|
||||
userId: string;
|
||||
skipProcessing?: boolean;
|
||||
}
|
||||
|
||||
@Controller('media')
|
||||
export class UploadController {
|
||||
constructor(private uploadService: UploadService) {}
|
||||
|
|
@ -60,6 +68,37 @@ export class UploadController {
|
|||
return this.toResponse(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import media from a Matrix MXC URL
|
||||
* Copies the file from Matrix to our storage with deduplication
|
||||
*/
|
||||
@Post('import/matrix')
|
||||
async importFromMatrix(@Body() dto: ImportFromMatrixDto): Promise<UploadResponse> {
|
||||
if (!dto.mxcUrl) {
|
||||
throw new BadRequestException('mxcUrl is required');
|
||||
}
|
||||
if (!dto.app) {
|
||||
throw new BadRequestException('app is required');
|
||||
}
|
||||
if (!dto.userId) {
|
||||
throw new BadRequestException('userId is required');
|
||||
}
|
||||
|
||||
const record = await this.uploadService.importFromMatrix(dto.mxcUrl, {
|
||||
app: dto.app,
|
||||
userId: dto.userId,
|
||||
skipProcessing: dto.skipProcessing,
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new BadRequestException(
|
||||
'Failed to import from Matrix. Invalid MXC URL or download failed.'
|
||||
);
|
||||
}
|
||||
|
||||
return this.toResponse(record);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async get(@Param('id') id: string): Promise<UploadResponse> {
|
||||
const record = await this.uploadService.get(id);
|
||||
|
|
@ -69,6 +108,19 @@ export class UploadController {
|
|||
return this.toResponse(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media by content hash (SHA-256)
|
||||
* Useful for checking if a file already exists before uploading
|
||||
*/
|
||||
@Get('hash/:hash')
|
||||
async getByHash(@Param('hash') hash: string): Promise<UploadResponse> {
|
||||
const record = await this.uploadService.getByHash(hash);
|
||||
if (!record) {
|
||||
throw new NotFoundException('Media not found');
|
||||
}
|
||||
return this.toResponse(record);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async list(
|
||||
@Query('app') app?: string,
|
||||
|
|
@ -93,7 +145,7 @@ export class UploadController {
|
|||
}
|
||||
|
||||
private toResponse(record: MediaRecord): UploadResponse {
|
||||
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3050/api/v1';
|
||||
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1';
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
|
|
@ -101,6 +153,7 @@ export class UploadController {
|
|||
originalName: record.originalName,
|
||||
mimeType: record.mimeType,
|
||||
size: record.size,
|
||||
hash: record.hash,
|
||||
urls: {
|
||||
original: `${baseUrl}/media/${record.id}/file`,
|
||||
thumbnail: record.keys.thumbnail ? `${baseUrl}/media/${record.id}/file/thumb` : undefined,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
import { MatrixModule } from '../matrix/matrix.module';
|
||||
import { PROCESS_QUEUE } from '../process/process.constants';
|
||||
|
||||
@Module({
|
||||
|
|
@ -9,6 +10,7 @@ import { PROCESS_QUEUE } from '../process/process.constants';
|
|||
BullModule.registerQueue({
|
||||
name: PROCESS_QUEUE,
|
||||
}),
|
||||
MatrixModule,
|
||||
],
|
||||
controllers: [UploadController],
|
||||
providers: [UploadService],
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as mime from 'mime-types';
|
||||
import * as crypto from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { MatrixService } from '../matrix/matrix.service';
|
||||
import { PROCESS_QUEUE } from '../process/process.constants';
|
||||
import { DATABASE_CONNECTION } from '../../db/database.module';
|
||||
import type { Database } from '../../db/connection';
|
||||
import {
|
||||
media,
|
||||
mediaReferences,
|
||||
type Media,
|
||||
type NewMedia,
|
||||
type NewMediaReference,
|
||||
} from '../../db/schema';
|
||||
|
||||
export interface MediaRecord {
|
||||
id: string;
|
||||
originalName: string;
|
||||
originalName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
|
|
@ -22,19 +32,23 @@ export interface MediaRecord {
|
|||
medium?: string;
|
||||
large?: string;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
metadata?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
format?: string;
|
||||
hasAlpha?: boolean;
|
||||
};
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// In-memory store for MVP (replace with DB later)
|
||||
const mediaStore = new Map<string, MediaRecord>();
|
||||
|
||||
@Injectable()
|
||||
export class UploadService {
|
||||
constructor(
|
||||
private storage: StorageService,
|
||||
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue
|
||||
private matrixService: MatrixService,
|
||||
@InjectQueue(PROCESS_QUEUE) private processQueue: Queue,
|
||||
@Inject(DATABASE_CONNECTION) private db: Database
|
||||
) {}
|
||||
|
||||
async upload(
|
||||
|
|
@ -45,113 +59,271 @@ export class UploadService {
|
|||
skipProcessing?: boolean;
|
||||
}
|
||||
): Promise<MediaRecord> {
|
||||
const id = uuid();
|
||||
const ext = mime.extension(file.mimetype) || 'bin';
|
||||
const hash = this.computeHash(file.buffer);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = this.findByHash(hash);
|
||||
// Check for existing media with same content hash
|
||||
const existing = await this.findByHash(hash);
|
||||
if (existing) {
|
||||
return existing;
|
||||
// If userId and app provided, create a reference
|
||||
if (options?.userId && options?.app) {
|
||||
await this.createReference(existing.id, options.userId, options.app);
|
||||
}
|
||||
return this.toMediaRecord(existing);
|
||||
}
|
||||
|
||||
// Generate storage keys
|
||||
// Generate storage key
|
||||
const ext = mime.extension(file.mimetype) || 'bin';
|
||||
const date = new Date();
|
||||
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
|
||||
const id = crypto.randomUUID();
|
||||
const originalKey = `originals/${datePath}/${id}.${ext}`;
|
||||
|
||||
// Upload original
|
||||
// Upload to storage
|
||||
await this.storage.upload(originalKey, file.buffer, file.mimetype, {
|
||||
'x-amz-meta-original-name': file.originalname,
|
||||
'x-amz-meta-media-id': id,
|
||||
});
|
||||
|
||||
// Create record
|
||||
const record: MediaRecord = {
|
||||
id,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
hash,
|
||||
status: options?.skipProcessing ? 'ready' : 'processing',
|
||||
app: options?.app,
|
||||
userId: options?.userId,
|
||||
keys: {
|
||||
original: originalKey,
|
||||
},
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
};
|
||||
// Insert into database
|
||||
const [inserted] = await this.db
|
||||
.insert(media)
|
||||
.values({
|
||||
id,
|
||||
contentHash: hash,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
originalKey,
|
||||
status: options?.skipProcessing ? 'ready' : 'processing',
|
||||
} satisfies NewMedia)
|
||||
.returning();
|
||||
|
||||
mediaStore.set(id, record);
|
||||
// Create reference if user provided
|
||||
if (options?.userId && options?.app) {
|
||||
await this.createReference(inserted.id, options.userId, options.app);
|
||||
}
|
||||
|
||||
// Queue processing job
|
||||
if (!options?.skipProcessing) {
|
||||
await this.processQueue.add('process-media', {
|
||||
mediaId: id,
|
||||
mediaId: inserted.id,
|
||||
mimeType: file.mimetype,
|
||||
originalKey,
|
||||
});
|
||||
}
|
||||
|
||||
return record;
|
||||
return this.toMediaRecord(inserted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import media from a Matrix MXC URL
|
||||
*/
|
||||
async importFromMatrix(
|
||||
mxcUrl: string,
|
||||
options: {
|
||||
app: string;
|
||||
userId: string;
|
||||
skipProcessing?: boolean;
|
||||
}
|
||||
): Promise<MediaRecord | null> {
|
||||
// Download from Matrix
|
||||
const matrixMedia = await this.matrixService.downloadFromMxc(mxcUrl);
|
||||
if (!matrixMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = this.computeHash(matrixMedia.buffer);
|
||||
|
||||
// Check for existing media
|
||||
const existing = await this.findByHash(hash);
|
||||
if (existing) {
|
||||
// Create reference with source URL
|
||||
await this.createReference(existing.id, options.userId, options.app, mxcUrl);
|
||||
return this.toMediaRecord(existing);
|
||||
}
|
||||
|
||||
// Generate storage key
|
||||
const ext = mime.extension(matrixMedia.mimeType) || 'bin';
|
||||
const date = new Date();
|
||||
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
|
||||
const id = crypto.randomUUID();
|
||||
const originalKey = `originals/${datePath}/${id}.${ext}`;
|
||||
|
||||
// Upload to storage
|
||||
await this.storage.upload(originalKey, matrixMedia.buffer, matrixMedia.mimeType, {
|
||||
'x-amz-meta-source': 'matrix',
|
||||
'x-amz-meta-source-url': mxcUrl,
|
||||
'x-amz-meta-media-id': id,
|
||||
});
|
||||
|
||||
// Insert into database
|
||||
const [inserted] = await this.db
|
||||
.insert(media)
|
||||
.values({
|
||||
id,
|
||||
contentHash: hash,
|
||||
originalName: matrixMedia.filename || null,
|
||||
mimeType: matrixMedia.mimeType,
|
||||
size: matrixMedia.size,
|
||||
originalKey,
|
||||
status: options?.skipProcessing ? 'ready' : 'processing',
|
||||
} satisfies NewMedia)
|
||||
.returning();
|
||||
|
||||
// Create reference with source URL
|
||||
await this.createReference(inserted.id, options.userId, options.app, mxcUrl);
|
||||
|
||||
// Queue processing job
|
||||
if (!options?.skipProcessing) {
|
||||
await this.processQueue.add('process-media', {
|
||||
mediaId: inserted.id,
|
||||
mimeType: matrixMedia.mimeType,
|
||||
originalKey,
|
||||
});
|
||||
}
|
||||
|
||||
return this.toMediaRecord(inserted);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<MediaRecord | null> {
|
||||
return mediaStore.get(id) || null;
|
||||
const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
|
||||
return result ? this.toMediaRecord(result) : null;
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<MediaRecord>): Promise<MediaRecord | null> {
|
||||
const record = mediaStore.get(id);
|
||||
if (!record) return null;
|
||||
async getByHash(hash: string): Promise<MediaRecord | null> {
|
||||
const result = await this.findByHash(hash);
|
||||
return result ? this.toMediaRecord(result) : null;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...record,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mediaStore.set(id, updated);
|
||||
return updated;
|
||||
async update(
|
||||
id: string,
|
||||
updates: Partial<
|
||||
Pick<
|
||||
Media,
|
||||
| 'status'
|
||||
| 'thumbnailKey'
|
||||
| 'mediumKey'
|
||||
| 'largeKey'
|
||||
| 'width'
|
||||
| 'height'
|
||||
| 'format'
|
||||
| 'hasAlpha'
|
||||
>
|
||||
>
|
||||
): Promise<MediaRecord | null> {
|
||||
const [updated] = await this.db
|
||||
.update(media)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(media.id, id))
|
||||
.returning();
|
||||
|
||||
return updated ? this.toMediaRecord(updated) : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const record = mediaStore.get(id);
|
||||
const [record] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
|
||||
if (!record) return false;
|
||||
|
||||
// Delete all associated files
|
||||
const keys = Object.values(record.keys).filter(Boolean) as string[];
|
||||
// Delete all associated storage files
|
||||
const keys = [
|
||||
record.originalKey,
|
||||
record.thumbnailKey,
|
||||
record.mediumKey,
|
||||
record.largeKey,
|
||||
].filter(Boolean) as string[];
|
||||
for (const key of keys) {
|
||||
await this.storage.delete(key).catch(() => {});
|
||||
}
|
||||
|
||||
mediaStore.delete(id);
|
||||
// Delete from database (references will cascade)
|
||||
await this.db.delete(media).where(eq(media.id, id));
|
||||
return true;
|
||||
}
|
||||
|
||||
async list(options?: { app?: string; userId?: string; limit?: number }): Promise<MediaRecord[]> {
|
||||
let records = Array.from(mediaStore.values());
|
||||
// If filtering by user/app, we need to join with references
|
||||
if (options?.userId || options?.app) {
|
||||
const query = this.db
|
||||
.select({ media: media })
|
||||
.from(media)
|
||||
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId));
|
||||
|
||||
if (options?.app) {
|
||||
records = records.filter((r) => r.app === options.app);
|
||||
}
|
||||
if (options?.userId) {
|
||||
records = records.filter((r) => r.userId === options.userId);
|
||||
// Build conditions
|
||||
const conditions = [];
|
||||
if (options.userId) {
|
||||
conditions.push(eq(mediaReferences.userId, options.userId));
|
||||
}
|
||||
if (options.app) {
|
||||
conditions.push(eq(mediaReferences.app, options.app));
|
||||
}
|
||||
|
||||
const results = await query
|
||||
.where(conditions.length === 1 ? conditions[0] : undefined)
|
||||
.limit(options.limit || 50);
|
||||
|
||||
return results.map((r) => this.toMediaRecord(r.media));
|
||||
}
|
||||
|
||||
records.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
// Simple list without filtering
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(media)
|
||||
.orderBy(media.createdAt)
|
||||
.limit(options?.limit || 50);
|
||||
|
||||
if (options?.limit) {
|
||||
records = records.slice(0, options.limit);
|
||||
}
|
||||
return results.map((r) => this.toMediaRecord(r));
|
||||
}
|
||||
|
||||
return records;
|
||||
private async findByHash(hash: string): Promise<Media | null> {
|
||||
const [result] = await this.db.select().from(media).where(eq(media.contentHash, hash)).limit(1);
|
||||
return result || null;
|
||||
}
|
||||
|
||||
private async createReference(
|
||||
mediaId: string,
|
||||
userId: string,
|
||||
app: string,
|
||||
sourceUrl?: string
|
||||
): Promise<void> {
|
||||
await this.db.insert(mediaReferences).values({
|
||||
mediaId,
|
||||
userId,
|
||||
app,
|
||||
sourceUrl: sourceUrl || null,
|
||||
} satisfies NewMediaReference);
|
||||
}
|
||||
|
||||
private computeHash(buffer: Buffer): string {
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
private findByHash(hash: string): MediaRecord | undefined {
|
||||
return Array.from(mediaStore.values()).find((r) => r.hash === hash);
|
||||
private toMediaRecord(m: Media): MediaRecord {
|
||||
return {
|
||||
id: m.id,
|
||||
originalName: m.originalName,
|
||||
mimeType: m.mimeType,
|
||||
size: Number(m.size),
|
||||
hash: m.contentHash,
|
||||
status: m.status as MediaRecord['status'],
|
||||
keys: {
|
||||
original: m.originalKey,
|
||||
thumbnail: m.thumbnailKey || undefined,
|
||||
medium: m.mediumKey || undefined,
|
||||
large: m.largeKey || undefined,
|
||||
},
|
||||
metadata: m.width
|
||||
? {
|
||||
width: m.width || undefined,
|
||||
height: m.height || undefined,
|
||||
format: m.format || undefined,
|
||||
hasAlpha: m.hasAlpha || undefined,
|
||||
}
|
||||
: undefined,
|
||||
createdAt: m.createdAt,
|
||||
updatedAt: m.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue