mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
af8ef60fe4
commit
fcc36eadcb
31 changed files with 0 additions and 2771 deletions
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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'],
|
|
||||||
});
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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',
|
|
||||||
];
|
|
||||||
|
|
@ -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>;
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
@ -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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775759324563,
|
|
||||||
"tag": "0000_marvelous_micromacro",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './media.schema';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -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:
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { createDrizzleConfig } from '@mana/shared-drizzle-config';
|
|
||||||
|
|
||||||
export default createDrizzleConfig('./src/db/schema');
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/nest-cli",
|
|
||||||
"collection": "@nestjs/schematics",
|
|
||||||
"sourceRoot": "src",
|
|
||||||
"compilerOptions": {
|
|
||||||
"deleteOutDir": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue