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:
Till-JS 2026-02-02 17:30:14 +01:00
parent 171cf7a854
commit d4663b5643
31 changed files with 2114 additions and 4419 deletions

View file

@ -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,

View file

@ -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],

View file

@ -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,
};
}
}