chore(cutover): remove services/mana-media/ — moved to mana-platform

Live containers on the Mac Mini build out of `../mana/services/mana-media/`
since the 8-Doppel-Cutover commit (774852ba2). Smoke test green
2026-05-08 — health endpoints, JWKS, login flow, Stripe-webhook all
reachable from the new build path. Removing the now-stale duplicate.

Was  17M in this repo, gone now. Active code lives in
`Code/mana/services/mana-media/` (siehe ../mana/CLAUDE.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 18:53:55 +02:00
parent af8ef60fe4
commit fcc36eadcb
31 changed files with 0 additions and 2771 deletions

View file

@ -1,22 +0,0 @@
# Server
PORT=3050
NODE_ENV=development
# Redis (for job queue)
REDIS_HOST=localhost
REDIS_PORT=6379
# MinIO / S3
S3_ENDPOINT=localhost
S3_PORT=9000
S3_USE_SSL=false
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=mana-media
S3_PUBLIC_URL=http://localhost:9000/mana-media
# Public URL for generated links
PUBLIC_URL=http://localhost:3050/api/v1
# CORS
CORS_ORIGINS=http://localhost:3000,http://localhost:5173

View file

@ -1,222 +0,0 @@
# mana-media - Unified Media Platform
Central media handling service for all Mana applications with content-addressable storage (CAS) and automatic deduplication.
**Stack:** Hono + Bun (migrated from NestJS 2026-03-28)
## Overview
mana-media provides:
- **Content-Addressable Storage** - SHA-256 based deduplication across all apps
- **Upload API** - File uploads with automatic deduplication
- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ)
- **Delivery** - Optimized file serving, on-the-fly transforms
**Port:** 3015 (production)
## Quick Start
```bash
# Start dependencies (Redis + MinIO + PostgreSQL)
pnpm docker:up
# Create database
PGPASSWORD=devpassword psql -h localhost -U mana -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:3015`
## API Endpoints
### Upload
```bash
# Upload file
curl -X POST http://localhost:3015/api/v1/media/upload \
-F "file=@image.jpg" \
-F "app=chat" \
-F "userId=user123"
# Response
{
"id": "abc123",
"status": "processing",
"hash": "sha256...",
"urls": {
"original": "http://localhost:3015/api/v1/media/abc123/file",
"thumbnail": "http://localhost:3015/api/v1/media/abc123/file/thumb"
}
}
```
### 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
# Get thumbnail
GET /api/v1/media/:id/file/thumb
# Get medium variant
GET /api/v1/media/:id/file/medium
# On-the-fly transform
GET /api/v1/media/:id/transform?w=400&h=300&fit=cover&format=webp
```
### List & Delete
```bash
# List media (filter by app/user)
GET /api/v1/media?app=chat&userId=user123&limit=50
# Delete
DELETE /api/v1/media/:id
```
## Client Library
```typescript
import { MediaClient } from '@mana/media-client';
const media = new MediaClient('http://localhost:3050');
// Upload
const result = await media.upload(file, { app: 'chat' });
// Wait for processing
const ready = await media.waitForReady(result.id);
// Get URLs
const thumbUrl = media.getThumbnailUrl(result.id);
const customUrl = media.getTransformUrl(result.id, {
width: 400,
format: 'webp'
});
```
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ mana-media (Port 3015) │
├─────────────────────────────────────────────────────────────┤
│ Upload Module │ File uploads, dedup │
│ 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 (food, contacts, etc.) |
| source_url | TEXT | Original source URL |
## Processing Pipeline
| File Type | Generated Variants |
|-----------|-------------------|
| Images | thumb (200x200), medium (800x800), large (1920x1920) |
| Videos | (planned) thumbnail, HLS streaming |
| Documents | (planned) thumbnail, text extraction |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| 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 |
| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL |
## Development
```bash
# Run with watch mode (Bun)
pnpm dev
# Type check
pnpm type-check
# Database commands
cd apps/api
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Storage Layout
```
mana-media bucket/
├── originals/ # Original uploads
│ └── 2026/02/01/
│ └── {id}.{ext}
├── processed/ # Generated variants
│ └── {id}/
│ ├── thumb.webp
│ ├── medium.webp
│ └── large.webp
└── cache/ # Transform cache
└── {id}_{params}.webp
```
## Roadmap
- [x] v0.1: Basic upload + thumbnails
- [x] v0.2: PostgreSQL persistence with Drizzle ORM
- [x] v0.3: Content-addressable storage with SHA-256 deduplication
- [ ] 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

View file

@ -1,18 +0,0 @@
FROM oven/bun:1 AS production
WORKDIR /app
COPY package.json bun.lock* ./
COPY src ./src
# Remove workspace devDependencies that can't resolve in Docker, then install
RUN sed -i '/"@mana\/shared-drizzle-config"/d' package.json && \
bun install --production --frozen-lockfile 2>/dev/null || bun install --production
EXPOSE 3015
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3015/health || exit 1
USER bun
CMD ["bun", "run", "src/index.ts"]

View file

@ -1,7 +0,0 @@
import { createDrizzleConfig } from '@mana/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'mana_platform',
schemaPath: './src/db/schema/index.ts',
schemaFilter: ['media'],
});

View file

@ -1,34 +0,0 @@
{
"name": "@mana-media/api",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun run src/db/migrate.ts",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"bullmq": "^5.34.0",
"drizzle-orm": "^0.38.3",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"hono": "^4.7.0",
"mime-types": "^2.1.35",
"minio": "^8.0.0",
"postgres": "^3.4.5",
"prom-client": "^15.1.0",
"sharp": "^0.33.0"
},
"devDependencies": {
"@mana/shared-drizzle-config": "workspace:*",
"@types/heic-convert": "^2.1.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.0.0",
"drizzle-kit": "^0.30.1",
"typescript": "^5.7.0"
}
}

View file

@ -1,17 +0,0 @@
export const PROCESS_QUEUE = 'media-process';
export const IMAGE_VARIANTS = {
thumbnail: { width: 200, height: 200, fit: 'cover' as const },
medium: { width: 800, height: 800, fit: 'inside' as const },
large: { width: 1920, height: 1920, fit: 'inside' as const },
};
export const SUPPORTED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/avif',
'image/heic',
'image/heif',
];

View file

@ -1,28 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './db/schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
db = drizzle(connection, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -1,46 +0,0 @@
/**
* Drizzle migration runner.
*
* Run on every container startup (and as `pnpm db:migrate` for local use)
* so that fresh deployments end up with the `media` schema and tables
* without any manual SQL. Drizzle's migrator tracks applied migrations
* in `drizzle.__drizzle_migrations`, so re-runs are no-ops.
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
export async function runMigrations(databaseUrl: string): Promise<void> {
// Migrations live next to this file at runtime, regardless of cwd.
const here = dirname(fileURLToPath(import.meta.url));
const migrationsFolder = resolve(here, 'migrations');
// `max: 1` is required by drizzle's migrator.
const sql = postgres(databaseUrl, { max: 1 });
try {
const db = drizzle(sql);
await migrate(db, { migrationsFolder });
} finally {
await sql.end({ timeout: 5 });
}
}
// Allow `bun run src/db/migrate.ts` for manual / CI use.
if (import.meta.main) {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL is required');
process.exit(1);
}
runMigrations(databaseUrl)
.then(() => {
console.log('Migrations applied');
process.exit(0);
})
.catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}

View file

@ -1,68 +0,0 @@
CREATE SCHEMA "media";
--> statement-breakpoint
CREATE TABLE "media"."media" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"content_hash" text NOT NULL,
"original_name" text,
"mime_type" text NOT NULL,
"size" bigint NOT NULL,
"original_key" text NOT NULL,
"status" text DEFAULT 'uploading' NOT NULL,
"width" integer,
"height" integer,
"format" text,
"has_alpha" boolean,
"exif_data" jsonb,
"date_taken" timestamp with time zone,
"camera_make" text,
"camera_model" text,
"focal_length" text,
"aperture" text,
"iso" integer,
"exposure_time" text,
"gps_latitude" text,
"gps_longitude" text,
"thumbnail_key" text,
"medium_key" text,
"large_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "media_content_hash_unique" UNIQUE("content_hash")
);
--> statement-breakpoint
CREATE TABLE "media"."media_references" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_id" uuid NOT NULL,
"user_id" text NOT NULL,
"app" text NOT NULL,
"source_url" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "media"."media_thumbnails" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"media_id" uuid NOT NULL,
"width" integer NOT NULL,
"height" integer NOT NULL,
"fit" text DEFAULT 'cover' NOT NULL,
"format" text DEFAULT 'webp' NOT NULL,
"quality" integer DEFAULT 80 NOT NULL,
"storage_key" text NOT NULL,
"size" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "media"."media_references" ADD CONSTRAINT "media_references_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "media"."media_thumbnails" ADD CONSTRAINT "media_thumbnails_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "media_content_hash_idx" ON "media"."media" USING btree ("content_hash");--> statement-breakpoint
CREATE INDEX "media_status_idx" ON "media"."media" USING btree ("status");--> statement-breakpoint
CREATE INDEX "media_created_at_idx" ON "media"."media" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "media_date_taken_idx" ON "media"."media" USING btree ("date_taken");--> statement-breakpoint
CREATE INDEX "media_camera_idx" ON "media"."media" USING btree ("camera_make","camera_model");--> statement-breakpoint
CREATE INDEX "media_ref_media_id_idx" ON "media"."media_references" USING btree ("media_id");--> statement-breakpoint
CREATE INDEX "media_ref_user_id_idx" ON "media"."media_references" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "media_ref_app_idx" ON "media"."media_references" USING btree ("app");--> statement-breakpoint
CREATE INDEX "media_ref_user_app_idx" ON "media"."media_references" USING btree ("user_id","app");--> statement-breakpoint
CREATE INDEX "media_thumb_media_id_idx" ON "media"."media_thumbnails" USING btree ("media_id");--> statement-breakpoint
CREATE INDEX "media_thumb_params_idx" ON "media"."media_thumbnails" USING btree ("media_id","width","height","fit","format");

View file

@ -1,561 +0,0 @@
{
"id": "270c9891-ddcf-4b1e-b987-d91f033e0767",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"media.media": {
"name": "media",
"schema": "media",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"content_hash": {
"name": "content_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_name": {
"name": "original_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"original_key": {
"name": "original_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'uploading'"
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": false
},
"has_alpha": {
"name": "has_alpha",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"exif_data": {
"name": "exif_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"date_taken": {
"name": "date_taken",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"camera_make": {
"name": "camera_make",
"type": "text",
"primaryKey": false,
"notNull": false
},
"camera_model": {
"name": "camera_model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"focal_length": {
"name": "focal_length",
"type": "text",
"primaryKey": false,
"notNull": false
},
"aperture": {
"name": "aperture",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iso": {
"name": "iso",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"exposure_time": {
"name": "exposure_time",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gps_latitude": {
"name": "gps_latitude",
"type": "text",
"primaryKey": false,
"notNull": false
},
"gps_longitude": {
"name": "gps_longitude",
"type": "text",
"primaryKey": false,
"notNull": false
},
"thumbnail_key": {
"name": "thumbnail_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"medium_key": {
"name": "medium_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"large_key": {
"name": "large_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_content_hash_idx": {
"name": "media_content_hash_idx",
"columns": [
{
"expression": "content_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_status_idx": {
"name": "media_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_created_at_idx": {
"name": "media_created_at_idx",
"columns": [
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_date_taken_idx": {
"name": "media_date_taken_idx",
"columns": [
{
"expression": "date_taken",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_camera_idx": {
"name": "media_camera_idx",
"columns": [
{
"expression": "camera_make",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "camera_model",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"media_content_hash_unique": {
"name": "media_content_hash_unique",
"nullsNotDistinct": false,
"columns": ["content_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"media.media_references": {
"name": "media_references",
"schema": "media",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"media_id": {
"name": "media_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"app": {
"name": "app",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_ref_media_id_idx": {
"name": "media_ref_media_id_idx",
"columns": [
{
"expression": "media_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_ref_user_id_idx": {
"name": "media_ref_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_ref_app_idx": {
"name": "media_ref_app_idx",
"columns": [
{
"expression": "app",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_ref_user_app_idx": {
"name": "media_ref_user_app_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "app",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"media_references_media_id_media_id_fk": {
"name": "media_references_media_id_media_id_fk",
"tableFrom": "media_references",
"tableTo": "media",
"schemaTo": "media",
"columnsFrom": ["media_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"media.media_thumbnails": {
"name": "media_thumbnails",
"schema": "media",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"media_id": {
"name": "media_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"fit": {
"name": "fit",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'cover'"
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'webp'"
},
"quality": {
"name": "quality",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 80
},
"storage_key": {
"name": "storage_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"media_thumb_media_id_idx": {
"name": "media_thumb_media_id_idx",
"columns": [
{
"expression": "media_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"media_thumb_params_idx": {
"name": "media_thumb_params_idx",
"columns": [
{
"expression": "media_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "width",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "height",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "fit",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "format",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"media_thumbnails_media_id_media_id_fk": {
"name": "media_thumbnails_media_id_media_id_fk",
"tableFrom": "media_thumbnails",
"tableTo": "media",
"schemaTo": "media",
"columnsFrom": ["media_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {
"media": "media"
},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775759324563,
"tag": "0000_marvelous_micromacro",
"breakpoints": true
}
]
}

View file

@ -1 +0,0 @@
export * from './media.schema';

View file

@ -1,164 +0,0 @@
import {
pgSchema,
uuid,
text,
timestamp,
integer,
boolean,
index,
jsonb,
bigint,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const mediaSchema = pgSchema('media');
/**
* Core media table - stores unique files by content hash (SHA-256)
* This is the Content-Addressable Storage (CAS) approach
*/
export const media = mediaSchema.table(
'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'),
// EXIF metadata
exifData: jsonb('exif_data'),
dateTaken: timestamp('date_taken', { withTimezone: true }),
cameraMake: text('camera_make'),
cameraModel: text('camera_model'),
focalLength: text('focal_length'),
aperture: text('aperture'),
iso: integer('iso'),
exposureTime: text('exposure_time'),
gpsLatitude: text('gps_latitude'),
gpsLongitude: text('gps_longitude'),
// 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),
index('media_date_taken_idx').on(table.dateTaken),
index('media_camera_idx').on(table.cameraMake, table.cameraModel),
]
);
/**
* Media references - tracks which user/app owns a reference to a media item
* Multiple users can reference the same media (deduplication)
*/
export const mediaReferences = mediaSchema.table(
'media_references',
{
id: uuid('id').primaryKey().defaultRandom(),
// The media being referenced
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Owner user ID
userId: text('user_id').notNull(),
// Source app (food, contacts, chat, etc.)
app: text('app').notNull(),
// Optional: reference to the source URL
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 = mediaSchema.table(
'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;

View file

@ -1,170 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { Queue, Worker, Job } from 'bullmq';
import { collectDefaultMetrics, Registry, Counter, Histogram } from 'prom-client';
import { getDb, closeConnection } from './db';
import { runMigrations } from './db/migrate';
import { StorageService } from './services/storage';
import { UploadService } from './services/upload';
import { ProcessService } from './services/process';
import { ExifService } from './services/exif';
import { uploadRoutes } from './routes/upload';
import { deliveryRoutes } from './routes/delivery';
import { PROCESS_QUEUE, SUPPORTED_IMAGE_TYPES } from './constants';
const port = parseInt(process.env.PORT || '3015');
// Database
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required');
// Apply pending Drizzle migrations before opening the pool. Idempotent —
// drizzle tracks applied migrations in drizzle.__drizzle_migrations, so
// existing deployments are unaffected.
await runMigrations(databaseUrl);
const db = getDb(databaseUrl);
// Services
const storage = new StorageService();
await storage.init();
const exifService = new ExifService();
const processService = new ProcessService(storage, exifService);
const processQueue = new Queue(PROCESS_QUEUE, {
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
},
});
const uploadService = new UploadService(db, storage, processQueue);
// BullMQ Worker
const worker = new Worker(
PROCESS_QUEUE,
async (job: Job<{ mediaId: string; mimeType: string; originalKey: string }>) => {
const { mediaId, mimeType, originalKey } = job.data;
console.log(`Processing media ${mediaId} (${mimeType})`);
try {
if (SUPPORTED_IMAGE_TYPES.includes(mimeType)) {
const result = await processService.processImage(mediaId, originalKey, mimeType);
await uploadService.update(mediaId, {
status: 'ready',
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,
exifData: result.exif?.raw,
dateTaken: result.exif?.dateTaken,
cameraMake: result.exif?.cameraMake,
cameraModel: result.exif?.cameraModel,
focalLength: result.exif?.focalLength,
aperture: result.exif?.aperture,
iso: result.exif?.iso,
exposureTime: result.exif?.exposureTime,
gpsLatitude: result.exif?.gpsLatitude,
gpsLongitude: result.exif?.gpsLongitude,
});
console.log(
`Processed image ${mediaId}: thumbnail=${!!result.thumbnail}, medium=${!!result.medium}, large=${!!result.large}, exif=${!!result.exif}`
);
} else {
await uploadService.update(mediaId, { status: 'ready' });
}
} catch (error) {
console.error(`Failed to process media ${mediaId}:`, error);
await uploadService.update(mediaId, { status: 'failed' });
throw error;
}
},
{
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
},
}
);
// Prometheus metrics
const register = new Registry();
register.setDefaultLabels({ service: 'mana-media' });
collectDefaultMetrics({ register, prefix: 'media_' });
const httpRequestsTotal = new Counter({
name: 'media_http_requests_total',
help: 'Total HTTP requests',
labelNames: ['method', 'path', 'status'] as const,
registers: [register],
});
const httpRequestDuration = new Histogram({
name: 'media_http_request_duration_seconds',
help: 'HTTP request duration in seconds',
labelNames: ['method', 'path', 'status'] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register],
});
// Hono app
const app = new Hono();
// CORS
app.use(
'*',
cors({
origin: process.env.CORS_ORIGINS?.split(',') || '*',
credentials: true,
})
);
// Metrics middleware
app.use('*', async (c, next) => {
const start = Date.now();
await next();
const duration = (Date.now() - start) / 1000;
const path = c.req.routePath || c.req.path;
httpRequestsTotal.inc({ method: c.req.method, path, status: c.res.status });
httpRequestDuration.observe({ method: c.req.method, path, status: c.res.status }, duration);
});
// Health
app.get('/health', (c) =>
c.json({ status: 'ok', service: 'mana-media', timestamp: new Date().toISOString() })
);
// Metrics
app.get('/metrics', async (c) => {
c.header('Content-Type', register.contentType);
return c.text(await register.metrics());
});
// API routes
const api = new Hono();
api.route('/media', uploadRoutes(uploadService));
api.route('/media', deliveryRoutes(uploadService, processService, storage));
app.route('/api/v1', api);
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down...');
await worker.close();
await processQueue.close();
await closeConnection();
process.exit(0);
});
console.log(`Mana Media service running on port ${port}`);
console.log(`Health check: http://localhost:${port}/health`);
export default {
port,
fetch: app.fetch,
};

View file

@ -1,108 +0,0 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import type { UploadService } from '../services/upload';
import type { ProcessService } from '../services/process';
import type { StorageService } from '../services/storage';
import { sniffImageMimeType } from '../services/sniff';
type Variant = 'thumb' | 'medium' | 'large';
export function deliveryRoutes(
uploadService: UploadService,
processService: ProcessService,
storage: StorageService
) {
const app = new Hono();
// Get original file
app.get('/:id/file', async (c) => {
const record = await uploadService.get(c.req.param('id'));
if (!record) return c.json({ error: 'Media not found' }, 404);
return streamFile(c, storage, record.keys.original, record.mimeType);
});
// Get variant
app.get('/:id/file/:variant', async (c) => {
const record = await uploadService.get(c.req.param('id'));
if (!record) return c.json({ error: 'Media not found' }, 404);
const variant = c.req.param('variant') as Variant;
const variantMap: Record<Variant, string | undefined> = {
thumb: record.keys.thumbnail,
medium: record.keys.medium,
large: record.keys.large,
};
const key = variantMap[variant];
if (!key) {
return streamFile(c, storage, record.keys.original, record.mimeType);
}
return streamFile(c, storage, key, 'image/webp');
});
// On-the-fly transform
app.get('/:id/transform', async (c) => {
const record = await uploadService.get(c.req.param('id'));
if (!record) return c.json({ error: 'Media not found' }, 404);
const originalBuffer = await storage.download(record.keys.original);
// Trust the stored mime first; fall back to magic-byte sniffing
// for legacy rows uploaded before the upload sniffer landed
// (HEIC from Chrome, etc.) where the row says
// `application/octet-stream` but the bytes are actually an image.
// Refuse only when neither header nor bytes look like an image.
const looksLikeImage =
record.mimeType.startsWith('image/') || sniffImageMimeType(originalBuffer) !== null;
if (!looksLikeImage) {
return c.json({ error: 'Transform only supported for images' }, 400);
}
const format = (c.req.query('format') as 'webp' | 'jpeg' | 'png' | 'avif') || 'webp';
const transformedBuffer = await processService.transformImage(originalBuffer, {
width: c.req.query('w') ? parseInt(c.req.query('w')!) : undefined,
height: c.req.query('h') ? parseInt(c.req.query('h')!) : undefined,
fit: (c.req.query('fit') as 'cover' | 'contain' | 'fill' | 'inside' | 'outside') || 'inside',
format,
quality: c.req.query('q') ? parseInt(c.req.query('q')!) : 85,
});
const mimeTypes: Record<string, string> = {
webp: 'image/webp',
jpeg: 'image/jpeg',
png: 'image/png',
avif: 'image/avif',
};
c.header('Content-Type', mimeTypes[format]);
c.header('Cache-Control', 'public, max-age=31536000');
// Hono 4.7 types `c.body()` as `Uint8Array<ArrayBuffer>` (strict,
// not ArrayBufferLike). Node's `Buffer<ArrayBufferLike>` and
// `new Uint8Array(buffer, ...)` views both carry the loose
// ArrayBufferLike tag. `Uint8Array.from()` copies into a fresh
// ArrayBuffer which satisfies the strict type.
return c.body(Uint8Array.from(transformedBuffer));
});
return app;
}
async function streamFile(c: any, storage: StorageService, key: string, contentType: string) {
try {
const fileStream = await storage.getStream(key);
c.header('Content-Type', contentType);
c.header('Cache-Control', 'public, max-age=31536000');
return stream(c, async (s) => {
for await (const chunk of fileStream) {
await s.write(chunk);
}
});
} catch {
return c.json({ error: 'File not found' }, 404);
}
}

View file

@ -1,169 +0,0 @@
import { Hono } from 'hono';
import type { UploadService, MediaRecord } from '../services/upload';
import { sniffImageMimeType } from '../services/sniff';
function toResponse(record: MediaRecord) {
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1';
return {
id: record.id,
status: record.status,
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,
medium: record.keys.medium ? `${baseUrl}/media/${record.id}/file/medium` : undefined,
large: record.keys.large ? `${baseUrl}/media/${record.id}/file/large` : undefined,
},
metadata: record.metadata,
exif: record.exif,
createdAt: record.createdAt,
};
}
export function uploadRoutes(uploadService: UploadService) {
const app = new Hono();
// Upload file
app.post('/upload', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || !(file instanceof File)) {
return c.json({ error: 'No file provided' }, 400);
}
if (file.size > 100 * 1024 * 1024) {
return c.json({ error: 'File too large (max 100MB)' }, 400);
}
let buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte sniff first; trust the bytes over the browser's
// `file.type`. Chrome on macOS doesn't recognise HEIC and sends
// an empty type, which would otherwise land as
// `application/octet-stream` and break every downstream
// `mimeType.startsWith('image/')` check (transform endpoint,
// process pipeline, etc). A successful sniff returns an
// authoritative image MIME; anything we don't recognise falls
// back to whatever the browser claimed.
const sniffed = sniffImageMimeType(buffer);
let mimeType = sniffed ?? file.type ?? 'application/octet-stream';
let storedName = file.name;
let storedSize = file.size;
// HEIC/HEIF transcode. The sharp version we ship has the heif
// container format but no HEVC decoder plugin (libde265 is not
// bundled in sharp's prebuilt binaries due to patent licensing),
// so iPhone HEIC uploads would fail every downstream sharp
// transform with `No decoding plugin installed for this
// compression format`. Convert to JPEG once at upload time via
// `heic-convert` (pure-JS WASM, no system deps); the server then
// stores standard JPEG and every later step is mime-agnostic.
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try {
const heicConvert = (await import('heic-convert')).default;
const jpegArrayBuffer = await heicConvert({
// `Buffer` extends `Uint8Array` and is what heic-convert
// actually accepts at runtime. `@types/heic-convert`
// over-tightens the param to `ArrayBufferLike` (which
// in TS ≥ 5.7 includes the `grow` property only on
// `SharedArrayBuffer`), so a normal Buffer doesn't
// match the declared type. Cast through `unknown` to
// avoid lying about a wider intersection.
buffer: buffer as unknown as ArrayBufferLike,
format: 'JPEG',
quality: 0.9,
});
buffer = Buffer.from(jpegArrayBuffer);
mimeType = 'image/jpeg';
storedName = file.name.replace(/\.(heic|heif)$/i, '.jpg');
storedSize = buffer.length;
} catch (err) {
console.error('[upload] HEIC convert failed', err);
return c.json({ error: 'HEIC conversion failed', detail: (err as Error).message }, 500);
}
}
const record = await uploadService.upload(buffer, storedName, mimeType, storedSize, {
app: body['app'] as string | undefined,
userId: body['userId'] as string | undefined,
skipProcessing: body['skipProcessing'] === 'true',
});
return c.json(toResponse(record), 201);
});
// Get by ID
app.get('/:id', async (c) => {
const id = c.req.param('id');
// Skip route conflicts with sub-paths
if (['hash', 'list', 'stats'].includes(id)) return;
const record = await uploadService.get(id);
if (!record) return c.json({ error: 'Media not found' }, 404);
return c.json(toResponse(record));
});
// Get by hash
app.get('/hash/:hash', async (c) => {
const record = await uploadService.getByHash(c.req.param('hash'));
if (!record) return c.json({ error: 'Media not found' }, 404);
return c.json(toResponse(record));
});
// List
app.get('/', async (c) => {
const records = await uploadService.list({
app: c.req.query('app'),
userId: c.req.query('userId'),
limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50,
});
return c.json(records.map(toResponse));
});
// List all with advanced filtering
app.get('/list/all', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.json({ error: 'userId is required' }, 400);
const apps = c.req.query('apps');
const result = await uploadService.listAll({
userId,
apps: apps ? apps.split(',').map((a) => a.trim()) : undefined,
mimeType: c.req.query('mimeType'),
dateFrom: c.req.query('dateFrom') ? new Date(c.req.query('dateFrom')!) : undefined,
dateTo: c.req.query('dateTo') ? new Date(c.req.query('dateTo')!) : undefined,
hasLocation: c.req.query('hasLocation') === 'true',
limit: c.req.query('limit') ? parseInt(c.req.query('limit')!) : 50,
offset: c.req.query('offset') ? parseInt(c.req.query('offset')!) : 0,
sortBy: (c.req.query('sortBy') as 'createdAt' | 'dateTaken' | 'size') || 'createdAt',
sortOrder: (c.req.query('sortOrder') as 'asc' | 'desc') || 'desc',
});
return c.json({
items: result.items.map(toResponse),
total: result.total,
hasMore: result.hasMore,
});
});
// Stats
app.get('/stats', async (c) => {
const userId = c.req.query('userId');
if (!userId) return c.json({ error: 'userId is required' }, 400);
return c.json(await uploadService.getStats(userId));
});
// Delete
app.delete('/:id', async (c) => {
const deleted = await uploadService.delete(c.req.param('id'));
if (!deleted) return c.json({ error: 'Media not found' }, 404);
return c.json({ success: true });
});
return app;
}

View file

@ -1,56 +0,0 @@
import exifr from 'exifr';
export interface ExifData {
cameraMake?: string;
cameraModel?: string;
focalLength?: string;
aperture?: string;
iso?: number;
exposureTime?: string;
dateTaken?: Date;
gpsLatitude?: string;
gpsLongitude?: string;
raw?: Record<string, unknown>;
}
export class ExifService {
async extract(buffer: Buffer): Promise<ExifData | null> {
try {
const exif = await exifr.parse(buffer, {
gps: true,
tiff: true,
exif: true,
});
if (!exif) return null;
const result: ExifData = { raw: exif };
if (exif.Make) result.cameraMake = String(exif.Make).trim();
if (exif.Model) result.cameraModel = String(exif.Model).trim();
if (exif.FocalLength) result.focalLength = `${exif.FocalLength}mm`;
if (exif.FNumber) result.aperture = String(exif.FNumber);
if (exif.ISO) result.iso = Number(exif.ISO);
if (exif.ExposureTime) {
result.exposureTime =
exif.ExposureTime < 1
? `1/${Math.round(1 / exif.ExposureTime)}`
: `${exif.ExposureTime}s`;
}
if (exif.DateTimeOriginal) {
result.dateTaken = new Date(exif.DateTimeOriginal);
} else if (exif.CreateDate) {
result.dateTaken = new Date(exif.CreateDate);
}
if (exif.latitude !== undefined && exif.longitude !== undefined) {
result.gpsLatitude = String(exif.latitude);
result.gpsLongitude = String(exif.longitude);
}
return result;
} catch (error) {
console.warn(`Failed to extract EXIF data: ${error}`);
return null;
}
}
}

View file

@ -1,136 +0,0 @@
import sharp from 'sharp';
import { StorageService } from './storage';
import { ExifService, type ExifData } from './exif';
import { IMAGE_VARIANTS, SUPPORTED_IMAGE_TYPES } from '../constants';
export interface ProcessResult {
thumbnail?: string;
medium?: string;
large?: string;
metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
exif?: ExifData;
}
export class ProcessService {
constructor(
private storage: StorageService,
private exifService: ExifService
) {}
async processImage(
mediaId: string,
originalKey: string,
mimeType: string
): Promise<ProcessResult> {
if (!SUPPORTED_IMAGE_TYPES.includes(mimeType)) return {};
const originalBuffer = await this.storage.download(originalKey);
const image = sharp(originalBuffer);
const metadata = await image.metadata();
const exifData = await this.exifService.extract(originalBuffer);
const result: ProcessResult = {
metadata: {
width: metadata.width,
height: metadata.height,
format: metadata.format,
hasAlpha: metadata.hasAlpha,
},
exif: exifData || undefined,
};
const basePath = originalKey.replace(/^originals\//, 'processed/').replace(/\.[^.]+$/, '');
// Thumbnail
const thumbKey = `${basePath}/thumb.webp`;
const thumbBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.thumbnail.width, IMAGE_VARIANTS.thumbnail.height, {
fit: IMAGE_VARIANTS.thumbnail.fit,
})
.webp({ quality: 80 })
.toBuffer();
await this.storage.upload(thumbKey, thumbBuffer, 'image/webp');
result.thumbnail = thumbKey;
// Medium
if (
(metadata.width || 0) > IMAGE_VARIANTS.medium.width ||
(metadata.height || 0) > IMAGE_VARIANTS.medium.height
) {
const mediumKey = `${basePath}/medium.webp`;
const mediumBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.medium.width, IMAGE_VARIANTS.medium.height, {
fit: IMAGE_VARIANTS.medium.fit,
withoutEnlargement: true,
})
.webp({ quality: 85 })
.toBuffer();
await this.storage.upload(mediumKey, mediumBuffer, 'image/webp');
result.medium = mediumKey;
}
// Large
if (
(metadata.width || 0) > IMAGE_VARIANTS.large.width ||
(metadata.height || 0) > IMAGE_VARIANTS.large.height
) {
const largeKey = `${basePath}/large.webp`;
const largeBuffer = await sharp(originalBuffer)
.resize(IMAGE_VARIANTS.large.width, IMAGE_VARIANTS.large.height, {
fit: IMAGE_VARIANTS.large.fit,
withoutEnlargement: true,
})
.webp({ quality: 90 })
.toBuffer();
await this.storage.upload(largeKey, largeBuffer, 'image/webp');
result.large = largeKey;
}
return result;
}
async transformImage(
buffer: Buffer,
options: {
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
format?: 'webp' | 'jpeg' | 'png' | 'avif';
quality?: number;
}
): Promise<Buffer> {
let pipeline = sharp(buffer);
if (options.width || options.height) {
pipeline = pipeline.resize(options.width, options.height, {
fit: options.fit || 'inside',
withoutEnlargement: true,
});
}
const format = options.format || 'webp';
const quality = options.quality || 85;
switch (format) {
case 'webp':
pipeline = pipeline.webp({ quality });
break;
case 'jpeg':
pipeline = pipeline.jpeg({ quality });
break;
case 'png':
pipeline = pipeline.png();
break;
case 'avif':
pipeline = pipeline.avif({ quality });
break;
}
return pipeline.toBuffer();
}
}

View file

@ -1,78 +0,0 @@
/**
* Magic-byte sniffer for image MIME types.
*
* Why this exists:
* Browsers don't all recognise the same set of image formats. Chrome
* on macOS, for example, hands a HEIC file to the upload endpoint
* with `file.type === ''`, which the server then stores as
* `application/octet-stream`. The transform endpoint subsequently
* refuses to touch the row because `mimeType.startsWith('image/')`
* is false even though the bytes on disk are a perfectly valid
* image. Sniffing the buffer's magic bytes at upload time fixes
* this at the source.
*
* The sniffer reads only the first ~16 bytes cheap, synchronous,
* runs once per upload. Only image formats are detected; any other
* file type returns null so the caller can fall back to whatever the
* browser reported.
*/
const ASCII = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
function bytesEqual(buf: Buffer, offset: number, expected: number[]): boolean {
if (offset + expected.length > buf.length) return false;
for (let i = 0; i < expected.length; i++) {
if (buf[offset + i] !== expected[i]) return false;
}
return true;
}
// HEIF/HEIC family — major brand at offset 8, after the 4-byte size +
// `ftyp` marker at offset 4. List from ISO/IEC 23008-12 + 14496-12.
// AVIF shares the same container with a different brand.
const HEIC_BRANDS = ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1'];
const AVIF_BRANDS = ['avif', 'avis'];
/**
* Inspect the first bytes of `buf` and return a canonical image MIME
* type if recognized, or null when nothing matches. Trustworthy
* substitute for `file.type` when the browser left it empty or
* defaulted it to `application/octet-stream`.
*/
export function sniffImageMimeType(buf: Buffer): string | null {
// JPEG — FF D8 FF
if (bytesEqual(buf, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg';
// PNG — 89 50 4E 47 0D 0A 1A 0A
if (bytesEqual(buf, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return 'image/png';
}
// GIF87a / GIF89a — 47 49 46 38 ...
if (bytesEqual(buf, 0, ASCII('GIF8'))) return 'image/gif';
// WebP — RIFF....WEBP at offset 8
if (bytesEqual(buf, 0, ASCII('RIFF')) && bytesEqual(buf, 8, ASCII('WEBP'))) {
return 'image/webp';
}
// BMP — 42 4D
if (bytesEqual(buf, 0, [0x42, 0x4d])) return 'image/bmp';
// TIFF — 49 49 2A 00 (LE) or 4D 4D 00 2A (BE)
if (
bytesEqual(buf, 0, [0x49, 0x49, 0x2a, 0x00]) ||
bytesEqual(buf, 0, [0x4d, 0x4d, 0x00, 0x2a])
) {
return 'image/tiff';
}
// HEIC / HEIF / AVIF — `ftyp` at offset 4, brand at offset 8.
if (bytesEqual(buf, 4, ASCII('ftyp'))) {
const brand = buf.slice(8, 12).toString('ascii');
if (HEIC_BRANDS.includes(brand)) return 'image/heic';
if (AVIF_BRANDS.includes(brand)) return 'image/avif';
}
return null;
}

View file

@ -1,91 +0,0 @@
import * as Minio from 'minio';
import { Readable } from 'stream';
export interface StorageObject {
key: string;
bucket: string;
size: number;
contentType: string;
etag: string;
}
export class StorageService {
private client: Minio.Client;
private bucket: string;
constructor() {
this.client = new Minio.Client({
endPoint: process.env.S3_ENDPOINT || 'localhost',
port: parseInt(process.env.S3_PORT || '9000'),
useSSL: process.env.S3_USE_SSL === 'true',
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
});
this.bucket = process.env.S3_BUCKET || 'mana-media';
}
async init() {
const exists = await this.client.bucketExists(this.bucket);
if (!exists) {
await this.client.makeBucket(this.bucket);
console.log(`Created bucket: ${this.bucket}`);
}
}
async upload(
key: string,
data: Buffer | Readable,
contentType: string,
metadata?: Record<string, string>
): Promise<StorageObject> {
const size = Buffer.isBuffer(data) ? data.length : undefined;
await this.client.putObject(this.bucket, key, data, size, {
'Content-Type': contentType,
...metadata,
});
const stat = await this.client.statObject(this.bucket, key);
return {
key,
bucket: this.bucket,
size: stat.size,
contentType,
etag: stat.etag,
};
}
async download(key: string): Promise<Buffer> {
const stream = await this.client.getObject(this.bucket, key);
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
async getStream(key: string): Promise<Readable> {
return this.client.getObject(this.bucket, key);
}
async delete(key: string): Promise<void> {
await this.client.removeObject(this.bucket, key);
}
async exists(key: string): Promise<boolean> {
try {
await this.client.statObject(this.bucket, key);
return true;
} catch {
return false;
}
}
getPublicUrl(key: string): string {
const endpoint = process.env.S3_PUBLIC_URL || `http://localhost:9000/${this.bucket}`;
return `${endpoint}/${key}`;
}
}

View file

@ -1,392 +0,0 @@
import { Queue } from 'bullmq';
import * as mime from 'mime-types';
import * as crypto from 'crypto';
import { eq, and, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm';
import type { Database } from '../db';
import { StorageService } from './storage';
import { PROCESS_QUEUE } from '../constants';
import {
media,
mediaReferences,
type Media,
type NewMedia,
type NewMediaReference,
} from '../db/schema';
export interface MediaRecord {
id: string;
originalName: string | null;
mimeType: string;
size: number;
hash: string;
status: 'uploading' | 'processing' | 'ready' | 'failed';
keys: {
original: string;
thumbnail?: string;
medium?: string;
large?: string;
};
metadata?: {
width?: number;
height?: number;
format?: string;
hasAlpha?: boolean;
};
exif?: {
cameraMake?: string;
cameraModel?: string;
dateTaken?: Date;
focalLength?: string;
aperture?: string;
iso?: number;
exposureTime?: string;
gpsLatitude?: string;
gpsLongitude?: string;
};
createdAt: Date;
updatedAt: Date;
}
export interface ListAllOptions {
userId: string;
apps?: string[];
mimeType?: string;
dateFrom?: Date;
dateTo?: Date;
hasLocation?: boolean;
limit?: number;
offset?: number;
sortBy?: 'createdAt' | 'dateTaken' | 'size';
sortOrder?: 'asc' | 'desc';
}
export interface ListAllResult {
items: MediaRecord[];
total: number;
hasMore: boolean;
}
export interface StatsResult {
totalCount: number;
totalSize: number;
byApp: Record<string, { count: number; size: number }>;
byYear: Record<string, number>;
}
export class UploadService {
constructor(
private db: Database,
private storage: StorageService,
private processQueue: Queue
) {}
async upload(
buffer: Buffer,
originalname: string,
mimetype: string,
fileSize: number,
options?: { app?: string; userId?: string; skipProcessing?: boolean }
): Promise<MediaRecord> {
const hash = this.computeHash(buffer);
const existing = await this.findByHash(hash);
if (existing) {
if (options?.userId && options?.app) {
await this.createReference(existing.id, options.userId, options.app);
}
return this.toMediaRecord(existing);
}
const ext = mime.extension(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}`;
await this.storage.upload(originalKey, buffer, mimetype, {
'x-amz-meta-original-name': originalname,
'x-amz-meta-media-id': id,
});
const [inserted] = await this.db
.insert(media)
.values({
id,
contentHash: hash,
originalName: originalname,
mimeType: mimetype,
size: fileSize,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} satisfies NewMedia)
.returning();
if (options?.userId && options?.app) {
await this.createReference(inserted.id, options.userId, options.app);
}
if (!options?.skipProcessing) {
await this.processQueue.add('process-media', {
mediaId: inserted.id,
mimeType: mimetype,
originalKey,
});
}
return this.toMediaRecord(inserted);
}
async get(id: string): Promise<MediaRecord | null> {
const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
return result ? this.toMediaRecord(result) : null;
}
async getByHash(hash: string): Promise<MediaRecord | null> {
const result = await this.findByHash(hash);
return result ? this.toMediaRecord(result) : null;
}
async update(
id: string,
updates: Partial<
Pick<
Media,
| 'status'
| 'thumbnailKey'
| 'mediumKey'
| 'largeKey'
| 'width'
| 'height'
| 'format'
| 'hasAlpha'
| 'exifData'
| 'dateTaken'
| 'cameraMake'
| 'cameraModel'
| 'focalLength'
| 'aperture'
| 'iso'
| 'exposureTime'
| 'gpsLatitude'
| 'gpsLongitude'
>
>
): 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] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
if (!record) return false;
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(() => {});
}
await this.db.delete(media).where(eq(media.id, id));
return true;
}
async list(options?: { app?: string; userId?: string; limit?: number }): Promise<MediaRecord[]> {
if (options?.userId || options?.app) {
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 this.db
.select({ media: media })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
.limit(options.limit || 50);
return results.map((r) => this.toMediaRecord(r.media));
}
const results = await this.db
.select()
.from(media)
.orderBy(media.createdAt)
.limit(options?.limit || 50);
return results.map((r) => this.toMediaRecord(r));
}
async listAll(options: ListAllOptions): Promise<ListAllResult> {
const conditions = [eq(mediaReferences.userId, options.userId)];
if (options.apps && options.apps.length > 0) {
conditions.push(inArray(mediaReferences.app, options.apps));
}
if (options.mimeType) {
if (options.mimeType.endsWith('/*')) {
conditions.push(like(media.mimeType, `${options.mimeType.slice(0, -1)}%`));
} else {
conditions.push(eq(media.mimeType, options.mimeType));
}
}
if (options.dateFrom) conditions.push(gte(media.createdAt, options.dateFrom));
if (options.dateTo) conditions.push(lte(media.createdAt, options.dateTo));
if (options.hasLocation) {
conditions.push(isNotNull(media.gpsLatitude));
conditions.push(isNotNull(media.gpsLongitude));
}
conditions.push(eq(media.status, 'ready'));
const orderColumn =
options.sortBy === 'dateTaken'
? media.dateTaken
: options.sortBy === 'size'
? media.size
: media.createdAt;
const orderFn = options.sortOrder === 'asc' ? asc : desc;
const countResult = await this.db
.select({ count: sql<number>`count(distinct ${media.id})` })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(and(...conditions));
const total = Number(countResult[0]?.count || 0);
const limit = options.limit || 50;
const offset = options.offset || 0;
const results = await this.db
.selectDistinct({ media: media })
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(and(...conditions))
.orderBy(orderFn(orderColumn))
.limit(limit)
.offset(offset);
return {
items: results.map((r) => this.toMediaRecord(r.media)),
total,
hasMore: offset + results.length < total,
};
}
async getStats(userId: string): Promise<StatsResult> {
const totalResult = await this.db
.select({
count: sql<number>`count(distinct ${media.id})`,
size: sql<number>`sum(${media.size})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId));
const byAppResult = await this.db
.select({
app: mediaReferences.app,
count: sql<number>`count(distinct ${media.id})`,
size: sql<number>`sum(${media.size})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId))
.groupBy(mediaReferences.app);
const byYearResult = await this.db
.select({
year: sql<string>`extract(year from ${media.createdAt})::text`,
count: sql<number>`count(distinct ${media.id})`,
})
.from(media)
.innerJoin(mediaReferences, eq(media.id, mediaReferences.mediaId))
.where(eq(mediaReferences.userId, userId))
.groupBy(sql`extract(year from ${media.createdAt})`);
const byApp: Record<string, { count: number; size: number }> = {};
for (const row of byAppResult) {
byApp[row.app] = { count: Number(row.count), size: Number(row.size) };
}
const byYear: Record<string, number> = {};
for (const row of byYearResult) {
byYear[row.year] = Number(row.count);
}
return {
totalCount: Number(totalResult[0]?.count || 0),
totalSize: Number(totalResult[0]?.size || 0),
byApp,
byYear,
};
}
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 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,
exif:
m.cameraMake || m.dateTaken || m.gpsLatitude
? {
cameraMake: m.cameraMake || undefined,
cameraModel: m.cameraModel || undefined,
dateTaken: m.dateTaken || undefined,
focalLength: m.focalLength || undefined,
aperture: m.aperture || undefined,
iso: m.iso || undefined,
exposureTime: m.exposureTime || undefined,
gpsLatitude: m.gpsLatitude || undefined,
gpsLongitude: m.gpsLongitude || undefined,
}
: undefined,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
};
}
}

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"types": ["bun-types"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"declaration": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,63 +0,0 @@
services:
api:
build:
context: .
dockerfile: apps/api/Dockerfile
container_name: mana-media-api
restart: unless-stopped
ports:
- "3050:3050"
environment:
- NODE_ENV=production
- PORT=3050
- REDIS_HOST=redis
- REDIS_PORT=6379
- S3_ENDPOINT=minio
- S3_PORT=9000
- S3_USE_SSL=false
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-minioadmin}
- S3_SECRET_KEY=${S3_SECRET_KEY:-minioadmin}
- S3_BUCKET=mana-media
depends_on:
- redis
- minio
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3050/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
redis:
image: redis:7-alpine
container_name: mana-media-redis
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: mana-media-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=${S3_ACCESS_KEY:-minioadmin}
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY:-minioadmin}
volumes:
- minio_data:/data
ports:
- "9010:9000"
- "9011:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
volumes:
redis_data:
minio_data:

View file

@ -1,3 +0,0 @@
import { createDrizzleConfig } from '@mana/shared-drizzle-config';
export default createDrizzleConfig('./src/db/schema');

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,9 +0,0 @@
{
"name": "@mana-media/root",
"private": true,
"scripts": {
"dev": "pnpm --filter @mana-media/api dev",
"start": "pnpm --filter @mana-media/api start",
"type-check": "tsc --noEmit -p apps/api/tsconfig.json && tsc --noEmit -p packages/client/tsconfig.json"
}
}

View file

@ -1,15 +0,0 @@
{
"name": "@mana/media-client",
"version": "0.1.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"dependencies": {},
"devDependencies": {
"typescript": "^5.7.0",
"@types/node": "^22.0.0"
}
}

View file

@ -1,224 +0,0 @@
export interface MediaResult {
id: string;
status: 'uploading' | 'processing' | 'ready' | 'failed';
originalName: string | null;
mimeType: string;
size: number;
hash: string;
urls: {
original: string;
thumbnail?: string;
medium?: string;
large?: string;
};
createdAt: Date;
}
export interface UploadOptions {
app?: string;
userId?: string;
skipProcessing?: boolean;
}
export interface SearchOptions {
type?: 'image' | 'video' | 'audio' | 'document';
app?: string;
limit?: number;
}
export interface TransformOptions {
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
format?: 'webp' | 'jpeg' | 'png' | 'avif';
quality?: number;
}
export class MediaClient {
private baseUrl: string;
private apiKey?: string;
constructor(baseUrl: string, apiKey?: string) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.apiKey = apiKey;
}
/**
* Upload a file to the media service
*/
async upload(
file: File | Blob | ArrayBuffer,
options?: UploadOptions & { filename?: string }
): Promise<MediaResult> {
const formData = new FormData();
if (file instanceof ArrayBuffer) {
// ArrayBuffer (works in both Node.js and browser)
const blob = new Blob([file]);
formData.append('file', blob, options?.filename || 'file');
} else {
// Browser File/Blob
formData.append('file', file, options?.filename);
}
if (options?.app) formData.append('app', options.app);
if (options?.userId) formData.append('userId', options.userId);
if (options?.skipProcessing) formData.append('skipProcessing', 'true');
const response = await fetch(`${this.baseUrl}/api/v1/media/upload`, {
method: 'POST',
headers: this.getHeaders(),
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.statusText}`);
}
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
*/
async get(id: string): Promise<MediaResult> {
const response = await fetch(`${this.baseUrl}/api/v1/media/${id}`, {
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Get failed: ${response.statusText}`);
}
return response.json();
}
/**
* List media
*/
async list(options?: { app?: string; userId?: string; limit?: number }): Promise<MediaResult[]> {
const params = new URLSearchParams();
if (options?.app) params.append('app', options.app);
if (options?.userId) params.append('userId', options.userId);
if (options?.limit) params.append('limit', options.limit.toString());
const response = await fetch(`${this.baseUrl}/api/v1/media?${params}`, {
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`List failed: ${response.statusText}`);
}
return response.json();
}
/**
* Delete media
*/
async delete(id: string): Promise<boolean> {
const response = await fetch(`${this.baseUrl}/api/v1/media/${id}`, {
method: 'DELETE',
headers: this.getHeaders(),
});
return response.ok;
}
/**
* Get URL for original file
*/
getOriginalUrl(id: string): string {
return `${this.baseUrl}/api/v1/media/${id}/file`;
}
/**
* Get URL for thumbnail
*/
getThumbnailUrl(id: string): string {
return `${this.baseUrl}/api/v1/media/${id}/file/thumb`;
}
/**
* Get URL for medium variant
*/
getMediumUrl(id: string): string {
return `${this.baseUrl}/api/v1/media/${id}/file/medium`;
}
/**
* Get URL for large variant
*/
getLargeUrl(id: string): string {
return `${this.baseUrl}/api/v1/media/${id}/file/large`;
}
/**
* Get URL for custom transform
*/
getTransformUrl(id: string, options: TransformOptions): string {
const params = new URLSearchParams();
if (options.width) params.append('w', options.width.toString());
if (options.height) params.append('h', options.height.toString());
if (options.fit) params.append('fit', options.fit);
if (options.format) params.append('format', options.format);
if (options.quality) params.append('q', options.quality.toString());
return `${this.baseUrl}/api/v1/media/${id}/transform?${params}`;
}
/**
* Wait for media processing to complete
*/
async waitForReady(id: string, timeoutMs = 30000, pollIntervalMs = 1000): Promise<MediaResult> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const result = await this.get(id);
if (result.status === 'ready') {
return result;
}
if (result.status === 'failed') {
throw new Error('Media processing failed');
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('Timeout waiting for media to be ready');
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
return headers;
}
}
export default MediaClient;

View file

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["apps/**/*", "packages/**/*"],
"exclude": ["node_modules", "dist"]
}