mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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
|
|
@ -99,6 +99,7 @@ services:
|
|||
BASE_URL: https://auth.mana.how
|
||||
# Cross-domain SSO: share session cookies across all *.mana.how subdomains
|
||||
COOKIE_DOMAIN: .mana.how
|
||||
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
SMTP_HOST: smtp-relay.brevo.com
|
||||
SMTP_PORT: 587
|
||||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
|
|
@ -746,15 +747,21 @@ services:
|
|||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
mana-media:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 4016
|
||||
TZ: Europe/Berlin
|
||||
REDIS_HOST: redis
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123}
|
||||
MANA_CORE_SERVICE_KEY: ${MANA_CORE_SERVICE_KEY}
|
||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_NUTRIPHI_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_NUTRIPHI_BOT_ROOMS:-}
|
||||
NUTRIPHI_BACKEND_URL: http://nutriphi-backend:3037
|
||||
MANA_CORE_AUTH_URL: http://mana-auth:3001
|
||||
MANA_MEDIA_URL: http://mana-media:3015
|
||||
volumes:
|
||||
- matrix_bots_data:/app/data
|
||||
ports:
|
||||
|
|
|
|||
1670
pnpm-lock.yaml
generated
1670
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,12 @@ packages:
|
|||
# Standalone microservices
|
||||
- 'services/*'
|
||||
|
||||
# Sub-apps within services (for services with nested structure like mana-media)
|
||||
- 'services/*/apps/*'
|
||||
|
||||
# Service-specific packages
|
||||
- 'services/*/packages/*'
|
||||
|
||||
# Monorepo-wide shared packages
|
||||
- 'packages/*'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,46 @@
|
|||
# mana-media - Unified Media Platform
|
||||
|
||||
Central media handling service for all ManaCore applications.
|
||||
Central media handling service for all ManaCore applications with content-addressable storage (CAS) and automatic deduplication.
|
||||
|
||||
## Overview
|
||||
|
||||
mana-media provides:
|
||||
- **Upload API** - Chunked uploads, deduplication
|
||||
- **Processing** - Thumbnails, WebP conversion, resizing
|
||||
- **Content-Addressable Storage** - SHA-256 based deduplication across all apps
|
||||
- **Upload API** - File uploads with automatic deduplication
|
||||
- **Matrix Import** - Copy images from Matrix MXC URLs to persistent storage
|
||||
- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ)
|
||||
- **Delivery** - Optimized file serving, on-the-fly transforms
|
||||
- **Search** (planned) - Vector-based semantic search
|
||||
|
||||
**Port:** 3015 (production)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start dependencies (Redis + MinIO)
|
||||
docker compose up redis minio -d
|
||||
# Start dependencies (Redis + MinIO + PostgreSQL)
|
||||
pnpm docker:up
|
||||
|
||||
# Create database
|
||||
PGPASSWORD=devpassword psql -h localhost -U manacore -d postgres -c "CREATE DATABASE mana_media;"
|
||||
|
||||
# Install dependencies
|
||||
cd services/mana-media/apps/api
|
||||
pnpm install
|
||||
|
||||
# Push schema
|
||||
pnpm db:push
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Service runs on `http://localhost:3050`
|
||||
Service runs on `http://localhost:3015`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Upload
|
||||
```bash
|
||||
# Upload file
|
||||
curl -X POST http://localhost:3050/api/v1/media/upload \
|
||||
curl -X POST http://localhost:3015/api/v1/media/upload \
|
||||
-F "file=@image.jpg" \
|
||||
-F "app=chat" \
|
||||
-F "userId=user123"
|
||||
|
|
@ -39,18 +49,34 @@ curl -X POST http://localhost:3050/api/v1/media/upload \
|
|||
{
|
||||
"id": "abc123",
|
||||
"status": "processing",
|
||||
"hash": "sha256...",
|
||||
"urls": {
|
||||
"original": "http://localhost:3050/api/v1/media/abc123/file",
|
||||
"thumbnail": "http://localhost:3050/api/v1/media/abc123/file/thumb"
|
||||
"original": "http://localhost:3015/api/v1/media/abc123/file",
|
||||
"thumbnail": "http://localhost:3015/api/v1/media/abc123/file/thumb"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Import from Matrix
|
||||
```bash
|
||||
# Import media from Matrix MXC URL
|
||||
curl -X POST http://localhost:3015/api/v1/media/import/matrix \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"mxcUrl": "mxc://matrix.mana.how/abc123",
|
||||
"app": "nutriphi",
|
||||
"userId": "user-uuid"
|
||||
}'
|
||||
```
|
||||
|
||||
### Get Media
|
||||
```bash
|
||||
# Get metadata
|
||||
GET /api/v1/media/:id
|
||||
|
||||
# Get by content hash (check if file exists)
|
||||
GET /api/v1/media/hash/:sha256hash
|
||||
|
||||
# Get original file
|
||||
GET /api/v1/media/:id/file
|
||||
|
||||
|
|
@ -66,8 +92,8 @@ GET /api/v1/media/:id/transform?w=400&h=300&fit=cover&format=webp
|
|||
|
||||
### List & Delete
|
||||
```bash
|
||||
# List media
|
||||
GET /api/v1/media?app=chat&limit=50
|
||||
# List media (filter by app/user)
|
||||
GET /api/v1/media?app=chat&userId=user123&limit=50
|
||||
|
||||
# Delete
|
||||
DELETE /api/v1/media/:id
|
||||
|
|
@ -97,22 +123,48 @@ const customUrl = media.getTransformUrl(result.id, {
|
|||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ mana-media │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Upload Module │ Handles file uploads │
|
||||
│ Process Module │ Sharp/FFmpeg processing │
|
||||
│ Storage Module │ MinIO abstraction │
|
||||
│ Delivery Module │ File serving + transforms │
|
||||
└─────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────┐ ┌─────────┐
|
||||
│ Redis │ │ MinIO │
|
||||
│ Queue │ │ Storage │
|
||||
└─────────┘ └─────────┘
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ mana-media (Port 3015) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Upload Module │ File uploads, Matrix import, dedup │
|
||||
│ Matrix Module │ Download from Matrix MXC URLs │
|
||||
│ Process Module │ Sharp thumbnail generation (BullMQ) │
|
||||
│ Storage Module │ MinIO S3 abstraction │
|
||||
│ Delivery Module │ File serving + on-the-fly transforms │
|
||||
│ Database Module │ PostgreSQL + Drizzle ORM │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌────────────┐
|
||||
│ Redis │ │ MinIO │ │ PostgreSQL │
|
||||
│ BullMQ │ │ Storage │ │ mana_media │
|
||||
└─────────┘ └─────────┘ └────────────┘
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### media (Content-Addressable Storage)
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key |
|
||||
| content_hash | TEXT | SHA-256 hash (unique) |
|
||||
| original_name | TEXT | Original filename |
|
||||
| mime_type | TEXT | MIME type |
|
||||
| size | BIGINT | File size in bytes |
|
||||
| original_key | TEXT | S3 storage key |
|
||||
| status | TEXT | uploading/processing/ready/failed |
|
||||
| thumbnail_key | TEXT | Thumbnail S3 key |
|
||||
| width/height | INT | Image dimensions |
|
||||
|
||||
### media_references (User ownership)
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | UUID | Primary key |
|
||||
| media_id | UUID | FK to media |
|
||||
| user_id | UUID | Owner user ID |
|
||||
| app | TEXT | Source app (nutriphi, contacts, etc.) |
|
||||
| source_url | TEXT | Original source (e.g., mxc:// URL) |
|
||||
|
||||
## Processing Pipeline
|
||||
|
||||
| File Type | Generated Variants |
|
||||
|
|
@ -125,14 +177,20 @@ const customUrl = media.getTransformUrl(result.id, {
|
|||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| PORT | 3050 | API port |
|
||||
| PORT | 3015 | API port |
|
||||
| DATABASE_URL | - | PostgreSQL connection string |
|
||||
| REDIS_HOST | localhost | Redis host |
|
||||
| REDIS_PORT | 6379 | Redis port |
|
||||
| REDIS_PASSWORD | - | Redis password (optional) |
|
||||
| S3_ENDPOINT | localhost | MinIO/S3 endpoint |
|
||||
| S3_PORT | 9000 | MinIO/S3 port |
|
||||
| S3_USE_SSL | false | Use HTTPS for S3 |
|
||||
| S3_ACCESS_KEY | minioadmin | S3 access key |
|
||||
| S3_SECRET_KEY | minioadmin | S3 secret key |
|
||||
| S3_BUCKET | mana-media | Storage bucket |
|
||||
| S3_PUBLIC_URL | - | Public URL for media |
|
||||
| MATRIX_HOMESERVER_URL | https://matrix.mana.how | Matrix homeserver |
|
||||
| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL |
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -145,6 +203,10 @@ pnpm type-check
|
|||
|
||||
# Build
|
||||
pnpm build
|
||||
|
||||
# Database commands
|
||||
pnpm db:push # Push schema to database
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Storage Layout
|
||||
|
|
@ -166,8 +228,29 @@ mana-media bucket/
|
|||
## Roadmap
|
||||
|
||||
- [x] v0.1: Basic upload + thumbnails
|
||||
- [ ] v0.2: Video thumbnails (FFmpeg)
|
||||
- [ ] v0.3: Chunked upload for large files
|
||||
- [ ] v0.4: OCR for documents
|
||||
- [ ] v0.5: Vector search (Qdrant)
|
||||
- [x] v0.2: PostgreSQL persistence with Drizzle ORM
|
||||
- [x] v0.3: Content-addressable storage with SHA-256 deduplication
|
||||
- [x] v0.4: Matrix MXC URL import
|
||||
- [ ] v0.5: Video thumbnails (FFmpeg)
|
||||
- [ ] v0.6: Chunked upload for large files
|
||||
- [ ] v0.7: OCR for documents
|
||||
- [ ] v0.8: Vector search (Qdrant)
|
||||
- [ ] v1.0: Full production ready
|
||||
|
||||
## Integration Example (NutriPhi Bot)
|
||||
|
||||
```typescript
|
||||
// In matrix-nutriphi-bot
|
||||
const response = await fetch('http://mana-media:3015/api/v1/media/import/matrix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mxcUrl: 'mxc://matrix.mana.how/abc123',
|
||||
app: 'nutriphi',
|
||||
userId: userUuid,
|
||||
}),
|
||||
});
|
||||
|
||||
const { id, hash, urls } = await response.json();
|
||||
// Store id or hash in meal record for reference
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,42 +1,64 @@
|
|||
FROM node:22-alpine AS builder
|
||||
|
||||
# ================================
|
||||
# Build Stage (Monorepo-aware)
|
||||
# ================================
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/shared-drizzle-config/package.json ./packages/shared-drizzle-config/
|
||||
COPY services/mana-media/package.json ./services/mana-media/
|
||||
COPY services/mana-media/apps/api/package.json ./services/mana-media/apps/api/
|
||||
COPY services/mana-media/packages/client/package.json ./services/mana-media/packages/client/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
# Build shared packages
|
||||
FROM base AS shared-builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/shared-drizzle-config/node_modules ./packages/shared-drizzle-config/node_modules
|
||||
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
|
||||
WORKDIR /app/packages/shared-drizzle-config
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:22-alpine AS runner
|
||||
# Build the application
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/services/mana-media/node_modules ./services/mana-media/node_modules
|
||||
COPY --from=deps /app/services/mana-media/apps/api/node_modules ./services/mana-media/apps/api/node_modules
|
||||
COPY --from=shared-builder /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
|
||||
COPY services/mana-media ./services/mana-media
|
||||
WORKDIR /app/services/mana-media/apps/api
|
||||
RUN pnpm build
|
||||
|
||||
# ================================
|
||||
# Production Stage
|
||||
# ================================
|
||||
FROM node:20-alpine AS runner
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
|
||||
&& apk add --no-cache postgresql-client wget
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nestjs
|
||||
USER nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-media/apps/api/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/shared-drizzle-config ./packages/shared-drizzle-config
|
||||
|
||||
# Copy package files and install production deps only
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
# Expose port
|
||||
EXPOSE 3015
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
USER nestjs
|
||||
|
||||
EXPOSE 3050
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main"]
|
||||
|
|
|
|||
6
services/mana-media/apps/api/drizzle.config.ts
Normal file
6
services/mana-media/apps/api/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'mana_media',
|
||||
schemaPath: './src/db/schema/index.ts',
|
||||
});
|
||||
|
|
@ -8,7 +8,9 @@
|
|||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"type-check": "tsc --noEmit",
|
||||
"lint": "eslint 'src/**/*.ts'"
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^11.0.0",
|
||||
|
|
@ -17,20 +19,25 @@
|
|||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"bullmq": "^5.34.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"express": "^4.21.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"minio": "^8.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"sharp": "^0.33.0",
|
||||
"uuid": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-drizzle-config": "workspace:*",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3602
services/mana-media/apps/api/pnpm-lock.yaml
generated
3602
services/mana-media/apps/api/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { UploadModule } from './modules/upload/upload.module';
|
||||
import { StorageModule } from './modules/storage/storage.module';
|
||||
import { ProcessModule } from './modules/process/process.module';
|
||||
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||
import { MatrixModule } from './modules/matrix/matrix.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
|
|
@ -16,12 +18,15 @@ import { HealthController } from './health.controller';
|
|||
connection: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
},
|
||||
}),
|
||||
DatabaseModule,
|
||||
StorageModule,
|
||||
UploadModule,
|
||||
ProcessModule,
|
||||
DeliveryModule,
|
||||
MatrixModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
|
|
|||
37
services/mana-media/apps/api/src/db/connection.ts
Normal file
37
services/mana-media/apps/api/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
30
services/mana-media/apps/api/src/db/database.module.ts
Normal file
30
services/mana-media/apps/api/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
1
services/mana-media/apps/api/src/db/schema/index.ts
Normal file
1
services/mana-media/apps/api/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './media.schema';
|
||||
149
services/mana-media/apps/api/src/db/schema/media.schema.ts
Normal file
149
services/mana-media/apps/api/src/db/schema/media.schema.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
bigint,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* Core media table - stores unique files by content hash (SHA-256)
|
||||
* This is the Content-Addressable Storage (CAS) approach
|
||||
*/
|
||||
export const media = pgTable(
|
||||
'media',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// Content-addressable: SHA-256 hash of the file content
|
||||
contentHash: text('content_hash').notNull().unique(),
|
||||
// Original filename (for display purposes)
|
||||
originalName: text('original_name'),
|
||||
// MIME type
|
||||
mimeType: text('mime_type').notNull(),
|
||||
// File size in bytes
|
||||
size: bigint('size', { mode: 'number' }).notNull(),
|
||||
// Storage keys
|
||||
originalKey: text('original_key').notNull(),
|
||||
// Processing status
|
||||
status: text('status', { enum: ['uploading', 'processing', 'ready', 'failed'] })
|
||||
.default('uploading')
|
||||
.notNull(),
|
||||
// Image metadata
|
||||
width: integer('width'),
|
||||
height: integer('height'),
|
||||
format: text('format'),
|
||||
hasAlpha: boolean('has_alpha'),
|
||||
// Generated variants
|
||||
thumbnailKey: text('thumbnail_key'),
|
||||
mediumKey: text('medium_key'),
|
||||
largeKey: text('large_key'),
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('media_content_hash_idx').on(table.contentHash),
|
||||
index('media_status_idx').on(table.status),
|
||||
index('media_created_at_idx').on(table.createdAt),
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Media references - tracks which user/app owns a reference to a media item
|
||||
* Multiple users can reference the same media (deduplication)
|
||||
*/
|
||||
export const mediaReferences = pgTable(
|
||||
'media_references',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
// The media being referenced
|
||||
mediaId: uuid('media_id')
|
||||
.references(() => media.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
// Owner info
|
||||
userId: uuid('user_id').notNull(),
|
||||
// Source app (nutriphi, contacts, chat, etc.)
|
||||
app: text('app').notNull(),
|
||||
// Optional: reference to the source (e.g., mxc:// URL from Matrix)
|
||||
sourceUrl: text('source_url'),
|
||||
// Custom metadata per reference
|
||||
metadata: jsonb('metadata'),
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('media_ref_media_id_idx').on(table.mediaId),
|
||||
index('media_ref_user_id_idx').on(table.userId),
|
||||
index('media_ref_app_idx').on(table.app),
|
||||
index('media_ref_user_app_idx').on(table.userId, table.app),
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Lazy-generated thumbnails cache
|
||||
* Stores on-the-fly generated thumbnails with specific parameters
|
||||
*/
|
||||
export const mediaThumbnails = pgTable(
|
||||
'media_thumbnails',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
mediaId: uuid('media_id')
|
||||
.references(() => media.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
// Parameters that define this thumbnail
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
fit: text('fit').default('cover').notNull(),
|
||||
format: text('format').default('webp').notNull(),
|
||||
quality: integer('quality').default(80).notNull(),
|
||||
// Storage key for this specific thumbnail
|
||||
storageKey: text('storage_key').notNull(),
|
||||
// Size of the generated thumbnail
|
||||
size: integer('size').notNull(),
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('media_thumb_media_id_idx').on(table.mediaId),
|
||||
index('media_thumb_params_idx').on(
|
||||
table.mediaId,
|
||||
table.width,
|
||||
table.height,
|
||||
table.fit,
|
||||
table.format
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
// Relations
|
||||
export const mediaRelations = relations(media, ({ many }) => ({
|
||||
references: many(mediaReferences),
|
||||
thumbnails: many(mediaThumbnails),
|
||||
}));
|
||||
|
||||
export const mediaReferencesRelations = relations(mediaReferences, ({ one }) => ({
|
||||
media: one(media, {
|
||||
fields: [mediaReferences.mediaId],
|
||||
references: [media.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const mediaThumbnailsRelations = relations(mediaThumbnails, ({ one }) => ({
|
||||
media: one(media, {
|
||||
fields: [mediaThumbnails.mediaId],
|
||||
references: [media.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Types
|
||||
export type Media = typeof media.$inferSelect;
|
||||
export type NewMedia = typeof media.$inferInsert;
|
||||
export type MediaReference = typeof mediaReferences.$inferSelect;
|
||||
export type NewMediaReference = typeof mediaReferences.$inferInsert;
|
||||
export type MediaThumbnail = typeof mediaThumbnails.$inferSelect;
|
||||
export type NewMediaThumbnail = typeof mediaThumbnails.$inferInsert;
|
||||
|
|
@ -1,16 +1,23 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// Increase body size limit for large file uploads
|
||||
app.use(json({ limit: '100mb' }));
|
||||
app.use(urlencoded({ extended: true, limit: '100mb' }));
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -19,10 +26,11 @@ async function bootstrap() {
|
|||
credentials: true,
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3050;
|
||||
const port = process.env.PORT || 3015;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`mana-media service running on port ${port}`);
|
||||
logger.log(`Mana Media service running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
services/mana-media/drizzle.config.ts
Normal file
3
services/mana-media/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig('./src/db/schema');
|
||||
8
services/mana-media/nest-cli.json
Normal file
8
services/mana-media/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
export interface MediaResult {
|
||||
id: string;
|
||||
status: 'uploading' | 'processing' | 'ready' | 'failed';
|
||||
originalName: string;
|
||||
originalName: string | null;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
hash: string;
|
||||
urls: {
|
||||
original: string;
|
||||
thumbnail?: string;
|
||||
|
|
@ -13,6 +14,13 @@ export interface MediaResult {
|
|||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ImportFromMatrixOptions {
|
||||
mxcUrl: string;
|
||||
app: string;
|
||||
userId: string;
|
||||
skipProcessing?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadOptions {
|
||||
app?: string;
|
||||
userId?: string;
|
||||
|
|
@ -77,6 +85,53 @@ export class MediaClient {
|
|||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import media from a Matrix MXC URL
|
||||
* Copies the file from Matrix to mana-media storage with deduplication
|
||||
*/
|
||||
async importFromMatrix(options: ImportFromMatrixOptions): Promise<MediaResult> {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/media/import/matrix`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mxcUrl: options.mxcUrl,
|
||||
app: options.app,
|
||||
userId: options.userId,
|
||||
skipProcessing: options.skipProcessing,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Import from Matrix failed: ${response.statusText} - ${error}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media by content hash (SHA-256)
|
||||
* Useful for checking if a file already exists before uploading
|
||||
*/
|
||||
async getByHash(hash: string): Promise<MediaResult | null> {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/media/hash/${hash}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Get by hash failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media by ID
|
||||
*/
|
||||
|
|
|
|||
25
services/mana-media/tsconfig.json
Normal file
25
services/mana-media/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ Matrix NutriPhi Bot is a Matrix chat bot for AI-powered nutrition tracking. It i
|
|||
- **Matrix**: matrix-bot-sdk
|
||||
- **Backend**: NutriPhi API (port 3023)
|
||||
- **Auth**: Mana Core Auth (JWT)
|
||||
- **Media Storage**: mana-media (port 3015)
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -41,9 +42,9 @@ services/matrix-nutriphi-bot/
|
|||
│ ├── nutriphi/
|
||||
│ │ ├── nutriphi.module.ts
|
||||
│ │ └── nutriphi.service.ts # NutriPhi API client
|
||||
│ └── session/
|
||||
│ ├── session.module.ts
|
||||
│ └── session.service.ts # User session & auth management
|
||||
│ └── media/
|
||||
│ ├── media.module.ts
|
||||
│ └── media.service.ts # mana-media client for persistent storage
|
||||
├── Dockerfile
|
||||
└── package.json
|
||||
```
|
||||
|
|
@ -67,10 +68,10 @@ services/matrix-nutriphi-bot/
|
|||
## Image Analysis Flow
|
||||
|
||||
1. User sends image to room
|
||||
2. Bot stores image URL: "Bild empfangen!"
|
||||
3. User sends `!analyze` or `!analyze Beschreibung`
|
||||
4. Bot downloads image, sends to NutriPhi API
|
||||
5. Bot displays nutrition results
|
||||
2. Bot acknowledges: "Bild empfangen! Analysiere..."
|
||||
3. Bot downloads image, sends to NutriPhi API for analysis
|
||||
4. Bot displays nutrition results
|
||||
5. (Background) Image is stored in mana-media for persistent storage with deduplication
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
|
@ -91,6 +92,9 @@ NUTRIPHI_API_PREFIX=/api/v1
|
|||
# Mana Core Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Mana Media (optional - for persistent image storage)
|
||||
MANA_MEDIA_URL=http://localhost:3015
|
||||
|
||||
# Development bypass (optional)
|
||||
DEV_BYPASS_AUTH=false
|
||||
DEV_USER_ID=
|
||||
|
|
@ -108,6 +112,7 @@ docker run -p 3315:3315 \
|
|||
-e MATRIX_ACCESS_TOKEN=syt_xxx \
|
||||
-e NUTRIPHI_BACKEND_URL=http://nutriphi-backend:3023 \
|
||||
-e MANA_CORE_AUTH_URL=http://mana-core-auth:3001 \
|
||||
-e MANA_MEDIA_URL=http://mana-media:3015 \
|
||||
-v matrix-nutriphi-bot-data:/app/data \
|
||||
matrix-nutriphi-bot
|
||||
```
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@manacore/bot-services": "workspace:*",
|
||||
"@manacore/matrix-bot-common": "workspace:*",
|
||||
"@manacore/media-client": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { MatrixService } from './matrix.service';
|
||||
import { NutriPhiModule } from '../nutriphi/nutriphi.module';
|
||||
import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-services';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -9,6 +10,7 @@ import { SessionModule, TranscriptionModule, CreditModule } from '@manacore/bot-
|
|||
SessionModule.forRoot({ storageMode: 'redis' }),
|
||||
TranscriptionModule.forRoot(),
|
||||
CreditModule.forRoot(),
|
||||
MediaModule,
|
||||
],
|
||||
providers: [MatrixService],
|
||||
exports: [MatrixService],
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
WeeklyStats,
|
||||
} from '../nutriphi/nutriphi.service';
|
||||
import { SessionService, TranscriptionService, CreditService } from '@manacore/bot-services';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { HELP_MESSAGE, MEAL_TYPE_LABELS } from '../config/configuration';
|
||||
|
||||
const PHOTO_ANALYSIS_CREDITS = 3;
|
||||
|
|
@ -36,7 +37,8 @@ export class MatrixService extends BaseMatrixService {
|
|||
private nutriphiService: NutriPhiService,
|
||||
private sessionService: SessionService,
|
||||
private transcriptionService: TranscriptionService,
|
||||
private creditService: CreditService
|
||||
private creditService: CreditService,
|
||||
private mediaService: MediaService
|
||||
) {
|
||||
super(configService);
|
||||
}
|
||||
|
|
@ -114,6 +116,19 @@ Sag "hilfe" fur alle Befehle!`;
|
|||
|
||||
const response = this.formatAnalysisResult(result);
|
||||
await this.sendMessage(roomId, response);
|
||||
|
||||
// Store image in mana-media for persistent storage (non-blocking)
|
||||
// Use Matrix sender ID as user identifier
|
||||
this.mediaService
|
||||
.storeFromMatrix(mxcUrl, sender)
|
||||
.then((mediaResult) => {
|
||||
if (mediaResult) {
|
||||
this.logger.log(`Image stored in mana-media: ${mediaResult.id}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.warn(`Failed to store image in mana-media: ${error}`);
|
||||
});
|
||||
} catch (error) {
|
||||
await this.client.setTyping(roomId, false);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export default () => ({
|
|||
stt: {
|
||||
url: process.env.STT_URL || 'http://localhost:3020',
|
||||
},
|
||||
media: {
|
||||
url: process.env.MANA_MEDIA_URL || 'http://localhost:3015',
|
||||
},
|
||||
});
|
||||
|
||||
export const HELP_MESSAGE = `**NutriPhi Bot - KI-Ernahrungsassistent**
|
||||
|
|
|
|||
8
services/matrix-nutriphi-bot/src/media/media.module.ts
Normal file
8
services/matrix-nutriphi-bot/src/media/media.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Module({
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
83
services/matrix-nutriphi-bot/src/media/media.service.ts
Normal file
83
services/matrix-nutriphi-bot/src/media/media.service.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MediaClient, MediaResult } from '@manacore/media-client';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
private client: MediaClient | null = null;
|
||||
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const mediaUrl = this.configService.get<string>('media.url');
|
||||
if (mediaUrl) {
|
||||
this.client = new MediaClient(mediaUrl);
|
||||
this.logger.log(`MediaClient initialized with URL: ${mediaUrl}`);
|
||||
} else {
|
||||
this.logger.warn('MANA_MEDIA_URL not configured, media storage disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an image from a Matrix MXC URL in mana-media.
|
||||
* Returns the media record if successful, null if disabled or failed.
|
||||
*/
|
||||
async storeFromMatrix(mxcUrl: string, userId: string): Promise<MediaResult | null> {
|
||||
if (!this.client) {
|
||||
this.logger.debug('Media storage disabled, skipping storage');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.client.importFromMatrix({
|
||||
mxcUrl,
|
||||
app: 'nutriphi',
|
||||
userId,
|
||||
});
|
||||
|
||||
this.logger.log(`Stored media from Matrix: ${result.id} (hash: ${result.hash})`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to store media from Matrix: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file already exists by hash
|
||||
*/
|
||||
async checkExists(hash: string): Promise<MediaResult | null> {
|
||||
if (!this.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.getByHash(hash);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media by ID
|
||||
*/
|
||||
async get(id: string): Promise<MediaResult | null> {
|
||||
if (!this.client) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.get(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if media service is available
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return this.client !== null;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue