initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
packages/*/dist
|
||||
*.log
|
||||
.git
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag for the image (optional, defaults to sha)'
|
||||
required: false
|
||||
type: string
|
||||
meta_core_version:
|
||||
description: 'meta-core version to use (default: latest)'
|
||||
required: false
|
||||
type: string
|
||||
default: 'latest'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive # For plugins
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
META_CORE_IMAGE=ghcr.io/worph/meta-core:${{ inputs.meta_core_version || 'latest' }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test data
|
||||
test-data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
# meta-sort standalone Docker image
|
||||
# Includes: Redis, nginx, rclone, FFmpeg, Node.js backend, React UI, and Editor
|
||||
#
|
||||
# Build standalone:
|
||||
# docker build -t meta-sort .
|
||||
#
|
||||
# Build with custom meta-core (for development):
|
||||
# docker build --build-arg META_CORE_IMAGE=meta-core:local -t meta-sort .
|
||||
|
||||
# Stage 0: Get meta-core binary from published image
|
||||
ARG META_CORE_IMAGE=ghcr.io/worph/meta-core:latest
|
||||
FROM ${META_CORE_IMAGE} AS meta-core
|
||||
|
||||
# Stage 1: Build UI
|
||||
FROM node:21-alpine AS ui-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy UI package and install
|
||||
COPY packages/meta-sort-ui/package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy UI source and build
|
||||
COPY packages/meta-sort-ui/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build Editor
|
||||
FROM node:21-alpine AS editor-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy editor package and install
|
||||
COPY packages/meta-sort-editor/package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy editor source and build
|
||||
COPY packages/meta-sort-editor/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Build Backend using pnpm workspace
|
||||
FROM node:21-alpine AS backend-builder
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace configuration
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy all packages for the workspace
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY packages/async-utils/ ./packages/async-utils/
|
||||
COPY packages/meta-sort-core/ ./packages/meta-sort-core/
|
||||
|
||||
# Install all dependencies
|
||||
RUN pnpm install --frozen-lockfile || pnpm install
|
||||
|
||||
# Build shared dependencies first
|
||||
RUN pnpm --filter="@metazla/meta-interface" build || true
|
||||
RUN pnpm --filter="@metazla/meta-hash" build || true
|
||||
RUN pnpm --filter="@metazla/filename-tools" build || true
|
||||
|
||||
# Build async-utils
|
||||
RUN pnpm --filter="@worph/async-utils" build || true
|
||||
|
||||
# Build the core package
|
||||
RUN pnpm --filter="@meta-sort/core" build
|
||||
|
||||
# Stage 4: Runtime
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Container registry metadata
|
||||
LABEL org.opencontainers.image.source=https://github.com/worph/meta-sort
|
||||
LABEL org.opencontainers.image.description="MetaMesh file processing and metadata extraction service"
|
||||
LABEL org.opencontainers.image.licenses=MIT
|
||||
|
||||
# Avoid prompts during install
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
gnupg \
|
||||
ca-certificates \
|
||||
nginx \
|
||||
redis-server \
|
||||
supervisor \
|
||||
ffmpeg \
|
||||
apache2-utils \
|
||||
fuse3 \
|
||||
unzip \
|
||||
cifs-utils \
|
||||
nfs-common \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js 21
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install rclone
|
||||
RUN curl https://rclone.org/install.sh | bash
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p \
|
||||
/app/backend \
|
||||
/app/ui \
|
||||
/app/editor \
|
||||
/data/watch \
|
||||
/data/cache \
|
||||
/data/redis \
|
||||
/data/mounts \
|
||||
/meta-core/db/redis \
|
||||
/meta-core/locks \
|
||||
/meta-core/services \
|
||||
/files \
|
||||
/var/log/supervisor \
|
||||
/var/log/nginx \
|
||||
/root/.config/rclone
|
||||
|
||||
# Copy built UI
|
||||
COPY --from=ui-builder /build/dist /app/ui
|
||||
RUN chmod -R 755 /app/ui
|
||||
|
||||
# Copy built Editor
|
||||
COPY --from=editor-builder /build/dist /app/editor
|
||||
RUN chmod -R 755 /app/editor
|
||||
|
||||
# Copy built backend with all dependencies
|
||||
COPY --from=backend-builder /build/packages/meta-sort-core/dist /app/backend/dist
|
||||
COPY --from=backend-builder /build/packages/meta-sort-core/package.json /app/backend/
|
||||
|
||||
# Copy plugins directory
|
||||
COPY --from=backend-builder /build/packages/meta-sort-core/plugins /app/backend/plugins
|
||||
|
||||
# Copy node_modules (has all deps including workspace packages)
|
||||
COPY --from=backend-builder /build/node_modules /app/node_modules
|
||||
|
||||
# Recreate ALL pnpm symlinks (pnpm stores in .pnpm, symlinks at top level)
|
||||
RUN for pkg_path in /app/node_modules/.pnpm/*/node_modules/*; do \
|
||||
pkg_name=$(basename "$pkg_path"); \
|
||||
if [ ! -L "/app/node_modules/$pkg_name" ] && [ ! -d "/app/node_modules/$pkg_name" ]; then \
|
||||
ln -sf "$pkg_path" /app/node_modules/$pkg_name 2>/dev/null || true; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Also handle scoped packages (@scope/name)
|
||||
RUN for scope_path in /app/node_modules/.pnpm/*/node_modules/@*; do \
|
||||
if [ -d "$scope_path" ]; then \
|
||||
scope_name=$(basename "$scope_path"); \
|
||||
mkdir -p "/app/node_modules/$scope_name" 2>/dev/null; \
|
||||
for pkg_path in "$scope_path"/*; do \
|
||||
pkg_name=$(basename "$pkg_path"); \
|
||||
if [ ! -L "/app/node_modules/$scope_name/$pkg_name" ] && [ ! -d "/app/node_modules/$scope_name/$pkg_name" ]; then \
|
||||
ln -sf "$pkg_path" /app/node_modules/$scope_name/$pkg_name 2>/dev/null || true; \
|
||||
fi; \
|
||||
done; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Copy built workspace packages (overwrite symlinks with actual dist)
|
||||
COPY --from=backend-builder /build/packages/shared/meta-hash/dist /app/node_modules/@metazla/meta-hash/dist
|
||||
COPY --from=backend-builder /build/packages/shared/meta-hash/package.json /app/node_modules/@metazla/meta-hash/
|
||||
COPY --from=backend-builder /build/packages/shared/meta-interface/dist /app/node_modules/@metazla/meta-interface/dist
|
||||
COPY --from=backend-builder /build/packages/shared/meta-interface/package.json /app/node_modules/@metazla/meta-interface/
|
||||
COPY --from=backend-builder /build/packages/shared/filename-tool/dist /app/node_modules/@metazla/filename-tools/dist
|
||||
COPY --from=backend-builder /build/packages/shared/filename-tool/package.json /app/node_modules/@metazla/filename-tools/
|
||||
COPY --from=backend-builder /build/packages/async-utils/dist /app/node_modules/@worph/async-utils/dist
|
||||
COPY --from=backend-builder /build/packages/async-utils/package.json /app/node_modules/@worph/async-utils/
|
||||
|
||||
# Copy meta-core sidecar binary
|
||||
COPY --from=meta-core /usr/local/bin/meta-core /usr/local/bin/meta-core
|
||||
RUN chmod +x /usr/local/bin/meta-core
|
||||
|
||||
# Copy configuration files
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/redis.conf /etc/redis/redis.conf
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# Create nginx htpasswd file (default: admin/admin)
|
||||
RUN htpasswd -bc /etc/nginx/.htpasswd admin admin
|
||||
|
||||
# Environment variables
|
||||
ENV NODE_ENV=production \
|
||||
NODE_PATH=/app/node_modules \
|
||||
WATCH_PATHS=/data/watch \
|
||||
META_CORE_PATH=/meta-core \
|
||||
FILES_PATH=/files \
|
||||
REDIS_URL=redis://127.0.0.1:6379 \
|
||||
REDIS_PREFIX=meta-sort: \
|
||||
API_HOST=0.0.0.0 \
|
||||
API_PORT=3000
|
||||
|
||||
# Set working directory for backend
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Expose port 80 (nginx)
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
|
||||
# Start supervisord (manages redis, backend, nginx)
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
@@ -0,0 +1,841 @@
|
||||
# meta-sort
|
||||
|
||||
A standalone file sorting and metadata extraction service for the MetaMesh ecosystem. meta-sort monitors watch folders, extracts metadata from media files, stores it in a distributed key-value store, and manages remote drive mounting for shared access.
|
||||
|
||||
## Overview
|
||||
|
||||
meta-sort is a **process-write** service that serves as the primary data ingestion point for the MetaMesh platform. It:
|
||||
|
||||
1. **Watches folders** for new media files (video, audio, subtitles)
|
||||
2. **Extracts metadata** using FFmpeg and filename parsing
|
||||
3. **Computes content identifiers** using midhash256 (fast) and SHA-256 (background)
|
||||
4. **Stores metadata** in a distributed KV store (Redis or etcd)
|
||||
5. **Manages remote mounts** (NFS, SMB, cloud storage) for shared access
|
||||
|
||||
---
|
||||
|
||||
## Meta-Core Architecture
|
||||
|
||||
MetaMesh uses **two shared volumes** for operation:
|
||||
|
||||
### 1. META_CORE_VOLUME - Infrastructure
|
||||
|
||||
The infrastructure volume stores all service coordination data:
|
||||
|
||||
| Context | Path | Description |
|
||||
|---------|------|-------------|
|
||||
| **Inside Container** | `/meta-core` | Mounted shared volume |
|
||||
| **Production (Host)** | `/DATA/Apps/meta-core` | External shared drive mount point |
|
||||
| **Development** | `./data/meta-core` | Local development directory |
|
||||
|
||||
```
|
||||
META_CORE_VOLUME (/meta-core or /DATA/Apps/meta-core)
|
||||
├── db/ # KV Database storage
|
||||
│ ├── redis/ # Redis persistence (RDB + AOF)
|
||||
│ │ ├── dump.rdb # Redis snapshot
|
||||
│ │ └── appendonly.aof # Redis append-only log
|
||||
│ └── etcd/ # etcd data (alternative)
|
||||
│ └── member/
|
||||
│
|
||||
├── locks/ # Distributed locking
|
||||
│ └── kv-leader.lock # Leader election lock file
|
||||
│ # Contains: {"host":"service-name","api":"http://host:port"}
|
||||
│
|
||||
├── services/ # Service discovery registry
|
||||
│ ├── meta-sort.json # {"name":"meta-sort","api":"http://...","status":"running","pid":123}
|
||||
│ ├── meta-fuse.json # {"name":"meta-fuse","api":"http://...","status":"running","pid":456}
|
||||
│ ├── meta-stremio.json # {"name":"meta-stremio","api":"http://...","status":"running","pid":789}
|
||||
│ └── meta-orbit.json # {"name":"meta-orbit","api":"http://...","status":"running","pid":321}
|
||||
│
|
||||
└── config/ # Shared configuration
|
||||
├── remote-storage.conf # Remote mount definitions
|
||||
└── smb-credentials.txt # SMB credentials (chmod 600)
|
||||
```
|
||||
|
||||
### 2. FILES_VOLUME - Shared Media Drive
|
||||
|
||||
The **FILES_VOLUME** (canonical name: `/files`) is the shared drive for all media files. This is where all actual media content resides, and file paths in the KV database reference this volume.
|
||||
|
||||
| Context | Path | Description |
|
||||
|---------|------|-------------|
|
||||
| **Inside Container** | `/files` | Mounted shared media volume |
|
||||
| **Production (Host)** | Varies (NFS/SMB/local) | External shared drive |
|
||||
| **Development** | `./data/files` | Local development directory |
|
||||
|
||||
```
|
||||
FILES_VOLUME (/files)
|
||||
├── media1/ # Local folder mounted from host
|
||||
│ ├── Movies/
|
||||
│ │ └── Inception (2010)/
|
||||
│ │ └── Inception.mkv
|
||||
│ └── TV Shows/
|
||||
│ └── Breaking Bad/
|
||||
│ └── Season 01/
|
||||
│
|
||||
├── media2/ # Another local media folder
|
||||
│ └── Anime/
|
||||
│ └── Steins Gate/
|
||||
│
|
||||
├── smb/ # SMB/CIFS remote mount
|
||||
│ └── NAS-Media/
|
||||
│ └── Movies/
|
||||
│
|
||||
├── nfs/ # NFS remote mount
|
||||
│ └── server-media/
|
||||
│
|
||||
└── gdrive/ # rclone remote mount (Google Drive)
|
||||
└── Cloud-Media/
|
||||
```
|
||||
|
||||
### File Path References
|
||||
|
||||
All file paths stored in the KV database are **relative to FILES_VOLUME**:
|
||||
|
||||
```
|
||||
KV Key: /file/{midhash256}/filePath
|
||||
KV Value: "media1/Movies/Inception (2010)/Inception.mkv"
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Relative to FILES_VOLUME (/files)
|
||||
|
||||
Full path construction:
|
||||
Container: /files + /media1/Movies/... = /files/media1/Movies/Inception (2010)/Inception.mkv
|
||||
Any host: $FILES_VOLUME + /media1/Movies/... = $FILES_VOLUME/media1/Movies/Inception (2010)/Inception.mkv
|
||||
```
|
||||
|
||||
This allows services to be deployed on different hosts while sharing the same volume. The FILES_VOLUME can be:
|
||||
- A local folder bind mount
|
||||
- An NFS share mounted on the host
|
||||
- An SMB/CIFS share
|
||||
- A FUSE mount (rclone, sshfs, etc.)
|
||||
|
||||
### Mount Management
|
||||
|
||||
meta-sort manages remote mounts **inside** the FILES_VOLUME for shared access:
|
||||
|
||||
```bash
|
||||
# meta-sort mounts remote storage INTO /files
|
||||
/files/smb/nas-share ← SMB mount managed by meta-sort
|
||||
/files/nfs/server ← NFS mount managed by meta-sort
|
||||
/files/gdrive/ ← rclone mount managed by meta-sort
|
||||
|
||||
# Other folders are bind mounts from host
|
||||
/files/media1 ← Host folder mounted into container
|
||||
/files/media2 ← Another host folder
|
||||
```
|
||||
|
||||
All services access files through `/files/*`, regardless of whether the underlying storage is local, NFS, SMB, or cloud.
|
||||
|
||||
---
|
||||
|
||||
## Leader Election and Lock Mechanism
|
||||
|
||||
### How It Works
|
||||
|
||||
MetaMesh uses a **filesystem-based distributed consensus** mechanism using `flock(2)`. The shared filesystem acts as the consensus layer.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Leader Election via flock │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Step 1: Service Startup │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ Service │ ──▶ Try to acquire flock on /meta-core/locks/kv-leader.lock│
|
||||
│ └─────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ flock() Result │ │
|
||||
│ │ ┌─────────────────────┐ ┌──────────────────────────────────┐ │ │
|
||||
│ │ │ Lock Acquired │ │ Lock Blocked │ │ │
|
||||
│ │ │ ────────────── │ │ ───────────── │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ BECOME LEADER │ │ BECOME FOLLOWER │ │ │
|
||||
│ │ │ 1. Spawn Redis │ │ 1. Read lock file │ │ │
|
||||
│ │ │ 2. Write API │ │ 2. Extract leader API │ │ │
|
||||
│ │ │ endpoint to │ │ 3. Connect to leader's │ │ │
|
||||
│ │ │ lock file │ │ Redis │ │ │
|
||||
│ │ │ 3. Accept clients │ │ 4. Start reconnect loop │ │ │
|
||||
│ │ └─────────────────────┘ └──────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Lock File Format
|
||||
|
||||
The lock file at `/meta-core/locks/kv-leader.lock` contains JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"host": "meta-sort-instance-1",
|
||||
"api": "redis://10.0.1.50:6379",
|
||||
"http": "http://10.0.1.50:3000",
|
||||
"timestamp": 1703808000000,
|
||||
"pid": 12345
|
||||
}
|
||||
```
|
||||
|
||||
### flock Implementation
|
||||
|
||||
```typescript
|
||||
import { open, flock } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const LOCK_FILE = '/meta-core/locks/kv-leader.lock';
|
||||
|
||||
async function tryBecomeLeader(): Promise<boolean> {
|
||||
const fd = await promisify(open)(LOCK_FILE, 'w');
|
||||
|
||||
try {
|
||||
// LOCK_EX = exclusive lock, LOCK_NB = non-blocking
|
||||
await promisify(flock)(fd, 'exnb'); // Throws if cannot acquire
|
||||
|
||||
// We are the leader - spawn KV database and write our endpoint
|
||||
await spawnRedis();
|
||||
await fs.writeFile(LOCK_FILE, JSON.stringify({
|
||||
host: hostname(),
|
||||
api: `redis://${myIP}:6379`,
|
||||
http: `http://${myIP}:3000`,
|
||||
timestamp: Date.now(),
|
||||
pid: process.pid
|
||||
}));
|
||||
|
||||
return true; // We are leader
|
||||
} catch (err) {
|
||||
if (err.code === 'EAGAIN' || err.code === 'EWOULDBLOCK') {
|
||||
// Lock held by another process - become follower
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Failure Detection and Recovery
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Failure Detection Loop │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Follower Service: │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ while (true) { │ │
|
||||
│ │ try { │ │
|
||||
│ │ await redis.ping(); // Health check │ │
|
||||
│ │ await sleep(5000); // Wait 5 seconds │ │
|
||||
│ │ } catch (connectionError) { │ │
|
||||
│ │ // Leader might be down │ │
|
||||
│ │ await tryBecomeLeader(); // Attempt to take over │ │
|
||||
│ │ if (isLeader) break; │ │
|
||||
│ │ await reconnectToNewLeader(); // Or connect to new leader │ │
|
||||
│ │ } │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ When Leader Dies: │
|
||||
│ 1. flock is automatically released by OS │
|
||||
│ 2. First follower to call flock() wins │
|
||||
│ 3. New leader spawns Redis, writes new endpoint │
|
||||
│ 4. Other followers detect connection failure, read new endpoint │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Discovery
|
||||
|
||||
### Registration Mechanism
|
||||
|
||||
Each service registers itself in `/meta-core/services/{service-name}.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "meta-sort",
|
||||
"version": "1.0.0",
|
||||
"api": "http://10.0.1.50:3000",
|
||||
"status": "running",
|
||||
"pid": 12345,
|
||||
"hostname": "meta-sort-instance-1",
|
||||
"startedAt": "2024-12-28T10:00:00Z",
|
||||
"lastHeartbeat": "2024-12-28T12:30:00Z",
|
||||
"capabilities": ["write", "mount", "monitor"],
|
||||
"endpoints": {
|
||||
"health": "/health",
|
||||
"api": "/api",
|
||||
"metrics": "/api/metrics"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Lifecycle
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Service Lifecycle │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Startup: │
|
||||
│ 1. Create/update /meta-core/services/{name}.json │
|
||||
│ 2. Set status = "starting" │
|
||||
│ 3. Initialize (connect to KV, etc.) │
|
||||
│ 4. Set status = "running" │
|
||||
│ 5. Start heartbeat loop (update lastHeartbeat every 30s) │
|
||||
│ │
|
||||
│ Running: │
|
||||
│ - Heartbeat updates lastHeartbeat timestamp │
|
||||
│ - Other services read this file to discover endpoints │
|
||||
│ - stale detection: if (now - lastHeartbeat > 60s) → service dead │
|
||||
│ │
|
||||
│ Shutdown: │
|
||||
│ 1. Set status = "stopping" │
|
||||
│ 2. Graceful shutdown (finish current work) │
|
||||
│ 3. Delete /meta-core/services/{name}.json OR set status = "stopped" │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Discovering Other Services
|
||||
|
||||
```typescript
|
||||
async function discoverService(name: string): Promise<ServiceInfo | null> {
|
||||
const servicePath = `/meta-core/services/${name}.json`;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(servicePath, 'utf-8');
|
||||
const service = JSON.parse(content);
|
||||
|
||||
// Check if service is alive (heartbeat within last 60 seconds)
|
||||
const lastHeartbeat = new Date(service.lastHeartbeat).getTime();
|
||||
const isAlive = Date.now() - lastHeartbeat < 60_000;
|
||||
|
||||
if (!isAlive) {
|
||||
console.warn(`Service ${name} appears stale (last heartbeat: ${service.lastHeartbeat})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return service;
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return null; // Service not registered
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MetaMesh Ecosystem Architecture
|
||||
|
||||
### Service Hierarchy
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MetaMesh Ecosystem │
|
||||
│ │
|
||||
│ Shared Volumes: │
|
||||
│ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ META_CORE_VOLUME │ │ FILES_VOLUME │ │
|
||||
│ │ (/meta-core) │ │ (/files) │ │
|
||||
│ │ ┌────────┐ ┌────────┐ ┌──────┐ │ │ ┌────────┐ ┌────────┐ ┌──────┐ │ │
|
||||
│ │ │ db/ │ │ locks/ │ │svc/ │ │ │ │media1/ │ │ smb/ │ │ nfs/ │ │ │
|
||||
│ │ │(Redis) │ │(flock) │ │(JSON)│ │ │ │(local) │ │(mount) │ │(mnt) │ │ │
|
||||
│ │ └────┬───┘ └───┬────┘ └──┬───┘ │ │ └───┬────┘ └───┬────┘ └──┬───┘ │ │
|
||||
│ └───────┼─────────┼─────────┼─────┘ └──────┼──────────┼─────────┼─────┘ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ ▼ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ ││
|
||||
│ │ [1] meta-sort PROCESS-WRITE ││
|
||||
│ │ ├── Watches /files/* for new media files ││
|
||||
│ │ ├── Extracts metadata → writes to KV (Redis in /meta-core/db) ││
|
||||
│ │ ├── Manages remote mounts INTO /files (NFS, SMB, rclone) ││
|
||||
│ │ ├── Leader election (spawns Redis if leader) ││
|
||||
│ │ └── Serves monitoring UI (meta-sort-editor) ││
|
||||
│ │ ││
|
||||
│ │ [2] meta-fuse PROCESS-READ ││
|
||||
│ │ ├── Reads metadata from KV (Redis) ││
|
||||
│ │ ├── Builds virtual filesystem from metadata ││
|
||||
│ │ ├── Serves FUSE mount + WebDAV ││
|
||||
│ │ └── File paths resolve to /files/* ││
|
||||
│ │ ││
|
||||
│ │ [3] meta-stremio PROCESS-READ ││
|
||||
│ │ ├── Reads metadata from KV (Redis) ││
|
||||
│ │ ├── Stremio addon server (manifest, catalog, streams) ││
|
||||
│ │ ├── HLS transcoding via FFmpeg ││
|
||||
│ │ └── Video files from /files/* ││
|
||||
│ │ ││
|
||||
│ │ [4] meta-orbit SHARING-READ-WRITE ││
|
||||
│ │ ├── P2P metadata synchronization (libp2p + OrbitDB) ││
|
||||
│ │ ├── Decentralized search across network ││
|
||||
│ │ ├── Reads from local KV, shares to network ││
|
||||
│ │ └── Receives from network, writes to local KV ││
|
||||
│ │ ││
|
||||
│ └───────────────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Data Flow │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ File Discovery: │
|
||||
│ │
|
||||
│ /files/media1/Movies/Inception (2010)/Inception.mkv │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ [meta-sort] ──▶ Extract metadata ──▶ Write to Redis (in /meta-core/db) │
|
||||
│ │ │ │
|
||||
│ │ Key: file:{midhash256} │ │
|
||||
│ │ filePath: "media1/Movies/Inception (2010)/Inception.mkv" │
|
||||
│ │ title: "Inception" │
|
||||
│ │ year: "2010" │
|
||||
│ │ duration: 8880 │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ │
|
||||
│ File Access: │
|
||||
│ │
|
||||
│ [meta-fuse] ◀── Read from Redis │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Virtual FS: /mnt/virtual/Movies/Inception (2010)/Inception.mkv │
|
||||
│ │ ↓ │
|
||||
│ │ symlink to /files/media1/Movies/Inception (2010)/... │
|
||||
│ │ │
|
||||
│ [meta-stremio] ◀── Read from Redis │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Stream: GET /stremio/stream/{midhash256}/playlist.m3u8 │
|
||||
│ │ ↓ │
|
||||
│ │ transcode /files/media1/Movies/Inception (2010)/... │
|
||||
│ │ │
|
||||
│ [meta-orbit] ◀──▶ Sync with P2P network │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Share metadata to peers / Receive metadata from peers │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Compose (Production)
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
meta-sort:
|
||||
image: metazla/meta-sort:latest
|
||||
volumes:
|
||||
# Infrastructure volume (KV DB, locks, services)
|
||||
- /DATA/Apps/meta-core:/meta-core:rw
|
||||
# Shared media volume
|
||||
- /DATA/Apps/meta-core/files:/files:rw,shared
|
||||
# OR mount multiple sources into /files
|
||||
# - /mnt/nas/media:/files/nas:ro
|
||||
# - /mnt/local/media:/files/local:ro
|
||||
environment:
|
||||
- META_CORE_PATH=/meta-core
|
||||
- FILES_PATH=/files
|
||||
- WATCH_PATHS=/files
|
||||
- REDIS_DATA_DIR=/meta-core/db/redis
|
||||
privileged: true # Required for rclone FUSE mounts
|
||||
|
||||
meta-fuse:
|
||||
image: metazla/meta-fuse:latest
|
||||
volumes:
|
||||
# Infrastructure (read-only for followers)
|
||||
- /DATA/Apps/meta-core:/meta-core:ro
|
||||
# Shared media volume (read-only)
|
||||
- /DATA/Apps/meta-core/files:/files:ro
|
||||
# FUSE mount output
|
||||
- /mnt/metamesh:/mnt/metamesh:rw,shared
|
||||
environment:
|
||||
- META_CORE_PATH=/meta-core
|
||||
- FILES_PATH=/files
|
||||
privileged: true # Required for FUSE
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
devices:
|
||||
- /dev/fuse
|
||||
|
||||
meta-stremio:
|
||||
image: metazla/meta-stremio:latest
|
||||
volumes:
|
||||
# Infrastructure (read-only)
|
||||
- /DATA/Apps/meta-core:/meta-core:ro
|
||||
# Shared media volume (read-only)
|
||||
- /DATA/Apps/meta-core/files:/files:ro
|
||||
environment:
|
||||
- META_CORE_PATH=/meta-core
|
||||
- FILES_PATH=/files
|
||||
ports:
|
||||
- "7000:7000"
|
||||
|
||||
meta-orbit:
|
||||
image: metazla/meta-orbit:latest
|
||||
volumes:
|
||||
# Infrastructure (read-write for P2P sync)
|
||||
- /DATA/Apps/meta-core:/meta-core:rw
|
||||
environment:
|
||||
- META_CORE_PATH=/meta-core
|
||||
ports:
|
||||
- "4001:4001" # libp2p
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `META_CORE_PATH` | `/meta-core` | Path to META_CORE_VOLUME (infrastructure) |
|
||||
| `FILES_PATH` | `/files` | Path to FILES_VOLUME (shared media) |
|
||||
| `REDIS_URL` | Auto-discover | Redis connection (from leader lock file) |
|
||||
| `WATCH_PATHS` | `$FILES_PATH` | Folders to watch for media |
|
||||
| `SERVICE_NAME` | Container hostname | Unique service identifier |
|
||||
|
||||
---
|
||||
|
||||
## File Processing Pipeline
|
||||
|
||||
meta-sort uses a **plugin-based architecture** with a two-queue task scheduler:
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌────────────────────────────────────────────────────┐
|
||||
│ Discovery │ │ Task Scheduler │
|
||||
│ (Stream) │───▶│ ┌─────────────────┐ ┌──────────────────────────┐ │
|
||||
└──────────────┘ │ │ Fast Queue │ │ Background Queue │ │
|
||||
Watch folder │ │ (16 workers) │ │ (4 workers) │ │
|
||||
+ manual scan │ │ <1s tasks │ │ >=1s tasks │ │
|
||||
│ └─────────────────┘ └──────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Tasks are automatically classified and scheduled based on plugin dependencies:
|
||||
|
||||
```
|
||||
Discovery → file-info → ffmpeg → filename-parser → jellyfin-nfo → tmdb
|
||||
↓
|
||||
anime-detector ← language ← subtitle ← torrent ← full-hash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin System
|
||||
|
||||
Plugins are the core processing units that extract and enrich metadata. Each plugin:
|
||||
- Has a `manifest.yml` defining metadata schema, config, and dependencies
|
||||
- Runs in either **fast** (high concurrency) or **background** (low concurrency) queue
|
||||
- Can depend on other plugins to ensure correct execution order
|
||||
|
||||
### Built-in Plugins
|
||||
|
||||
| Plugin | Queue | Priority | Description |
|
||||
|--------|-------|----------|-------------|
|
||||
| **file-info** | fast | 10 | Basic file info (type, MIME, size, midhash256) |
|
||||
| **ffmpeg** | fast | 15 | Video/audio/subtitle stream metadata via FFprobe |
|
||||
| **filename-parser** | fast | 20 | Parse title, season, episode, year, quality from filename |
|
||||
| **jellyfin-nfo** | fast | 25 | Parse Jellyfin/Kodi NFO metadata files |
|
||||
| **tmdb** | background | 30 | Fetch metadata from The Movie Database API |
|
||||
| **anime-detector** | fast | 35 | Detect anime content from keywords and audio tracks |
|
||||
| **language** | fast | 40 | Aggregate languages from all streams (ISO 639-3) |
|
||||
| **subtitle** | fast | 45 | Find sibling subtitle files and detect language |
|
||||
| **torrent** | fast | 50 | Parse .torrent file metadata |
|
||||
| **full-hash** | background | 100 | Compute SHA-256 hash of entire file |
|
||||
|
||||
### Plugin Manifest Example
|
||||
|
||||
```yaml
|
||||
id: ffmpeg
|
||||
name: FFmpeg Metadata
|
||||
version: 1.0.0
|
||||
description: Extracts video/audio/subtitle stream metadata using FFprobe
|
||||
priority: 15
|
||||
color: "#4CAF50"
|
||||
defaultQueue: fast
|
||||
|
||||
dependencies:
|
||||
- file-info
|
||||
|
||||
config: {}
|
||||
|
||||
schema:
|
||||
fileinfo/duration:
|
||||
label: Duration
|
||||
type: string
|
||||
readonly: true
|
||||
fileinfo/streamdetails/video:
|
||||
label: Video Streams
|
||||
type: json
|
||||
readonly: true
|
||||
pattern: "fileinfo/streamdetails/video/{n}/*"
|
||||
```
|
||||
|
||||
### Task Queue Architecture
|
||||
|
||||
- **Fast Queue** (16 workers): High-throughput for quick operations (<1s)
|
||||
- File info extraction, FFprobe, filename parsing, NFO reading
|
||||
- **Background Queue** (4 workers): Low-concurrency for slow operations (>=1s)
|
||||
- TMDB API calls, full SHA-256 hashing
|
||||
- Automatically pauses when fast queue has work, resumes when idle
|
||||
|
||||
### Plugin Management
|
||||
|
||||
Plugins can be enabled/disabled and configured via:
|
||||
- **Dashboard**: http://localhost:8180/ → Plugins tab
|
||||
- **Config file**: `plugins.json` in cache directory
|
||||
|
||||
---
|
||||
|
||||
## Processing States
|
||||
|
||||
1. **Pending** - File discovered in watch folder
|
||||
2. **Processing** - Plugins running (fast queue first, then background)
|
||||
3. **Done** - All plugins completed successfully
|
||||
|
||||
---
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
meta-sort/
|
||||
├── packages/
|
||||
│ ├── meta-sort-core/ # Core processing service (@meta-sort/core)
|
||||
│ │ ├── plugins/ # Built-in plugins
|
||||
│ │ │ ├── file-info/ # Basic file information
|
||||
│ │ │ ├── ffmpeg/ # FFprobe metadata extraction
|
||||
│ │ │ ├── filename-parser/# Filename parsing
|
||||
│ │ │ ├── jellyfin-nfo/ # NFO file parsing
|
||||
│ │ │ ├── tmdb/ # TMDB API integration
|
||||
│ │ │ ├── anime-detector/ # Anime content detection
|
||||
│ │ │ ├── language/ # Language aggregation
|
||||
│ │ │ ├── subtitle/ # Subtitle file finder
|
||||
│ │ │ ├── torrent/ # Torrent file parsing
|
||||
│ │ │ └── full-hash/ # SHA-256 full file hashing
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── api/ # Fastify REST API (UnifiedAPIServer)
|
||||
│ │ │ ├── config/ # Environment configuration
|
||||
│ │ │ ├── jellyfin/ # Jellyfin integration
|
||||
│ │ │ ├── kv/ # KV manager with leader election (Redis)
|
||||
│ │ │ ├── logic/ # File processing logic
|
||||
│ │ │ ├── metrics/ # Performance tracking
|
||||
│ │ │ ├── plugin-engine/ # Plugin loader and task scheduler
|
||||
│ │ │ ├── types/ # TypeScript types
|
||||
│ │ │ ├── utils/ # Utility functions
|
||||
│ │ │ └── index.ts # Entry point
|
||||
│ │ └── package.json
|
||||
│ │
|
||||
│ ├── meta-sort-editor/ # Metadata editor UI (@meta-sort/editor)
|
||||
│ │ └── ... # React/Vite app
|
||||
│ │
|
||||
│ ├── meta-sort-ui/ # Monitoring dashboard (@meta-sort/ui)
|
||||
│ │ └── ... # React/Vite app
|
||||
│ │
|
||||
│ └── async-utils/ # Async utility library
|
||||
│ └── ... # MultiQueue, PromiseQueue, etc.
|
||||
│
|
||||
├── docker/
|
||||
│ ├── nginx.conf # Reverse proxy config
|
||||
│ ├── redis.conf # Redis configuration
|
||||
│ └── supervisord.conf # Process management
|
||||
│
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json # Workspace root
|
||||
├── pnpm-workspace.yaml
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Key Components (meta-sort-core)
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| Entry Point | `src/index.ts` | Initializes KV manager, plugin engine, API server, file watching |
|
||||
| File Processor | `src/logic/WatchedFileProcessor.ts` | File discovery and plugin task scheduling |
|
||||
| Plugin Loader | `src/plugin-engine/PluginLoader.ts` | Loads plugins from manifest.yml, manages plugin lifecycle |
|
||||
| Task Scheduler | `src/plugin-engine/TaskScheduler.ts` | Two-queue scheduler (fast/background) with dependency resolution |
|
||||
| KV Manager | `src/kv/KVManager.ts` | Leader election via flock, Redis spawning, service discovery |
|
||||
| Unified API | `src/api/UnifiedAPIServer.ts` | Fastify REST API for monitoring and control |
|
||||
| Performance Metrics | `src/metrics/PerformanceMetrics.ts` | Tracks processing throughput and timing |
|
||||
| Config | `src/config/EnvConfig.ts` | Environment variable parsing |
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Folder Watching
|
||||
|
||||
- **Real-time monitoring** using chokidar for file system events
|
||||
- **Streaming discovery** via async generators for efficient memory usage
|
||||
- **Configurable watch paths** supporting local and remote mounted directories
|
||||
- **File type filtering** (video, audio, subtitle, torrent files)
|
||||
|
||||
### 2. Plugin-Based Metadata Extraction
|
||||
|
||||
Metadata extraction is handled by plugins that can be enabled/disabled and configured:
|
||||
|
||||
- **file-info** - Basic file info, MIME type, midhash256 content identifier
|
||||
- **ffmpeg** - Duration, codec, resolution, bitrate, audio tracks, subtitles via FFprobe
|
||||
- **filename-parser** - Extract title, year, season, episode, quality from filenames
|
||||
- **jellyfin-nfo** - Parse NFO sidecar files (Jellyfin/Kodi format)
|
||||
- **tmdb** - Fetch rich metadata from The Movie Database API
|
||||
- **anime-detector** - Detect anime content from keywords and audio tracks
|
||||
- **language** - Aggregate languages from all streams (ISO 639-3)
|
||||
- **subtitle** - Find sibling subtitle files and detect language
|
||||
- **torrent** - Parse .torrent file metadata
|
||||
- **full-hash** - Compute SHA-256 hash of entire file (background)
|
||||
|
||||
### 3. KV Storage
|
||||
|
||||
- **Nested key architecture** for property-level granularity
|
||||
- **Leader election** via flock for high availability
|
||||
- **Service discovery** via shared filesystem
|
||||
- **CRDT-compatible** for distributed synchronization
|
||||
|
||||
```
|
||||
Key Structure:
|
||||
/file/{midhash256}/title → "Inception"
|
||||
/file/{midhash256}/year → "2010"
|
||||
/file/{midhash256}/video/codec → "h265"
|
||||
/file/{midhash256}/audio/0/language → "en"
|
||||
/file/{midhash256}/filePath → "media1/Movies/Inception (2010)/Inception.mkv"
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
Relative to FILES_VOLUME (/files)
|
||||
```
|
||||
|
||||
### 4. Remote Mount Management
|
||||
|
||||
- **NFS shares** - Network file systems
|
||||
- **SMB/CIFS** - Windows network shares with credentials
|
||||
- **rclone** - Cloud storage (Google Drive, Dropbox, S3, etc.)
|
||||
- **Automatic mounting** on startup
|
||||
- **Mounts created inside FILES_VOLUME** for shared access by all services
|
||||
|
||||
Mounts are created inside `/files/` (FILES_VOLUME) so all services can access them:
|
||||
|
||||
```bash
|
||||
# Example mount structure
|
||||
/files/ # FILES_VOLUME
|
||||
├── media1/ # Local folder bind mount from host
|
||||
├── media2/ # Another local folder
|
||||
├── smb/ # SMB/CIFS remote mounts
|
||||
│ └── nas-share/ # SMB: //server/share
|
||||
├── nfs/ # NFS remote mounts
|
||||
│ └── server-media/ # NFS: nas.local:/volume1/media
|
||||
└── gdrive/ # rclone: gdrive:Media
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Volume paths
|
||||
META_CORE_PATH=/meta-core # Infrastructure volume
|
||||
FILES_PATH=/files # Shared media volume
|
||||
|
||||
# Watch paths (comma-separated, relative to FILES_PATH or absolute)
|
||||
WATCH_PATHS=/files
|
||||
|
||||
# Processing configuration
|
||||
MAX_WORKER_THREADS=16 # Defaults to CPU count
|
||||
LIGHT_PROCESSING_TIMEOUT_MS=120000
|
||||
HASH_PROCESSING_TIMEOUT_MS=7200000
|
||||
|
||||
# Remote mount configuration
|
||||
REMOTE_STORAGE_CONFIG=/meta-core/config/remote-storage.conf
|
||||
```
|
||||
|
||||
### Remote Storage Configuration
|
||||
|
||||
Create `/meta-core/config/remote-storage.conf`:
|
||||
|
||||
```conf
|
||||
# NFS mount (into FILES_VOLUME)
|
||||
nfs://nas.local:/volume1/media /files/nfs/nas-media nfs defaults,rw 0 0
|
||||
|
||||
# SMB mount (credentials in separate file)
|
||||
//server/share /files/smb/nas-share cifs credentials=/meta-core/config/smb-creds.txt,uid=1000 0 0
|
||||
|
||||
# rclone mount
|
||||
gdrive: /files/gdrive rclone vfs-cache-mode=full 0 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/health` | GET | Service health check |
|
||||
| `/api/scan/trigger` | POST | Trigger manual folder scan |
|
||||
| `/api/processing/status` | GET | Current pipeline state |
|
||||
| `/api/metrics` | GET | Performance metrics |
|
||||
| `/api/mounts` | GET | List mounted remote storage |
|
||||
| `/api/mounts` | POST | Add new remote mount |
|
||||
| `/api/mounts/{id}` | DELETE | Remove remote mount |
|
||||
|
||||
### Processing Status Response
|
||||
|
||||
```json
|
||||
{
|
||||
"totalPending": 150,
|
||||
"totalLightProcessing": 16,
|
||||
"totalHashProcessing": 8,
|
||||
"totalDone": 45000,
|
||||
"totalFilesInVFS": 45150,
|
||||
"throughput": {
|
||||
"filesPerSecond": 34,
|
||||
"avgLightProcessingMs": 470,
|
||||
"avgHashProcessingMs": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build all packages
|
||||
pnpm run build
|
||||
|
||||
# Run in development mode
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# Check health
|
||||
curl http://localhost/health
|
||||
curl http://localhost/api/processing/status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
@@ -0,0 +1,91 @@
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# Upstream servers
|
||||
upstream api {
|
||||
server 127.0.0.1:3000;
|
||||
}
|
||||
|
||||
upstream rclone {
|
||||
server 127.0.0.1:5572;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Static files (UI)
|
||||
root /app/ui;
|
||||
index index.html;
|
||||
|
||||
# SPA routing - serve index.html for any non-file request
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Editor UI - SPA with base path /editor/
|
||||
location /editor/ {
|
||||
alias /app/editor/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /editor/index.html;
|
||||
}
|
||||
|
||||
# Redirect /editor to /editor/
|
||||
location = /editor {
|
||||
return 301 /editor/;
|
||||
}
|
||||
|
||||
# API routes
|
||||
location /api/ {
|
||||
proxy_pass http://api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Health check (direct to API)
|
||||
location /health {
|
||||
proxy_pass http://api/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# rclone web UI
|
||||
location /rclone/ {
|
||||
proxy_pass http://rclone/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Basic auth for rclone
|
||||
auth_basic "rclone";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Redis configuration for meta-sort
|
||||
# Optimized for metadata storage
|
||||
|
||||
# Basic settings
|
||||
bind 0.0.0.0
|
||||
port 6379
|
||||
protected-mode no
|
||||
daemonize no
|
||||
|
||||
# Persistence
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
dir /data/redis
|
||||
|
||||
# Memory management
|
||||
maxmemory 256mb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# Logging
|
||||
loglevel notice
|
||||
logfile ""
|
||||
|
||||
# Performance
|
||||
tcp-keepalive 300
|
||||
timeout 0
|
||||
|
||||
# Disable dangerous commands in production
|
||||
rename-command FLUSHDB ""
|
||||
rename-command FLUSHALL ""
|
||||
rename-command DEBUG ""
|
||||
@@ -0,0 +1,69 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
[unix_http_server]
|
||||
file=/var/run/supervisor.sock
|
||||
chmod=0700
|
||||
|
||||
[rpcinterface:supervisor]
|
||||
supervisor.rpc_interface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||
|
||||
[supervisorctl]
|
||||
serverurl=unix:///var/run/supervisor.sock
|
||||
|
||||
[program:meta-core]
|
||||
command=/usr/local/bin/meta-core
|
||||
priority=5
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
startsecs=3
|
||||
environment=
|
||||
META_CORE_PATH="/meta-core",
|
||||
FILES_PATH="/files",
|
||||
SERVICE_NAME="meta-sort",
|
||||
SERVICE_VERSION="1.0.0",
|
||||
META_CORE_HTTP_PORT="9000",
|
||||
REDIS_PORT="6379"
|
||||
|
||||
[program:nginx]
|
||||
command=/usr/sbin/nginx -g "daemon off;"
|
||||
priority=20
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/var/log/supervisor/nginx.log
|
||||
stderr_logfile=/var/log/supervisor/nginx_error.log
|
||||
startsecs=2
|
||||
|
||||
[program:rclone]
|
||||
command=/usr/bin/rclone rcd --rc-web-gui --rc-web-gui-no-open-browser --rc-addr=:5572 --rc-user=admin --rc-pass=admin
|
||||
priority=30
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/var/log/supervisor/rclone.log
|
||||
stderr_logfile=/var/log/supervisor/rclone_error.log
|
||||
startsecs=3
|
||||
|
||||
[program:meta-sort]
|
||||
command=/usr/bin/node /app/backend/dist/index.js
|
||||
directory=/app/backend
|
||||
priority=40
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
startsecs=5
|
||||
environment=
|
||||
REDIS_URL="redis://127.0.0.1:6379",
|
||||
REDIS_PREFIX="",
|
||||
API_HOST="0.0.0.0",
|
||||
API_PORT="3000"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "meta-sort",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Standalone file sorting and metadata extraction service",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"start": "pnpm --filter @meta-sort/core start",
|
||||
"start:core": "pnpm --filter @meta-sort/core start",
|
||||
"start:ui": "pnpm --filter @meta-sort/ui dev",
|
||||
"test": "pnpm --filter @meta-sort/core test",
|
||||
"lint": "pnpm -r lint"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Pierre-Henri
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Async Utils
|
||||
|
||||
A TypeScript library providing utility classes and functions to simplify working with asynchronous operations and promises.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
yarn add github:worph/async-utils#main
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Lazy<T>**: A utility type for lazy initialization of asynchronous operations.
|
||||
- **ListenerCleaner**: Manages cleanup functions, allowing for easy resource management.
|
||||
- **PromiseQueue**: A queue system for promises, allowing for sequential execution and cancellation.
|
||||
- **MultiQueue**: Manages multiple `PromiseQueue` instances, distributing tasks to optimize concurrency.
|
||||
- **Id Generation**: Utility function for generating random IDs.
|
||||
|
||||
## Usage
|
||||
|
||||
### Lazy Initialization
|
||||
|
||||
```typescript
|
||||
import { Lazy } from "@worph/async-utils";
|
||||
|
||||
const lazyValue: Lazy<number> = async () => {
|
||||
// some asynchronous operation
|
||||
return 42;
|
||||
};
|
||||
|
||||
// Usage
|
||||
lazyValue().then(value => console.log(value));
|
||||
```
|
||||
|
||||
### Listener Cleaner
|
||||
|
||||
```typescript
|
||||
import { ListenerCleaner } from "worph/async-utils";
|
||||
|
||||
const cleaner = new ListenerCleaner();
|
||||
cleaner.add(() => console.log("Cleanup action"));
|
||||
|
||||
// Trigger all cleanup actions
|
||||
cleaner.cleanUp();
|
||||
```
|
||||
|
||||
### Promise Queue
|
||||
|
||||
```typescript
|
||||
import { PromiseQueue } from "@worph/async-utils";
|
||||
|
||||
const queue = new PromiseQueue<number>();
|
||||
|
||||
queue.add(async () => {
|
||||
// some asynchronous task
|
||||
return 1;
|
||||
});
|
||||
|
||||
// Wait for the queue to be empty
|
||||
queue.awaitQueueEmpty().then(() => console.log("Queue empty"));
|
||||
```
|
||||
|
||||
### Multi Queue
|
||||
|
||||
```typescript
|
||||
import { MultiQueue } from "@worph/async-utils";
|
||||
|
||||
const multiQueue = new MultiQueue<number>(2); // 2 concurrent tasks
|
||||
|
||||
multiQueue.add(async () => {
|
||||
// some asynchronous task
|
||||
return 1;
|
||||
});
|
||||
|
||||
// Add more tasks...
|
||||
|
||||
// Wait for all queues to be empty
|
||||
multiQueue.awaitQueueEmpty().then(() => console.log("All queues empty"));
|
||||
```
|
||||
|
||||
### Generating an ID
|
||||
|
||||
```typescript
|
||||
import { makeid } from "@worph/async-utils";
|
||||
|
||||
const id = makeid(10); // Generates a random 10 character string
|
||||
console.log(id);
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please submit a pull request or open an issue for discussion.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@worph/async-utils",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/ApteroSAS/async-utils.git"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ApteroSAS/async-utils/issues"
|
||||
},
|
||||
"homepage": "https://github.com/ApteroSAS/async-utils#readme",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"jest": "^29.5.0",
|
||||
"tsup": "^6.7.0",
|
||||
"typescript": "^5.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function makeid(length) {
|
||||
let result = '';
|
||||
let characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let charactersLength = characters.length;
|
||||
for ( let i = 0; i < length; i++ ) {
|
||||
result += characters.charAt(Math.floor(Math.random() *
|
||||
charactersLength));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
export type Lazy<T> = () => Promise<T>;
|
||||
@@ -0,0 +1,23 @@
|
||||
export class ListenerCleaner {
|
||||
cleaners: (() => void)[] = [];
|
||||
|
||||
add(cleanerCallback: () => void) {
|
||||
this.cleaners.push(cleanerCallback);
|
||||
}
|
||||
|
||||
cleaner(): () => void {
|
||||
return () => {
|
||||
this.cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call all the cleaner callbacks and reset this cleaner to be reused.
|
||||
*/
|
||||
cleanUp() {
|
||||
for (const cleaner of this.cleaners) {
|
||||
cleaner();
|
||||
}
|
||||
this.cleaners = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {PromiseQueue} from "./PromiseQueue";
|
||||
|
||||
export class MultiQueue<T> {
|
||||
private readonly queues:PromiseQueue<T>[];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param concurrentTask
|
||||
*/
|
||||
constructor(
|
||||
concurrentTask:number=1,
|
||||
) {
|
||||
if (concurrentTask<1){
|
||||
throw new Error("concurrentTask must be >=1");
|
||||
}
|
||||
this.queues = [];
|
||||
for(let i=0;i<concurrentTask;i++){
|
||||
this.queues.push(new PromiseQueue<T>());
|
||||
}
|
||||
}
|
||||
|
||||
public getQueueSize(): number {
|
||||
let ret = 0;
|
||||
for(let queue of this.queues){
|
||||
ret+=queue.getQueueSize();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
public async awaitQueueEmpty(){
|
||||
for(let queue of this.queues){
|
||||
await queue.awaitQueueEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a task to the queue. The queue with the smallest task size is used
|
||||
* @param task
|
||||
* @param id
|
||||
**/
|
||||
public add(task: () => Promise<T>, id: string = null): { id: string, promise: Promise<T> } {
|
||||
let queue = this.queues[0];
|
||||
for(let i=1;i<this.queues.length;i++){
|
||||
if(this.queues[i].getQueueSize()<queue.getQueueSize()){
|
||||
queue = this.queues[i];
|
||||
}
|
||||
}
|
||||
return queue.add(task,id);
|
||||
}
|
||||
|
||||
public cancel(id: string) {
|
||||
for(let queue of this.queues){
|
||||
queue.cancel(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The last task in progress is not canceled
|
||||
*/
|
||||
public async cancelAll(){
|
||||
for(let queue of this.queues){
|
||||
await queue.cancelAll();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {makeid} from "./Id";
|
||||
|
||||
export class PromiseQueue<T> {
|
||||
private nextPromise: Promise<T> = Promise.resolve(null);
|
||||
private canceled = {};
|
||||
private queueSize = 0;
|
||||
private queueID = 0;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param onlyLast if true all task are cancel and only the last one is keep alive (a task started still finishes)
|
||||
*/
|
||||
constructor(
|
||||
private onlyLast: boolean = false
|
||||
) {
|
||||
this.cancelAll();
|
||||
}
|
||||
|
||||
getQueueSize(): number {
|
||||
return this.queueSize;
|
||||
}
|
||||
|
||||
async awaitQueueEmpty(){
|
||||
await this.nextPromise;//await the current promise
|
||||
while (this.queueSize!=0){//await all the fowllofing promise
|
||||
await this.nextPromise;
|
||||
}
|
||||
}
|
||||
|
||||
add(task: () => Promise<T>, id: string = null): { id: string, promise: Promise<T> } {
|
||||
if (!id) {
|
||||
id = "promise-" + makeid(8);
|
||||
}
|
||||
let lastPromise = this.nextPromise;
|
||||
this.queueSize++;
|
||||
let currentID = this.queueID;
|
||||
let currentPromise=null;
|
||||
currentPromise = (async () => {
|
||||
this.queueSize--;
|
||||
try {
|
||||
await lastPromise;
|
||||
}catch (e) {
|
||||
/* ignore error of previous promise*/
|
||||
}
|
||||
if(currentID!==this.queueID){
|
||||
throw new Error("canceled caused by new queue");
|
||||
}
|
||||
if (this.canceled[id]) {
|
||||
delete this.canceled[id];
|
||||
throw new Error("canceled by user");
|
||||
}
|
||||
if(this.onlyLast && currentPromise!=this.nextPromise){
|
||||
throw new Error("canceled caused by not last");
|
||||
}
|
||||
//execute task and return result
|
||||
return task();
|
||||
})();
|
||||
currentPromise.catch(e=>{
|
||||
/* we catch the exception this way to avoid an uncaught exception in the console*/
|
||||
});
|
||||
this.nextPromise = currentPromise
|
||||
return {id, promise: this.nextPromise};
|
||||
}
|
||||
|
||||
cancel(id: string) {
|
||||
this.canceled[id] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The last task in progress is not canceled
|
||||
*/
|
||||
async cancelAll(){
|
||||
this.queueID++;//create a new queue will cancel all previous task except the one in progress
|
||||
await this.awaitQueueEmpty();
|
||||
this.nextPromise = Promise.resolve(null);
|
||||
this.canceled = {};
|
||||
this.queueSize = 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./ListenerCleaner";
|
||||
export * from "./PromiseQueue";
|
||||
export * from "./MultiQueue";
|
||||
export * from "./Id";
|
||||
export * from "./Lazy";
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,//needed for inversify
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": false,
|
||||
"noLib": false,
|
||||
"skipLibCheck": true,
|
||||
"removeComments": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"test/*",
|
||||
"examples/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts:true,
|
||||
minify:true,
|
||||
format: ['cjs', 'esm'],
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
# meta-sort-core Environment Configuration
|
||||
# Copy this file to .env and adjust values as needed
|
||||
|
||||
# =============================================================================
|
||||
# Required Settings
|
||||
# =============================================================================
|
||||
|
||||
# Comma-separated list of folders to watch for media files (REQUIRED)
|
||||
WATCH_FOLDER_LIST=/files
|
||||
|
||||
# =============================================================================
|
||||
# Volume Paths (New Architecture)
|
||||
# =============================================================================
|
||||
|
||||
# Path to META_CORE_VOLUME - infrastructure volume for KV DB, locks, services
|
||||
# This is where Redis data, leader election locks, and service discovery files are stored
|
||||
META_CORE_PATH=/meta-core
|
||||
|
||||
# Path to FILES_VOLUME - shared media volume
|
||||
# All file paths in the KV database are relative to this path
|
||||
FILES_PATH=/files
|
||||
|
||||
# =============================================================================
|
||||
# KV Storage Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Use legacy etcd mode instead of Redis (default: false)
|
||||
# Set to 'true' to use etcd instead of Redis
|
||||
USE_ETCD=false
|
||||
|
||||
# etcd API URL (only used if USE_ETCD=true)
|
||||
# ETCD_URL=http://localhost:2379
|
||||
|
||||
# Direct Redis URL (skip leader election if provided)
|
||||
# If set, the service will connect directly to this Redis instance
|
||||
# instead of participating in leader election
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Redis port for leader to listen on (default: 6379)
|
||||
REDIS_PORT=6379
|
||||
|
||||
# =============================================================================
|
||||
# Service Identity
|
||||
# =============================================================================
|
||||
|
||||
# Service name for leader election and service discovery
|
||||
SERVICE_NAME=meta-sort
|
||||
|
||||
# Service version
|
||||
SERVICE_VERSION=1.0.0
|
||||
|
||||
# =============================================================================
|
||||
# API Server
|
||||
# =============================================================================
|
||||
|
||||
# FUSE API server port (default: 3000)
|
||||
FUSE_API_PORT=3000
|
||||
|
||||
# FUSE API server host (default: localhost)
|
||||
FUSE_API_HOST=0.0.0.0
|
||||
|
||||
# =============================================================================
|
||||
# Processing Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Maximum number of worker threads for Piscina worker pool
|
||||
# Default: CPU count
|
||||
# MAX_WORKER_THREADS=16
|
||||
|
||||
# Interval in milliseconds for polling watch folders (0 to disable)
|
||||
# WATCH_FOLDER_POOLING_INTERVAL_MS=0
|
||||
|
||||
# Metadata formats to generate: 'meta' (.meta YAML), 'jellyfin' (.nfo XML)
|
||||
METADATA_FORMATS=meta
|
||||
|
||||
# =============================================================================
|
||||
# Cache Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Path to hash index cache
|
||||
INDEX_FOLDER_PATH=/data/cache/hash
|
||||
|
||||
# Path to plugin cache folder
|
||||
CACHE_FOLDER_PATH=/data/cache/plugins
|
||||
|
||||
# =============================================================================
|
||||
# Jellyfin Integration (Optional)
|
||||
# =============================================================================
|
||||
|
||||
# Jellyfin server endpoint
|
||||
# JELLYFIN_ENDPOINT=http://localhost:8096
|
||||
|
||||
# Jellyfin API key
|
||||
# JELLYFIN_API_KEY=your-api-key-here
|
||||
|
||||
# =============================================================================
|
||||
# Polling Paths (Optional)
|
||||
# =============================================================================
|
||||
|
||||
# JSON array of polling path configurations for paths that don't support inotify
|
||||
# Example: '[{"path": "/files/smb/downloads", "intervalMs": 60000}]'
|
||||
# POLLING_PATHS=[]
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@meta-sort/core",
|
||||
"version": "1.0.0",
|
||||
"description": "Core file sorting and metadata extraction service",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"start": "node dist/index.js",
|
||||
"exec": "node dist/index.js",
|
||||
"test": "mocha",
|
||||
"lint": "eslint src --ext ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"@metazla/filename-tools": "github:worph/filename-tool",
|
||||
"@metazla/meta-hash": "workspace:*",
|
||||
"@metazla/meta-interface": "workspace:*",
|
||||
"@worph/async-utils": "workspace:*",
|
||||
"axios": "^1.6.7",
|
||||
"bencode": "^4.0.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"dockerode": "^4.0.2",
|
||||
"dotenv": "^16.4.1",
|
||||
"fastify": "^5.6.1",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"iso-639-1": "^3.1.2",
|
||||
"lodash": "^4.17.21",
|
||||
"multiformats": "^13.1.0",
|
||||
"p-queue": "^8.0.1",
|
||||
"piscina": "^4.4.0",
|
||||
"tslog": "^4.9.2",
|
||||
"wanakana": "^5.3.1",
|
||||
"xml2js": "^0.6.2",
|
||||
"yaml": "^2.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.11",
|
||||
"@types/dockerode": "^3.3.31",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/node": "^20.11.16",
|
||||
"chai": "^5.0.3",
|
||||
"mocha": "^10.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* HTTP REST API Server for Duplicate Files
|
||||
*
|
||||
* Provides endpoints for accessing duplicate file detection results.
|
||||
* Separate from FUSE API to maintain proper separation of concerns.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
|
||||
export interface DuplicatesAPIConfig {
|
||||
/** Port to listen on (default: 3003) */
|
||||
port?: number;
|
||||
/** Host to bind to (default: 'localhost') */
|
||||
host?: string;
|
||||
/** Enable CORS (default: true) */
|
||||
enableCors?: boolean;
|
||||
/** Mount path for duplicates endpoint (default: '/duplicates') */
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export class DuplicatesAPI {
|
||||
private server: http.Server | null = null;
|
||||
private config: Required<DuplicatesAPIConfig>;
|
||||
|
||||
constructor(config: DuplicatesAPIConfig = {}) {
|
||||
this.config = {
|
||||
port: config.port ?? 3003,
|
||||
host: config.host ?? 'localhost',
|
||||
enableCors: config.enableCors ?? true,
|
||||
mountPath: config.mountPath ?? '/duplicates'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(this.handleRequest.bind(this));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
console.log(`Duplicates API listening on http://${this.config.host}:${this.config.port}${this.config.mountPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Duplicates API stopped');
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS headers
|
||||
if (this.config.enableCors) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method!;
|
||||
|
||||
// Route the request
|
||||
if (method === 'GET' && pathname === this.config.mountPath) {
|
||||
await this.handleDuplicates(res);
|
||||
} else if (method === 'GET' && pathname === '/health') {
|
||||
this.handleHealth(res);
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
this.sendError(res, 500, 'Internal Server Error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any, statusCode: number = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string, error?: any): void {
|
||||
this.sendJson(res, {
|
||||
error: message,
|
||||
details: error?.message ?? error
|
||||
}, statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /duplicates
|
||||
* Get duplicate files
|
||||
* NOTE: This API is deprecated. Use UnifiedAPIServer's /api/duplicates endpoint instead.
|
||||
* Disk-based duplicate logging has been removed for performance.
|
||||
*/
|
||||
private async handleDuplicates(res: http.ServerResponse): Promise<void> {
|
||||
try {
|
||||
// Return empty results - duplicate detection now happens in-memory via UnifiedAPIServer
|
||||
const hashDuplicates: Array<{ key: string; files: string[]; virtualPath?: string }> = [];
|
||||
const titleDuplicates: Array<{ key: string; files: string[]; virtualPath?: string }> = [];
|
||||
|
||||
this.sendJson(res, {
|
||||
hashDuplicates,
|
||||
titleDuplicates,
|
||||
stats: {
|
||||
hashGroupCount: 0,
|
||||
hashFileCount: 0,
|
||||
titleGroupCount: 0,
|
||||
titleFileCount: 0
|
||||
},
|
||||
deprecated: true,
|
||||
message: 'This API is deprecated. Use UnifiedAPIServer /api/duplicates endpoint instead.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in duplicates endpoint:', error);
|
||||
this.sendError(res, 500, 'Failed to get duplicate data', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
this.sendJson(res, {
|
||||
status: 'ok',
|
||||
service: 'duplicates-api',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* FUSE API for meta-mesh
|
||||
*
|
||||
* This API provides a virtual filesystem interface that can be consumed by a FUSE driver.
|
||||
* It exposes the computed folder structure without creating actual hardlinks.
|
||||
*/
|
||||
|
||||
import { HashMeta } from '@metazla/meta-interface';
|
||||
import { Stats } from 'fs';
|
||||
|
||||
/**
|
||||
* Represents a file or directory in the virtual filesystem
|
||||
*/
|
||||
export interface VirtualNode {
|
||||
/** Virtual path in the output filesystem */
|
||||
path: string;
|
||||
/** Node type */
|
||||
type: 'file' | 'directory';
|
||||
/** File name */
|
||||
name: string;
|
||||
/** Parent directory path */
|
||||
parent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a regular file in the virtual filesystem
|
||||
*/
|
||||
export interface VirtualFile extends VirtualNode {
|
||||
type: 'file';
|
||||
/** Source file path on the real filesystem (null for virtual files like .meta) */
|
||||
sourcePath: string | null;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified time */
|
||||
mtime: Date;
|
||||
/** Creation time */
|
||||
ctime: Date;
|
||||
/** File permissions (octal) */
|
||||
mode: number;
|
||||
/** Associated metadata */
|
||||
metadata?: HashMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a directory in the virtual filesystem
|
||||
*/
|
||||
export interface VirtualDirectory extends VirtualNode {
|
||||
type: 'directory';
|
||||
/** Child nodes (file/directory names) */
|
||||
children: string[];
|
||||
/** Directory permissions (octal) */
|
||||
mode: number;
|
||||
/** Last modified time */
|
||||
mtime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* File attributes returned by getattr() - compatible with FUSE stat structure
|
||||
*/
|
||||
export interface FileAttributes {
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** File mode (permissions + type) */
|
||||
mode: number;
|
||||
/** Last modified time (Unix timestamp in seconds) */
|
||||
mtime: number;
|
||||
/** Last access time (Unix timestamp in seconds) */
|
||||
atime: number;
|
||||
/** Creation/change time (Unix timestamp in seconds) */
|
||||
ctime: number;
|
||||
/** Number of hard links */
|
||||
nlink: number;
|
||||
/** User ID */
|
||||
uid: number;
|
||||
/** Group ID */
|
||||
gid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read result for file content
|
||||
*/
|
||||
export interface ReadResult {
|
||||
/** For regular files: source path to read from. For virtual files: null */
|
||||
sourcePath: string | null;
|
||||
/** For virtual files: generated content. For regular files: null */
|
||||
content: Buffer | null;
|
||||
/** Total file size */
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* FUSE API operations
|
||||
*/
|
||||
export interface IFuseAPI {
|
||||
/**
|
||||
* List directory contents
|
||||
* @param path Virtual directory path (e.g., "/Anime/Naruto/Season 1")
|
||||
* @returns Array of file/directory names in the directory, or null if not found
|
||||
*/
|
||||
readdir(path: string): string[] | null;
|
||||
|
||||
/**
|
||||
* Get file or directory attributes
|
||||
* @param path Virtual file or directory path
|
||||
* @returns File attributes, or null if not found
|
||||
*/
|
||||
getattr(path: string): FileAttributes | null;
|
||||
|
||||
/**
|
||||
* Check if a path exists
|
||||
* @param path Virtual path
|
||||
* @returns true if path exists, false otherwise
|
||||
*/
|
||||
exists(path: string): boolean;
|
||||
|
||||
/**
|
||||
* Get read information for a file
|
||||
* @param path Virtual file path
|
||||
* @returns Read result with source path or content, or null if not found
|
||||
*/
|
||||
read(path: string): ReadResult | null;
|
||||
|
||||
/**
|
||||
* Get metadata for a file
|
||||
* @param path Virtual file path
|
||||
* @returns HashMeta if available, null otherwise
|
||||
*/
|
||||
getMetadata(path: string): HashMeta | null;
|
||||
|
||||
/**
|
||||
* Get the complete virtual filesystem tree
|
||||
* @returns Root directory node
|
||||
*/
|
||||
getTree(): VirtualDirectory;
|
||||
|
||||
/**
|
||||
* Refresh the virtual filesystem from current state
|
||||
* Called after processing completes to update the VFS
|
||||
*/
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the FUSE API
|
||||
*/
|
||||
export interface FuseAPIEvents {
|
||||
/** Emitted when the virtual filesystem is updated */
|
||||
'vfs-updated': () => void;
|
||||
/** Emitted when a file is added */
|
||||
'file-added': (path: string) => void;
|
||||
/** Emitted when a file is updated (e.g., metadata changed) */
|
||||
'file-updated': (path: string) => void;
|
||||
/** Emitted when a file is removed */
|
||||
'file-removed': (path: string) => void;
|
||||
/** Emitted when a directory is added */
|
||||
'directory-added': (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for FUSE API
|
||||
*/
|
||||
export interface FuseAPIConfig {
|
||||
/** Base path for the virtual filesystem (default: "/") */
|
||||
basePath?: string;
|
||||
/** Default file permissions (default: 0o644) */
|
||||
fileMode?: number;
|
||||
/** Default directory permissions (default: 0o755) */
|
||||
directoryMode?: number;
|
||||
/** User ID for files (default: process.getuid()) */
|
||||
uid?: number;
|
||||
/** Group ID for files (default: process.getgid()) */
|
||||
gid?: number;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* HTTP REST API Server for FUSE operations
|
||||
*
|
||||
* Exposes the VirtualFileSystem over HTTP for consumption by a FUSE driver.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { VirtualFileSystem } from './VirtualFileSystem.js';
|
||||
import { FileAttributes, ReadResult } from './FuseAPI.js';
|
||||
|
||||
export interface FuseAPIServerConfig {
|
||||
/** Port to listen on (default: 3000) */
|
||||
port?: number;
|
||||
/** Host to bind to (default: 'localhost') */
|
||||
host?: string;
|
||||
/** Enable CORS (default: false) */
|
||||
enableCors?: boolean;
|
||||
}
|
||||
|
||||
export class FuseAPIServer {
|
||||
private server: http.Server | null = null;
|
||||
private vfs: VirtualFileSystem;
|
||||
private config: Required<FuseAPIServerConfig>;
|
||||
|
||||
constructor(vfs: VirtualFileSystem, config: FuseAPIServerConfig = {}) {
|
||||
this.vfs = vfs;
|
||||
this.config = {
|
||||
port: config.port ?? 3000,
|
||||
host: config.host ?? 'localhost',
|
||||
enableCors: config.enableCors ?? false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(this.handleRequest.bind(this));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
console.log(`FUSE API Server listening on http://${this.config.host}:${this.config.port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('FUSE API Server stopped');
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS headers
|
||||
if (this.config.enableCors) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method!;
|
||||
|
||||
// Route the request
|
||||
if (method === 'GET' && pathname === '/api/fuse/health') {
|
||||
this.handleHealth(res);
|
||||
} else if (method === 'GET' && pathname === '/api/fuse/stats') {
|
||||
this.handleStats(res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/readdir') {
|
||||
await this.handleReaddir(req, res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/getattr') {
|
||||
await this.handleGetattr(req, res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/exists') {
|
||||
await this.handleExists(req, res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/read') {
|
||||
await this.handleRead(req, res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/metadata') {
|
||||
await this.handleMetadata(req, res);
|
||||
} else if (method === 'GET' && pathname === '/api/fuse/tree') {
|
||||
this.handleTree(res);
|
||||
} else if (method === 'GET' && pathname === '/api/fuse/files') {
|
||||
this.handleFiles(res);
|
||||
} else if (method === 'GET' && pathname === '/api/fuse/directories') {
|
||||
this.handleDirectories(res);
|
||||
} else if (method === 'POST' && pathname === '/api/fuse/refresh') {
|
||||
await this.handleRefresh(req, res);
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
this.sendError(res, 500, 'Internal Server Error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON body from request
|
||||
*/
|
||||
private async parseBody(req: http.IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (error) {
|
||||
reject(new Error('Invalid JSON'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any, statusCode: number = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string, error?: any): void {
|
||||
this.sendJson(res, {
|
||||
error: message,
|
||||
details: error?.message ?? error
|
||||
}, statusCode);
|
||||
}
|
||||
|
||||
// API Handlers
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
this.sendJson(res, {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stats
|
||||
* Get VFS statistics
|
||||
*/
|
||||
private handleStats(res: http.ServerResponse): void {
|
||||
const stats = this.vfs.getStats();
|
||||
this.sendJson(res, stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/readdir
|
||||
* List directory contents
|
||||
* Body: { path: string }
|
||||
* Response: { entries: string[] } | { error: string }
|
||||
*/
|
||||
private async handleReaddir(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { path } = await this.parseBody(req);
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
this.sendError(res, 400, 'Missing or invalid "path" parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = this.vfs.readdir(path);
|
||||
|
||||
if (entries === null) {
|
||||
this.sendError(res, 404, 'Directory not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson(res, { entries });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/getattr
|
||||
* Get file attributes
|
||||
* Body: { path: string }
|
||||
* Response: FileAttributes | { error: string }
|
||||
*/
|
||||
private async handleGetattr(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { path } = await this.parseBody(req);
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
this.sendError(res, 400, 'Missing or invalid "path" parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs = this.vfs.getattr(path);
|
||||
|
||||
if (attrs === null) {
|
||||
this.sendError(res, 404, 'Path not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson(res, attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/exists
|
||||
* Check if path exists
|
||||
* Body: { path: string }
|
||||
* Response: { exists: boolean }
|
||||
*/
|
||||
private async handleExists(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { path } = await this.parseBody(req);
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
this.sendError(res, 400, 'Missing or invalid "path" parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = this.vfs.exists(path);
|
||||
this.sendJson(res, { exists });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/read
|
||||
* Read file
|
||||
* Body: { path: string }
|
||||
* Response: ReadResult | { error: string }
|
||||
*/
|
||||
private async handleRead(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { path } = await this.parseBody(req);
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
this.sendError(res, 400, 'Missing or invalid "path" parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this.vfs.read(path);
|
||||
|
||||
if (result === null) {
|
||||
this.sendError(res, 404, 'File not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert Buffer to base64 for JSON serialization
|
||||
const response: any = {
|
||||
sourcePath: result.sourcePath,
|
||||
size: result.size
|
||||
};
|
||||
|
||||
if (result.content !== null) {
|
||||
response.content = result.content.toString('base64');
|
||||
response.contentEncoding = 'base64';
|
||||
}
|
||||
|
||||
this.sendJson(res, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/metadata
|
||||
* Get file metadata
|
||||
* Body: { path: string }
|
||||
* Response: HashMeta | { error: string }
|
||||
*/
|
||||
private async handleMetadata(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const { path } = await this.parseBody(req);
|
||||
|
||||
if (typeof path !== 'string') {
|
||||
this.sendError(res, 400, 'Missing or invalid "path" parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this.vfs.getMetadata(path);
|
||||
|
||||
if (metadata === null) {
|
||||
this.sendError(res, 404, 'Metadata not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson(res, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tree
|
||||
* Get complete VFS tree
|
||||
*/
|
||||
private handleTree(res: http.ServerResponse): void {
|
||||
const tree = this.vfs.getTree();
|
||||
this.sendJson(res, tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/files
|
||||
* Get all file paths
|
||||
*/
|
||||
private handleFiles(res: http.ServerResponse): void {
|
||||
const files = this.vfs.getAllFiles();
|
||||
this.sendJson(res, { files });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/directories
|
||||
* Get all directory paths
|
||||
*/
|
||||
private handleDirectories(res: http.ServerResponse): void {
|
||||
const directories = this.vfs.getAllDirectories();
|
||||
this.sendJson(res, { directories });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/refresh
|
||||
* Refresh the VFS (no-op, VFS is updated by processing pipeline)
|
||||
*/
|
||||
private async handleRefresh(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
await this.vfs.refresh();
|
||||
this.sendJson(res, { status: 'ok' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* HTTP REST API Server for Performance Metrics
|
||||
*
|
||||
* Provides a dedicated endpoint for exposing application performance metrics.
|
||||
* Separate from FUSE API to maintain proper separation of concerns.
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { performanceMetrics } from '../metrics/PerformanceMetrics.js';
|
||||
|
||||
export interface MetricsAPIConfig {
|
||||
/** Port to listen on (default: 3001) */
|
||||
port?: number;
|
||||
/** Host to bind to (default: 'localhost') */
|
||||
host?: string;
|
||||
/** Enable CORS (default: true for metrics) */
|
||||
enableCors?: boolean;
|
||||
/** Mount path for metrics endpoint (default: '/metrics') */
|
||||
mountPath?: string;
|
||||
}
|
||||
|
||||
export class MetricsAPI {
|
||||
private server: http.Server | null = null;
|
||||
private config: Required<MetricsAPIConfig>;
|
||||
|
||||
constructor(config: MetricsAPIConfig = {}) {
|
||||
this.config = {
|
||||
port: config.port ?? 3001,
|
||||
host: config.host ?? 'localhost',
|
||||
enableCors: config.enableCors ?? true,
|
||||
mountPath: config.mountPath ?? '/metrics'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the HTTP server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server = http.createServer(this.handleRequest.bind(this));
|
||||
|
||||
this.server.on('error', reject);
|
||||
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
console.log(`Metrics API listening on http://${this.config.host}:${this.config.port}${this.config.mountPath}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the HTTP server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server!.close((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
console.log('Metrics API stopped');
|
||||
this.server = null;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests
|
||||
*/
|
||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
// CORS headers
|
||||
if (this.config.enableCors) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const pathname = url.pathname;
|
||||
const method = req.method!;
|
||||
|
||||
// Route the request
|
||||
if (method === 'GET' && pathname === this.config.mountPath) {
|
||||
this.handleMetrics(res);
|
||||
} else if (method === 'GET' && pathname === '/health') {
|
||||
this.handleHealth(res);
|
||||
} else if (method === 'POST' && pathname === '/reset') {
|
||||
this.handleReset(res);
|
||||
} else {
|
||||
this.sendError(res, 404, 'Not Found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling request:', error);
|
||||
this.sendError(res, 500, 'Internal Server Error', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send JSON response
|
||||
*/
|
||||
private sendJson(res: http.ServerResponse, data: any, statusCode: number = 200): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error response
|
||||
*/
|
||||
private sendError(res: http.ServerResponse, statusCode: number, message: string, error?: any): void {
|
||||
this.sendJson(res, {
|
||||
error: message,
|
||||
details: error?.message ?? error
|
||||
}, statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /metrics
|
||||
* Get performance metrics
|
||||
*/
|
||||
private handleMetrics(res: http.ServerResponse): void {
|
||||
const metrics = performanceMetrics.getMetrics();
|
||||
this.sendJson(res, metrics);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Health check endpoint
|
||||
*/
|
||||
private handleHealth(res: http.ServerResponse): void {
|
||||
this.sendJson(res, {
|
||||
status: 'ok',
|
||||
service: 'metrics-api',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /reset
|
||||
* Reset all metrics
|
||||
*/
|
||||
private handleReset(res: http.ServerResponse): void {
|
||||
performanceMetrics.reset();
|
||||
this.sendJson(res, {
|
||||
status: 'ok',
|
||||
message: 'Metrics reset successfully'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
# FUSE API for Meta-Mesh
|
||||
|
||||
This module provides a FUSE-compatible HTTP API that runs alongside meta-mesh's hardlink system. The API exposes a virtual filesystem that can be consumed by a future FUSE driver.
|
||||
|
||||
## Overview
|
||||
|
||||
The FUSE API runs **always-on alongside hardlinks**, providing:
|
||||
|
||||
- **Dual output**: Both hardlinks (for local access) and virtual filesystem (for FUSE driver)
|
||||
- **Dynamic updates**: VirtualFileSystem rebuilds on every processing cycle
|
||||
- **Virtual .meta files**: Metadata files are generated on-the-fly without disk I/O
|
||||
- **HTTP REST API**: Can be consumed by local or remote FUSE drivers
|
||||
- **Zero configuration**: Always enabled, no opt-in required
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **VirtualFileSystem** - In-memory representation of the output directory structure
|
||||
2. **FuseAPIServer** - HTTP REST API server exposing FUSE operations
|
||||
3. **FuseAPI** - TypeScript interfaces defining the API contract
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Input Files → File Processing → Metadata Extraction → Duplicate Removal
|
||||
↓
|
||||
Virtual Folder Structure Computation (diff generation)
|
||||
↓
|
||||
├─→ VirtualFileSystem Build (in-memory) → FUSE API Server → Future FUSE Driver
|
||||
│
|
||||
└─→ Hardlink Creation (on disk) → Direct File Access
|
||||
```
|
||||
|
||||
Both outputs are generated in parallel from the same computed folder structure.
|
||||
|
||||
## Configuration
|
||||
|
||||
The FUSE API is **always enabled** and runs alongside hardlinks. You can optionally configure the server port and host:
|
||||
|
||||
```bash
|
||||
# FUSE API Server configuration (optional)
|
||||
FUSE_API_PORT=3000 # Default: 3000
|
||||
FUSE_API_HOST=localhost # Default: localhost
|
||||
```
|
||||
|
||||
The API server starts automatically when meta-mesh starts.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The FUSE API Server exposes the following HTTP REST endpoints:
|
||||
|
||||
### Health & Info
|
||||
|
||||
- **GET /api/health** - Health check
|
||||
```json
|
||||
{ "status": "ok", "timestamp": "2025-01-15T10:30:00.000Z" }
|
||||
```
|
||||
|
||||
- **GET /api/stats** - VFS statistics
|
||||
```json
|
||||
{
|
||||
"fileCount": 1250,
|
||||
"directoryCount": 45,
|
||||
"totalSize": 52428800,
|
||||
"metaFileCount": 1250
|
||||
}
|
||||
```
|
||||
|
||||
### Directory Operations
|
||||
|
||||
- **POST /api/readdir** - List directory contents
|
||||
```json
|
||||
// Request
|
||||
{ "path": "/Anime/Naruto/Season 1" }
|
||||
|
||||
// Response
|
||||
{ "entries": ["Naruto S01E01.mkv", "Naruto S01E01.mkv.meta", ...] }
|
||||
```
|
||||
|
||||
### File Operations
|
||||
|
||||
- **POST /api/getattr** - Get file attributes
|
||||
```json
|
||||
// Request
|
||||
{ "path": "/Anime/Naruto/Season 1/Naruto S01E01.mkv" }
|
||||
|
||||
// Response
|
||||
{
|
||||
"size": 524288000,
|
||||
"mode": 33188, // 0o100644 (regular file, rw-r--r--)
|
||||
"mtime": 1705315200,
|
||||
"atime": 1705315200,
|
||||
"ctime": 1705315200,
|
||||
"nlink": 1,
|
||||
"uid": 1000,
|
||||
"gid": 1000
|
||||
}
|
||||
```
|
||||
|
||||
- **POST /api/exists** - Check if path exists
|
||||
```json
|
||||
// Request
|
||||
{ "path": "/Anime/Naruto" }
|
||||
|
||||
// Response
|
||||
{ "exists": true }
|
||||
```
|
||||
|
||||
- **POST /api/read** - Read file
|
||||
```json
|
||||
// Request
|
||||
{ "path": "/Anime/Naruto/Season 1/Naruto S01E01.mkv" }
|
||||
|
||||
// Response (regular file)
|
||||
{
|
||||
"sourcePath": "/data/input/naruto_01.mkv",
|
||||
"size": 524288000
|
||||
}
|
||||
|
||||
// Response (virtual .meta file)
|
||||
{
|
||||
"sourcePath": null,
|
||||
"content": "..base64-encoded content..",
|
||||
"contentEncoding": "base64",
|
||||
"size": 1024
|
||||
}
|
||||
```
|
||||
|
||||
- **POST /api/metadata** - Get file metadata
|
||||
```json
|
||||
// Request
|
||||
{ "path": "/Anime/Naruto/Season 1/Naruto S01E01.mkv" }
|
||||
|
||||
// Response
|
||||
{
|
||||
"title": "Naruto",
|
||||
"season": "01",
|
||||
"episode": "01",
|
||||
"anime": true,
|
||||
"sha256": "abc123...",
|
||||
// ... full HashMeta object
|
||||
}
|
||||
```
|
||||
|
||||
### Tree Operations
|
||||
|
||||
- **GET /api/tree** - Get complete VFS tree structure
|
||||
- **GET /api/files** - Get all file paths
|
||||
- **GET /api/directories** - Get all directory paths
|
||||
- **POST /api/refresh** - Refresh VFS (no-op, VFS updates automatically)
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Starting meta-mesh with FUSE API
|
||||
|
||||
The FUSE API starts automatically with meta-mesh:
|
||||
|
||||
```bash
|
||||
# Run meta-mesh (FUSE API starts automatically on port 3000)
|
||||
pnpm run start:mesh
|
||||
|
||||
# Or customize the port
|
||||
export FUSE_API_PORT=8080
|
||||
pnpm run start:mesh
|
||||
```
|
||||
|
||||
You'll see output like:
|
||||
```
|
||||
FUSE API Server started on http://localhost:3000
|
||||
VirtualFileSystem build took 15ms
|
||||
VirtualFileSystem: 1250 files (1250 .meta), 45 directories, 50.00 MB
|
||||
```
|
||||
|
||||
### Consuming the API
|
||||
|
||||
```typescript
|
||||
import { VirtualFileSystem, FuseAPIServer } from '@metazla/meta-mesh/api';
|
||||
|
||||
// Create VFS instance
|
||||
const vfs = new VirtualFileSystem({
|
||||
fileMode: 0o644,
|
||||
directoryMode: 0o755,
|
||||
uid: process.getuid(),
|
||||
gid: process.getgid()
|
||||
});
|
||||
|
||||
// Build from computed structure
|
||||
vfs.buildFromComputed(sourceToDestMap, metadataMap);
|
||||
|
||||
// Start API server
|
||||
const server = new FuseAPIServer(vfs, {
|
||||
port: 3000,
|
||||
host: 'localhost'
|
||||
});
|
||||
|
||||
await server.start();
|
||||
```
|
||||
|
||||
### FUSE Driver Integration
|
||||
|
||||
A FUSE driver would make HTTP requests to these endpoints:
|
||||
|
||||
```typescript
|
||||
// Example pseudo-code for FUSE driver
|
||||
async function readdir(path: string): Promise<string[]> {
|
||||
const response = await fetch('http://localhost:3000/api/readdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const { entries } = await response.json();
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function read(path: string): Promise<Buffer> {
|
||||
const response = await fetch('http://localhost:3000/api/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.sourcePath) {
|
||||
// Regular file - read from source
|
||||
return fs.readFileSync(result.sourcePath);
|
||||
} else {
|
||||
// Virtual file - use provided content
|
||||
return Buffer.from(result.content, 'base64');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Virtual .meta Files
|
||||
|
||||
`.meta` files are entirely virtual and generated on-the-fly:
|
||||
|
||||
- **No disk I/O**: Content is serialized from in-memory metadata
|
||||
- **Always up-to-date**: Reflects current metadata state
|
||||
- **YAML format**: Uses `MetaMeshFormat.serialize()` for consistency
|
||||
- **No source path**: `sourcePath` is `null`, `content` contains the YAML
|
||||
|
||||
## Events
|
||||
|
||||
The VirtualFileSystem emits events for monitoring:
|
||||
|
||||
```typescript
|
||||
vfs.on('vfs-updated', () => {
|
||||
console.log('Virtual filesystem updated');
|
||||
});
|
||||
|
||||
vfs.on('file-added', (path: string) => {
|
||||
console.log(`File added: ${path}`);
|
||||
});
|
||||
|
||||
vfs.on('file-removed', (path: string) => {
|
||||
console.log(`File removed: ${path}`);
|
||||
});
|
||||
|
||||
vfs.on('directory-added', (path: string) => {
|
||||
console.log(`Directory added: ${path}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
The VirtualFileSystem is highly performant:
|
||||
|
||||
- **In-memory**: All operations are O(1) or O(n) in-memory
|
||||
- **No disk I/O**: Except for reading source files when FUSE driver requests content
|
||||
- **Lazy .meta generation**: Content is only serialized when requested
|
||||
- **Fast updates**: Rebuilding the VFS takes milliseconds
|
||||
|
||||
## Dual Output: Hardlinks + FUSE API
|
||||
|
||||
Meta-mesh now provides **both** output methods simultaneously:
|
||||
|
||||
| Feature | Hardlinks | FUSE API | Best For |
|
||||
|---------|-----------|----------|----------|
|
||||
| Disk usage | 1 inode per file | 0 (virtual) | Hardlinks: local use |
|
||||
| Update speed | Slow (file I/O) | Fast (in-memory) | FUSE: frequent updates |
|
||||
| .meta files | Written to disk | Virtual (on-demand) | FUSE: dynamic metadata |
|
||||
| Remote access | No | Yes (HTTP) | FUSE: network drives |
|
||||
| Compatibility | Any filesystem | Requires FUSE driver | Hardlinks: broad compat |
|
||||
| Access method | Direct file access | HTTP + FUSE driver | Hardlinks: native apps |
|
||||
|
||||
**Use hardlinks** for local applications like Jellyfin, Plex, Kodi that read files directly.
|
||||
|
||||
**Use FUSE API** for future FUSE drivers that want dynamic, virtual filesystem access.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **WebSocket support**: Real-time updates for FUSE drivers
|
||||
- **Authentication**: API key or JWT-based auth
|
||||
- **Compression**: Gzip/Brotli for API responses
|
||||
- **Caching**: ETag/Last-Modified headers
|
||||
- **Batch operations**: Multi-file operations in single request
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* VirtualFileSystem - In-memory representation of the virtual output filesystem
|
||||
*
|
||||
* This class maintains the virtual directory structure and provides FUSE-compatible operations.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { HashMeta } from '@metazla/meta-interface';
|
||||
import {
|
||||
IFuseAPI,
|
||||
VirtualDirectory,
|
||||
VirtualFile,
|
||||
FileAttributes,
|
||||
ReadResult,
|
||||
FuseAPIConfig,
|
||||
FuseAPIEvents
|
||||
} from './FuseAPI.js';
|
||||
import { MetaMeshFormat } from '../logic/MetaMeshFormat.js';
|
||||
import { MetadataFormatRegistry, IMetadataFormatHandler } from '../logic/MetadataFormatRegistry.js';
|
||||
import { config } from '../config/EnvConfig.js';
|
||||
|
||||
interface VFSNode {
|
||||
type: 'file' | 'directory';
|
||||
name: string;
|
||||
parent: string | null;
|
||||
// For files
|
||||
sourcePath?: string | null;
|
||||
size?: number;
|
||||
mtime?: Date;
|
||||
ctime?: Date;
|
||||
metadata?: HashMeta;
|
||||
// For virtual metadata files
|
||||
formatHandler?: IMetadataFormatHandler;
|
||||
// For directories
|
||||
children?: Set<string>;
|
||||
}
|
||||
|
||||
export class VirtualFileSystem extends EventEmitter implements IFuseAPI {
|
||||
private nodes: Map<string, VFSNode> = new Map();
|
||||
private config: Required<FuseAPIConfig>;
|
||||
private metaFormat: MetaMeshFormat = new MetaMeshFormat();
|
||||
private formatRegistry: MetadataFormatRegistry = new MetadataFormatRegistry();
|
||||
private activeFormatHandlers: IMetadataFormatHandler[] = [];
|
||||
|
||||
// Cached stats - updated incrementally on each modification
|
||||
private cachedStats = {
|
||||
fileCount: 0,
|
||||
directoryCount: 0,
|
||||
totalSize: 0,
|
||||
metaFileCount: 0,
|
||||
metadataFileCount: 0
|
||||
};
|
||||
|
||||
constructor(fuseConfig: FuseAPIConfig = {}) {
|
||||
super();
|
||||
this.config = {
|
||||
basePath: fuseConfig.basePath ?? '/',
|
||||
fileMode: fuseConfig.fileMode ?? 0o644,
|
||||
directoryMode: fuseConfig.directoryMode ?? 0o755,
|
||||
uid: fuseConfig.uid ?? process.getuid(),
|
||||
gid: fuseConfig.gid ?? process.getgid()
|
||||
};
|
||||
|
||||
// Initialize active format handlers from configuration
|
||||
this.activeFormatHandlers = this.formatRegistry.getHandlersForFormats(config.METADATA_FORMATS);
|
||||
|
||||
if (this.activeFormatHandlers.length > 0) {
|
||||
console.log(`VirtualFileSystem: Active metadata formats: ${this.activeFormatHandlers.map(h => h.formatType).join(', ')}`);
|
||||
} else {
|
||||
console.log('VirtualFileSystem: No metadata formats configured');
|
||||
}
|
||||
|
||||
// Initialize root directory
|
||||
this.nodes.set('/', {
|
||||
type: 'directory',
|
||||
name: '',
|
||||
parent: null,
|
||||
children: new Set()
|
||||
});
|
||||
|
||||
// Initialize cached stats with root directory
|
||||
this.cachedStats.directoryCount = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the virtual filesystem from a computed folder structure
|
||||
* @param computed Map of source paths to destination paths
|
||||
* @param metadata Map of source paths to HashMeta
|
||||
* @param basePath Base path to strip from destination paths (e.g., OUTPUT_FOLDER)
|
||||
*/
|
||||
public buildFromComputed(
|
||||
computed: Map<string, string>,
|
||||
metadata: Map<string, HashMeta>,
|
||||
basePath?: string
|
||||
): void {
|
||||
// Clear existing structure (except root)
|
||||
this.nodes.clear();
|
||||
this.nodes.set('/', {
|
||||
type: 'directory',
|
||||
name: '',
|
||||
parent: null,
|
||||
children: new Set()
|
||||
});
|
||||
|
||||
// Reset cached stats (root directory will be counted)
|
||||
this.cachedStats = {
|
||||
fileCount: 0,
|
||||
directoryCount: 1, // Root directory
|
||||
totalSize: 0,
|
||||
metaFileCount: 0,
|
||||
metadataFileCount: 0
|
||||
};
|
||||
|
||||
// Normalize basePath if provided
|
||||
const normalizedBasePath = basePath ? path.normalize(basePath) : null;
|
||||
|
||||
// Add all files and their parent directories
|
||||
for (const [sourcePath, destPath] of computed.entries()) {
|
||||
// Strip basePath from destPath to create relative virtual path
|
||||
let virtualPath = destPath;
|
||||
if (normalizedBasePath && destPath.startsWith(normalizedBasePath)) {
|
||||
virtualPath = destPath.slice(normalizedBasePath.length);
|
||||
// Ensure path starts with /
|
||||
if (!virtualPath.startsWith('/')) {
|
||||
virtualPath = '/' + virtualPath;
|
||||
}
|
||||
}
|
||||
this.addFile(virtualPath, sourcePath, metadata.get(sourcePath));
|
||||
}
|
||||
|
||||
this.emit('vfs-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the virtual filesystem, creating parent directories as needed
|
||||
*/
|
||||
public addFile(virtualPath: string, sourcePath: string, metadata?: HashMeta): void {
|
||||
// Normalize path
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
|
||||
// Create parent directories
|
||||
const dirname = path.dirname(virtualPath);
|
||||
this.ensureDirectory(dirname);
|
||||
|
||||
// Try to use metadata for file stats (avoid disk I/O during fast load)
|
||||
let size: number;
|
||||
let mtime: Date;
|
||||
let ctime: Date;
|
||||
|
||||
if (metadata && (metadata as any).sizeByte) {
|
||||
// Use cached metadata (fast path - no disk I/O)
|
||||
size = parseInt((metadata as any).sizeByte, 10);
|
||||
// Use current time as placeholder for mtime/ctime (will be corrected on next scan)
|
||||
mtime = new Date();
|
||||
ctime = new Date();
|
||||
} else {
|
||||
// Fallback: stat the file (slow path - disk I/O)
|
||||
try {
|
||||
const stats = fs.statSync(sourcePath);
|
||||
size = stats.size;
|
||||
mtime = stats.mtime;
|
||||
ctime = stats.ctime;
|
||||
} catch (error) {
|
||||
console.warn(`Cannot stat source file: ${sourcePath}`, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the file node
|
||||
const filename = path.basename(virtualPath);
|
||||
this.nodes.set(virtualPath, {
|
||||
type: 'file',
|
||||
name: filename,
|
||||
parent: dirname,
|
||||
sourcePath,
|
||||
size,
|
||||
mtime,
|
||||
ctime,
|
||||
metadata
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(dirname);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(filename);
|
||||
}
|
||||
|
||||
// Update cached stats
|
||||
this.cachedStats.fileCount++;
|
||||
this.cachedStats.totalSize += size;
|
||||
|
||||
// Also add corresponding metadata files based on configured formats
|
||||
this.addMetadataFiles(virtualPath, metadata, mtime);
|
||||
|
||||
this.emit('file-added', virtualPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add virtual metadata files for all configured formats
|
||||
*/
|
||||
private addMetadataFiles(mediaPath: string, metadata: HashMeta | undefined, mtime: Date): void {
|
||||
if (!metadata) return;
|
||||
|
||||
// Create a metadata file for each configured format
|
||||
for (const handler of this.activeFormatHandlers) {
|
||||
const metaPath = `${mediaPath}${handler.extension}`;
|
||||
const dirname = path.dirname(metaPath);
|
||||
const filename = path.basename(metaPath);
|
||||
|
||||
// Pre-generate metadata content to calculate size
|
||||
const metaContent = handler.serialize(metadata);
|
||||
const metaBuffer = Buffer.from(metaContent, 'utf-8');
|
||||
|
||||
// Add the virtual metadata file node
|
||||
this.nodes.set(metaPath, {
|
||||
type: 'file',
|
||||
name: filename,
|
||||
parent: dirname,
|
||||
sourcePath: null, // Virtual file, no source
|
||||
size: metaBuffer.length,
|
||||
mtime: mtime,
|
||||
ctime: mtime,
|
||||
metadata,
|
||||
formatHandler: handler
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(dirname);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(filename);
|
||||
}
|
||||
|
||||
// Update cached stats
|
||||
this.cachedStats.fileCount++;
|
||||
this.cachedStats.totalSize += metaBuffer.length;
|
||||
this.cachedStats.metadataFileCount++;
|
||||
// Legacy: Count .meta files separately for backward compatibility
|
||||
if (handler.extension === '.meta') {
|
||||
this.cachedStats.metaFileCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists in the VFS, creating parent directories as needed
|
||||
*/
|
||||
private ensureDirectory(dirPath: string): void {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
|
||||
if (dirPath === '/' || this.nodes.has(dirPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively create parent
|
||||
const parentPath = path.dirname(dirPath);
|
||||
this.ensureDirectory(parentPath);
|
||||
|
||||
// Create this directory
|
||||
const dirName = path.basename(dirPath);
|
||||
this.nodes.set(dirPath, {
|
||||
type: 'directory',
|
||||
name: dirName,
|
||||
parent: parentPath,
|
||||
children: new Set()
|
||||
});
|
||||
|
||||
// Add to parent's children
|
||||
const parentNode = this.nodes.get(parentPath);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(dirName);
|
||||
}
|
||||
|
||||
// Update cached stats
|
||||
this.cachedStats.directoryCount++;
|
||||
|
||||
this.emit('directory-added', dirPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path to use forward slashes and ensure leading slash
|
||||
*/
|
||||
private normalizePath(p: string): string {
|
||||
// Convert to forward slashes
|
||||
p = p.split(path.sep).join('/');
|
||||
|
||||
// Ensure leading slash
|
||||
if (!p.startsWith('/')) {
|
||||
p = '/' + p;
|
||||
}
|
||||
|
||||
// Remove trailing slash (except for root)
|
||||
if (p !== '/' && p.endsWith('/')) {
|
||||
p = p.slice(0, -1);
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
// IFuseAPI implementation
|
||||
|
||||
public readdir(dirPath: string): string[] | null {
|
||||
dirPath = this.normalizePath(dirPath);
|
||||
const node = this.nodes.get(dirPath);
|
||||
|
||||
if (!node || node.type !== 'directory') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Array.from(node.children!);
|
||||
}
|
||||
|
||||
public getattr(filepath: string): FileAttributes | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.type === 'directory') {
|
||||
return {
|
||||
size: 4096, // Standard directory size
|
||||
mode: 0o040000 | this.config.directoryMode, // S_IFDIR | permissions
|
||||
mtime: Date.now() / 1000,
|
||||
atime: Date.now() / 1000,
|
||||
ctime: Date.now() / 1000,
|
||||
nlink: 2, // . and ..
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
size: node.size!,
|
||||
mode: 0o100000 | this.config.fileMode, // S_IFREG | permissions
|
||||
mtime: node.mtime!.getTime() / 1000,
|
||||
atime: node.mtime!.getTime() / 1000,
|
||||
ctime: node.ctime!.getTime() / 1000,
|
||||
nlink: 1,
|
||||
uid: this.config.uid,
|
||||
gid: this.config.gid
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public exists(filepath: string): boolean {
|
||||
filepath = this.normalizePath(filepath);
|
||||
return this.nodes.has(filepath);
|
||||
}
|
||||
|
||||
public read(filepath: string): ReadResult | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Virtual metadata file (any format)
|
||||
if (node.sourcePath === null && node.metadata && node.formatHandler) {
|
||||
const metaContent = node.formatHandler.serialize(node.metadata);
|
||||
const metaBuffer = Buffer.from(metaContent, 'utf-8');
|
||||
|
||||
return {
|
||||
sourcePath: null,
|
||||
content: metaBuffer,
|
||||
size: metaBuffer.length
|
||||
};
|
||||
}
|
||||
|
||||
// Regular file - return source path for reading
|
||||
return {
|
||||
sourcePath: node.sourcePath!,
|
||||
content: null,
|
||||
size: node.size!
|
||||
};
|
||||
}
|
||||
|
||||
public getMetadata(filepath: string): HashMeta | null {
|
||||
filepath = this.normalizePath(filepath);
|
||||
const node = this.nodes.get(filepath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return node.metadata ?? null;
|
||||
}
|
||||
|
||||
public getTree(): VirtualDirectory {
|
||||
const rootNode = this.nodes.get('/')!;
|
||||
|
||||
return {
|
||||
path: '/',
|
||||
type: 'directory',
|
||||
name: '',
|
||||
parent: '',
|
||||
children: Array.from(rootNode.children!),
|
||||
mode: this.config.directoryMode,
|
||||
mtime: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
// Refresh is triggered by external calls after processing
|
||||
// The buildFromComputed method handles the actual refresh
|
||||
this.emit('vfs-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all file paths in the VFS
|
||||
*/
|
||||
public getAllFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const [filepath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'file') {
|
||||
files.push(filepath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directory paths in the VFS
|
||||
*/
|
||||
public getAllDirectories(): string[] {
|
||||
const dirs: string[] = [];
|
||||
for (const [dirpath, node] of this.nodes.entries()) {
|
||||
if (node.type === 'directory') {
|
||||
dirs.push(dirpath);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about the VFS (returns cached values, updated incrementally)
|
||||
*/
|
||||
public getStats(): {
|
||||
fileCount: number;
|
||||
directoryCount: number;
|
||||
totalSize: number;
|
||||
metaFileCount: number;
|
||||
metadataFileCount: number;
|
||||
} {
|
||||
// Return cached stats - no iteration needed!
|
||||
return { ...this.cachedStats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count files in VFS that have completed hash computation
|
||||
* A file is considered "done" if hashStatus === 'complete' (all hashes computed without errors)
|
||||
*/
|
||||
public countFilesWithHash(): number {
|
||||
let count = 0;
|
||||
for (const [filepath, node] of this.nodes.entries()) {
|
||||
// Only count actual media files (not directories, not metadata files)
|
||||
if (node.type === 'file' && node.sourcePath && node.metadata) {
|
||||
// Check if file has completed processing (both light and hash phases)
|
||||
if ((node.metadata as any).processingStatus === 'complete') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata for an existing file in the VFS (e.g., when hash computation completes)
|
||||
* This also regenerates the metadata files with updated content
|
||||
*/
|
||||
public updateFileMetadata(virtualPath: string, metadata: HashMeta): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
const node = this.nodes.get(virtualPath);
|
||||
|
||||
if (!node || node.type !== 'directory') {
|
||||
// Update file metadata
|
||||
if (node) {
|
||||
node.metadata = metadata;
|
||||
|
||||
// Regenerate metadata files with updated content
|
||||
if (node.mtime) {
|
||||
// Get old metadata file sizes before regenerating
|
||||
const oldSizes = new Map<string, number>();
|
||||
for (const handler of this.activeFormatHandlers) {
|
||||
const metaPath = `${virtualPath}${handler.extension}`;
|
||||
const oldMetaNode = this.nodes.get(metaPath);
|
||||
if (oldMetaNode) {
|
||||
oldSizes.set(metaPath, oldMetaNode.size ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate metadata files (this will overwrite the nodes)
|
||||
this.addMetadataFilesUpdate(virtualPath, metadata, node.mtime, oldSizes);
|
||||
}
|
||||
|
||||
this.emit('file-updated', virtualPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/update virtual metadata files for all configured formats (used during updates)
|
||||
* This version adjusts cached stats based on size differences
|
||||
*/
|
||||
private addMetadataFilesUpdate(
|
||||
mediaPath: string,
|
||||
metadata: HashMeta,
|
||||
mtime: Date,
|
||||
oldSizes: Map<string, number>
|
||||
): void {
|
||||
// Create a metadata file for each configured format
|
||||
for (const handler of this.activeFormatHandlers) {
|
||||
const metaPath = `${mediaPath}${handler.extension}`;
|
||||
const dirname = path.dirname(metaPath);
|
||||
const filename = path.basename(metaPath);
|
||||
|
||||
// Pre-generate metadata content to calculate size
|
||||
const metaContent = handler.serialize(metadata);
|
||||
const metaBuffer = Buffer.from(metaContent, 'utf-8');
|
||||
const newSize = metaBuffer.length;
|
||||
|
||||
const oldSize = oldSizes.get(metaPath);
|
||||
const isNew = oldSize === undefined;
|
||||
|
||||
// Update the virtual metadata file node
|
||||
this.nodes.set(metaPath, {
|
||||
type: 'file',
|
||||
name: filename,
|
||||
parent: dirname,
|
||||
sourcePath: null, // Virtual file, no source
|
||||
size: newSize,
|
||||
mtime: mtime,
|
||||
ctime: mtime,
|
||||
metadata,
|
||||
formatHandler: handler
|
||||
});
|
||||
|
||||
// Add to parent's children (no-op if already exists)
|
||||
const parentNode = this.nodes.get(dirname);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.add(filename);
|
||||
}
|
||||
|
||||
// Update cached stats
|
||||
if (isNew) {
|
||||
// New metadata file
|
||||
this.cachedStats.fileCount++;
|
||||
this.cachedStats.totalSize += newSize;
|
||||
this.cachedStats.metadataFileCount++;
|
||||
if (handler.extension === '.meta') {
|
||||
this.cachedStats.metaFileCount++;
|
||||
}
|
||||
} else {
|
||||
// Update existing - adjust size difference
|
||||
this.cachedStats.totalSize += (newSize - oldSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file and its associated metadata files from the VFS
|
||||
*/
|
||||
public removeFile(virtualPath: string): void {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
const node = this.nodes.get(virtualPath);
|
||||
|
||||
if (!node || node.type !== 'file') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cached stats for main file
|
||||
this.cachedStats.fileCount--;
|
||||
this.cachedStats.totalSize -= node.size ?? 0;
|
||||
|
||||
// Remove the main file
|
||||
this.nodes.delete(virtualPath);
|
||||
|
||||
// Remove from parent's children
|
||||
const parentNode = this.nodes.get(node.parent!);
|
||||
if (parentNode && parentNode.type === 'directory') {
|
||||
parentNode.children!.delete(node.name);
|
||||
}
|
||||
|
||||
// Remove associated metadata files (.nfo, .meta, etc.)
|
||||
for (const handler of this.activeFormatHandlers) {
|
||||
const metaPath = `${virtualPath}${handler.extension}`;
|
||||
const metaNode = this.nodes.get(metaPath);
|
||||
|
||||
if (metaNode) {
|
||||
// Update cached stats for metadata file
|
||||
this.cachedStats.fileCount--;
|
||||
this.cachedStats.totalSize -= metaNode.size ?? 0;
|
||||
this.cachedStats.metadataFileCount--;
|
||||
if (handler.extension === '.meta') {
|
||||
this.cachedStats.metaFileCount--;
|
||||
}
|
||||
|
||||
this.nodes.delete(metaPath);
|
||||
|
||||
// Remove from parent's children
|
||||
const metaParent = this.nodes.get(metaNode.parent!);
|
||||
if (metaParent && metaParent.type === 'directory') {
|
||||
metaParent.children!.delete(metaNode.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('file-removed', virtualPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in the VFS and return its metadata
|
||||
*/
|
||||
public getFileMetadata(virtualPath: string): HashMeta | undefined {
|
||||
virtualPath = this.normalizePath(virtualPath);
|
||||
const node = this.nodes.get(virtualPath);
|
||||
return node?.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
// Type-safe event emitter
|
||||
export interface VirtualFileSystem {
|
||||
on<K extends keyof FuseAPIEvents>(event: K, listener: FuseAPIEvents[K]): this;
|
||||
emit<K extends keyof FuseAPIEvents>(event: K, ...args: Parameters<FuseAPIEvents[K]>): boolean;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* API exports
|
||||
*
|
||||
* This module exports all API components including FUSE API, Metrics API, and Unified API Server.
|
||||
*/
|
||||
|
||||
export { IFuseAPI, VirtualNode, VirtualFile, VirtualDirectory, FileAttributes, ReadResult, FuseAPIConfig, FuseAPIEvents } from './FuseAPI.js';
|
||||
export { VirtualFileSystem } from './VirtualFileSystem.js';
|
||||
export { FuseAPIServer, FuseAPIServerConfig } from './FuseAPIServer.js';
|
||||
export { MetricsAPI, MetricsAPIConfig } from './MetricsAPI.js';
|
||||
export { UnifiedAPIServer, UnifiedAPIServerConfig } from './UnifiedAPIServer.js';
|
||||
@@ -0,0 +1,216 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export interface PollingPathConfig {
|
||||
path: string;
|
||||
intervalMs: number;
|
||||
}
|
||||
|
||||
interface EnvConfig {
|
||||
/** Comma-separated list of folders to watch for changes.(mandatory) */
|
||||
WATCH_FOLDER_LIST: string;
|
||||
|
||||
/** Path to the index file within the NFO CID library. */
|
||||
INDEX_FOLDER_PATH: string;
|
||||
|
||||
/** Interval in milliseconds to update/check for changes. (default 30000) */
|
||||
UPDATE_INTERVAL_MS: number;
|
||||
|
||||
/** Interval in milliseconds for polling watch folders. Set to 0 to disable polling. (default 0) */
|
||||
WATCH_FOLDER_POOLING_INTERVAL_MS: number;
|
||||
|
||||
/** CHMOD for new folder format (default 0o777) */
|
||||
CHMOD_FOR_NEW_FOLDER: number;
|
||||
|
||||
/** Path to the cache folder. */
|
||||
CACHE_FOLDER_PATH: string;
|
||||
|
||||
/** jellyfin */
|
||||
JELLYFIN_ENDPOINT: string;
|
||||
JELLYFIN_API_KEY: string;
|
||||
|
||||
/** FUSE API server port (default 3000) */
|
||||
FUSE_API_PORT: number;
|
||||
|
||||
/** FUSE API server host (default 'localhost') */
|
||||
FUSE_API_HOST: string;
|
||||
|
||||
/** Comma-separated list of metadata formats to generate in virtual filesystem (default 'meta'). Options: 'meta' (.meta YAML files), 'jellyfin' (.nfo XML files). Example: 'meta,jellyfin' or 'jellyfin' or '' (no metadata files) */
|
||||
METADATA_FORMATS: string[];
|
||||
|
||||
/** Maximum number of worker threads for Piscina worker pool AND max concurrent file processing (default: CPU count) */
|
||||
MAX_WORKER_THREADS?: number;
|
||||
|
||||
/** JSON array of polling path configurations. Example: '[{"path": "/data/watch/smb/downloads", "intervalMs": 60000}]' (optional) */
|
||||
POLLING_PATHS?: PollingPathConfig[];
|
||||
|
||||
// ========================================================================
|
||||
// KV Storage Configuration (NEW)
|
||||
// ========================================================================
|
||||
|
||||
/** Path to META_CORE_VOLUME - infrastructure volume for KV DB, locks, services (default '/meta-core') */
|
||||
META_CORE_PATH: string;
|
||||
|
||||
/** Path to FILES_VOLUME - shared media volume (default '/files') */
|
||||
FILES_PATH: string;
|
||||
|
||||
/** Service name for leader election and service discovery (default 'meta-sort') */
|
||||
SERVICE_NAME: string;
|
||||
|
||||
/** Service version (default '1.0.0') */
|
||||
SERVICE_VERSION: string;
|
||||
|
||||
/** Redis port (default 6379) */
|
||||
REDIS_PORT: number;
|
||||
|
||||
/** Direct Redis URL (skip leader election if provided) */
|
||||
REDIS_URL?: string;
|
||||
|
||||
/** Base URL for service discovery (e.g., 'http://localhost:3000' or 'http://meta-sort:80') */
|
||||
BASE_URL?: string;
|
||||
|
||||
/**
|
||||
* Hostname to advertise in lock file for other services to connect.
|
||||
* Use Docker service name (e.g., 'meta-sort-dev') for stable DNS resolution.
|
||||
* If not set, auto-detects local IP (may be unstable across container restarts).
|
||||
*/
|
||||
ADVERTISE_HOST?: string;
|
||||
|
||||
// ========================================================================
|
||||
// TaskScheduler Configuration (Plugin Queue System)
|
||||
// ========================================================================
|
||||
|
||||
/** Concurrency for fast plugin queue (plugins with <1s avg execution time). Default: 32 */
|
||||
FAST_QUEUE_CONCURRENCY: number;
|
||||
|
||||
/** Concurrency for background plugin queue (plugins with >=1s avg execution time). Default: 8 */
|
||||
BACKGROUND_QUEUE_CONCURRENCY: number;
|
||||
|
||||
/** Threshold in ms to classify plugin as fast vs background. Default: 1000 */
|
||||
FAST_THRESHOLD_MS: number;
|
||||
|
||||
/** Minimum samples before using measured timing for classification. Default: 10 */
|
||||
MIN_SAMPLES_FOR_MEASUREMENT: number;
|
||||
|
||||
// ========================================================================
|
||||
// Container Plugins Configuration
|
||||
// All plugins run as Docker containers for isolation and flexibility
|
||||
// ========================================================================
|
||||
|
||||
/** Path to container plugins config file (default: '/app/plugins.yml') */
|
||||
CONTAINER_PLUGINS_CONFIG: string;
|
||||
|
||||
/** Docker socket path (default: '/var/run/docker.sock') */
|
||||
DOCKER_SOCKET_PATH: string;
|
||||
|
||||
/** Callback URL for container plugins (default: 'http://meta-sort:8180') */
|
||||
CONTAINER_CALLBACK_URL: string;
|
||||
|
||||
/** meta-core URL for container plugins (default: 'http://meta-sort:9000') */
|
||||
CONTAINER_META_CORE_URL: string;
|
||||
|
||||
/** Docker network for plugin containers (default: 'meta-network') */
|
||||
CONTAINER_NETWORK: string;
|
||||
|
||||
/**
|
||||
* Plugin file mounts - maps subpaths to host paths for /files in plugin containers.
|
||||
* Format: "subpath:host_path,subpath2:host_path2"
|
||||
* Example: "watch:/data/media,test:/app/test/media"
|
||||
* Results in /files/watch and /files/test mounted in plugin containers (read-only).
|
||||
*/
|
||||
PLUGIN_FILE_MOUNTS?: string;
|
||||
|
||||
/** Host path for plugin cache folder - each plugin gets its own subdirectory at /cache (optional, dev only) */
|
||||
PLUGIN_CACHE_FOLDER?: string;
|
||||
|
||||
/**
|
||||
* Host path for plugin output folders - each plugin gets /files/plugin/<id> mount (READ-WRITE)
|
||||
* Plugins decide whether to use this folder - we always mount it if this env is set
|
||||
*/
|
||||
PLUGIN_OUTPUT_FOLDER?: string;
|
||||
|
||||
/** Docker Compose project name for grouping containers in Docker Desktop (optional). When set, plugin containers appear grouped as a stack. */
|
||||
PLUGIN_STACK_NAME?: string;
|
||||
}
|
||||
|
||||
// Parse POLLING_PATHS from JSON string
|
||||
function parsePollingPaths(): PollingPathConfig[] | undefined {
|
||||
if (!process.env.POLLING_PATHS) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.POLLING_PATHS);
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.error('POLLING_PATHS must be a JSON array');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const valid = parsed.every(item =>
|
||||
item && typeof item === 'object' &&
|
||||
typeof item.path === 'string' &&
|
||||
typeof item.intervalMs === 'number'
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
console.error('POLLING_PATHS entries must have {path: string, intervalMs: number}');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed as PollingPathConfig[];
|
||||
} catch (error) {
|
||||
console.error('Failed to parse POLLING_PATHS:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const config: EnvConfig = {
|
||||
WATCH_FOLDER_LIST: process.env.WATCH_FOLDER_LIST,
|
||||
INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash',//default /data/cache/hash
|
||||
UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),//default 30000
|
||||
WATCH_FOLDER_POOLING_INTERVAL_MS: parseInt(process.env.WATCH_FOLDER_POOLING_INTERVAL_MS || "0", 10),//default 0
|
||||
CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),//default 0o777 (this is the default for mkdir)
|
||||
CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache/plugins',//default /data/cache/plugins
|
||||
JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!,
|
||||
JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!,
|
||||
FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),//default 3000
|
||||
FUSE_API_HOST: process.env.FUSE_API_HOST || 'localhost',//default localhost
|
||||
METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),//default 'meta'
|
||||
MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined,
|
||||
POLLING_PATHS: parsePollingPaths(),
|
||||
|
||||
// KV Storage Configuration
|
||||
META_CORE_PATH: process.env.META_CORE_PATH || '/meta-core',
|
||||
FILES_PATH: process.env.FILES_PATH || '/files',
|
||||
SERVICE_NAME: process.env.SERVICE_NAME || 'meta-sort',
|
||||
SERVICE_VERSION: process.env.SERVICE_VERSION || '1.0.0',
|
||||
REDIS_PORT: parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||
REDIS_URL: process.env.REDIS_URL,
|
||||
BASE_URL: process.env.BASE_URL,
|
||||
ADVERTISE_HOST: process.env.ADVERTISE_HOST,
|
||||
|
||||
// TaskScheduler Configuration
|
||||
FAST_QUEUE_CONCURRENCY: parseInt(process.env.FAST_QUEUE_CONCURRENCY || "32", 10),
|
||||
BACKGROUND_QUEUE_CONCURRENCY: parseInt(process.env.BACKGROUND_QUEUE_CONCURRENCY || "8", 10),
|
||||
FAST_THRESHOLD_MS: parseInt(process.env.FAST_THRESHOLD_MS || "1000", 10),
|
||||
MIN_SAMPLES_FOR_MEASUREMENT: parseInt(process.env.MIN_SAMPLES_FOR_MEASUREMENT || "10", 10),
|
||||
|
||||
// Container Plugins Configuration
|
||||
CONTAINER_PLUGINS_CONFIG: process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml',
|
||||
DOCKER_SOCKET_PATH: process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock',
|
||||
CONTAINER_CALLBACK_URL: process.env.CONTAINER_CALLBACK_URL || 'http://meta-sort:8180',
|
||||
CONTAINER_META_CORE_URL: process.env.CONTAINER_META_CORE_URL || 'http://meta-sort:9000',
|
||||
CONTAINER_NETWORK: process.env.CONTAINER_NETWORK || 'meta-network',
|
||||
PLUGIN_FILE_MOUNTS: process.env.PLUGIN_FILE_MOUNTS,
|
||||
PLUGIN_CACHE_FOLDER: process.env.PLUGIN_CACHE_FOLDER,
|
||||
PLUGIN_OUTPUT_FOLDER: process.env.PLUGIN_OUTPUT_FOLDER,
|
||||
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
||||
};
|
||||
|
||||
if(process.env.TEST!=='true') {
|
||||
if (!config.WATCH_FOLDER_LIST) {
|
||||
console.error('Invalid configuration: WATCH_FOLDER_LIST is required');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
AnimeMeta, FileNameMeta,
|
||||
FileStatMeta,
|
||||
HashMeta,
|
||||
LanguagesMeta, SubtitleFileMeta,
|
||||
VersionMeta,
|
||||
VideoMeta
|
||||
} from "@metazla/meta-interface";
|
||||
|
||||
type Local = VideoMeta & HashMeta & FileStatMeta & AnimeMeta & VersionMeta & LanguagesMeta & SubtitleFileMeta & FileNameMeta;
|
||||
|
||||
/**
|
||||
* return the path relative to the root folder
|
||||
*/
|
||||
export function renamingRule(metamesh: Local, filepath?: string): string | null {
|
||||
let supported = ['video', 'subtitle', 'torrent'];
|
||||
|
||||
//check if filetype is one of the suported ones
|
||||
if (!supported.includes(metamesh.fileType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the file extension from the original file path
|
||||
const extension = metamesh.extension;
|
||||
if (!extension) {
|
||||
throw new Error(`No extension found for file: ${filepath}`);
|
||||
}
|
||||
|
||||
const title = metamesh?.titles?.eng || metamesh.originalTitle;
|
||||
if (!title) {
|
||||
if (metamesh.fileType !== "torrent") {
|
||||
//don't warn for torrent files because they don't always have a title (eg representing a folder of files)
|
||||
throw new Error(`No title found for file: ${filepath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
let newPath = "";
|
||||
|
||||
const season = metamesh.season ? ("S" + String(metamesh.season).padStart(2, '0')) : "";
|
||||
const seasonSpace = metamesh.season ? ` ${season}` : "";
|
||||
const episode = metamesh.episode ? ("E" + String(metamesh.episode).padStart(2, '0')) : "";
|
||||
const version = metamesh.version ? ` ${metamesh.version}` : "";
|
||||
const year = metamesh.movieYear ? ` (${metamesh.movieYear})` : "";
|
||||
const subtitle = metamesh.fileType === "subtitle" && metamesh.subtitleLanguage ? `.${metamesh.subtitleLanguage}` : "";
|
||||
const fileName = `${title}${seasonSpace}${episode}${year}${subtitle}${version}.${extension}`;
|
||||
|
||||
// Simplified structure: TV Shows for series, Movies for standalone content
|
||||
// Note: season can be 0 (special episodes), so check for null/undefined explicitly
|
||||
const hasSeason = metamesh.season !== null && metamesh.season !== undefined;
|
||||
const hasEpisode = metamesh.episode !== null && metamesh.episode !== undefined;
|
||||
|
||||
if (metamesh.extra) {
|
||||
newPath = `TV Shows/${title}/extra/${fileName}`;
|
||||
} else if (hasSeason && hasEpisode) {
|
||||
newPath = `TV Shows/${title}/${season}/${fileName}`;
|
||||
} else {
|
||||
newPath = `Movies/${title}${year}/${fileName}`;
|
||||
}
|
||||
return newPath;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Supported file extensions for Meta Mesh
|
||||
* Application-specific configuration
|
||||
*/
|
||||
|
||||
export const SUPPORTED_VIDEO_EXTENSIONS = new Set([
|
||||
'.mkv',
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.wmv',
|
||||
'.flv',
|
||||
'.webm',
|
||||
'.m4v',
|
||||
'.mpg',
|
||||
'.mpeg',
|
||||
'.m2ts',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.3gp',
|
||||
'.ogv',
|
||||
'.divx',
|
||||
'.xvid',
|
||||
'.rm',
|
||||
'.rmvb'
|
||||
]);
|
||||
|
||||
export const SUPPORTED_SUBTITLE_EXTENSIONS = new Set([
|
||||
'.srt',
|
||||
'.ass',
|
||||
'.ssa',
|
||||
'.sub',
|
||||
'.vtt',
|
||||
'.idx'
|
||||
]);
|
||||
|
||||
export const SUPPORTED_METADATA_EXTENSIONS = new Set([
|
||||
'.torrent'
|
||||
]);
|
||||
|
||||
export const SUPPORTED_IMAGE_EXTENSIONS = new Set([
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.tif'
|
||||
]);
|
||||
|
||||
/**
|
||||
* All supported file extensions
|
||||
*/
|
||||
export const SUPPORTED_EXTENSIONS = new Set([
|
||||
...SUPPORTED_VIDEO_EXTENSIONS,
|
||||
...SUPPORTED_SUBTITLE_EXTENSIONS,
|
||||
...SUPPORTED_METADATA_EXTENSIONS,
|
||||
...SUPPORTED_IMAGE_EXTENSIONS
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if file extension is supported
|
||||
*/
|
||||
export function isSupportedExtension(filePath: string): boolean {
|
||||
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
|
||||
return SUPPORTED_EXTENSIONS.has(ext);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {CID_ALGORITHM_NAMES} from "@metazla/meta-hash";
|
||||
|
||||
// Target hashes for Piscina worker (full-file hash computation)
|
||||
// Note: midhash256 is NOT included here because it's computed in the main thread
|
||||
// during light processing, not by the Piscina worker
|
||||
export const targetHash = [
|
||||
CID_ALGORITHM_NAMES.sha1, // For torrent verification
|
||||
CID_ALGORITHM_NAMES.sha256, // For IPFS compatibility
|
||||
CID_ALGORITHM_NAMES.md5, // For compatibility
|
||||
CID_ALGORITHM_NAMES.sha3_256, // Additional cryptographic hash
|
||||
CID_ALGORITHM_NAMES.sha3_384, // Additional cryptographic hash
|
||||
CID_ALGORITHM_NAMES.crc32, // Fast checksum
|
||||
CID_ALGORITHM_NAMES.btih_v2 // BitTorrent v2 info hash (BEP 52)
|
||||
]
|
||||
|
||||
// Target hashes for index-cid cache (includes all hashes we want to cache)
|
||||
// This includes midhash256 which is computed separately from the worker hashes
|
||||
export const targetHashForIndex = [
|
||||
...targetHash,
|
||||
CID_ALGORITHM_NAMES.midhash256 // Fast hash for file identification (computed in light processing)
|
||||
]
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Container Plugin Configuration Parser
|
||||
*
|
||||
* Parses plugins.yml configuration files for container plugins.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import YAML from 'yaml';
|
||||
import type {
|
||||
ContainerPluginsConfig,
|
||||
ContainerPluginConfig,
|
||||
ContainerResourceLimits,
|
||||
ContainerMount,
|
||||
ContainerHealthCheck,
|
||||
} from './types.js';
|
||||
import type { TaskQueueType } from '../plugin-engine/types.js';
|
||||
|
||||
/**
|
||||
* Parse error with context
|
||||
*/
|
||||
export class ConfigParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly filePath: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(`${message} (file: ${filePath})`);
|
||||
this.name = 'ConfigParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULTS = {
|
||||
version: '1.0',
|
||||
instances: 1,
|
||||
enabled: true,
|
||||
network: false,
|
||||
healthCheck: {
|
||||
interval: '30s',
|
||||
timeout: '5s',
|
||||
retries: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Substitute environment variables in a string
|
||||
* Supports: ${VAR}, ${VAR:-default}, ${VAR:=default}
|
||||
*/
|
||||
function substituteEnvVars(value: string): string {
|
||||
// Match ${VAR}, ${VAR:-default}, or ${VAR:=default}
|
||||
const envPattern = /\$\{([A-Z_][A-Z0-9_]*)(?:(:?[-=])([^}]*))?\}/gi;
|
||||
|
||||
return value.replace(envPattern, (match, varName, operator, defaultValue) => {
|
||||
const envValue = process.env[varName];
|
||||
|
||||
if (envValue !== undefined && envValue !== '') {
|
||||
return envValue;
|
||||
}
|
||||
|
||||
// Handle default values
|
||||
if (operator === ':-' || operator === ':=') {
|
||||
return defaultValue || '';
|
||||
}
|
||||
if (operator === '-' || operator === '=') {
|
||||
// Only use default if variable is unset (not if empty)
|
||||
if (envValue === undefined) {
|
||||
return defaultValue || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// No default, return empty string
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively substitute env vars in an object
|
||||
*/
|
||||
function substituteEnvVarsInObject(obj: unknown): unknown {
|
||||
if (typeof obj === 'string') {
|
||||
return substituteEnvVars(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => substituteEnvVarsInObject(item));
|
||||
}
|
||||
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = substituteEnvVarsInObject(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate resource limits
|
||||
*/
|
||||
function validateResourceLimits(
|
||||
resources: unknown,
|
||||
pluginId: string
|
||||
): ContainerResourceLimits | undefined {
|
||||
if (!resources || typeof resources !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const r = resources as Record<string, unknown>;
|
||||
const result: ContainerResourceLimits = {};
|
||||
|
||||
if (r.memory !== undefined) {
|
||||
if (typeof r.memory !== 'string') {
|
||||
throw new Error(`Plugin '${pluginId}': resources.memory must be a string (e.g., '512m', '2g')`);
|
||||
}
|
||||
// Validate format
|
||||
if (!/^\d+[kmgKMG]?$/.test(r.memory)) {
|
||||
throw new Error(`Plugin '${pluginId}': invalid memory format '${r.memory}' (use e.g., '512m', '2g')`);
|
||||
}
|
||||
result.memory = r.memory;
|
||||
}
|
||||
|
||||
if (r.cpus !== undefined) {
|
||||
const cpus = Number(r.cpus);
|
||||
if (isNaN(cpus) || cpus <= 0) {
|
||||
throw new Error(`Plugin '${pluginId}': resources.cpus must be a positive number`);
|
||||
}
|
||||
result.cpus = cpus;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate volume mounts
|
||||
*/
|
||||
function validateMounts(mounts: unknown, pluginId: string): ContainerMount[] | undefined {
|
||||
if (!mounts) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(mounts)) {
|
||||
throw new Error(`Plugin '${pluginId}': mounts must be an array`);
|
||||
}
|
||||
|
||||
return mounts.map((mount, index) => {
|
||||
if (!mount || typeof mount !== 'object') {
|
||||
throw new Error(`Plugin '${pluginId}': mount[${index}] must be an object`);
|
||||
}
|
||||
|
||||
const m = mount as Record<string, unknown>;
|
||||
|
||||
if (typeof m.source !== 'string' || !m.source) {
|
||||
throw new Error(`Plugin '${pluginId}': mount[${index}].source is required`);
|
||||
}
|
||||
|
||||
if (typeof m.target !== 'string' || !m.target) {
|
||||
throw new Error(`Plugin '${pluginId}': mount[${index}].target is required`);
|
||||
}
|
||||
|
||||
return {
|
||||
source: m.source,
|
||||
target: m.target,
|
||||
readonly: m.readonly !== false, // Default to true
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate health check config
|
||||
*/
|
||||
function validateHealthCheck(
|
||||
healthCheck: unknown,
|
||||
pluginId: string
|
||||
): ContainerHealthCheck | undefined {
|
||||
if (!healthCheck || typeof healthCheck !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const h = healthCheck as Record<string, unknown>;
|
||||
const result: ContainerHealthCheck = {};
|
||||
|
||||
if (h.interval !== undefined) {
|
||||
if (typeof h.interval !== 'string') {
|
||||
throw new Error(`Plugin '${pluginId}': healthCheck.interval must be a string (e.g., '30s')`);
|
||||
}
|
||||
result.interval = h.interval;
|
||||
}
|
||||
|
||||
if (h.timeout !== undefined) {
|
||||
if (typeof h.timeout !== 'string') {
|
||||
throw new Error(`Plugin '${pluginId}': healthCheck.timeout must be a string (e.g., '5s')`);
|
||||
}
|
||||
result.timeout = h.timeout;
|
||||
}
|
||||
|
||||
if (h.retries !== undefined) {
|
||||
const retries = Number(h.retries);
|
||||
if (isNaN(retries) || retries < 0 || !Number.isInteger(retries)) {
|
||||
throw new Error(`Plugin '${pluginId}': healthCheck.retries must be a non-negative integer`);
|
||||
}
|
||||
result.retries = retries;
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single plugin configuration
|
||||
*/
|
||||
function validatePluginConfig(
|
||||
pluginId: string,
|
||||
config: unknown
|
||||
): ContainerPluginConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error(`Plugin '${pluginId}': configuration must be an object`);
|
||||
}
|
||||
|
||||
const c = config as Record<string, unknown>;
|
||||
|
||||
// Required: image
|
||||
if (typeof c.image !== 'string' || !c.image) {
|
||||
throw new Error(`Plugin '${pluginId}': 'image' is required and must be a non-empty string`);
|
||||
}
|
||||
|
||||
// Optional: enabled (default: true)
|
||||
const enabled = c.enabled !== false;
|
||||
|
||||
// Optional: instances (default: 1)
|
||||
let instances: number = DEFAULTS.instances;
|
||||
if (c.instances !== undefined) {
|
||||
instances = Number(c.instances);
|
||||
if (isNaN(instances) || instances < 1 || !Number.isInteger(instances)) {
|
||||
throw new Error(`Plugin '${pluginId}': 'instances' must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: defaultQueue
|
||||
let defaultQueue: TaskQueueType | undefined;
|
||||
if (c.defaultQueue !== undefined) {
|
||||
if (c.defaultQueue !== 'fast' && c.defaultQueue !== 'background') {
|
||||
throw new Error(`Plugin '${pluginId}': 'defaultQueue' must be 'fast' or 'background'`);
|
||||
}
|
||||
defaultQueue = c.defaultQueue;
|
||||
}
|
||||
|
||||
// Optional: network (default: false)
|
||||
const network = c.network === true;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
image: c.image,
|
||||
instances,
|
||||
resources: validateResourceLimits(c.resources, pluginId),
|
||||
config: c.config && typeof c.config === 'object'
|
||||
? c.config as Record<string, unknown>
|
||||
: undefined,
|
||||
network,
|
||||
mounts: validateMounts(c.mounts, pluginId),
|
||||
healthCheck: validateHealthCheck(c.healthCheck, pluginId),
|
||||
defaultQueue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate container plugins configuration
|
||||
*/
|
||||
export function parseConfig(rawConfig: unknown, filePath: string): ContainerPluginsConfig {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
throw new ConfigParseError('Configuration must be an object', filePath);
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
|
||||
// Optional: version
|
||||
const version = typeof config.version === 'string'
|
||||
? config.version
|
||||
: DEFAULTS.version;
|
||||
|
||||
// Required: plugins
|
||||
if (!config.plugins || typeof config.plugins !== 'object') {
|
||||
throw new ConfigParseError("'plugins' section is required and must be an object", filePath);
|
||||
}
|
||||
|
||||
const plugins: Record<string, ContainerPluginConfig> = {};
|
||||
|
||||
for (const [pluginId, pluginConfig] of Object.entries(config.plugins)) {
|
||||
// Validate plugin ID format
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(pluginId)) {
|
||||
throw new ConfigParseError(
|
||||
`Invalid plugin ID '${pluginId}': must be lowercase alphanumeric with hyphens, starting with a letter`,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
plugins[pluginId] = validatePluginConfig(pluginId, pluginConfig);
|
||||
} catch (error) {
|
||||
throw new ConfigParseError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
filePath
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { version, plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse plugins.yml configuration file
|
||||
*/
|
||||
export async function loadConfig(configPath: string): Promise<ContainerPluginsConfig> {
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(configPath, fs.constants.R_OK);
|
||||
} catch {
|
||||
throw new ConfigParseError(`Configuration file not found or not readable`, configPath);
|
||||
}
|
||||
|
||||
// Read file
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(configPath, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new ConfigParseError(
|
||||
'Failed to read configuration file',
|
||||
configPath,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
let rawConfig: unknown;
|
||||
try {
|
||||
rawConfig = YAML.parse(content);
|
||||
} catch (error) {
|
||||
throw new ConfigParseError(
|
||||
'Invalid YAML syntax',
|
||||
configPath,
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Substitute environment variables
|
||||
const substituted = substituteEnvVarsInObject(rawConfig);
|
||||
|
||||
// Validate and return
|
||||
return parseConfig(substituted, configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled plugins from config
|
||||
*/
|
||||
export function getEnabledPlugins(
|
||||
config: ContainerPluginsConfig
|
||||
): Array<{ id: string; config: ContainerPluginConfig }> {
|
||||
return Object.entries(config.plugins)
|
||||
.filter(([, pluginConfig]) => (pluginConfig as ContainerPluginConfig).enabled !== false)
|
||||
.map(([id, pluginConfig]) => ({ id, config: pluginConfig as ContainerPluginConfig }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default config path
|
||||
*/
|
||||
export function getDefaultConfigPath(): string {
|
||||
return process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml';
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* Container Manager
|
||||
*
|
||||
* Manages the lifecycle of plugin containers including spawning,
|
||||
* health checking, configuration, and cleanup.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { DockerClient } from './DockerClient.js';
|
||||
import {
|
||||
loadConfig,
|
||||
getEnabledPlugins,
|
||||
} from './ConfigParser.js';
|
||||
import type {
|
||||
ContainerPluginsConfig,
|
||||
ContainerPluginConfig,
|
||||
ContainerPluginManifest,
|
||||
ContainerPluginInstance,
|
||||
ContainerInstanceStatus,
|
||||
ContainerManagerStatus,
|
||||
PluginHealthResponse,
|
||||
PluginConfigureResponse,
|
||||
} from './types.js';
|
||||
import { config } from '../config/EnvConfig.js';
|
||||
|
||||
/**
|
||||
* Container manager events
|
||||
*/
|
||||
export interface ContainerManagerEvents {
|
||||
'plugin:starting': { pluginId: string; instanceIndex: number };
|
||||
'plugin:started': { pluginId: string; instance: ContainerPluginInstance };
|
||||
'plugin:healthy': { pluginId: string; instance: ContainerPluginInstance };
|
||||
'plugin:unhealthy': { pluginId: string; instance: ContainerPluginInstance; error: string };
|
||||
'plugin:stopped': { pluginId: string; instanceIndex: number };
|
||||
'plugin:error': { pluginId: string; error: Error };
|
||||
'manager:initialized': { pluginCount: number };
|
||||
'manager:shutdown': {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Container naming helper
|
||||
*/
|
||||
function getContainerName(pluginId: string, instanceIndex: number): string {
|
||||
return `meta-plugin-${pluginId}-${instanceIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container Manager
|
||||
*/
|
||||
/**
|
||||
* ============================================================================
|
||||
* PLUGIN MOUNT ARCHITECTURE - HARDCODED PATHS
|
||||
* ============================================================================
|
||||
*
|
||||
* Each plugin container has exactly 3 mount points (hardcoded):
|
||||
*
|
||||
* /files (READ-ONLY) - Files to process, same structure as meta-sort
|
||||
* /output (READ-WRITE) - Plugin-generated files
|
||||
* /cache (READ-WRITE) - Plugin-specific cache
|
||||
*
|
||||
* Host paths are configured via environment variables:
|
||||
* - PLUGIN_FILE_MOUNTS: Maps subpaths under /files to host paths
|
||||
* Format: "subpath:host_path,subpath2:host_path2"
|
||||
* Example: "watch:/data/media,test:/app/test/media"
|
||||
* Results in: /files/watch, /files/test mounted in container
|
||||
*
|
||||
* - PLUGIN_CACHE_FOLDER: Base path for /cache mounts
|
||||
* - PLUGIN_OUTPUT_FOLDER: Base path for /output mounts
|
||||
*
|
||||
* SECURITY: Plugins have read-only access to /files.
|
||||
* Only /cache and /output are writable.
|
||||
*
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* File mount configuration - maps container path to host path
|
||||
*/
|
||||
interface FileMountConfig {
|
||||
containerPath: string;
|
||||
hostPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse PLUGIN_FILE_MOUNTS env var into mount configs
|
||||
* Format: "container_path:host_path,container_path2:host_path2"
|
||||
*/
|
||||
function parseFileMounts(mountsStr?: string): FileMountConfig[] {
|
||||
if (!mountsStr) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mounts: FileMountConfig[] = [];
|
||||
const pairs = mountsStr.split(',').map(s => s.trim()).filter(s => s);
|
||||
|
||||
for (const pair of pairs) {
|
||||
const [containerPath, hostPath] = pair.split(':').map(s => s.trim());
|
||||
if (containerPath && hostPath) {
|
||||
mounts.push({ containerPath, hostPath });
|
||||
} else {
|
||||
console.warn(`[ContainerManager] Invalid mount format: "${pair}" - expected "container_path:host_path"`);
|
||||
}
|
||||
}
|
||||
|
||||
return mounts;
|
||||
}
|
||||
|
||||
export class ContainerManager extends EventEmitter {
|
||||
private dockerClient: DockerClient;
|
||||
private pluginsConfig: ContainerPluginsConfig | null = null;
|
||||
private instances: Map<string, ContainerPluginInstance[]> = new Map();
|
||||
private healthCheckIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
private roundRobinCounters: Map<string, number> = new Map();
|
||||
private initialized: boolean = false;
|
||||
private shutdownInProgress: boolean = false;
|
||||
private fileMounts: FileMountConfig[] = [];
|
||||
private pluginCacheFolder?: string;
|
||||
private pluginOutputFolder?: string;
|
||||
private stackName?: string;
|
||||
|
||||
constructor(
|
||||
private configPath: string = config.CONTAINER_PLUGINS_CONFIG,
|
||||
private network: string = config.CONTAINER_NETWORK,
|
||||
private filesPath: string = config.FILES_PATH,
|
||||
private callbackUrl: string = config.CONTAINER_CALLBACK_URL,
|
||||
private metaCoreUrl: string = config.CONTAINER_META_CORE_URL,
|
||||
dockerClient?: DockerClient
|
||||
) {
|
||||
super();
|
||||
this.dockerClient = dockerClient || new DockerClient(config.DOCKER_SOCKET_PATH);
|
||||
|
||||
// Parse file mounts from PLUGIN_FILE_MOUNTS env var
|
||||
this.fileMounts = parseFileMounts(config.PLUGIN_FILE_MOUNTS);
|
||||
|
||||
// Configure plugin cache folder (READ-WRITE)
|
||||
this.pluginCacheFolder = config.PLUGIN_CACHE_FOLDER;
|
||||
|
||||
// Configure plugin output folder (READ-WRITE for /plugin-files)
|
||||
this.pluginOutputFolder = config.PLUGIN_OUTPUT_FOLDER;
|
||||
|
||||
// Configure stack name for Docker Desktop grouping
|
||||
this.stackName = config.PLUGIN_STACK_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the container manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ContainerManager] Initializing...');
|
||||
|
||||
// Log configured host mounts
|
||||
console.log('[ContainerManager] Plugin mount configuration (hardcoded paths):');
|
||||
console.log(' /files (RO) - Files to process:');
|
||||
if (this.fileMounts.length > 0) {
|
||||
for (const mount of this.fileMounts) {
|
||||
const targetPath = mount.containerPath.startsWith('/files')
|
||||
? mount.containerPath
|
||||
: `/files/${mount.containerPath}`;
|
||||
console.log(` ${targetPath} -> ${mount.hostPath}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(' (not configured) - Set PLUGIN_FILE_MOUNTS');
|
||||
}
|
||||
console.log(` /cache (RW) - Plugin cache: ${this.pluginCacheFolder || '(not configured)'}`);
|
||||
console.log(` /output (RW) - Plugin output: ${this.pluginOutputFolder || '(not configured)'}`)
|
||||
|
||||
// Initialize Docker client
|
||||
await this.dockerClient.initialize();
|
||||
|
||||
// Ensure network exists
|
||||
await this.dockerClient.ensureNetwork(this.network);
|
||||
|
||||
// Load configuration
|
||||
this.pluginsConfig = await loadConfig(this.configPath);
|
||||
|
||||
// Get enabled plugins
|
||||
const enabledPlugins = getEnabledPlugins(this.pluginsConfig);
|
||||
|
||||
console.log(`[ContainerManager] Found ${enabledPlugins.length} enabled plugins`);
|
||||
|
||||
// Clean up any stale containers from previous runs
|
||||
await this.cleanupStaleContainers();
|
||||
|
||||
// Spawn containers for each enabled plugin
|
||||
for (const { id, config: pluginConfig } of enabledPlugins) {
|
||||
try {
|
||||
await this.spawnPlugin(id, pluginConfig);
|
||||
} catch (error) {
|
||||
console.error(`[ContainerManager] Failed to spawn plugin '${id}':`, error);
|
||||
this.emit('plugin:error', {
|
||||
pluginId: id,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
this.emit('manager:initialized', { pluginCount: enabledPlugins.length });
|
||||
|
||||
console.log('[ContainerManager] Initialization complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the container manager
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.shutdownInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shutdownInProgress = true;
|
||||
console.log('[ContainerManager] Shutting down...');
|
||||
|
||||
// Stop health checks
|
||||
for (const interval of this.healthCheckIntervals.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
this.healthCheckIntervals.clear();
|
||||
|
||||
// Stop all plugin containers
|
||||
for (const [pluginId, instances] of this.instances) {
|
||||
for (const instance of instances) {
|
||||
try {
|
||||
await this.stopInstance(instance);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ContainerManager] Error stopping ${instance.containerName}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.instances.clear();
|
||||
this.initialized = false;
|
||||
this.shutdownInProgress = false;
|
||||
this.emit('manager:shutdown', {});
|
||||
|
||||
console.log('[ContainerManager] Shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn all instances for a plugin
|
||||
*/
|
||||
async spawnPlugin(pluginId: string, pluginConfig: ContainerPluginConfig): Promise<void> {
|
||||
const instanceCount = pluginConfig.instances || 1;
|
||||
const instances: ContainerPluginInstance[] = [];
|
||||
|
||||
console.log(
|
||||
`[ContainerManager] Spawning ${instanceCount} instance(s) for plugin '${pluginId}'`
|
||||
);
|
||||
|
||||
// Pull image if needed
|
||||
const imageExists = await this.dockerClient.imageExists(pluginConfig.image);
|
||||
if (!imageExists) {
|
||||
console.log(`[ContainerManager] Pulling image ${pluginConfig.image}...`);
|
||||
await this.dockerClient.pullImage(pluginConfig.image, (event) => {
|
||||
if (event.status) {
|
||||
console.log(`[ContainerManager] Pull: ${event.status}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn each instance
|
||||
for (let i = 0; i < instanceCount; i++) {
|
||||
this.emit('plugin:starting', { pluginId, instanceIndex: i });
|
||||
|
||||
const instance = await this.spawnInstance(pluginId, pluginConfig, i);
|
||||
instances.push(instance);
|
||||
|
||||
this.emit('plugin:started', { pluginId, instance });
|
||||
}
|
||||
|
||||
this.instances.set(pluginId, instances);
|
||||
|
||||
// Start health checking
|
||||
this.startHealthCheck(pluginId);
|
||||
|
||||
// Wait for at least one instance to be healthy
|
||||
await this.waitForHealthy(pluginId, 30000);
|
||||
|
||||
// Fetch manifest from first healthy instance
|
||||
const healthyInstance = this.getHealthyInstance(pluginId);
|
||||
if (healthyInstance) {
|
||||
try {
|
||||
const manifest = await this.fetchManifest(healthyInstance);
|
||||
// Update all instances with manifest
|
||||
for (const inst of instances) {
|
||||
inst.manifest = manifest;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ContainerManager] Failed to fetch manifest for '${pluginId}':`,
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
// Send configuration
|
||||
if (pluginConfig.config) {
|
||||
try {
|
||||
await this.sendConfig(healthyInstance, pluginConfig.config);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ContainerManager] Failed to configure '${pluginId}':`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a single container instance
|
||||
*/
|
||||
private async spawnInstance(
|
||||
pluginId: string,
|
||||
pluginConfig: ContainerPluginConfig,
|
||||
instanceIndex: number
|
||||
): Promise<ContainerPluginInstance> {
|
||||
const containerName = getContainerName(pluginId, instanceIndex);
|
||||
|
||||
// Check if container already exists
|
||||
const existingContainers = await this.dockerClient.listContainers({
|
||||
name: [containerName],
|
||||
});
|
||||
|
||||
if (existingContainers.length > 0) {
|
||||
const existing = existingContainers[0];
|
||||
// Remove existing container
|
||||
console.log(`[ContainerManager] Removing existing container ${containerName}`);
|
||||
await this.dockerClient.removeContainer(existing.Id, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* PLUGIN MOUNT ARCHITECTURE - HARDCODED PATHS:
|
||||
* /files (READ-ONLY) - Files to process
|
||||
* /output (READ-WRITE) - Plugin-generated files
|
||||
* /cache (READ-WRITE) - Plugin-specific cache
|
||||
*/
|
||||
const mounts: Array<{ source: string; target: string; readonly: boolean }> = [];
|
||||
|
||||
// /files/* (READ-ONLY) - Mount each configured subpath under /files
|
||||
for (const fileMount of this.fileMounts) {
|
||||
// fileMount.containerPath is the subpath (e.g., "watch", "test")
|
||||
// Mount at /files/{subpath}
|
||||
const targetPath = fileMount.containerPath.startsWith('/files')
|
||||
? fileMount.containerPath
|
||||
: `/files/${fileMount.containerPath}`;
|
||||
mounts.push({
|
||||
source: fileMount.hostPath,
|
||||
target: targetPath,
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.fileMounts.length === 0) {
|
||||
console.warn(`[ContainerManager] WARNING: No file mounts configured. Plugin '${pluginId}' won't have access to files.`);
|
||||
}
|
||||
|
||||
// /cache (READ-WRITE) - Plugin-specific cache
|
||||
if (this.pluginCacheFolder) {
|
||||
mounts.push({
|
||||
source: `${this.pluginCacheFolder}/${pluginId}`,
|
||||
target: '/cache',
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
// /output (READ-WRITE) - Plugin-generated files
|
||||
if (this.pluginOutputFolder) {
|
||||
mounts.push({
|
||||
source: `${this.pluginOutputFolder}/${pluginId}`,
|
||||
target: '/output',
|
||||
readonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Create container
|
||||
const containerId = await this.dockerClient.createContainer({
|
||||
image: pluginConfig.image,
|
||||
name: containerName,
|
||||
network: this.network,
|
||||
resources: pluginConfig.resources,
|
||||
mounts,
|
||||
labels: {
|
||||
'meta-mesh.plugin': 'true',
|
||||
'meta-mesh.plugin.id': pluginId,
|
||||
'meta-mesh.plugin.instance': String(instanceIndex),
|
||||
},
|
||||
env: {
|
||||
PLUGIN_ID: pluginId,
|
||||
META_CORE_URL: this.metaCoreUrl,
|
||||
CALLBACK_URL: this.callbackUrl,
|
||||
},
|
||||
// Docker Desktop grouping
|
||||
stackName: this.stackName,
|
||||
serviceName: pluginId,
|
||||
});
|
||||
|
||||
// Start container
|
||||
await this.dockerClient.startContainer(containerId);
|
||||
|
||||
// Create instance record
|
||||
const instance: ContainerPluginInstance = {
|
||||
pluginId,
|
||||
containerId,
|
||||
containerName,
|
||||
baseUrl: `http://${containerName}:8080`,
|
||||
instanceIndex,
|
||||
status: 'starting',
|
||||
tasksProcessed: 0,
|
||||
tasksFailed: 0,
|
||||
};
|
||||
|
||||
console.log(`[ContainerManager] Started container ${containerName}`);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a container instance
|
||||
*/
|
||||
private async stopInstance(instance: ContainerPluginInstance): Promise<void> {
|
||||
console.log(`[ContainerManager] Stopping ${instance.containerName}...`);
|
||||
|
||||
try {
|
||||
await this.dockerClient.stopContainer(instance.containerId, 10);
|
||||
} catch (error) {
|
||||
// Container might already be stopped
|
||||
}
|
||||
|
||||
try {
|
||||
await this.dockerClient.removeContainer(instance.containerId, true);
|
||||
} catch (error) {
|
||||
// Container might already be removed
|
||||
}
|
||||
|
||||
instance.status = 'stopped';
|
||||
this.emit('plugin:stopped', {
|
||||
pluginId: instance.pluginId,
|
||||
instanceIndex: instance.instanceIndex,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health check loop for a plugin
|
||||
*/
|
||||
private startHealthCheck(pluginId: string, intervalMs: number = 30000): void {
|
||||
// Clear existing interval if any
|
||||
const existing = this.healthCheckIntervals.get(pluginId);
|
||||
if (existing) {
|
||||
clearInterval(existing);
|
||||
}
|
||||
|
||||
const checkHealth = async () => {
|
||||
const instances = this.instances.get(pluginId);
|
||||
if (!instances) return;
|
||||
|
||||
for (const instance of instances) {
|
||||
if (instance.status === 'stopped') continue;
|
||||
|
||||
try {
|
||||
const healthy = await this.checkHealth(instance);
|
||||
const previousStatus = instance.status;
|
||||
|
||||
if (healthy) {
|
||||
instance.status = 'healthy';
|
||||
instance.lastHealthCheck = Date.now();
|
||||
|
||||
if (previousStatus !== 'healthy') {
|
||||
this.emit('plugin:healthy', { pluginId, instance });
|
||||
}
|
||||
} else {
|
||||
instance.status = 'unhealthy';
|
||||
instance.lastError = 'Health check failed';
|
||||
|
||||
if (previousStatus === 'healthy') {
|
||||
this.emit('plugin:unhealthy', {
|
||||
pluginId,
|
||||
instance,
|
||||
error: 'Health check failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
instance.status = 'unhealthy';
|
||||
instance.lastError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.emit('plugin:unhealthy', {
|
||||
pluginId,
|
||||
instance,
|
||||
error: instance.lastError,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial check
|
||||
checkHealth();
|
||||
|
||||
// Schedule periodic checks
|
||||
const interval = setInterval(checkHealth, intervalMs);
|
||||
this.healthCheckIntervals.set(pluginId, interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check health of a container instance
|
||||
*/
|
||||
async checkHealth(instance: ContainerPluginInstance): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${instance.baseUrl}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PluginHealthResponse;
|
||||
return data.status === 'healthy' && data.ready === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for at least one healthy instance
|
||||
*/
|
||||
private async waitForHealthy(pluginId: string, timeoutMs: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const checkInterval = 1000;
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const instances = this.instances.get(pluginId);
|
||||
if (!instances) return;
|
||||
|
||||
for (const instance of instances) {
|
||||
const healthy = await this.checkHealth(instance);
|
||||
if (healthy) {
|
||||
instance.status = 'healthy';
|
||||
instance.lastHealthCheck = Date.now();
|
||||
this.emit('plugin:healthy', { pluginId, instance });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
console.warn(`[ContainerManager] Timeout waiting for healthy instance of '${pluginId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch manifest from a container instance
|
||||
*/
|
||||
async fetchManifest(instance: ContainerPluginInstance): Promise<ContainerPluginManifest> {
|
||||
const response = await fetch(`${instance.baseUrl}/manifest`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch manifest: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<ContainerPluginManifest>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send configuration to a container instance
|
||||
*/
|
||||
async sendConfig(
|
||||
instance: ContainerPluginInstance,
|
||||
pluginConfig: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const response = await fetch(`${instance.baseUrl}/configure`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: pluginConfig }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to configure plugin: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PluginConfigureResponse;
|
||||
if (!data.success) {
|
||||
throw new Error(`Plugin configuration failed: ${data.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a healthy instance for a plugin (round-robin)
|
||||
*/
|
||||
getHealthyInstance(pluginId: string): ContainerPluginInstance | null {
|
||||
const instances = this.instances.get(pluginId);
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const healthyInstances = instances.filter((i) => i.status === 'healthy');
|
||||
if (healthyInstances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Round-robin selection
|
||||
const counter = this.roundRobinCounters.get(pluginId) || 0;
|
||||
const selected = healthyInstances[counter % healthyInstances.length];
|
||||
this.roundRobinCounters.set(pluginId, counter + 1);
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all instances for a plugin
|
||||
*/
|
||||
getInstances(pluginId: string): ContainerPluginInstance[] {
|
||||
return this.instances.get(pluginId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plugins
|
||||
*/
|
||||
getPluginIds(): string[] {
|
||||
return Array.from(this.instances.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin is a container plugin
|
||||
*/
|
||||
isContainerPlugin(pluginId: string): boolean {
|
||||
return this.instances.has(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plugin manifest
|
||||
*/
|
||||
getPluginManifest(pluginId: string): ContainerPluginManifest | undefined {
|
||||
const instances = this.instances.get(pluginId);
|
||||
if (!instances || instances.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return instances[0].manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get manager status
|
||||
*/
|
||||
getStatus(): ContainerManagerStatus {
|
||||
const plugins: ContainerManagerStatus['plugins'] = [];
|
||||
|
||||
for (const [pluginId, instances] of this.instances) {
|
||||
const pluginConfig = this.pluginsConfig?.plugins[pluginId];
|
||||
const healthyCount = instances.filter((i) => i.status === 'healthy').length;
|
||||
|
||||
plugins.push({
|
||||
pluginId,
|
||||
enabled: pluginConfig?.enabled !== false,
|
||||
image: pluginConfig?.image || 'unknown',
|
||||
instances: instances.length,
|
||||
healthyInstances: healthyCount,
|
||||
manifest: instances[0]?.manifest,
|
||||
});
|
||||
}
|
||||
|
||||
const totalInstances = Array.from(this.instances.values()).reduce(
|
||||
(sum, instances) => sum + instances.length,
|
||||
0
|
||||
);
|
||||
|
||||
const healthyInstances = Array.from(this.instances.values()).reduce(
|
||||
(sum, instances) => sum + instances.filter((i) => i.status === 'healthy').length,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
initialized: this.initialized,
|
||||
pluginCount: this.instances.size,
|
||||
runningContainers: totalInstances,
|
||||
healthyContainers: healthyInstances,
|
||||
plugins,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a plugin (stop and respawn all instances)
|
||||
*/
|
||||
async restartPlugin(pluginId: string): Promise<void> {
|
||||
console.log(`[ContainerManager] Restarting plugin '${pluginId}'...`);
|
||||
|
||||
const instances = this.instances.get(pluginId);
|
||||
const pluginConfig = this.pluginsConfig?.plugins[pluginId];
|
||||
|
||||
if (!instances || !pluginConfig) {
|
||||
throw new Error(`Plugin '${pluginId}' not found`);
|
||||
}
|
||||
|
||||
// Stop health check
|
||||
const interval = this.healthCheckIntervals.get(pluginId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
this.healthCheckIntervals.delete(pluginId);
|
||||
}
|
||||
|
||||
// Stop all instances
|
||||
for (const instance of instances) {
|
||||
await this.stopInstance(instance);
|
||||
}
|
||||
|
||||
this.instances.delete(pluginId);
|
||||
|
||||
// Respawn
|
||||
await this.spawnPlugin(pluginId, pluginConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale containers from previous runs
|
||||
*/
|
||||
private async cleanupStaleContainers(): Promise<void> {
|
||||
console.log('[ContainerManager] Cleaning up stale containers...');
|
||||
|
||||
const staleContainers = await this.dockerClient.listPluginContainers();
|
||||
|
||||
for (const container of staleContainers) {
|
||||
console.log(
|
||||
`[ContainerManager] Removing stale container ${container.Names?.[0] || container.Id}`
|
||||
);
|
||||
try {
|
||||
await this.dockerClient.removeContainer(container.Id, true);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[ContainerManager] Failed to remove stale container:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record task completion for statistics
|
||||
*/
|
||||
recordTaskCompletion(pluginId: string, instanceName: string, success: boolean): void {
|
||||
const instances = this.instances.get(pluginId);
|
||||
if (!instances) return;
|
||||
|
||||
const instance = instances.find((i) => i.containerName === instanceName);
|
||||
if (!instance) return;
|
||||
|
||||
if (success) {
|
||||
instance.tasksProcessed++;
|
||||
} else {
|
||||
instance.tasksFailed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a container manager instance
|
||||
*/
|
||||
export function createContainerManager(
|
||||
configPath?: string,
|
||||
network?: string
|
||||
): ContainerManager {
|
||||
return new ContainerManager(configPath, network);
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
/**
|
||||
* Container Plugin Scheduler
|
||||
*
|
||||
* Handles dispatching tasks to containerized plugins and
|
||||
* processing completion callbacks.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import PQueue from 'p-queue';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { ContainerManager } from './ContainerManager.js';
|
||||
import type {
|
||||
ContainerTask,
|
||||
ContainerTaskStatus,
|
||||
PluginProcessRequest,
|
||||
PluginProcessResponse,
|
||||
PluginCallbackPayload,
|
||||
CallbackStatus,
|
||||
} from './types.js';
|
||||
import type { TaskQueueType } from '../plugin-engine/types.js';
|
||||
import type { IKVClient } from '../kv/IKVClient.js';
|
||||
import { config } from '../config/EnvConfig.js';
|
||||
|
||||
/**
|
||||
* Scheduler events
|
||||
*/
|
||||
export interface ContainerSchedulerEvents {
|
||||
'task:created': { task: ContainerTask };
|
||||
'task:dispatched': { task: ContainerTask };
|
||||
'task:completed': { task: ContainerTask };
|
||||
'task:failed': { task: ContainerTask; error: string };
|
||||
'task:timeout': { task: ContainerTask };
|
||||
'file:complete': { fileHash: string; filePath: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending callback info
|
||||
*/
|
||||
interface PendingCallback {
|
||||
task: ContainerTask;
|
||||
resolve: (result: PluginCallbackPayload) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container Plugin Scheduler
|
||||
*/
|
||||
export class ContainerPluginScheduler extends EventEmitter {
|
||||
private containerManager: ContainerManager;
|
||||
private fastQueue: PQueue;
|
||||
private backgroundQueue: PQueue;
|
||||
private pendingTasks: Map<string, ContainerTask> = new Map();
|
||||
private pendingCallbacks: Map<string, PendingCallback> = new Map();
|
||||
private fileTasks: Map<string, Set<string>> = new Map(); // fileHash -> taskIds
|
||||
private fileCompletedPlugins: Map<string, Set<string>> = new Map(); // fileHash -> completed pluginIds
|
||||
private dependencyWaiters: Map<string, Array<() => void>> = new Map(); // "fileHash:pluginId" -> resolve callbacks
|
||||
private callbackUrl: string;
|
||||
private metaCoreUrl: string;
|
||||
private defaultTimeout: number = 60000; // 60 seconds
|
||||
private kvClient: IKVClient | null = null;
|
||||
|
||||
constructor(
|
||||
containerManager: ContainerManager,
|
||||
options?: {
|
||||
fastConcurrency?: number;
|
||||
backgroundConcurrency?: number;
|
||||
callbackUrl?: string;
|
||||
metaCoreUrl?: string;
|
||||
defaultTimeout?: number;
|
||||
kvClient?: IKVClient;
|
||||
}
|
||||
) {
|
||||
super();
|
||||
this.containerManager = containerManager;
|
||||
|
||||
this.fastQueue = new PQueue({
|
||||
concurrency: options?.fastConcurrency ?? config.FAST_QUEUE_CONCURRENCY,
|
||||
});
|
||||
|
||||
this.backgroundQueue = new PQueue({
|
||||
concurrency: options?.backgroundConcurrency ?? config.BACKGROUND_QUEUE_CONCURRENCY,
|
||||
});
|
||||
|
||||
this.callbackUrl = options?.callbackUrl ?? `${config.CONTAINER_CALLBACK_URL}/api/plugins/callback`;
|
||||
this.metaCoreUrl = options?.metaCoreUrl ?? config.CONTAINER_META_CORE_URL;
|
||||
this.defaultTimeout = options?.defaultTimeout ?? 60000;
|
||||
this.kvClient = options?.kvClient ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for dependency plugins to complete for a file
|
||||
*/
|
||||
private async waitForDependencies(
|
||||
fileHash: string,
|
||||
dependencies: string[],
|
||||
timeoutMs: number = 30000
|
||||
): Promise<void> {
|
||||
if (!dependencies || dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const completedPlugins = this.fileCompletedPlugins.get(fileHash) || new Set();
|
||||
const pendingDeps = dependencies.filter(dep => !completedPlugins.has(dep));
|
||||
|
||||
if (pendingDeps.length === 0) {
|
||||
return; // All dependencies already completed
|
||||
}
|
||||
|
||||
// Wait for each pending dependency
|
||||
const waitPromises = pendingDeps.map(depPluginId => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const key = `${fileHash}:${depPluginId}`;
|
||||
|
||||
// Check again in case it completed while we were setting up
|
||||
const currentCompleted = this.fileCompletedPlugins.get(fileHash);
|
||||
if (currentCompleted?.has(depPluginId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
// Remove waiter on timeout
|
||||
const waiters = this.dependencyWaiters.get(key);
|
||||
if (waiters) {
|
||||
const idx = waiters.indexOf(resolve);
|
||||
if (idx >= 0) waiters.splice(idx, 1);
|
||||
}
|
||||
// Resolve anyway to not block - plugin will get stale metadata but won't hang
|
||||
console.warn(`[ContainerScheduler] Timeout waiting for dependency '${depPluginId}' for file ${fileHash}`);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
// Register waiter
|
||||
if (!this.dependencyWaiters.has(key)) {
|
||||
this.dependencyWaiters.set(key, []);
|
||||
}
|
||||
this.dependencyWaiters.get(key)!.push(() => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(waitPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a plugin as completed for a file and notify waiters
|
||||
*/
|
||||
private markPluginCompleted(fileHash: string, pluginId: string, filePath?: string): void {
|
||||
// Track completion
|
||||
if (!this.fileCompletedPlugins.has(fileHash)) {
|
||||
this.fileCompletedPlugins.set(fileHash, new Set());
|
||||
}
|
||||
this.fileCompletedPlugins.get(fileHash)!.add(pluginId);
|
||||
|
||||
// Notify waiters
|
||||
const key = `${fileHash}:${pluginId}`;
|
||||
const waiters = this.dependencyWaiters.get(key);
|
||||
if (waiters) {
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
this.dependencyWaiters.delete(key);
|
||||
}
|
||||
|
||||
// Publish to Redis for external subscribers (e.g., meta-stremio)
|
||||
this.publishPluginComplete(fileHash, pluginId, filePath || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish plugin completion event to Redis
|
||||
* Channel: meta-sort:plugin:complete
|
||||
*
|
||||
* External services (like meta-stremio) can subscribe to this channel
|
||||
* to be notified when specific plugins complete for a file.
|
||||
*/
|
||||
private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> {
|
||||
if (!this.kvClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.stringify({
|
||||
fileHash,
|
||||
pluginId,
|
||||
filePath,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
await (this.kvClient as any).publish('meta-sort:plugin:complete', message);
|
||||
} catch (error) {
|
||||
// Silent fail - pub/sub is optional
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task for a container plugin
|
||||
*/
|
||||
createTask(
|
||||
pluginId: string,
|
||||
fileHash: string,
|
||||
filePath: string,
|
||||
dependencies: string[] = [],
|
||||
queue: TaskQueueType = 'fast'
|
||||
): ContainerTask {
|
||||
const task: ContainerTask = {
|
||||
id: randomUUID(),
|
||||
fileHash,
|
||||
filePath,
|
||||
pluginId,
|
||||
dependencies,
|
||||
queue,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
this.pendingTasks.set(task.id, task);
|
||||
|
||||
// Track tasks by file
|
||||
if (!this.fileTasks.has(fileHash)) {
|
||||
this.fileTasks.set(fileHash, new Set());
|
||||
}
|
||||
this.fileTasks.get(fileHash)!.add(task.id);
|
||||
|
||||
this.emit('task:created', { task });
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a task for execution
|
||||
*/
|
||||
enqueueTask(task: ContainerTask): void {
|
||||
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
|
||||
|
||||
queue.add(async () => {
|
||||
await this.executeTask(task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task (dispatch to container)
|
||||
*/
|
||||
private async executeTask(task: ContainerTask): Promise<void> {
|
||||
// Wait for dependencies to complete first
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
task.status = 'waiting';
|
||||
await this.waitForDependencies(task.fileHash, task.dependencies);
|
||||
}
|
||||
|
||||
const instance = this.containerManager.getHealthyInstance(task.pluginId);
|
||||
|
||||
if (!instance) {
|
||||
task.status = 'failed';
|
||||
task.error = `No healthy instance available for plugin '${task.pluginId}'`;
|
||||
this.emit('task:failed', { task, error: task.error });
|
||||
this.checkFileCompletion(task.fileHash);
|
||||
return;
|
||||
}
|
||||
|
||||
task.status = 'dispatched';
|
||||
task.dispatchedAt = Date.now();
|
||||
task.instanceName = instance.containerName;
|
||||
|
||||
this.emit('task:dispatched', { task });
|
||||
|
||||
try {
|
||||
// Fetch fresh metadata after dependencies complete
|
||||
// This ensures we get metadata written by dependency plugins
|
||||
const existingMeta = await this.fetchExistingMetadata(task.fileHash);
|
||||
|
||||
// Build process request
|
||||
const request: PluginProcessRequest = {
|
||||
taskId: task.id,
|
||||
cid: task.fileHash,
|
||||
filePath: task.filePath,
|
||||
callbackUrl: this.callbackUrl,
|
||||
metaCoreUrl: this.metaCoreUrl,
|
||||
existingMeta,
|
||||
};
|
||||
|
||||
// Dispatch to container
|
||||
const response = await this.dispatchToContainer(instance.baseUrl, request);
|
||||
|
||||
if (response.status !== 'accepted') {
|
||||
throw new Error(response.error || 'Task rejected by plugin');
|
||||
}
|
||||
|
||||
// Wait for callback with timeout
|
||||
const manifest = instance.manifest;
|
||||
const timeout = manifest?.timeout ?? this.defaultTimeout;
|
||||
|
||||
await this.waitForCallback(task, timeout);
|
||||
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.error = error instanceof Error ? error.message : String(error);
|
||||
task.completedAt = Date.now();
|
||||
|
||||
if (task.dispatchedAt) {
|
||||
task.duration = task.completedAt - task.dispatchedAt;
|
||||
}
|
||||
|
||||
this.containerManager.recordTaskCompletion(
|
||||
task.pluginId,
|
||||
task.instanceName!,
|
||||
false
|
||||
);
|
||||
|
||||
this.emit('task:failed', { task, error: task.error });
|
||||
}
|
||||
|
||||
this.checkFileCompletion(task.fileHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch task to container plugin
|
||||
*/
|
||||
private async dispatchToContainer(
|
||||
baseUrl: string,
|
||||
request: PluginProcessRequest
|
||||
): Promise<PluginProcessResponse> {
|
||||
const response = await fetch(`${baseUrl}/process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: AbortSignal.timeout(10000), // 10s timeout for dispatch
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Plugin returned ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<PluginProcessResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for callback from container
|
||||
*/
|
||||
private waitForCallback(task: ContainerTask, timeoutMs: number): Promise<PluginCallbackPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
this.pendingCallbacks.delete(task.id);
|
||||
task.status = 'timeout';
|
||||
task.completedAt = Date.now();
|
||||
if (task.dispatchedAt) {
|
||||
task.duration = task.completedAt - task.dispatchedAt;
|
||||
}
|
||||
this.emit('task:timeout', { task });
|
||||
reject(new Error(`Task timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingCallbacks.set(task.id, {
|
||||
task,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutHandle,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle callback from container plugin
|
||||
*/
|
||||
handleCallback(payload: PluginCallbackPayload): boolean {
|
||||
const pending = this.pendingCallbacks.get(payload.taskId);
|
||||
|
||||
if (!pending) {
|
||||
console.warn(
|
||||
`[ContainerScheduler] Received callback for unknown task: ${payload.taskId}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
this.pendingCallbacks.delete(payload.taskId);
|
||||
|
||||
const task = pending.task;
|
||||
task.completedAt = Date.now();
|
||||
task.duration = payload.duration;
|
||||
|
||||
if (payload.status === 'completed') {
|
||||
task.status = 'completed';
|
||||
|
||||
this.containerManager.recordTaskCompletion(
|
||||
task.pluginId,
|
||||
task.instanceName!,
|
||||
true
|
||||
);
|
||||
|
||||
// Mark plugin as completed for dependency tracking
|
||||
this.markPluginCompleted(task.fileHash, task.pluginId, task.filePath);
|
||||
|
||||
this.emit('task:completed', { task });
|
||||
pending.resolve(payload);
|
||||
|
||||
} else if (payload.status === 'failed') {
|
||||
task.status = 'failed';
|
||||
task.error = payload.error;
|
||||
|
||||
this.containerManager.recordTaskCompletion(
|
||||
task.pluginId,
|
||||
task.instanceName!,
|
||||
false
|
||||
);
|
||||
|
||||
// Still mark as completed for dependency purposes (don't block other plugins)
|
||||
this.markPluginCompleted(task.fileHash, task.pluginId, task.filePath);
|
||||
|
||||
this.emit('task:failed', { task, error: payload.error || 'Unknown error' });
|
||||
pending.reject(new Error(payload.error || 'Task failed'));
|
||||
|
||||
} else if (payload.status === 'skipped') {
|
||||
task.status = 'completed'; // Treat skipped as completed
|
||||
task.error = payload.reason;
|
||||
|
||||
this.containerManager.recordTaskCompletion(
|
||||
task.pluginId,
|
||||
task.instanceName!,
|
||||
true
|
||||
);
|
||||
|
||||
// Mark plugin as completed for dependency tracking
|
||||
this.markPluginCompleted(task.fileHash, task.pluginId, task.filePath);
|
||||
|
||||
this.emit('task:completed', { task });
|
||||
pending.resolve(payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all tasks for a file are complete
|
||||
*/
|
||||
private checkFileCompletion(fileHash: string): void {
|
||||
const taskIds = this.fileTasks.get(fileHash);
|
||||
if (!taskIds) return;
|
||||
|
||||
let allComplete = true;
|
||||
let filePath: string | undefined;
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const task = this.pendingTasks.get(taskId);
|
||||
if (!task) continue;
|
||||
|
||||
filePath = task.filePath;
|
||||
|
||||
if (
|
||||
task.status !== 'completed' &&
|
||||
task.status !== 'failed' &&
|
||||
task.status !== 'timeout'
|
||||
) {
|
||||
allComplete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allComplete && filePath) {
|
||||
// Clean up task tracking
|
||||
for (const taskId of taskIds) {
|
||||
this.pendingTasks.delete(taskId);
|
||||
}
|
||||
this.fileTasks.delete(fileHash);
|
||||
|
||||
// Clean up dependency tracking
|
||||
this.fileCompletedPlugins.delete(fileHash);
|
||||
// Clean up any stale dependency waiters for this file
|
||||
for (const key of this.dependencyWaiters.keys()) {
|
||||
if (key.startsWith(`${fileHash}:`)) {
|
||||
this.dependencyWaiters.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('file:complete', { fileHash, filePath });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch existing metadata for a file
|
||||
*/
|
||||
private async fetchExistingMetadata(fileHash: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const response = await fetch(`${this.metaCoreUrl}/meta/${fileHash}`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await response.json() as { metadata?: Record<string, string> };
|
||||
return data.metadata || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue status
|
||||
*/
|
||||
getQueueStatus(): {
|
||||
fast: { pending: number; running: number };
|
||||
background: { pending: number; running: number };
|
||||
pendingTasks: number;
|
||||
pendingCallbacks: number;
|
||||
} {
|
||||
return {
|
||||
fast: {
|
||||
pending: this.fastQueue.pending,
|
||||
running: this.fastQueue.pending - this.fastQueue.size,
|
||||
},
|
||||
background: {
|
||||
pending: this.backgroundQueue.pending,
|
||||
running: this.backgroundQueue.pending - this.backgroundQueue.size,
|
||||
},
|
||||
pendingTasks: this.pendingTasks.size,
|
||||
pendingCallbacks: this.pendingCallbacks.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tasks for a file
|
||||
*/
|
||||
getPendingTasksForFile(fileHash: string): ContainerTask[] {
|
||||
const taskIds = this.fileTasks.get(fileHash);
|
||||
if (!taskIds) return [];
|
||||
|
||||
return Array.from(taskIds)
|
||||
.map(id => this.pendingTasks.get(id))
|
||||
.filter((t): t is ContainerTask => t !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all tasks for a file
|
||||
*/
|
||||
cancelFile(fileHash: string): void {
|
||||
const taskIds = this.fileTasks.get(fileHash);
|
||||
if (!taskIds) return;
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
const pending = this.pendingCallbacks.get(taskId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
pending.reject(new Error('Task cancelled'));
|
||||
this.pendingCallbacks.delete(taskId);
|
||||
}
|
||||
this.pendingTasks.delete(taskId);
|
||||
}
|
||||
|
||||
this.fileTasks.delete(fileHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all queues
|
||||
*/
|
||||
clear(): void {
|
||||
this.fastQueue.clear();
|
||||
this.backgroundQueue.clear();
|
||||
|
||||
for (const pending of this.pendingCallbacks.values()) {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
pending.reject(new Error('Scheduler cleared'));
|
||||
}
|
||||
|
||||
this.pendingCallbacks.clear();
|
||||
this.pendingTasks.clear();
|
||||
this.fileTasks.clear();
|
||||
this.fileCompletedPlugins.clear();
|
||||
this.dependencyWaiters.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queues
|
||||
*/
|
||||
pause(): void {
|
||||
this.fastQueue.pause();
|
||||
this.backgroundQueue.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queues
|
||||
*/
|
||||
resume(): void {
|
||||
this.fastQueue.start();
|
||||
this.backgroundQueue.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch tasks to all enabled plugins for a file
|
||||
* This creates and enqueues tasks for all plugins that should process this file
|
||||
*/
|
||||
async dispatchAllPlugins(
|
||||
fileHash: string,
|
||||
filePath: string,
|
||||
existingMeta: Record<string, string>
|
||||
): Promise<void> {
|
||||
const status = this.containerManager.getStatus();
|
||||
|
||||
if (!status.initialized || status.pluginCount === 0) {
|
||||
return; // No plugins available
|
||||
}
|
||||
|
||||
console.log(`[ContainerScheduler] Dispatching tasks for ${fileHash} to ${status.pluginCount} plugins`);
|
||||
|
||||
// Get plugin info and dispatch tasks
|
||||
for (const pluginStatus of status.plugins) {
|
||||
if (!pluginStatus.enabled || pluginStatus.healthyInstances === 0) {
|
||||
continue; // Skip disabled or unhealthy plugins
|
||||
}
|
||||
|
||||
const manifest = this.containerManager.getPluginManifest(pluginStatus.pluginId);
|
||||
const queue: TaskQueueType = manifest?.defaultQueue || 'fast';
|
||||
const dependencies = manifest?.dependencies || [];
|
||||
|
||||
// Create and enqueue task
|
||||
const task = this.createTask(
|
||||
pluginStatus.pluginId,
|
||||
fileHash,
|
||||
filePath,
|
||||
dependencies,
|
||||
queue
|
||||
);
|
||||
|
||||
// Store existing metadata in task for dispatch
|
||||
(task as any).existingMeta = existingMeta;
|
||||
|
||||
this.enqueueTask(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a container plugin scheduler
|
||||
*/
|
||||
export function createContainerPluginScheduler(
|
||||
containerManager: ContainerManager,
|
||||
options?: {
|
||||
fastConcurrency?: number;
|
||||
backgroundConcurrency?: number;
|
||||
callbackUrl?: string;
|
||||
metaCoreUrl?: string;
|
||||
kvClient?: IKVClient;
|
||||
}
|
||||
): ContainerPluginScheduler {
|
||||
return new ContainerPluginScheduler(containerManager, options);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Docker Client
|
||||
*
|
||||
* Wrapper around the Docker Engine API for container management.
|
||||
* Requires: npm install dockerode @types/dockerode
|
||||
*/
|
||||
|
||||
import Docker from 'dockerode';
|
||||
import type { Container, ContainerInfo, ContainerCreateOptions } from 'dockerode';
|
||||
import type { Readable } from 'stream';
|
||||
import type { ContainerSpawnOptions, ContainerResourceLimits } from './types.js';
|
||||
|
||||
/**
|
||||
* Docker client for container operations
|
||||
*/
|
||||
export class DockerClient {
|
||||
private docker: Docker;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(socketPath: string = '/var/run/docker.sock') {
|
||||
this.docker = new Docker({ socketPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and verify Docker connection
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
await this.docker.ping();
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to connect to Docker: ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker is connected
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker info
|
||||
*/
|
||||
async getInfo(): Promise<any> {
|
||||
return this.docker.info();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a Docker image
|
||||
*/
|
||||
async pullImage(imageName: string, onProgress?: (event: any) => void): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.docker.pull(imageName, (err: Error | null, stream: NodeJS.ReadableStream) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.docker.modem.followProgress(
|
||||
stream,
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
onProgress
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an image exists locally
|
||||
*/
|
||||
async imageExists(imageName: string): Promise<boolean> {
|
||||
try {
|
||||
await this.docker.getImage(imageName).inspect();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a container
|
||||
*/
|
||||
async createContainer(options: ContainerSpawnOptions): Promise<string> {
|
||||
// Build labels, adding Docker Compose labels if stackName is provided
|
||||
const labels: Record<string, string> = {
|
||||
'meta-mesh.plugin': 'true',
|
||||
...options.labels,
|
||||
};
|
||||
|
||||
// Add compose labels for Docker Desktop grouping
|
||||
if (options.stackName) {
|
||||
labels['com.docker.compose.project'] = options.stackName;
|
||||
if (options.serviceName) {
|
||||
labels['com.docker.compose.service'] = options.serviceName;
|
||||
}
|
||||
}
|
||||
|
||||
const createOptions: ContainerCreateOptions = {
|
||||
Image: options.image,
|
||||
name: options.name,
|
||||
Env: options.env
|
||||
? Object.entries(options.env).map(([k, v]) => `${k}=${v}`)
|
||||
: undefined,
|
||||
Labels: labels,
|
||||
HostConfig: {
|
||||
Binds: options.mounts?.map(m =>
|
||||
`${m.source}:${m.target}:${m.readonly ? 'ro' : 'rw'}`
|
||||
),
|
||||
NetworkMode: options.network,
|
||||
Memory: options.resources?.memory
|
||||
? parseMemoryLimit(options.resources.memory)
|
||||
: undefined,
|
||||
NanoCpus: options.resources?.cpus
|
||||
? Math.floor(options.resources.cpus * 1e9)
|
||||
: undefined,
|
||||
RestartPolicy: {
|
||||
Name: 'unless-stopped',
|
||||
},
|
||||
},
|
||||
ExposedPorts: {
|
||||
'8080/tcp': {},
|
||||
},
|
||||
};
|
||||
|
||||
const container = await this.docker.createContainer(createOptions);
|
||||
return container.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a container
|
||||
*/
|
||||
async startContainer(containerId: string): Promise<void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
async stopContainer(containerId: string, timeout: number = 10): Promise<void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.stop({ t: timeout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a container
|
||||
*/
|
||||
async removeContainer(containerId: string, force: boolean = false): Promise<void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
await container.remove({ force });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container info
|
||||
*/
|
||||
async inspectContainer(containerId: string): Promise<Docker.ContainerInspectInfo> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
return container.inspect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container exists
|
||||
*/
|
||||
async containerExists(containerId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.inspectContainer(containerId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container is running
|
||||
*/
|
||||
async isContainerRunning(containerId: string): Promise<boolean> {
|
||||
try {
|
||||
const info = await this.inspectContainer(containerId);
|
||||
return info.State?.Running === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container IP address on a specific network
|
||||
*/
|
||||
async getContainerIP(containerId: string, network: string): Promise<string | null> {
|
||||
try {
|
||||
const info = await this.inspectContainer(containerId);
|
||||
const networkSettings = info.NetworkSettings?.Networks?.[network];
|
||||
return networkSettings?.IPAddress || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List containers with optional filters
|
||||
*/
|
||||
async listContainers(filters?: {
|
||||
label?: string[];
|
||||
name?: string[];
|
||||
status?: string[];
|
||||
}): Promise<ContainerInfo[]> {
|
||||
const dockerFilters: Record<string, string[]> = {};
|
||||
|
||||
if (filters?.label) {
|
||||
dockerFilters.label = filters.label;
|
||||
}
|
||||
if (filters?.name) {
|
||||
dockerFilters.name = filters.name;
|
||||
}
|
||||
if (filters?.status) {
|
||||
dockerFilters.status = filters.status;
|
||||
}
|
||||
|
||||
return this.docker.listContainers({
|
||||
all: true,
|
||||
filters: Object.keys(dockerFilters).length > 0 ? dockerFilters : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List plugin containers (created by meta-sort)
|
||||
*/
|
||||
async listPluginContainers(): Promise<ContainerInfo[]> {
|
||||
return this.listContainers({
|
||||
label: ['meta-mesh.plugin=true'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get container logs
|
||||
*/
|
||||
async getContainerLogs(
|
||||
containerId: string,
|
||||
options?: {
|
||||
tail?: number;
|
||||
since?: number;
|
||||
stdout?: boolean;
|
||||
stderr?: boolean;
|
||||
}
|
||||
): Promise<string> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const logs = await container.logs({
|
||||
stdout: options?.stdout ?? true,
|
||||
stderr: options?.stderr ?? true,
|
||||
tail: options?.tail ?? 100,
|
||||
since: options?.since,
|
||||
});
|
||||
|
||||
// Convert buffer to string
|
||||
if (Buffer.isBuffer(logs)) {
|
||||
return logs.toString('utf-8');
|
||||
}
|
||||
return logs as unknown as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream container logs
|
||||
*/
|
||||
async streamContainerLogs(
|
||||
containerId: string,
|
||||
onLog: (log: string) => void,
|
||||
options?: {
|
||||
stdout?: boolean;
|
||||
stderr?: boolean;
|
||||
}
|
||||
): Promise<() => void> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const stream = await container.logs({
|
||||
stdout: options?.stdout ?? true,
|
||||
stderr: options?.stderr ?? true,
|
||||
follow: true,
|
||||
tail: 0,
|
||||
});
|
||||
|
||||
const readable = stream as Readable;
|
||||
readable.on('data', (chunk: Buffer) => {
|
||||
onLog(chunk.toString('utf-8'));
|
||||
});
|
||||
|
||||
return () => {
|
||||
readable.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a network exists
|
||||
*/
|
||||
async networkExists(networkName: string): Promise<boolean> {
|
||||
try {
|
||||
const networks = await this.docker.listNetworks({
|
||||
filters: { name: [networkName] },
|
||||
});
|
||||
return networks.some(n => n.Name === networkName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a network if it doesn't exist
|
||||
*/
|
||||
async ensureNetwork(networkName: string): Promise<void> {
|
||||
if (await this.networkExists(networkName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.docker.createNetwork({
|
||||
Name: networkName,
|
||||
Driver: 'bridge',
|
||||
Labels: {
|
||||
'meta-mesh': 'true',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect container to network
|
||||
*/
|
||||
async connectToNetwork(containerId: string, networkName: string): Promise<void> {
|
||||
const network = this.docker.getNetwork(networkName);
|
||||
await network.connect({ Container: containerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect container from network
|
||||
*/
|
||||
async disconnectFromNetwork(containerId: string, networkName: string): Promise<void> {
|
||||
const network = this.docker.getNetwork(networkName);
|
||||
await network.disconnect({ Container: containerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in a container
|
||||
*/
|
||||
async exec(
|
||||
containerId: string,
|
||||
cmd: string[],
|
||||
options?: { workingDir?: string }
|
||||
): Promise<{ exitCode: number; output: string }> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
WorkingDir: options?.workingDir,
|
||||
});
|
||||
|
||||
const stream = await exec.start({ Detach: false });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
output += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
stream.on('end', async () => {
|
||||
try {
|
||||
const info = await exec.inspect();
|
||||
resolve({
|
||||
exitCode: info.ExitCode ?? -1,
|
||||
output,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse memory limit string to bytes
|
||||
* Supports: '512m', '1g', '1024k'
|
||||
*/
|
||||
function parseMemoryLimit(limit: string): number {
|
||||
const match = limit.toLowerCase().match(/^(\d+)([kmg]?)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid memory limit format: ${limit}`);
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2] || '';
|
||||
|
||||
switch (unit) {
|
||||
case 'k':
|
||||
return value * 1024;
|
||||
case 'm':
|
||||
return value * 1024 * 1024;
|
||||
case 'g':
|
||||
return value * 1024 * 1024 * 1024;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Docker client instance
|
||||
*/
|
||||
export function createDockerClient(socketPath?: string): DockerClient {
|
||||
return new DockerClient(socketPath);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Container Plugins Module
|
||||
*
|
||||
* Provides support for running plugins as Docker containers.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types.js';
|
||||
|
||||
// Configuration
|
||||
export {
|
||||
loadConfig,
|
||||
parseConfig,
|
||||
getEnabledPlugins,
|
||||
getDefaultConfigPath,
|
||||
ConfigParseError,
|
||||
} from './ConfigParser.js';
|
||||
|
||||
// Docker Client
|
||||
export { DockerClient, createDockerClient } from './DockerClient.js';
|
||||
|
||||
// Container Manager
|
||||
export {
|
||||
ContainerManager,
|
||||
createContainerManager,
|
||||
type ContainerManagerEvents,
|
||||
} from './ContainerManager.js';
|
||||
|
||||
// Container Plugin Scheduler
|
||||
export {
|
||||
ContainerPluginScheduler,
|
||||
createContainerPluginScheduler,
|
||||
type ContainerSchedulerEvents,
|
||||
} from './ContainerPluginScheduler.js';
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Container Plugin System Type Definitions
|
||||
*
|
||||
* Types for containerized plugins that run in Docker containers
|
||||
* and communicate via HTTP APIs.
|
||||
*/
|
||||
|
||||
import type { ConfigField, SchemaField, TaskQueueType } from '../plugin-engine/types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Configuration (stored in plugins.yml)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Resource limits for a container plugin
|
||||
*/
|
||||
export interface ContainerResourceLimits {
|
||||
/** Memory limit (e.g., '512m', '2g') */
|
||||
memory?: string;
|
||||
/** CPU limit (e.g., 1.0, 2.5) */
|
||||
cpus?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume mount configuration
|
||||
*/
|
||||
export interface ContainerMount {
|
||||
/** Source path (relative to FILES_PATH) */
|
||||
source: string;
|
||||
/** Target path inside container */
|
||||
target: string;
|
||||
/** Mount as read-only (default: true) */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check configuration
|
||||
*/
|
||||
export interface ContainerHealthCheck {
|
||||
/** Health check interval (e.g., '30s', '1m') */
|
||||
interval?: string;
|
||||
/** Health check timeout (e.g., '5s') */
|
||||
timeout?: string;
|
||||
/** Number of retries before marking unhealthy */
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single container plugin configuration (from plugins.yml)
|
||||
*/
|
||||
export interface ContainerPluginConfig {
|
||||
/** Whether this plugin is enabled */
|
||||
enabled?: boolean;
|
||||
/** Docker image name and tag */
|
||||
image: string;
|
||||
/** Number of container instances to spawn (default: 1) */
|
||||
instances?: number;
|
||||
/** Resource limits */
|
||||
resources?: ContainerResourceLimits;
|
||||
/** Plugin configuration values (sent to /configure endpoint) */
|
||||
config?: Record<string, unknown>;
|
||||
/** Whether plugin needs external network access (default: false) */
|
||||
network?: boolean;
|
||||
/** Additional volume mounts */
|
||||
mounts?: ContainerMount[];
|
||||
/** Health check settings */
|
||||
healthCheck?: ContainerHealthCheck;
|
||||
/** Default queue override (normally from manifest) */
|
||||
defaultQueue?: TaskQueueType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root configuration file structure (plugins.yml)
|
||||
*/
|
||||
export interface ContainerPluginsConfig {
|
||||
/** Config version */
|
||||
version?: string;
|
||||
/** Plugin configurations keyed by plugin ID */
|
||||
plugins: Record<string, ContainerPluginConfig>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest (returned by plugin container via GET /manifest)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* File filter criteria for plugin
|
||||
*/
|
||||
export interface PluginFileFilter {
|
||||
/** MIME type patterns (e.g., 'video/*', 'image/jpeg') */
|
||||
mimeTypes?: string[];
|
||||
/** File extension patterns (e.g., 'mkv', 'mp4') */
|
||||
extensions?: string[];
|
||||
/** Minimum file size in bytes */
|
||||
minSize?: number;
|
||||
/** Maximum file size in bytes */
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI display hints
|
||||
*/
|
||||
export interface PluginUIConfig {
|
||||
/** Color for UI display (e.g., '#4CAF50') */
|
||||
color?: string;
|
||||
/** Icon name (optional) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container plugin manifest (from GET /manifest)
|
||||
*/
|
||||
export interface ContainerPluginManifest {
|
||||
/** Unique plugin identifier */
|
||||
id: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Semantic version */
|
||||
version: string;
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Author or organization */
|
||||
author?: string;
|
||||
/** Source repository URL */
|
||||
repository?: string;
|
||||
|
||||
/** Execution priority (lower = runs earlier, 10-100 range) */
|
||||
priority?: number;
|
||||
/** Default queue assignment */
|
||||
defaultQueue?: TaskQueueType;
|
||||
/** Max processing time in milliseconds */
|
||||
timeout?: number;
|
||||
|
||||
/** Plugin IDs that must complete before this one */
|
||||
dependencies?: string[];
|
||||
|
||||
/** File filter criteria */
|
||||
filter?: PluginFileFilter;
|
||||
|
||||
/** Configuration schema (aliased as config for compatibility) */
|
||||
config?: Record<string, ConfigField>;
|
||||
/** Metadata output schema (aliased as schema for compatibility) */
|
||||
schema?: Record<string, SchemaField>;
|
||||
|
||||
/** UI display color */
|
||||
color?: string;
|
||||
|
||||
/** UI display hints */
|
||||
ui?: PluginUIConfig;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Container Plugin Instance (runtime state)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Container instance status
|
||||
*/
|
||||
export type ContainerInstanceStatus = 'starting' | 'healthy' | 'unhealthy' | 'stopped' | 'error';
|
||||
|
||||
/**
|
||||
* Runtime state of a container plugin instance
|
||||
*/
|
||||
export interface ContainerPluginInstance {
|
||||
/** Plugin ID */
|
||||
pluginId: string;
|
||||
/** Docker container ID */
|
||||
containerId: string;
|
||||
/** Container name (e.g., 'meta-plugin-ffmpeg-0') */
|
||||
containerName: string;
|
||||
/** HTTP base URL (e.g., 'http://meta-plugin-ffmpeg-0:8080') */
|
||||
baseUrl: string;
|
||||
/** Instance index (0, 1, 2, ...) */
|
||||
instanceIndex: number;
|
||||
/** Current status */
|
||||
status: ContainerInstanceStatus;
|
||||
/** Cached manifest from container */
|
||||
manifest?: ContainerPluginManifest;
|
||||
/** Last successful health check timestamp */
|
||||
lastHealthCheck?: number;
|
||||
/** Last error message */
|
||||
lastError?: string;
|
||||
/** Number of tasks processed */
|
||||
tasksProcessed: number;
|
||||
/** Number of failed tasks */
|
||||
tasksFailed: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin HTTP API Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Health check response from GET /health
|
||||
*/
|
||||
export interface PluginHealthResponse {
|
||||
status: 'healthy' | 'unhealthy';
|
||||
ready: boolean;
|
||||
version: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure request body for POST /configure
|
||||
*/
|
||||
export interface PluginConfigureRequest {
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure response from POST /configure
|
||||
*/
|
||||
export interface PluginConfigureResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request body for POST /process
|
||||
*/
|
||||
export interface PluginProcessRequest {
|
||||
/** Unique task identifier */
|
||||
taskId: string;
|
||||
/** Content identifier (midhash256) */
|
||||
cid: string;
|
||||
/** Absolute path to file in container */
|
||||
filePath: string;
|
||||
/** Callback URL for completion notification */
|
||||
callbackUrl: string;
|
||||
/** meta-core API base URL */
|
||||
metaCoreUrl: string;
|
||||
/** Current metadata (flat key-value map) */
|
||||
existingMeta: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process response from POST /process (immediate)
|
||||
*/
|
||||
export interface PluginProcessResponse {
|
||||
status: 'accepted' | 'rejected';
|
||||
taskId: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback status
|
||||
*/
|
||||
export type CallbackStatus = 'completed' | 'failed' | 'skipped';
|
||||
|
||||
/**
|
||||
* Callback payload sent by plugin to meta-sort
|
||||
*/
|
||||
export interface PluginCallbackPayload {
|
||||
/** Task identifier */
|
||||
taskId: string;
|
||||
/** Plugin ID */
|
||||
pluginId: string;
|
||||
/** Content identifier */
|
||||
cid: string;
|
||||
/** Task status */
|
||||
status: CallbackStatus;
|
||||
/** Processing duration in milliseconds */
|
||||
duration: number;
|
||||
/** Error message (if status is 'failed') */
|
||||
error?: string;
|
||||
/** Skip reason (if status is 'skipped') */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Container Manager Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Options for spawning a container
|
||||
*/
|
||||
export interface ContainerSpawnOptions {
|
||||
/** Docker image */
|
||||
image: string;
|
||||
/** Container name */
|
||||
name: string;
|
||||
/** Environment variables */
|
||||
env?: Record<string, string>;
|
||||
/** Resource limits */
|
||||
resources?: ContainerResourceLimits;
|
||||
/** Volume mounts */
|
||||
mounts?: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
readonly?: boolean;
|
||||
}>;
|
||||
/** Docker network to join */
|
||||
network?: string;
|
||||
/** Labels for the container */
|
||||
labels?: Record<string, string>;
|
||||
/** Stack name for grouping containers in Docker Desktop (adds compose labels) */
|
||||
stackName?: string;
|
||||
/** Service name for compose grouping (usually the plugin ID) */
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container manager status
|
||||
*/
|
||||
export interface ContainerManagerStatus {
|
||||
/** Whether manager is initialized */
|
||||
initialized: boolean;
|
||||
/** Total number of plugin types */
|
||||
pluginCount: number;
|
||||
/** Total running containers */
|
||||
runningContainers: number;
|
||||
/** Healthy containers */
|
||||
healthyContainers: number;
|
||||
/** Per-plugin status */
|
||||
plugins: Array<{
|
||||
pluginId: string;
|
||||
enabled: boolean;
|
||||
image: string;
|
||||
instances: number;
|
||||
healthyInstances: number;
|
||||
manifest?: ContainerPluginManifest;
|
||||
}>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Container Task Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Container task status
|
||||
*/
|
||||
export type ContainerTaskStatus = 'pending' | 'waiting' | 'dispatched' | 'processing' | 'completed' | 'failed' | 'timeout';
|
||||
|
||||
/**
|
||||
* Container plugin task
|
||||
*/
|
||||
export interface ContainerTask {
|
||||
/** Unique task ID (UUID) */
|
||||
id: string;
|
||||
/** File hash (midhash256) */
|
||||
fileHash: string;
|
||||
/** File path */
|
||||
filePath: string;
|
||||
/** Target plugin ID */
|
||||
pluginId: string;
|
||||
/** Plugin dependencies */
|
||||
dependencies: string[];
|
||||
/** Task queue */
|
||||
queue: TaskQueueType;
|
||||
/** Task status */
|
||||
status: ContainerTaskStatus;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
/** Timestamp when dispatched to container */
|
||||
dispatchedAt?: number;
|
||||
/** Timestamp when completed */
|
||||
completedAt?: number;
|
||||
/** Container instance that processed this task */
|
||||
instanceName?: string;
|
||||
/** Processing duration in ms */
|
||||
duration?: number;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import {FolderWatcher, PollingWatcher} from "@metazla/meta-hash";
|
||||
import {config} from "./config/EnvConfig.js";
|
||||
|
||||
import os from 'os';
|
||||
import {existsSync} from 'fs';
|
||||
import {WatchedFileProcessor} from "./logic/WatchedFileProcessor.js";
|
||||
import {UnifiedAPIServer} from "./api/UnifiedAPIServer.js";
|
||||
import {StreamingPipeline} from "./logic/pipeline/StreamingPipeline.js";
|
||||
import {SUPPORTED_EXTENSIONS} from "./config/SupportedFileTypes.js";
|
||||
import {performanceMetrics} from "./metrics/PerformanceMetrics.js";
|
||||
import {KVManager} from "./kv/KVManager.js";
|
||||
import {getPluginManagerInstance, initializePluginManager} from "./logic/fileProcessor/FileProcessorPiscina.js";
|
||||
import {ContainerManager} from "./container-plugins/ContainerManager.js";
|
||||
import {ContainerPluginScheduler} from "./container-plugins/ContainerPluginScheduler.js";
|
||||
|
||||
try {
|
||||
// Get current user information
|
||||
const userInfo = os.userInfo();
|
||||
// Display the current user and UID
|
||||
console.log(`[node] Current User: ${userInfo.username}`);
|
||||
console.log(`[node] User ID (UID): ${userInfo.uid}`);
|
||||
}catch (error) {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
console.log(`BUILD_VERSION: ${process.env.BUILD_VERSION || 'dev'}`);
|
||||
|
||||
//display the config
|
||||
console.log(config);
|
||||
|
||||
process.umask(0);//This unable the process to create files with 777 permissions
|
||||
|
||||
let fileProcessor = new WatchedFileProcessor();
|
||||
let folders = config.WATCH_FOLDER_LIST;
|
||||
|
||||
// KV Manager for Redis-based storage with leader election (optional)
|
||||
let kvManager: KVManager | null = null;
|
||||
|
||||
// Container Manager for containerized plugins
|
||||
let containerManager: ContainerManager | null = null;
|
||||
let containerPluginScheduler: ContainerPluginScheduler | null = null;
|
||||
|
||||
// Calculate concurrency for pipeline stages
|
||||
const cpuCount = os.cpus().length;
|
||||
// Smart defaults optimized for network storage + HDD scenarios:
|
||||
// - Fast queue: CPU (fast operations: filename parsing, stats)
|
||||
// - Background queue: CPU / 2 (slow operations: SHA-256, FFmpeg)
|
||||
const defaultBackgroundWorkers = Math.max(1, Math.floor(cpuCount / 2));
|
||||
const validationConcurrency = config.MAX_WORKER_THREADS ? config.MAX_WORKER_THREADS * 2 : cpuCount * 2;
|
||||
const fastQueueConcurrency = config.MAX_WORKER_THREADS ? config.MAX_WORKER_THREADS : cpuCount;
|
||||
const backgroundQueueConcurrency = config.MAX_WORKER_THREADS || defaultBackgroundWorkers;
|
||||
|
||||
console.log(`Pipeline concurrency: validation=${validationConcurrency}, fastQueue=${fastQueueConcurrency}, backgroundQueue=${backgroundQueueConcurrency} (cpuCount=${cpuCount}, defaultBackgroundWorkers=${defaultBackgroundWorkers})`);
|
||||
|
||||
// Create streaming pipeline
|
||||
const pipeline = new StreamingPipeline({
|
||||
supportedExtensions: SUPPORTED_EXTENSIONS,
|
||||
validationConcurrency,
|
||||
fastQueueConcurrency,
|
||||
backgroundQueueConcurrency,
|
||||
strictMimeValidation: false, // Skip MIME for speed
|
||||
fileProcessor: fileProcessor.fileProcessor,
|
||||
stateManager: fileProcessor.unifiedStateManager,
|
||||
kvClient: fileProcessor.getKVClient(),
|
||||
virtualFileSystem: fileProcessor.getVirtualFileSystem(),
|
||||
metaDataToFolderStruct: fileProcessor.metaDataToFolderStruct
|
||||
});
|
||||
|
||||
// Connect pipeline to fileProcessor for queue status monitoring
|
||||
fileProcessor.setPipeline(pipeline);
|
||||
|
||||
// Create generic file discovery
|
||||
const folderWatcher = new FolderWatcher();
|
||||
|
||||
// Parse folder list
|
||||
const folderList = folders.split(',').map(f => f.trim()).filter(f => f.length > 0);
|
||||
console.log(`Watching folders: ${folderList.join(', ')}`);
|
||||
|
||||
// VFS and state manager are available immediately
|
||||
const vfs = fileProcessor.getVirtualFileSystem();
|
||||
const unifiedStateManager = fileProcessor.getUnifiedStateManager();
|
||||
const getDuplicateResult = () => fileProcessor.getDuplicateResult();
|
||||
|
||||
// API Server will be initialized after KV is ready (deferred to async init)
|
||||
let apiServer: UnifiedAPIServer | null = null;
|
||||
|
||||
// Shared progressive discovery and cleanup logic (used by both startup and manual scan)
|
||||
const runProgressiveDiscovery = async () => {
|
||||
console.log(`[Discovery] Progressive scan mode: ${folderList.length} folders`);
|
||||
|
||||
// Process folders one at a time for progressive cleanup
|
||||
for (const folder of folderList) {
|
||||
console.log(`[Discovery] Scanning folder: ${folder}`);
|
||||
|
||||
// Track discovered files for this folder
|
||||
const discoveredFilesInFolder = new Set<string>();
|
||||
|
||||
// Wrap discovery stream to track files
|
||||
async function* trackDiscoveredFiles(stream: AsyncGenerator<string>) {
|
||||
for await (const filePath of stream) {
|
||||
discoveredFilesInFolder.add(filePath);
|
||||
yield filePath;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveryStream = folderWatcher.discoverFiles([folder]);
|
||||
const trackedStream = trackDiscoveredFiles(discoveryStream);
|
||||
|
||||
await pipeline.start(trackedStream);
|
||||
|
||||
// Progressive cleanup: clean up after each folder completes
|
||||
console.log(`[Discovery] Folder scan complete: ${folder}. Found ${discoveredFilesInFolder.size} files. Running cleanup...`);
|
||||
await fileProcessor.cleanupStaleEntries([folder], discoveredFilesInFolder);
|
||||
console.log(`[Discovery] Cleanup complete for folder: ${folder}`);
|
||||
}
|
||||
|
||||
console.log(`[Discovery] All folders scanned and cleaned up`);
|
||||
};
|
||||
|
||||
// Manual scan trigger (called from API)
|
||||
// Note: Stremio updates happen automatically via incremental add/remove notifications
|
||||
// during file processing (like FUSE), so no explicit refresh needed here
|
||||
const triggerScan = async () => {
|
||||
console.log('Triggering manual scan from API...');
|
||||
await runProgressiveDiscovery();
|
||||
};
|
||||
|
||||
// Global error handlers - prevent crashes from unforeseen errors
|
||||
process.on('uncaughtException', (error: Error) => {
|
||||
console.error('❌ [CRITICAL] Uncaught Exception:', error);
|
||||
console.error('Stack:', error.stack);
|
||||
// Log but don't exit - allow process to continue
|
||||
// Monitor for worker health issues
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
|
||||
console.error('❌ [CRITICAL] Unhandled Rejection at:', promise);
|
||||
console.error('Reason:', reason);
|
||||
// Log but don't exit - allow process to continue
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nShutting down...');
|
||||
if (apiServer) {
|
||||
await apiServer.stop();
|
||||
}
|
||||
|
||||
// Stop Container Manager if running
|
||||
if (containerManager) {
|
||||
console.log('[Shutdown] Stopping Container Manager...');
|
||||
await containerManager.shutdown();
|
||||
}
|
||||
|
||||
// Stop KV Manager if running
|
||||
if (kvManager) {
|
||||
console.log('[Shutdown] Stopping KV Manager...');
|
||||
await kvManager.stop();
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start initial discovery and processing (streaming pipeline)
|
||||
(async () => {
|
||||
try {
|
||||
// Initialize KV Manager if configured (Redis + leader election)
|
||||
if (config.META_CORE_PATH) {
|
||||
console.log('[Startup] Initializing KV Manager (Redis + leader election)...');
|
||||
kvManager = new KVManager({
|
||||
metaCorePath: config.META_CORE_PATH,
|
||||
filesPath: config.FILES_PATH,
|
||||
serviceName: config.SERVICE_NAME,
|
||||
version: config.SERVICE_VERSION,
|
||||
apiPort: config.FUSE_API_PORT,
|
||||
redisPort: config.REDIS_PORT,
|
||||
redisUrl: config.REDIS_URL,
|
||||
baseUrl: config.BASE_URL,
|
||||
capabilities: ['write', 'mount', 'monitor']
|
||||
});
|
||||
|
||||
// Wait for KV to be ready
|
||||
await kvManager.start();
|
||||
await kvManager.waitForReady();
|
||||
|
||||
// Pass KV client to file processor
|
||||
const client = kvManager.getClient();
|
||||
if (client) {
|
||||
fileProcessor.setKVClient(client);
|
||||
console.log(`[Startup] KV Manager ready (role: ${kvManager.isLeader() ? 'LEADER' : 'FOLLOWER'})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Start Unified API Server AFTER KV is ready (so kvClient is available)
|
||||
const kvClient = fileProcessor.getKVClient();
|
||||
apiServer = new UnifiedAPIServer(vfs, {
|
||||
port: config.FUSE_API_PORT,
|
||||
host: config.FUSE_API_HOST,
|
||||
enableCors: true,
|
||||
}, unifiedStateManager, getDuplicateResult, kvClient || undefined, triggerScan, backgroundQueueConcurrency, fastQueueConcurrency, () => fileProcessor.getQueueStatus(), kvManager || undefined, getPluginManagerInstance, () => fileProcessor.getTaskScheduler());
|
||||
|
||||
await apiServer.start();
|
||||
console.log('Unified API Server started successfully');
|
||||
|
||||
// Initialize plugin manager early so it's available for API
|
||||
console.log('[Startup] Initializing plugin manager...');
|
||||
const pluginManager = await initializePluginManager();
|
||||
console.log('[Startup] Plugin manager initialized');
|
||||
|
||||
// Initialize container plugins if config file exists
|
||||
if (existsSync(config.CONTAINER_PLUGINS_CONFIG)) {
|
||||
console.log('[Startup] Initializing container plugins...');
|
||||
try {
|
||||
containerManager = new ContainerManager();
|
||||
await containerManager.initialize();
|
||||
|
||||
// Connect container manager to plugin manager
|
||||
pluginManager.setContainerManager(containerManager);
|
||||
await pluginManager.loadContainerPlugins();
|
||||
|
||||
// Create container plugin scheduler
|
||||
containerPluginScheduler = new ContainerPluginScheduler(containerManager, {
|
||||
fastConcurrency: config.FAST_QUEUE_CONCURRENCY,
|
||||
backgroundConcurrency: config.BACKGROUND_QUEUE_CONCURRENCY,
|
||||
kvClient: kvClient || undefined,
|
||||
});
|
||||
|
||||
// Connect to API server (for callback handling)
|
||||
apiServer.setContainerPluginManager(containerManager, containerPluginScheduler);
|
||||
console.log('[Startup] ContainerPluginScheduler connected to API server');
|
||||
|
||||
const status = containerManager.getStatus();
|
||||
console.log(`[Startup] Container plugins initialized: ${status.pluginCount} plugins, ${status.healthyContainers}/${status.runningContainers} healthy`);
|
||||
} catch (error) {
|
||||
console.error('[Startup] Failed to initialize container plugins:', error);
|
||||
console.log('[Startup] Continuing without container plugins...');
|
||||
}
|
||||
} else {
|
||||
console.log(`[Startup] Container plugins config not found at ${config.CONTAINER_PLUGINS_CONFIG}, skipping`);
|
||||
}
|
||||
|
||||
// Initialize hash index cache (loads existing hashes from disk)
|
||||
// This also initializes the TaskScheduler
|
||||
console.log('[Startup] Initializing hash index cache...');
|
||||
await fileProcessor.initialize();
|
||||
console.log('[Startup] Hash index cache initialized');
|
||||
|
||||
// Connect ContainerPluginScheduler to TaskScheduler and Pipeline
|
||||
if (containerPluginScheduler) {
|
||||
const taskScheduler = fileProcessor.getTaskScheduler();
|
||||
if (taskScheduler) {
|
||||
taskScheduler.setContainerPluginScheduler(containerPluginScheduler);
|
||||
console.log('[Startup] ContainerPluginScheduler connected to TaskScheduler');
|
||||
}
|
||||
|
||||
// Connect to StreamingPipeline for file processing dispatch
|
||||
pipeline.setContainerPluginScheduler(containerPluginScheduler);
|
||||
console.log('[Startup] ContainerPluginScheduler connected to StreamingPipeline');
|
||||
}
|
||||
|
||||
// First, rebuild VFS from KV to restore state from previous runs
|
||||
console.log('[Startup] Rebuilding VirtualFileSystem from KV...');
|
||||
await fileProcessor.rebuildVFSFromKV();
|
||||
console.log('[Startup] VFS rebuild completed');
|
||||
|
||||
console.log('[Startup] Starting initial file discovery with progressive cleanup...');
|
||||
await runProgressiveDiscovery();
|
||||
console.log('[Startup] Initial discovery completed, processing continues in background');
|
||||
|
||||
// Log cache performance summary
|
||||
const metrics = performanceMetrics.getMetrics();
|
||||
if (metrics.cacheHits.midhash256 || metrics.cacheMisses.midhash256) {
|
||||
const hits = metrics.cacheHits.midhash256 || 0;
|
||||
const misses = metrics.cacheMisses.midhash256 || 0;
|
||||
const total = hits + misses;
|
||||
const hitRate = total > 0 ? ((hits / total) * 100).toFixed(1) : '0.0';
|
||||
console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`);
|
||||
}
|
||||
|
||||
// Setup file watching for changes
|
||||
console.log('[Startup] Setting up file watching...');
|
||||
folderWatcher.watch(folderList, {
|
||||
onAdd: (filePath) => pipeline.handleFileAdded(filePath),
|
||||
onChange: (filePath) => pipeline.handleFileChanged(filePath),
|
||||
onUnlink: (filePath) => pipeline.handleFileDeleted(filePath)
|
||||
}, {
|
||||
interval: config.WATCH_FOLDER_POOLING_INTERVAL_MS || 30000,
|
||||
stabilityThreshold: 30000,
|
||||
pollInterval: 5000
|
||||
});
|
||||
console.log('[Startup] File watching active');
|
||||
} catch (error) {
|
||||
console.error('[Startup] Error during initial discovery:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
// Start PollingWatcher for path-specific polling (if configured)
|
||||
if (config.POLLING_PATHS && config.POLLING_PATHS.length > 0) {
|
||||
console.log(`PollingWatcher: ${config.POLLING_PATHS.length} paths configured`);
|
||||
const pollingWatcher = new PollingWatcher(fileProcessor, config.POLLING_PATHS);
|
||||
pollingWatcher.start().then(() => {
|
||||
console.log('PollingWatcher started successfully');
|
||||
}).catch((error) => {
|
||||
console.error('Error starting PollingWatcher:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('PollingWatcher: No polling paths configured (skipped)');
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {config} from "../config/EnvConfig.js";
|
||||
import axios from "axios";
|
||||
|
||||
export class JellyfinAPI {
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
async getMetaData(filepath: string): Promise<any> {
|
||||
console.log("geteMetaData");
|
||||
}
|
||||
|
||||
private async sendAuthedRequest(url: string, method: string, data?: any, params?: any) {
|
||||
return (await axios({
|
||||
method: method,
|
||||
url: url,
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': config.JELLYFIN_API_KEY
|
||||
},
|
||||
data: data,
|
||||
params: params
|
||||
})).data;
|
||||
}
|
||||
|
||||
private async getRootUserId(): Promise<string> {
|
||||
const users = await this.sendAuthedRequest(`${config.JELLYFIN_ENDPOINT}/Users`, 'GET');
|
||||
for (const user of users) {
|
||||
if (user.Policy.IsAdministrator) {
|
||||
return user.Id;
|
||||
}
|
||||
}
|
||||
return users[0].Id;
|
||||
}
|
||||
|
||||
private async getItemIdByFilePath(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
let user = await this.getRootUserId();
|
||||
const items = await this.sendAuthedRequest(`${config.JELLYFIN_ENDPOINT}/Items`, 'GET', null, {
|
||||
Path: encodeURIComponent(filePath),
|
||||
userId: user
|
||||
});
|
||||
|
||||
if (items.Items && items.Items.length > 0) {
|
||||
return items.Items[0].Id;
|
||||
} else {
|
||||
console.log(`Item with file path "${filePath}" not found.`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching item ID by file path:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
jellyApiAvailable(): boolean {
|
||||
return !!config.JELLYFIN_API_KEY && !!config.JELLYFIN_ENDPOINT;
|
||||
}
|
||||
|
||||
/** takes around 30s to complete (if light update) */
|
||||
async refreshLibrary(): Promise<void>{
|
||||
try {
|
||||
const response = await this.sendAuthedRequest(`${config.JELLYFIN_ENDPOINT}/Library/Refresh`, "POST",null, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': config.JELLYFIN_API_KEY
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error refreshing library:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyJellyfin(filePath): Promise<void> {
|
||||
const itemId = await this.getItemIdByFilePath(filePath);
|
||||
if(!itemId) {
|
||||
return;
|
||||
}
|
||||
// Fetch the public system info
|
||||
const response = await axios.post(`${config.JELLYFIN_ENDPOINT}/Items/${itemId}/Refresh`, {
|
||||
|
||||
},{
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': config.JELLYFIN_API_KEY
|
||||
}
|
||||
});
|
||||
console.log(response.data);
|
||||
}
|
||||
|
||||
async systemEndpoint() {
|
||||
const response = await axios.get(`${config.JELLYFIN_ENDPOINT}/System/Endpoint`, {
|
||||
headers: {
|
||||
'X-MediaBrowser-Token': config.JELLYFIN_API_KEY
|
||||
}
|
||||
});
|
||||
console.log(response.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import {readFile, writeFile} from 'fs/promises';
|
||||
import {Builder, Parser} from 'xml2js';
|
||||
import {mergeMetadata} from "../logic/tool/MetadataMerger.js";
|
||||
import {existsAsync} from "@metazla/meta-hash";
|
||||
import {VideoMeta, JellyfinMeta, FileStatMeta} from "@metazla/meta-interface";
|
||||
import lodash from 'lodash';
|
||||
import * as xml2js from "xml2js";
|
||||
|
||||
export class JellyfinNfo {
|
||||
|
||||
options = {explicitArray: true, ignoreAttrs: true};
|
||||
|
||||
/**
|
||||
* Convert a JSON XML object back to a regular JSON object where arrays containing `set` are converted back to Set objects
|
||||
* @param input - The input JSON XML object
|
||||
*/
|
||||
private convertFromJsonXml(input: any): any {
|
||||
const output: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error("Unexpected format: each property should be an array.");
|
||||
}
|
||||
|
||||
const firstElement = value[0];
|
||||
|
||||
if (firstElement && typeof firstElement === 'object') {
|
||||
if ('set' in firstElement) {
|
||||
// Convert array from 'set' back to Set object
|
||||
output[key] = new Set(firstElement.set);
|
||||
} else {
|
||||
// Recursively convert from JsonXml for nested objects
|
||||
output[key] = this.convertFromJsonXml(firstElement);
|
||||
}
|
||||
} else {
|
||||
// Primitive value, simply assign it as it is the only element in the array
|
||||
output[key] = firstElement;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter only known jellyfin supported data
|
||||
* @param data
|
||||
*/
|
||||
filterData(data: any): any {
|
||||
const supportedFields = ["originalTitle", "season", "episode", "videoType", "fileType"]
|
||||
const result = {};
|
||||
for (const key of supportedFields) {
|
||||
if (data[key]) {
|
||||
result[key] = data[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async read<T>(nfoPath: string): Promise<T> {
|
||||
if (!nfoPath.endsWith('.nfo')) {
|
||||
throw new Error(`NFO file path must end with '.nfo'`);
|
||||
}
|
||||
let metadata: T = {} as T;
|
||||
if (await existsAsync(nfoPath)) {
|
||||
try {
|
||||
const nfoContent = await readFile(nfoPath, {encoding: 'utf8'});
|
||||
const parser = new xml2js.Parser(this.options);
|
||||
const result = await parser.parseStringPromise(nfoContent);
|
||||
metadata = result.root || result.episodedetails || result.movie || {};
|
||||
metadata = this.convertFromJsonXml(metadata);
|
||||
} catch (error) {
|
||||
console.error(`Error parsing NFO file ${nfoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// we always read the file for meta data but ignore its content except for cid which are long to compute
|
||||
return metadata;
|
||||
}
|
||||
|
||||
async update<T extends FileStatMeta>(nfoPath: string, data: T): Promise<void> {
|
||||
data = this.filterData(data);
|
||||
if (!nfoPath.endsWith('.nfo')) {
|
||||
throw new Error(`NFO file path must end with '.nfo'`);
|
||||
}
|
||||
if (data.fileType !== "video") {
|
||||
return;//only process video files for writing jellyfin nfo
|
||||
}
|
||||
if (await existsAsync(nfoPath)) {
|
||||
await this.updateNfoFile(nfoPath, data as any);
|
||||
} else {
|
||||
throw new Error(`NFO file does not exist at path: ${nfoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createOrUpdate<T extends FileStatMeta>(nfoPath: string, data: T): Promise<void> {
|
||||
data = this.filterData(data);
|
||||
if (!nfoPath.endsWith('.nfo')) {
|
||||
throw new Error(`NFO file path must end with '.nfo'`);
|
||||
}
|
||||
if (data.fileType !== "video") {
|
||||
return;//only process video files for writing jellyfin nfo
|
||||
}
|
||||
if (await existsAsync(nfoPath)) {
|
||||
await this.updateNfoFile(nfoPath, data as any);
|
||||
} else {
|
||||
// If the NFO file doesn't exist, just generate a new one
|
||||
await this.generateNfoFile(nfoPath, data as any);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a regular JSON object to an XML JSON object (add arrays)
|
||||
* @param input
|
||||
*/
|
||||
private convertToJsonXml(input: any): any {
|
||||
const output: any = {};
|
||||
|
||||
// Use Object.entries to loop through each key-value pair in the input object
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (!this.assertLegalName(key)) {
|
||||
throw new Error(`Illegal character in key: ${key}`);
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
// If the value is a Set, convert it to an array and then to XmlJson format
|
||||
const array = Array.from(value as any);
|
||||
if (array.length > 0) {
|
||||
output[key] = [{set: array}];
|
||||
}
|
||||
} else if (value instanceof Array) {
|
||||
throw new Error("unsupported");
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// For nested objects, recursively convert
|
||||
let jsonXml = this.convertToJsonXml(value);
|
||||
if (jsonXml instanceof Array) {
|
||||
output[key] = jsonXml;
|
||||
} else {
|
||||
output[key] = [jsonXml];
|
||||
}
|
||||
} else {
|
||||
output[key] = [value];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
private async updateNfoFile(nfoFilePath: string, data: JellyfinMeta) {
|
||||
try {
|
||||
const existingContent = await readFile(nfoFilePath, {encoding: "utf-8"});
|
||||
const parser = new Parser(this.options);
|
||||
let existingData = await parser.parseStringPromise(existingContent);
|
||||
let hasChanged = false;
|
||||
|
||||
// 1st try to update the type of the metadata (root can be "root" or "movie" or "episodedetails") and
|
||||
// according to the 'jelly' parameter and the isMovieOrTvShow function
|
||||
const targetRoot = (this.isMovieOrTvShow(data) === "tvshow" ? 'episodedetails' : "movie");
|
||||
if (existingData.root) {
|
||||
existingData[targetRoot] = existingData.root;
|
||||
delete existingData.root;
|
||||
hasChanged = true;
|
||||
} else if (existingData.movie && "movie" !== targetRoot) {
|
||||
existingData[targetRoot] = existingData.movie;
|
||||
delete existingData.movie;
|
||||
hasChanged = true;
|
||||
} else if (existingData.episodedetails && "episodedetails" !== targetRoot) {
|
||||
existingData[targetRoot] = existingData.episodedetails;
|
||||
delete existingData.episodedetails;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
//2nd update the data
|
||||
let root = existingData[targetRoot];
|
||||
if (typeof root !== 'object') {
|
||||
root = {};//don't allow primitive type in root
|
||||
hasChanged = true;
|
||||
}
|
||||
const convertedData = this.convertToJsonXml(data);
|
||||
hasChanged = mergeMetadata(root, convertedData, nfoFilePath) || hasChanged;
|
||||
root = this.sanitizeData(root);
|
||||
existingData[targetRoot] = root;
|
||||
|
||||
if (hasChanged) {
|
||||
//const builder = new Builder({ headless: true, renderOpts: { 'pretty': true } });
|
||||
const builder = new Builder();
|
||||
// Convert the updated object back to XML
|
||||
try {
|
||||
const updatedContent = builder.buildObject(existingData);
|
||||
await writeFile(nfoFilePath, updatedContent, {encoding: "utf-8"});
|
||||
} catch (error) {
|
||||
console.error(`Failed to write updated NFO content to ${nfoFilePath} : ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse existing NFO content ${nfoFilePath} : ${error}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private isMovieOrTvShow(data: VideoMeta): "tvshow" | "movie" {
|
||||
if (data.videoType) {
|
||||
return data.videoType;
|
||||
} else if (data.episode) {
|
||||
return "tvshow";
|
||||
} else {
|
||||
return "movie";
|
||||
}
|
||||
}
|
||||
|
||||
private assertLegalChar(str): boolean {
|
||||
return str.match(/[\0\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/);
|
||||
};
|
||||
|
||||
private assertLegalName(str: string) {
|
||||
if (this.assertLegalChar(str)) {
|
||||
return false;
|
||||
}
|
||||
const regex = /^([:A-Z_a-z\xC0-\xD6\xD8-\xF6\xF8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])([\x2D\.0-:A-Z_a-z\xB7\xC0-\xD6\xD8-\xF6\xF8-\u037D\u037F-\u1FFF\u200C\u200D\u203F\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]|[\uD800-\uDB7F][\uDC00-\uDFFF])*$/;
|
||||
if (!str.match(regex)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private sanitizeData(data: any): any {
|
||||
const sanitize = (value: any): any => {
|
||||
if (typeof value === 'string') {
|
||||
// Escaping the string values
|
||||
return lodash.escape(value);
|
||||
} else if (Array.isArray(value)) {
|
||||
throw new Error(`Arrays are not supported in NFO files`);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively sanitize each property in the object
|
||||
let result = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
if (this.assertLegalName(key)) {
|
||||
result[key] = value[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return sanitize(data);
|
||||
}
|
||||
|
||||
private async generateNfoFile(nfoFilePath: string, data: JellyfinMeta) {
|
||||
if (!nfoFilePath.endsWith('.nfo')) {
|
||||
throw new Error(`NFO file path must end with '.nfo'`);
|
||||
}
|
||||
if (await existsAsync(nfoFilePath)) {
|
||||
throw new Error(`NFO file already exists at path: ${nfoFilePath}`);
|
||||
}
|
||||
|
||||
const rootElementName = data.videoType === "tvshow" ? 'episodedetails' : "movie";
|
||||
/*const schemaAttributes = jelly ? {} : {
|
||||
'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
|
||||
'xsi:schemaLocation': 'http://www.example.com/schema http://www.example.com/schema/mediaFileSchema.xsd'
|
||||
};*/
|
||||
const schemaAttributes = {};
|
||||
|
||||
const convertedData = this.convertToJsonXml(data);
|
||||
const sanitizedData = this.sanitizeData(convertedData);
|
||||
|
||||
// Prepare the data object with the correct root element and attributes
|
||||
const obj = {
|
||||
[rootElementName]: {
|
||||
...({$: schemaAttributes}),
|
||||
...sanitizedData
|
||||
}
|
||||
};
|
||||
|
||||
const builder = new Builder({xmldec: {version: '1.0', encoding: 'UTF-8', standalone: true}});
|
||||
const nfoContent = builder.buildObject(obj);
|
||||
await writeFile(nfoFilePath, nfoContent, {encoding: "utf-8"});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
TODO:
|
||||
Jellyfin nfo format (only for video)
|
||||
- nfo to metadata (this is used as lib)
|
||||
- metadata to nfo
|
||||
- create or update nfo
|
||||
|
||||
meta yml to nfo
|
||||
- nfo to metadata (this is used as lib)
|
||||
- metadata to nfo
|
||||
- create or update nfo
|
||||
@@ -0,0 +1,22 @@
|
||||
import { parse, sep } from "path";
|
||||
|
||||
/**
|
||||
* Get the nfo file path for a given file path (replace the extension with .nfo)
|
||||
* @param filePath
|
||||
* @param ext - The extension to replace with, default is "nfo"
|
||||
* @returns The nfo file path
|
||||
*/
|
||||
export function filePathReplace(filePath: string, ext: string = "nfo"): string {
|
||||
const { dir, name } = parse(filePath);
|
||||
return `${dir}${sep}${name}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the nfo file path for a given folder path (add .nfo to the folder path)
|
||||
* @param folderPath
|
||||
* @param ext
|
||||
* @returns The nfo file path
|
||||
*/
|
||||
export function filePathAdd(folderPath: string, ext: string = "nfo"): string {
|
||||
return `${folderPath}.${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* KV Client Interface - Abstraction layer for key-value storage
|
||||
*
|
||||
* Redis-based key-value storage with nested key architecture.
|
||||
* Uses property-level granularity for efficient updates.
|
||||
*
|
||||
* Key Structure:
|
||||
* /file/{hashId}/{property/path} → value
|
||||
*
|
||||
* Example:
|
||||
* /file/midhash256:abc123/title → "Inception"
|
||||
* /file/midhash256:abc123/video/codec → "h265"
|
||||
*/
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface IKVClient {
|
||||
// ========================================================================
|
||||
// Basic Operations
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Set a key-value pair (value is JSON serialized)
|
||||
*/
|
||||
set(key: string, value: any): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a value by key (value is JSON deserialized)
|
||||
*/
|
||||
get(key: string): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Delete a key
|
||||
*/
|
||||
delete(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Count keys with a given prefix
|
||||
*/
|
||||
countKeysWithPrefix(prefix: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Health check - verifies KV store is accessible
|
||||
*/
|
||||
health(): Promise<boolean>;
|
||||
|
||||
// ========================================================================
|
||||
// Nested Key Architecture Methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Set a single property value (raw string, not JSON)
|
||||
*/
|
||||
setProperty(key: string, value: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get a single property value (raw string)
|
||||
*/
|
||||
getProperty(key: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get all key-value pairs with a given prefix
|
||||
*/
|
||||
getRange(prefix: string): Promise<KeyValuePair[]>;
|
||||
|
||||
/**
|
||||
* Set multiple key-value pairs atomically (or as close as possible)
|
||||
*/
|
||||
setMultiple(pairs: KeyValuePair[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all keys with a given prefix
|
||||
*/
|
||||
deleteRange(prefix: string): Promise<number>;
|
||||
|
||||
// ========================================================================
|
||||
// High-Level Metadata Operations
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Store file metadata using nested key architecture
|
||||
* Flattens the metadata object and stores each property as a separate key
|
||||
*/
|
||||
setMetadataFlat(hashId: string, metadata: any, excludeFields?: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Retrieve file metadata - reconstructs from individual property keys
|
||||
*/
|
||||
getMetadataFlat(hashId: string): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Get a specific property from a file's metadata
|
||||
*/
|
||||
getMetadata(hashId: string, propertyPath: string): Promise<any | null>;
|
||||
|
||||
/**
|
||||
* Delete all metadata for a file
|
||||
*/
|
||||
deleteMetadataFlat(hashId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Get all unique hash IDs (files) stored in KV
|
||||
*/
|
||||
getAllHashIds(): Promise<string[]>;
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Close connections and cleanup resources
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* KV Client configuration
|
||||
*/
|
||||
export interface KVClientConfig {
|
||||
/** Backend type */
|
||||
type: 'redis';
|
||||
|
||||
/** Connection URL (e.g., redis://localhost:6379) */
|
||||
url: string;
|
||||
|
||||
/** Operation timeout in ms (default: 30000) */
|
||||
timeout?: number;
|
||||
|
||||
/** Key prefix for all operations (default: '' for none) */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader lock file content format
|
||||
*/
|
||||
export interface LeaderLockInfo {
|
||||
/** Hostname of the leader */
|
||||
host: string;
|
||||
|
||||
/** Redis connection URL (e.g., redis://10.0.1.50:6379) */
|
||||
api: string;
|
||||
|
||||
/** HTTP API URL for the leader service */
|
||||
http: string;
|
||||
|
||||
/** Configured BASE_URL for stable service discovery (optional, falls back to http) */
|
||||
baseUrl?: string;
|
||||
|
||||
/** Timestamp when leadership was acquired */
|
||||
timestamp: number;
|
||||
|
||||
/** Process ID of the leader */
|
||||
pid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service registration info
|
||||
*/
|
||||
export interface ServiceInfo {
|
||||
/** Service name (e.g., 'meta-sort', 'meta-fuse') */
|
||||
name: string;
|
||||
|
||||
/** Service version */
|
||||
version: string;
|
||||
|
||||
/** HTTP API URL */
|
||||
api: string;
|
||||
|
||||
/** Current status */
|
||||
status: 'starting' | 'running' | 'stopping' | 'stopped';
|
||||
|
||||
/** Process ID */
|
||||
pid: number;
|
||||
|
||||
/** Hostname */
|
||||
hostname: string;
|
||||
|
||||
/** Start timestamp (ISO string) */
|
||||
startedAt: string;
|
||||
|
||||
/** Last heartbeat timestamp (ISO string) */
|
||||
lastHeartbeat: string;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities: string[];
|
||||
|
||||
/** Named endpoints */
|
||||
endpoints: Record<string, string>;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* KV Manager - Unified manager for KV storage, leader election, and service discovery
|
||||
*
|
||||
* This is the main entry point for the KV subsystem. It:
|
||||
* 1. Participates in leader election (spawns Redis if leader)
|
||||
* 2. Creates and manages the KV client (Redis)
|
||||
* 3. Handles service discovery registration
|
||||
* 4. Manages reconnection on leader failure
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const kvManager = new KVManager({
|
||||
* metaCorePath: '/meta-core',
|
||||
* filesPath: '/files',
|
||||
* serviceName: 'meta-sort',
|
||||
* apiPort: 3000
|
||||
* });
|
||||
*
|
||||
* await kvManager.start();
|
||||
* const client = kvManager.getClient();
|
||||
* // Use client...
|
||||
* await kvManager.stop();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { networkInterfaces } from 'os';
|
||||
import { LeaderElection } from './LeaderElection.js';
|
||||
import { ServiceDiscovery } from './ServiceDiscovery.js';
|
||||
import { RedisKVClient } from './RedisClient.js';
|
||||
import type { IKVClient, LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
interface KVManagerConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Path to FILES_VOLUME (e.g., /files) */
|
||||
filesPath: string;
|
||||
|
||||
/** Service name (e.g., 'meta-sort', 'meta-fuse') */
|
||||
serviceName: string;
|
||||
|
||||
/** Service version */
|
||||
version?: string;
|
||||
|
||||
/** HTTP API port */
|
||||
apiPort: number;
|
||||
|
||||
/** Redis port (default: 6379) */
|
||||
redisPort?: number;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities?: string[];
|
||||
|
||||
/** Whether to skip leader election and use provided Redis URL */
|
||||
redisUrl?: string;
|
||||
|
||||
/** Base URL for service discovery (overrides auto-detected URL) */
|
||||
baseUrl?: string;
|
||||
|
||||
/**
|
||||
* Hostname to advertise in lock file for other services to connect.
|
||||
* Use Docker service name (e.g., 'meta-sort-dev') for stable DNS resolution.
|
||||
*/
|
||||
advertiseHost?: string;
|
||||
}
|
||||
|
||||
export class KVManager {
|
||||
private config: KVManagerConfig;
|
||||
private leaderElection: LeaderElection | null = null;
|
||||
private serviceDiscovery: ServiceDiscovery | null = null;
|
||||
private kvClient: IKVClient | null = null;
|
||||
private isStarted = false;
|
||||
private isShuttingDown = false;
|
||||
|
||||
// Event callbacks
|
||||
private onReadyCallbacks: (() => void)[] = [];
|
||||
private onReconnectCallbacks: (() => void)[] = [];
|
||||
|
||||
constructor(config: KVManagerConfig) {
|
||||
this.config = {
|
||||
version: '1.0.0',
|
||||
redisPort: 6379,
|
||||
capabilities: [],
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local IP for API URL construction
|
||||
*/
|
||||
private getLocalIP(): string {
|
||||
const interfaces = networkInterfaces();
|
||||
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
const addrs = interfaces[name];
|
||||
if (!addrs) continue;
|
||||
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Redis client from URL
|
||||
*/
|
||||
private async createRedisClient(redisUrl: string): Promise<RedisKVClient> {
|
||||
const client = new RedisKVClient({
|
||||
url: redisUrl,
|
||||
timeout: 30000,
|
||||
prefix: '' // No prefix for shared access
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the KV manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
console.warn('[KVManager] Already started');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[KVManager] Starting for ${this.config.serviceName}...`);
|
||||
|
||||
// Check for direct Redis URL (skip leader election)
|
||||
if (this.config.redisUrl) {
|
||||
console.log(`[KVManager] Using direct Redis URL: ${this.config.redisUrl}`);
|
||||
this.kvClient = await this.createRedisClient(this.config.redisUrl);
|
||||
|
||||
// Initialize service discovery even with direct Redis URL
|
||||
const apiUrl = this.config.baseUrl || `http://localhost:${this.config.apiPort}`;
|
||||
this.serviceDiscovery = new ServiceDiscovery({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
version: this.config.version!,
|
||||
apiUrl,
|
||||
capabilities: this.config.capabilities,
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
dashboard: '/',
|
||||
api: '/api',
|
||||
metrics: '/api/metrics',
|
||||
processing: '/api/processing/status'
|
||||
}
|
||||
});
|
||||
await this.serviceDiscovery.start();
|
||||
|
||||
this.isStarted = true;
|
||||
this.notifyReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize leader election
|
||||
const localIP = this.getLocalIP();
|
||||
|
||||
this.leaderElection = new LeaderElection({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
apiPort: this.config.apiPort,
|
||||
redisPort: this.config.redisPort,
|
||||
baseUrl: this.config.baseUrl
|
||||
});
|
||||
|
||||
// Setup leader election callbacks
|
||||
this.leaderElection.onLeader(async () => {
|
||||
console.log('[KVManager] We are the leader, connecting to local Redis...');
|
||||
|
||||
// Connect to local Redis (spawned by leader election)
|
||||
const redisUrl = `redis://127.0.0.1:${this.config.redisPort}`;
|
||||
this.kvClient = await this.createRedisClient(redisUrl);
|
||||
|
||||
this.notifyReady();
|
||||
});
|
||||
|
||||
this.leaderElection.onFollower(async (leaderInfo: LeaderLockInfo) => {
|
||||
console.log(`[KVManager] We are a follower, connecting to leader at ${leaderInfo.api}...`);
|
||||
|
||||
// Connect to leader's Redis
|
||||
this.kvClient = await this.createRedisClient(leaderInfo.api);
|
||||
|
||||
this.notifyReady();
|
||||
});
|
||||
|
||||
this.leaderElection.onLostLeader(async () => {
|
||||
console.log('[KVManager] Leader lost, disconnecting...');
|
||||
|
||||
if (this.kvClient) {
|
||||
await this.kvClient.close();
|
||||
this.kvClient = null;
|
||||
}
|
||||
|
||||
// Leader election will handle re-election and reconnection
|
||||
});
|
||||
|
||||
// Initialize service discovery
|
||||
// Use BASE_URL if provided, otherwise fall back to auto-detected local IP
|
||||
const apiUrl = this.config.baseUrl || `http://${localIP}:${this.config.apiPort}`;
|
||||
|
||||
this.serviceDiscovery = new ServiceDiscovery({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
version: this.config.version!,
|
||||
apiUrl,
|
||||
capabilities: this.config.capabilities,
|
||||
endpoints: {
|
||||
health: '/health',
|
||||
dashboard: '/',
|
||||
api: '/api',
|
||||
metrics: '/api/metrics',
|
||||
processing: '/api/processing/status'
|
||||
}
|
||||
});
|
||||
|
||||
// Start leader election first
|
||||
await this.leaderElection.start();
|
||||
|
||||
// Start service discovery
|
||||
await this.serviceDiscovery.start();
|
||||
|
||||
this.isStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the KV manager
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
console.log('[KVManager] Stopping...');
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Stop service discovery
|
||||
if (this.serviceDiscovery) {
|
||||
await this.serviceDiscovery.stop();
|
||||
this.serviceDiscovery = null;
|
||||
}
|
||||
|
||||
// Close KV client
|
||||
if (this.kvClient) {
|
||||
await this.kvClient.close();
|
||||
this.kvClient = null;
|
||||
}
|
||||
|
||||
// Stop leader election (releases lock, stops Redis if leader)
|
||||
if (this.leaderElection) {
|
||||
await this.leaderElection.stop();
|
||||
this.leaderElection = null;
|
||||
}
|
||||
|
||||
this.isStarted = false;
|
||||
console.log('[KVManager] Stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify ready callbacks
|
||||
*/
|
||||
private notifyReady(): void {
|
||||
for (const callback of this.onReadyCallbacks) {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('[KVManager] Error in ready callback:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for when the KV client is ready
|
||||
*/
|
||||
onReady(callback: () => void): this {
|
||||
this.onReadyCallbacks.push(callback);
|
||||
|
||||
// If already ready, call immediately
|
||||
if (this.kvClient) {
|
||||
callback();
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for reconnection events
|
||||
*/
|
||||
onReconnect(callback: () => void): this {
|
||||
this.onReconnectCallbacks.push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the KV client to be ready
|
||||
*/
|
||||
async waitForReady(timeoutMs: number = 30000): Promise<void> {
|
||||
if (this.kvClient) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`KV client not ready after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.onReady(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Get the KV client (may be null if not yet connected)
|
||||
*/
|
||||
getClient(): IKVClient | null {
|
||||
return this.kvClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KV client (throws if not connected)
|
||||
*/
|
||||
requireClient(): IKVClient {
|
||||
if (!this.kvClient) {
|
||||
throw new Error('KV client not available. Call start() and wait for ready.');
|
||||
}
|
||||
return this.kvClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this service is the leader
|
||||
*/
|
||||
isLeader(): boolean {
|
||||
return this.leaderElection?.isLeader() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this service is a follower
|
||||
*/
|
||||
isFollower(): boolean {
|
||||
return this.leaderElection?.isFollower() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current leader info
|
||||
*/
|
||||
getLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderElection?.getLeaderInfo() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service discovery instance
|
||||
*/
|
||||
getServiceDiscovery(): ServiceDiscovery | null {
|
||||
return this.serviceDiscovery;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover another service
|
||||
*/
|
||||
async discoverService(name: string) {
|
||||
return this.serviceDiscovery?.discoverService(name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the KV store is healthy
|
||||
*/
|
||||
async isHealthy(): Promise<boolean> {
|
||||
if (!this.kvClient) return false;
|
||||
return this.kvClient.health();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paths configuration
|
||||
*/
|
||||
getPaths() {
|
||||
return {
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
filesPath: this.config.filesPath
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* Leader Election using flock on shared filesystem
|
||||
*
|
||||
* This implements a distributed leader election mechanism using:
|
||||
* 1. flock (advisory file locking) for mutual exclusion
|
||||
* 2. Shared filesystem as the consensus layer
|
||||
* 3. Separate info file for service discovery
|
||||
*
|
||||
* Architecture:
|
||||
* - Service tries to acquire exclusive flock on /meta-core/locks/kv-leader.lock
|
||||
* - If acquired → becomes LEADER, spawns Redis, writes endpoint to info file
|
||||
* - If blocked → becomes FOLLOWER, reads leader endpoint from info file
|
||||
* - flock automatically releases when process dies (no stale lock issues!)
|
||||
*
|
||||
* Key advantage over O_EXCL approach:
|
||||
* - Lock is automatically released when process exits/crashes
|
||||
* - No need for timestamp-based stale detection
|
||||
* - No race conditions during takeover
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { hostname, networkInterfaces } from 'os';
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import * as IORedis from 'ioredis';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
// ESM/CJS interop for ioredis
|
||||
const Redis = (IORedis as any).default ?? IORedis;
|
||||
|
||||
interface LeaderElectionConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Service name for identification */
|
||||
serviceName: string;
|
||||
|
||||
/** HTTP API port */
|
||||
apiPort: number;
|
||||
|
||||
/** Redis port (default: 6379) */
|
||||
redisPort?: number;
|
||||
|
||||
/** Redis data directory (relative to META_CORE_VOLUME/db/redis) */
|
||||
redisDataDir?: string;
|
||||
|
||||
/** Health check interval in ms (default: 5000) */
|
||||
healthCheckInterval?: number;
|
||||
|
||||
/** Base URL for stable service discovery (e.g., http://localhost:8180) */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
type LeaderRole = 'leader' | 'follower' | 'unknown';
|
||||
|
||||
export class LeaderElection {
|
||||
private config: LeaderElectionConfig;
|
||||
private lockFilePath: string;
|
||||
private infoFilePath: string;
|
||||
private flockProcess: ChildProcess | null = null;
|
||||
private role: LeaderRole = 'unknown';
|
||||
private leaderInfo: LeaderLockInfo | null = null;
|
||||
private redisProcess: ChildProcess | null = null;
|
||||
private healthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private isShuttingDown = false;
|
||||
|
||||
// Event callbacks
|
||||
private onBecomeLeader?: () => void;
|
||||
private onBecomeFollower?: (leaderInfo: LeaderLockInfo) => void;
|
||||
private onLeaderLost?: () => void;
|
||||
|
||||
constructor(config: LeaderElectionConfig) {
|
||||
this.config = {
|
||||
redisPort: 6379,
|
||||
healthCheckInterval: 5000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.lockFilePath = `${this.config.metaCorePath}/locks/kv-leader.lock`;
|
||||
this.infoFilePath = `${this.config.metaCorePath}/locks/kv-leader.info`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local machine's IP address
|
||||
*/
|
||||
private getLocalIP(): string {
|
||||
const interfaces = networkInterfaces();
|
||||
|
||||
// Prefer non-internal IPv4 addresses
|
||||
for (const name of Object.keys(interfaces)) {
|
||||
const addrs = interfaces[name];
|
||||
if (!addrs) continue;
|
||||
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hostname or localhost
|
||||
return hostname() || 'localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build leader info for this service
|
||||
*/
|
||||
private buildLeaderInfo(): LeaderLockInfo {
|
||||
const ip = this.getLocalIP();
|
||||
return {
|
||||
host: hostname(),
|
||||
api: `redis://${ip}:${this.config.redisPort}`,
|
||||
http: `http://${ip}:${this.config.apiPort}`,
|
||||
baseUrl: this.config.baseUrl,
|
||||
timestamp: Date.now(),
|
||||
pid: process.pid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure lock directory exists
|
||||
*/
|
||||
private async ensureLockDir(): Promise<void> {
|
||||
const lockDir = dirname(this.lockFilePath);
|
||||
await fs.mkdir(lockDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to acquire the leader lock using flock
|
||||
* Returns true if lock acquired (we are leader), false otherwise
|
||||
*
|
||||
* Uses `flock -n -x` for non-blocking exclusive lock attempt.
|
||||
* The lock is held by keeping a child process alive with the lock fd open.
|
||||
*/
|
||||
private async tryAcquireLock(): Promise<boolean> {
|
||||
await this.ensureLockDir();
|
||||
|
||||
// Ensure lock file exists (flock needs an existing file)
|
||||
try {
|
||||
await fs.writeFile(this.lockFilePath, '', { flag: 'a' });
|
||||
} catch {
|
||||
// Ignore if file already exists
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// Spawn a process that tries to acquire the lock and holds it
|
||||
// Using flock with file descriptor: open fd 200 on lock file, then sleep forever
|
||||
// The lock is held as long as fd 200 is open (process alive)
|
||||
this.flockProcess = spawn('bash', [
|
||||
'-c',
|
||||
`exec 200>"${this.lockFilePath}" && flock -n -x 200 && echo LOCKED && while true; do sleep 86400; done`
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
|
||||
// Check stdout for "LOCKED" message indicating we got the lock
|
||||
this.flockProcess.stdout?.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output === 'LOCKED' && !resolved) {
|
||||
resolved = true;
|
||||
console.log('[LeaderElection] Acquired flock on', this.lockFilePath);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
this.flockProcess.stderr?.on('data', (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) {
|
||||
console.error('[LeaderElection] flock stderr:', msg);
|
||||
}
|
||||
});
|
||||
|
||||
// If process exits quickly, we didn't get the lock
|
||||
this.flockProcess.on('exit', (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
console.log('[LeaderElection] Could not acquire flock (lock held by another process)');
|
||||
this.flockProcess = null;
|
||||
resolve(false);
|
||||
} else if (!this.isShuttingDown && this.role === 'leader') {
|
||||
// Lock process died unexpectedly while we were leader
|
||||
console.error('[LeaderElection] flock process died unexpectedly!');
|
||||
this.flockProcess = null;
|
||||
this.handleLockLost();
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout: if we don't get the lock within 2 seconds, assume it's held
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
if (this.flockProcess) {
|
||||
this.flockProcess.kill();
|
||||
this.flockProcess = null;
|
||||
}
|
||||
console.log('[LeaderElection] Timeout acquiring flock (lock held by another process)');
|
||||
resolve(false);
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle losing the lock unexpectedly
|
||||
*/
|
||||
private async handleLockLost(): Promise<void> {
|
||||
if (this.isShuttingDown) return;
|
||||
|
||||
console.log('[LeaderElection] Lost leadership, cleaning up...');
|
||||
this.role = 'unknown';
|
||||
|
||||
// Stop Redis
|
||||
await this.stopRedis();
|
||||
|
||||
// Notify callback
|
||||
this.onLeaderLost?.();
|
||||
|
||||
// Try to re-elect
|
||||
const acquired = await this.tryAcquireLock();
|
||||
if (acquired) {
|
||||
await this.transitionToLeader();
|
||||
} else {
|
||||
await this.transitionToFollower();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the lock is currently held by someone
|
||||
* Uses flock -n which fails immediately if lock is held
|
||||
*/
|
||||
private isLockHeld(): boolean {
|
||||
try {
|
||||
// Try to acquire lock non-blocking - if it succeeds, no one holds it
|
||||
execSync(`flock -n -x "${this.lockFilePath}" -c "exit 0"`, {
|
||||
timeout: 1000,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
// Lock was free (we briefly held it and released)
|
||||
return false;
|
||||
} catch {
|
||||
// Lock is held by someone else
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read leader info from info file
|
||||
*/
|
||||
private async readLeaderInfo(): Promise<LeaderLockInfo | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.infoFilePath, 'utf-8');
|
||||
return JSON.parse(content) as LeaderLockInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write leader info to info file (atomic write)
|
||||
*/
|
||||
private async writeLeaderInfo(): Promise<void> {
|
||||
this.leaderInfo = this.buildLeaderInfo();
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = `${this.infoFilePath}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(this.leaderInfo, null, 2));
|
||||
await fs.rename(tempPath, this.infoFilePath);
|
||||
|
||||
console.log('[LeaderElection] Wrote leader info to', this.infoFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update leader timestamp (heartbeat) using atomic write
|
||||
*/
|
||||
private async updateLeaderTimestamp(): Promise<void> {
|
||||
if (this.role !== 'leader' || !this.leaderInfo) return;
|
||||
|
||||
this.leaderInfo.timestamp = Date.now();
|
||||
try {
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tempPath = `${this.infoFilePath}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(this.leaderInfo, null, 2));
|
||||
await fs.rename(tempPath, this.infoFilePath);
|
||||
} catch (error) {
|
||||
console.error('[LeaderElection] Failed to update timestamp:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn Redis server as child process
|
||||
*/
|
||||
private async spawnRedis(): Promise<void> {
|
||||
const redisDataDir = `${this.config.metaCorePath}/db/redis`;
|
||||
|
||||
// Ensure Redis data directory exists
|
||||
await fs.mkdir(redisDataDir, { recursive: true });
|
||||
|
||||
console.log(`[LeaderElection] Spawning Redis on port ${this.config.redisPort}...`);
|
||||
|
||||
// Spawn Redis server
|
||||
this.redisProcess = spawn('redis-server', [
|
||||
'--port', String(this.config.redisPort),
|
||||
'--bind', '0.0.0.0',
|
||||
'--dir', redisDataDir,
|
||||
'--appendonly', 'yes',
|
||||
'--appendfilename', 'appendonly.aof',
|
||||
'--dbfilename', 'dump.rdb',
|
||||
'--save', '60', '1', // Save after 60 seconds if at least 1 key changed
|
||||
'--loglevel', 'warning'
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false
|
||||
});
|
||||
|
||||
this.redisProcess.stdout?.on('data', (data) => {
|
||||
console.log(`[Redis] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
this.redisProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[Redis] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
this.redisProcess.on('exit', (code, signal) => {
|
||||
console.log(`[Redis] Process exited with code ${code}, signal ${signal}`);
|
||||
this.redisProcess = null;
|
||||
|
||||
if (!this.isShuttingDown && this.role === 'leader') {
|
||||
// Redis crashed - try to restart
|
||||
console.log('[LeaderElection] Redis crashed, attempting restart...');
|
||||
this.spawnRedis().catch(console.error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for Redis to be ready
|
||||
await this.waitForRedisReady();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Redis to be ready to accept connections
|
||||
*/
|
||||
private async waitForRedisReady(): Promise<void> {
|
||||
const maxAttempts = 30;
|
||||
const ip = this.getLocalIP();
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const client = new Redis({
|
||||
host: ip,
|
||||
port: this.config.redisPort,
|
||||
connectTimeout: 1000,
|
||||
maxRetriesPerRequest: 1
|
||||
});
|
||||
|
||||
const pong = await client.ping();
|
||||
await client.quit();
|
||||
|
||||
if (pong === 'PONG') {
|
||||
console.log('[LeaderElection] Redis is ready');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Redis not ready yet
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error('Redis failed to start within timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Redis server
|
||||
*/
|
||||
private async stopRedis(): Promise<void> {
|
||||
if (this.redisProcess) {
|
||||
console.log('[LeaderElection] Stopping Redis...');
|
||||
this.redisProcess.kill('SIGTERM');
|
||||
|
||||
// Wait for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.redisProcess) {
|
||||
this.redisProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
this.redisProcess!.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.redisProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the leader lock by killing the flock process
|
||||
*/
|
||||
private async releaseLock(): Promise<void> {
|
||||
if (this.flockProcess) {
|
||||
console.log('[LeaderElection] Releasing flock...');
|
||||
|
||||
// Kill the flock holder process - this releases the lock
|
||||
this.flockProcess.kill('SIGTERM');
|
||||
|
||||
// Wait briefly for graceful exit
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.flockProcess) {
|
||||
this.flockProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 1000);
|
||||
|
||||
if (this.flockProcess) {
|
||||
this.flockProcess.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
this.flockProcess = null;
|
||||
}
|
||||
|
||||
// Clean up info file (optional, but good hygiene)
|
||||
try {
|
||||
await fs.unlink(this.infoFilePath);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health check loop
|
||||
*/
|
||||
private startHealthCheck(): void {
|
||||
this.healthCheckTimer = setInterval(async () => {
|
||||
if (this.isShuttingDown) return;
|
||||
|
||||
try {
|
||||
if (this.role === 'leader') {
|
||||
// Update timestamp as heartbeat
|
||||
await this.updateLeaderTimestamp();
|
||||
|
||||
// Verify we still hold the lock
|
||||
if (!this.flockProcess || this.flockProcess.exitCode !== null) {
|
||||
console.error('[LeaderElection] Lost flock process!');
|
||||
await this.handleLockLost();
|
||||
}
|
||||
} else if (this.role === 'follower') {
|
||||
// Check if lock is still held (leader still alive)
|
||||
if (!this.isLockHeld()) {
|
||||
console.log('[LeaderElection] Lock is free, attempting to become leader...');
|
||||
this.onLeaderLost?.();
|
||||
|
||||
// Try to become leader
|
||||
const acquired = await this.tryAcquireLock();
|
||||
if (acquired) {
|
||||
await this.transitionToLeader();
|
||||
} else {
|
||||
// Another service became leader - reconnect
|
||||
await this.transitionToFollower();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LeaderElection] Health check error:', error);
|
||||
}
|
||||
}, this.config.healthCheckInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to leader role
|
||||
*/
|
||||
private async transitionToLeader(): Promise<void> {
|
||||
console.log('[LeaderElection] Becoming LEADER');
|
||||
this.role = 'leader';
|
||||
|
||||
// Write leader info BEFORE spawning Redis so followers can find us
|
||||
await this.writeLeaderInfo();
|
||||
|
||||
// Spawn Redis
|
||||
await this.spawnRedis();
|
||||
|
||||
// Notify callback
|
||||
this.onBecomeLeader?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition to follower role
|
||||
*/
|
||||
private async transitionToFollower(): Promise<void> {
|
||||
console.log('[LeaderElection] Becoming FOLLOWER');
|
||||
this.role = 'follower';
|
||||
|
||||
// Wait a bit for leader to write info file
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Read leader info
|
||||
this.leaderInfo = await this.readLeaderInfo();
|
||||
|
||||
if (this.leaderInfo) {
|
||||
console.log(`[LeaderElection] Leader is ${this.leaderInfo.host} at ${this.leaderInfo.api}`);
|
||||
this.onBecomeFollower?.(this.leaderInfo);
|
||||
} else {
|
||||
console.warn('[LeaderElection] Could not read leader info, will retry...');
|
||||
// Retry reading after a delay
|
||||
setTimeout(async () => {
|
||||
this.leaderInfo = await this.readLeaderInfo();
|
||||
if (this.leaderInfo) {
|
||||
console.log(`[LeaderElection] Leader is ${this.leaderInfo.host} at ${this.leaderInfo.api}`);
|
||||
this.onBecomeFollower?.(this.leaderInfo);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the leader election process
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
console.log(`[LeaderElection] Starting election for ${this.config.serviceName}...`);
|
||||
console.log(`[LeaderElection] Lock file: ${this.lockFilePath}`);
|
||||
console.log(`[LeaderElection] Info file: ${this.infoFilePath}`);
|
||||
|
||||
// Try to become leader
|
||||
const acquired = await this.tryAcquireLock();
|
||||
|
||||
if (acquired) {
|
||||
await this.transitionToLeader();
|
||||
} else {
|
||||
await this.transitionToFollower();
|
||||
}
|
||||
|
||||
// Start health check
|
||||
this.startHealthCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
console.log('[LeaderElection] Stopping...');
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Stop health check
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
this.healthCheckTimer = null;
|
||||
}
|
||||
|
||||
// If we were leader, cleanup
|
||||
if (this.role === 'leader') {
|
||||
await this.stopRedis();
|
||||
await this.releaseLock();
|
||||
}
|
||||
|
||||
this.role = 'unknown';
|
||||
console.log('[LeaderElection] Stopped');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Event Registration
|
||||
// ========================================================================
|
||||
|
||||
onLeader(callback: () => void): this {
|
||||
this.onBecomeLeader = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onFollower(callback: (leaderInfo: LeaderLockInfo) => void): this {
|
||||
this.onBecomeFollower = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
onLostLeader(callback: () => void): this {
|
||||
this.onLeaderLost = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
getRole(): LeaderRole {
|
||||
return this.role;
|
||||
}
|
||||
|
||||
isLeader(): boolean {
|
||||
return this.role === 'leader';
|
||||
}
|
||||
|
||||
isFollower(): boolean {
|
||||
return this.role === 'follower';
|
||||
}
|
||||
|
||||
getLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderInfo;
|
||||
}
|
||||
|
||||
getRedisUrl(): string | null {
|
||||
if (this.leaderInfo) {
|
||||
return this.leaderInfo.api;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Utility functions for converting between nested JSON metadata and flat key-value pairs.
|
||||
* Implements the hierarchical nested key architecture for KV storage (Redis, etc.)
|
||||
*/
|
||||
|
||||
import type { KeyValuePair } from './IKVClient.js';
|
||||
|
||||
/**
|
||||
* Flattens a nested metadata object into an array of key-value pairs.
|
||||
* Each property becomes a separate key with a hierarchical path.
|
||||
*
|
||||
* @param metadata - The nested metadata object to flatten
|
||||
* @param prefix - The key prefix (e.g., "/file/{hashId}")
|
||||
* @param excludeFields - Fields to exclude from flattening (e.g., internal tracking fields)
|
||||
* @returns Array of key-value pairs suitable for KV storage
|
||||
*
|
||||
* @example
|
||||
* flattenMetadata({ title: "Inception", video: { codec: "h265" } }, "/file/abc123")
|
||||
* // Returns:
|
||||
* // [
|
||||
* // { key: "/file/abc123/title", value: "Inception" },
|
||||
* // { key: "/file/abc123/video/codec", value: "h265" }
|
||||
* // ]
|
||||
*/
|
||||
export function flattenMetadata(
|
||||
metadata: any,
|
||||
prefix: string,
|
||||
excludeFields: string[] = []
|
||||
): KeyValuePair[] {
|
||||
const pairs: KeyValuePair[] = [];
|
||||
|
||||
function flatten(obj: any, path: string) {
|
||||
if (obj === null || obj === undefined) {
|
||||
// Store null/undefined as empty string
|
||||
pairs.push({ key: path, value: '' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
// Primitive value - store as string
|
||||
pairs.push({ key: path, value: String(obj) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// Handle arrays with numeric indices
|
||||
obj.forEach((item, index) => {
|
||||
flatten(item, `${path}/${index}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle objects recursively
|
||||
Object.keys(obj).forEach((key) => {
|
||||
// Skip excluded fields
|
||||
if (excludeFields.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = obj[key];
|
||||
const newPath = path ? `${path}/${key}` : key;
|
||||
|
||||
flatten(value, newPath);
|
||||
});
|
||||
}
|
||||
|
||||
flatten(metadata, prefix);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a nested metadata object from flat key-value pairs.
|
||||
* Reverses the flattening process to restore the original structure.
|
||||
*
|
||||
* @param pairs - Array of key-value pairs from KV storage
|
||||
* @param prefix - The key prefix to strip (e.g., "/file/{hashId}")
|
||||
* @returns Reconstructed nested metadata object
|
||||
*
|
||||
* @example
|
||||
* reconstructMetadata([
|
||||
* { key: "/file/abc123/title", value: "Inception" },
|
||||
* { key: "/file/abc123/video/codec", value: "h265" }
|
||||
* ], "/file/abc123")
|
||||
* // Returns: { title: "Inception", video: { codec: "h265" } }
|
||||
*/
|
||||
export function reconstructMetadata(
|
||||
pairs: KeyValuePair[],
|
||||
prefix: string
|
||||
): any {
|
||||
const result: any = {};
|
||||
|
||||
pairs.forEach(({ key, value }) => {
|
||||
// Strip the prefix to get the property path
|
||||
if (!key.startsWith(prefix)) {
|
||||
return; // Skip keys that don't match the prefix
|
||||
}
|
||||
|
||||
const propertyPath = key.slice(prefix.length + 1); // +1 to skip the leading slash
|
||||
if (!propertyPath) {
|
||||
return; // Skip if no property path after prefix
|
||||
}
|
||||
|
||||
const parts = propertyPath.split('/');
|
||||
let current = result;
|
||||
|
||||
// Navigate/create the nested structure
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const isLast = i === parts.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
// Last part - set the value
|
||||
current[part] = parseValue(value);
|
||||
} else {
|
||||
// Intermediate part - create object or array as needed
|
||||
const nextPart = parts[i + 1];
|
||||
const isNextPartNumeric = /^\d+$/.test(nextPart);
|
||||
|
||||
if (!(part in current)) {
|
||||
// Create array if next part is numeric, otherwise create object
|
||||
current[part] = isNextPartNumeric ? [] : {};
|
||||
}
|
||||
|
||||
current = current[part];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Post-process: Convert objects with only numeric keys to arrays
|
||||
return convertNumericObjectsToArrays(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts objects with only numeric sequential keys to arrays.
|
||||
* This handles cases where arrays were stored with numeric indices.
|
||||
*/
|
||||
function convertNumericObjectsToArrays(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// Already an array, recursively process elements
|
||||
return obj.map(convertNumericObjectsToArrays);
|
||||
}
|
||||
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
// Check if all keys are numeric and sequential (0, 1, 2, ...)
|
||||
const allNumeric = keys.every(key => /^\d+$/.test(key));
|
||||
|
||||
if (allNumeric && keys.length > 0) {
|
||||
// Sort keys numerically
|
||||
const sortedKeys = keys.map(Number).sort((a, b) => a - b);
|
||||
|
||||
// Check if keys are sequential starting from 0
|
||||
const isSequential = sortedKeys.every((key, index) => key === index);
|
||||
|
||||
if (isSequential) {
|
||||
// Convert to array
|
||||
const arr = sortedKeys.map(index =>
|
||||
convertNumericObjectsToArrays(obj[index.toString()])
|
||||
);
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular object - recursively process values
|
||||
const result: any = {};
|
||||
for (const key of keys) {
|
||||
result[key] = convertNumericObjectsToArrays(obj[key]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a string value from KV storage into the appropriate JavaScript type.
|
||||
* Handles numbers, booleans, null, and strings.
|
||||
*
|
||||
* @param value - The string value from KV storage
|
||||
* @returns Parsed value with appropriate type
|
||||
*/
|
||||
function parseValue(value: string): any {
|
||||
// Handle empty string as null
|
||||
if (value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle booleans
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
// Handle null
|
||||
if (value === 'null') return null;
|
||||
|
||||
// Try to parse as number, but be conservative
|
||||
// Only parse as number if it's a valid integer or floating point without leading zeros
|
||||
// This preserves strings like "5.1" (channel layout), "23.976" (frame rate)
|
||||
// Note: In actual storage, these will be stored as strings, so this only matters
|
||||
// when the original value was numeric. For metadata consistency, we convert
|
||||
// what looks like numbers to numbers.
|
||||
const num = Number(value);
|
||||
if (!isNaN(num) && value.trim() !== '' && /^-?\d+(\.\d+)?$/.test(value)) {
|
||||
return num;
|
||||
}
|
||||
|
||||
// Return as string
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the hash ID from a file key.
|
||||
*
|
||||
* @param key - Full key like "/file/{hashId}/property/path"
|
||||
* @returns The hash ID or null if not a valid file key
|
||||
*
|
||||
* @example
|
||||
* extractHashId("/file/sha256:abc123/title") // Returns "sha256:abc123"
|
||||
*/
|
||||
export function extractHashId(key: string): string | null {
|
||||
const match = key.match(/^\/file\/([^/]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a property key path.
|
||||
*
|
||||
* @param hashId - The file's hash ID
|
||||
* @param propertyPath - The property path (e.g., "video/codec")
|
||||
* @returns Full KV key path
|
||||
*
|
||||
* @example
|
||||
* buildPropertyKey("sha256:abc123", "video/codec")
|
||||
* // Returns "/file/sha256:abc123/video/codec"
|
||||
*/
|
||||
export function buildPropertyKey(hashId: string, propertyPath: string): string {
|
||||
return `/file/${hashId}/${propertyPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a file prefix for range queries.
|
||||
*
|
||||
* @param hashId - The file's hash ID
|
||||
* @returns Prefix for querying all properties of a file
|
||||
*
|
||||
* @example
|
||||
* buildFilePrefix("sha256:abc123") // Returns "/file/sha256:abc123"
|
||||
*/
|
||||
export function buildFilePrefix(hashId: string): string {
|
||||
return `/file/${hashId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups key-value pairs by hash ID.
|
||||
* Useful for batch processing multiple files' metadata.
|
||||
*
|
||||
* @param pairs - Array of key-value pairs
|
||||
* @returns Map of hash ID to array of key-value pairs
|
||||
*/
|
||||
export function groupByHashId(pairs: KeyValuePair[]): Map<string, KeyValuePair[]> {
|
||||
const grouped = new Map<string, KeyValuePair[]>();
|
||||
|
||||
pairs.forEach((pair) => {
|
||||
const hashId = extractHashId(pair.key);
|
||||
if (hashId) {
|
||||
if (!grouped.has(hashId)) {
|
||||
grouped.set(hashId, []);
|
||||
}
|
||||
grouped.get(hashId)!.push(pair);
|
||||
}
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Redis KV Client - Implements IKVClient interface using Redis
|
||||
*
|
||||
* Uses Redis hashes for efficient nested key storage:
|
||||
* - Each file's metadata is stored as a Redis hash
|
||||
* - Hash key: file:{hashId}
|
||||
* - Hash field: property/path (e.g., "video/codec")
|
||||
*
|
||||
* Benefits of Redis hashes:
|
||||
* - Single command to get all file metadata (HGETALL)
|
||||
* - Single command to set multiple properties (HMSET)
|
||||
* - Atomic operations on file metadata
|
||||
*/
|
||||
|
||||
import * as IORedis from 'ioredis';
|
||||
import type { IKVClient, KeyValuePair } from './IKVClient.js';
|
||||
import {
|
||||
flattenMetadata,
|
||||
reconstructMetadata,
|
||||
buildFilePrefix,
|
||||
buildPropertyKey,
|
||||
} from './MetadataUtils.js';
|
||||
|
||||
// ESM/CJS interop for ioredis
|
||||
const Redis = (IORedis as any).default ?? IORedis;
|
||||
type RedisInstance = InstanceType<typeof Redis>;
|
||||
|
||||
interface RedisClientConfig {
|
||||
url: string;
|
||||
timeout?: number;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export class RedisKVClient implements IKVClient {
|
||||
private redis: RedisInstance;
|
||||
private timeout: number;
|
||||
private prefix: string;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
constructor(config: RedisClientConfig) {
|
||||
this.timeout = config.timeout || 30000;
|
||||
this.prefix = config.prefix || '';
|
||||
|
||||
// Parse Redis URL and create client
|
||||
this.redis = new Redis(config.url, {
|
||||
commandTimeout: this.timeout,
|
||||
connectTimeout: this.timeout,
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 3,
|
||||
enableOfflineQueue: true,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 200, 2000);
|
||||
console.log(`[Redis] Retry attempt ${times}, waiting ${delay}ms`);
|
||||
return delay;
|
||||
}
|
||||
});
|
||||
|
||||
// Event handlers
|
||||
this.redis.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
console.log('[Redis] Connected');
|
||||
});
|
||||
|
||||
this.redis.on('error', (err) => {
|
||||
console.error('[Redis] Error:', err.message);
|
||||
});
|
||||
|
||||
this.redis.on('close', () => {
|
||||
this.isConnected = false;
|
||||
console.log('[Redis] Connection closed');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis (call before using other methods)
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.isConnected) return;
|
||||
await this.redis.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full Redis key with optional prefix
|
||||
*/
|
||||
private buildKey(key: string): string {
|
||||
return this.prefix ? `${this.prefix}${key}` : key;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Basic Operations
|
||||
// ========================================================================
|
||||
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
const fullKey = this.buildKey(key);
|
||||
await this.redis.set(fullKey, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<any | null> {
|
||||
const fullKey = this.buildKey(key);
|
||||
const value = await this.redis.get(fullKey);
|
||||
if (value === null) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const fullKey = this.buildKey(key);
|
||||
await this.redis.del(fullKey);
|
||||
}
|
||||
|
||||
async countKeysWithPrefix(prefix: string): Promise<number> {
|
||||
const fullPrefix = this.buildKey(prefix);
|
||||
// Use SCAN to count keys (don't use KEYS in production)
|
||||
let count = 0;
|
||||
let cursor = '0';
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${fullPrefix}*`,
|
||||
'COUNT',
|
||||
1000
|
||||
);
|
||||
cursor = nextCursor;
|
||||
count += keys.length;
|
||||
} while (cursor !== '0');
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redis.ping();
|
||||
return result === 'PONG';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Nested Key Architecture Methods
|
||||
// ========================================================================
|
||||
|
||||
async setProperty(key: string, value: string): Promise<void> {
|
||||
const fullKey = this.buildKey(key);
|
||||
await this.redis.set(fullKey, value);
|
||||
}
|
||||
|
||||
async getProperty(key: string): Promise<string | null> {
|
||||
const fullKey = this.buildKey(key);
|
||||
return await this.redis.get(fullKey);
|
||||
}
|
||||
|
||||
async getRange(prefix: string): Promise<KeyValuePair[]> {
|
||||
const fullPrefix = this.buildKey(prefix);
|
||||
const pairs: KeyValuePair[] = [];
|
||||
let cursor = '0';
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${fullPrefix}*`,
|
||||
'COUNT',
|
||||
1000
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
if (keys.length > 0) {
|
||||
// Get values for all found keys
|
||||
const values = await this.redis.mget(...keys);
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value !== null) {
|
||||
// Strip prefix to get original key
|
||||
const originalKey = this.prefix
|
||||
? key.slice(this.prefix.length)
|
||||
: key;
|
||||
pairs.push({ key: originalKey, value });
|
||||
}
|
||||
});
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
async setMultiple(pairs: KeyValuePair[]): Promise<void> {
|
||||
if (pairs.length === 0) return;
|
||||
|
||||
// Use pipeline for efficient batch operations
|
||||
const pipeline = this.redis.pipeline();
|
||||
|
||||
for (const pair of pairs) {
|
||||
const fullKey = this.buildKey(pair.key);
|
||||
pipeline.set(fullKey, pair.value);
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async deleteRange(prefix: string): Promise<number> {
|
||||
const fullPrefix = this.buildKey(prefix);
|
||||
let deletedCount = 0;
|
||||
let cursor = '0';
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${fullPrefix}*`,
|
||||
'COUNT',
|
||||
1000
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
if (keys.length > 0) {
|
||||
const deleted = await this.redis.del(...keys);
|
||||
deletedCount += deleted;
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// High-Level Metadata Operations (Using Redis Hashes)
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Store file metadata using Redis hash
|
||||
* More efficient than individual keys - single HMSET command
|
||||
*/
|
||||
async setMetadataFlat(
|
||||
hashId: string,
|
||||
metadata: any,
|
||||
excludeFields: string[] = []
|
||||
): Promise<void> {
|
||||
const prefix = buildFilePrefix(hashId);
|
||||
const pairs = flattenMetadata(metadata, prefix, excludeFields);
|
||||
|
||||
if (pairs.length === 0) return;
|
||||
|
||||
// Convert to Redis hash format
|
||||
// Hash key: file:{hashId}
|
||||
// Hash fields: property paths (without /file/{hashId} prefix)
|
||||
const hashKey = this.buildKey(`file:${hashId}`);
|
||||
const hashData: Record<string, string> = {};
|
||||
|
||||
for (const pair of pairs) {
|
||||
// Extract field name by removing the prefix
|
||||
const field = pair.key.slice(prefix.length + 1); // +1 for trailing /
|
||||
if (field) {
|
||||
hashData[field] = pair.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await this.redis.hmset(hashKey, hashData);
|
||||
}
|
||||
|
||||
// Also store in key index for getAllHashIds
|
||||
await this.redis.sadd(this.buildKey('file:__index__'), hashId);
|
||||
}
|
||||
|
||||
async getMetadataFlat(hashId: string): Promise<any | null> {
|
||||
const hashKey = this.buildKey(`file:${hashId}`);
|
||||
const hashData = await this.redis.hgetall(hashKey);
|
||||
|
||||
if (!hashData || Object.keys(hashData).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert Redis hash back to KeyValuePair format for reconstruction
|
||||
const prefix = buildFilePrefix(hashId);
|
||||
const pairs: KeyValuePair[] = Object.entries(hashData).map(([field, value]) => ({
|
||||
key: `${prefix}/${field}`,
|
||||
value: value as string
|
||||
}));
|
||||
|
||||
return reconstructMetadata(pairs, prefix);
|
||||
}
|
||||
|
||||
async getMetadata(hashId: string, propertyPath: string): Promise<any | null> {
|
||||
const hashKey = this.buildKey(`file:${hashId}`);
|
||||
const value = await this.redis.hget(hashKey, propertyPath);
|
||||
|
||||
if (value === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMetadataFlat(hashId: string): Promise<number> {
|
||||
const hashKey = this.buildKey(`file:${hashId}`);
|
||||
const fieldCount = await this.redis.hlen(hashKey);
|
||||
|
||||
await this.redis.del(hashKey);
|
||||
await this.redis.srem(this.buildKey('file:__index__'), hashId);
|
||||
|
||||
return fieldCount;
|
||||
}
|
||||
|
||||
async getAllHashIds(): Promise<string[]> {
|
||||
const indexKey = this.buildKey('file:__index__');
|
||||
return await this.redis.smembers(indexKey);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Pub/Sub Operations
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Publish a message to a Redis channel
|
||||
* Used for notifying other services (meta-fuse) about file changes
|
||||
* Note: Channel names are NOT prefixed - they use their own namespace
|
||||
*/
|
||||
async publish(channel: string, message: string): Promise<number> {
|
||||
return await this.redis.publish(channel, message);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is connected
|
||||
*/
|
||||
isHealthy(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Redis client (for advanced operations)
|
||||
*/
|
||||
getRedisClient(): RedisInstance {
|
||||
return this.redis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Service Discovery using shared filesystem
|
||||
*
|
||||
* Each service registers itself in /meta-core/services/{service-name}.json
|
||||
* with its API endpoint, status, and heartbeat timestamp.
|
||||
*
|
||||
* Services can discover each other by reading these JSON files.
|
||||
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered dead.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { hostname } from 'os';
|
||||
import { join } from 'path';
|
||||
import type { ServiceInfo } from './IKVClient.js';
|
||||
|
||||
interface ServiceDiscoveryConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Service name */
|
||||
serviceName: string;
|
||||
|
||||
/** Service version */
|
||||
version: string;
|
||||
|
||||
/** HTTP API URL */
|
||||
apiUrl: string;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities?: string[];
|
||||
|
||||
/** Named endpoints */
|
||||
endpoints?: Record<string, string>;
|
||||
|
||||
/** Heartbeat interval in ms (default: 30000) */
|
||||
heartbeatInterval?: number;
|
||||
|
||||
/** Stale threshold in ms (default: 60000) */
|
||||
staleThreshold?: number;
|
||||
}
|
||||
|
||||
export class ServiceDiscovery {
|
||||
private config: ServiceDiscoveryConfig;
|
||||
private servicesDir: string;
|
||||
private serviceFilePath: string;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private isShuttingDown = false;
|
||||
|
||||
constructor(config: ServiceDiscoveryConfig) {
|
||||
this.config = {
|
||||
heartbeatInterval: 30000,
|
||||
staleThreshold: 60000,
|
||||
capabilities: [],
|
||||
endpoints: {},
|
||||
...config
|
||||
};
|
||||
|
||||
this.servicesDir = `${this.config.metaCorePath}/services`;
|
||||
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure services directory exists
|
||||
*/
|
||||
private async ensureServicesDir(): Promise<void> {
|
||||
await fs.mkdir(this.servicesDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build service info for this service
|
||||
*/
|
||||
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
|
||||
return {
|
||||
name: this.config.serviceName,
|
||||
version: this.config.version,
|
||||
api: this.config.apiUrl,
|
||||
status,
|
||||
pid: process.pid,
|
||||
hostname: hostname(),
|
||||
startedAt: new Date().toISOString(),
|
||||
lastHeartbeat: new Date().toISOString(),
|
||||
capabilities: this.config.capabilities || [],
|
||||
endpoints: this.config.endpoints || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this service
|
||||
*/
|
||||
async register(): Promise<void> {
|
||||
await this.ensureServicesDir();
|
||||
|
||||
const info = this.buildServiceInfo('starting');
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
|
||||
console.log(`[ServiceDiscovery] Registered ${this.config.serviceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service status
|
||||
*/
|
||||
async updateStatus(status: ServiceInfo['status']): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.status = status;
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ServiceDiscovery] Failed to update status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat (update lastHeartbeat timestamp)
|
||||
*/
|
||||
async heartbeat(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error) {
|
||||
// If file was deleted, re-register
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
await this.register();
|
||||
await this.updateStatus('running');
|
||||
} else {
|
||||
console.error('[ServiceDiscovery] Heartbeat failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat loop
|
||||
*/
|
||||
startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.isShuttingDown) return;
|
||||
await this.heartbeat();
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat loop
|
||||
*/
|
||||
stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister this service (on shutdown)
|
||||
*/
|
||||
async unregister(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
this.stopHeartbeat();
|
||||
|
||||
try {
|
||||
// Update status to stopped before removing
|
||||
await this.updateStatus('stopped');
|
||||
|
||||
// Optionally remove the file (or keep for debugging)
|
||||
// await fs.unlink(this.serviceFilePath);
|
||||
|
||||
console.log(`[ServiceDiscovery] Unregistered ${this.config.serviceName}`);
|
||||
} catch (error) {
|
||||
console.error('[ServiceDiscovery] Failed to unregister:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Discovery Methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Discover a service by name
|
||||
*/
|
||||
async discoverService(name: string): Promise<ServiceInfo | null> {
|
||||
const servicePath = join(this.servicesDir, `${name}.json`);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(servicePath, 'utf-8');
|
||||
const service = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
// Check if service is alive (heartbeat within threshold)
|
||||
const lastHeartbeat = new Date(service.lastHeartbeat).getTime();
|
||||
const isAlive = Date.now() - lastHeartbeat < this.config.staleThreshold!;
|
||||
|
||||
if (!isAlive) {
|
||||
console.warn(`[ServiceDiscovery] Service ${name} appears stale (last heartbeat: ${service.lastHeartbeat})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return service;
|
||||
} catch (error) {
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
return null; // Service not registered
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all registered services
|
||||
*/
|
||||
async discoverAllServices(): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const files = await fs.readdir(this.servicesDir);
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const name = file.replace('.json', '');
|
||||
const service = await this.discoverService(name);
|
||||
|
||||
if (service && service.status === 'running') {
|
||||
services.push(service);
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
} catch (error) {
|
||||
if ((error as any).code === 'ENOENT') {
|
||||
return []; // Services directory doesn't exist yet
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a service to become available
|
||||
*/
|
||||
async waitForService(
|
||||
name: string,
|
||||
timeoutMs: number = 30000,
|
||||
pollInterval: number = 1000
|
||||
): Promise<ServiceInfo> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const service = await this.discoverService(name);
|
||||
if (service && service.status === 'running') {
|
||||
return service;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
throw new Error(`Service ${name} not available after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is healthy
|
||||
*/
|
||||
async isServiceHealthy(name: string): Promise<boolean> {
|
||||
const service = await this.discoverService(name);
|
||||
if (!service) return false;
|
||||
|
||||
// Try to ping the service's health endpoint
|
||||
const healthEndpoint = service.endpoints.health || '/health';
|
||||
const url = `${service.api}${healthEndpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Full startup sequence
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.register();
|
||||
await this.updateStatus('running');
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full shutdown sequence
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.unregister();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
getServiceFilePath(): string {
|
||||
return this.serviceFilePath;
|
||||
}
|
||||
|
||||
getServicesDir(): string {
|
||||
return this.servicesDir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* KV Module - Unified key-value storage with leader election
|
||||
*
|
||||
* This module provides:
|
||||
* - IKVClient interface for KV storage abstraction
|
||||
* - RedisKVClient for Redis-based storage
|
||||
* - LeaderElection for distributed leader election via flock
|
||||
* - ServiceDiscovery for service registration and discovery
|
||||
* - KVManager for unified management of all components
|
||||
* - MetadataUtils for flattening/reconstructing nested metadata
|
||||
*/
|
||||
|
||||
export * from './IKVClient.js';
|
||||
export * from './RedisClient.js';
|
||||
export * from './LeaderElection.js';
|
||||
export * from './ServiceDiscovery.js';
|
||||
export * from './KVManager.js';
|
||||
export * from './MetadataUtils.js';
|
||||
@@ -0,0 +1,113 @@
|
||||
import {CID_ALGORITHM_NAMES} from "@metazla/meta-hash";
|
||||
import {VideoMeta, HashMeta, VersionMeta, FileNameMeta, FileStatMeta} from "@metazla/meta-interface";
|
||||
import {renamingRule} from "../config/RenamingRule.js";
|
||||
|
||||
type Local = VideoMeta & HashMeta & VersionMeta & FileNameMeta & FileStatMeta;
|
||||
|
||||
export interface DuplicateGroup {
|
||||
key: string;
|
||||
files: string[];
|
||||
virtualPath?: string;
|
||||
}
|
||||
|
||||
export interface DuplicateResult {
|
||||
hashDuplicates: DuplicateGroup[];
|
||||
titleDuplicates: DuplicateGroup[];
|
||||
}
|
||||
|
||||
export class DuplicateFinder {
|
||||
async findDuplicates(database: Map<string, Local>): Promise<DuplicateResult> {
|
||||
let [hashDuplicates, titleDuplicates] = await Promise.all([
|
||||
this.findDuplicatesGeneric(database, this.hashKeyExtractor, 'remove'),
|
||||
this.findDuplicatesGeneric(database, this.pathExtractor, 'version')
|
||||
]);
|
||||
|
||||
return {
|
||||
hashDuplicates,
|
||||
titleDuplicates
|
||||
};
|
||||
}
|
||||
|
||||
private hashKeyExtractor(metadata: Local): string | null {
|
||||
return metadata[CID_ALGORITHM_NAMES.sha256] ?? null;
|
||||
}
|
||||
|
||||
private pathExtractor(metadata: Local): string | null {
|
||||
//case insensitive
|
||||
try {
|
||||
return renamingRule(metadata, '')?.toLowerCase() ?? null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async findDuplicatesGeneric(
|
||||
database: Map<string, Local>,
|
||||
keyExtractor: (metadata: Local) => string | null,
|
||||
action?: 'remove' | 'version'
|
||||
): Promise<DuplicateGroup[]> {
|
||||
const duplicates = new Map<string, string[]>();
|
||||
const duplicateGroups: DuplicateGroup[] = [];
|
||||
|
||||
for (const [filePath, metadata] of database) {
|
||||
const key = keyExtractor(metadata);
|
||||
if (key) {
|
||||
const files = duplicates.get(key) ?? [];
|
||||
files.push(filePath);
|
||||
duplicates.set(key, files);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, files] of duplicates) {
|
||||
if (files.length > 1) {
|
||||
// Store duplicate group before modifying database
|
||||
const sortedFiles = [...files].sort();
|
||||
|
||||
// Generate virtualPath for both hash and title duplicates
|
||||
let virtualPath: string | undefined;
|
||||
if (action === 'version') {
|
||||
// For title duplicates, use the key (normalized title path)
|
||||
virtualPath = key;
|
||||
} else if (action === 'remove') {
|
||||
// For hash duplicates, use renamingRule on the first file's metadata
|
||||
const firstFile = sortedFiles[0];
|
||||
const metadata = database.get(firstFile);
|
||||
if (metadata) {
|
||||
try {
|
||||
virtualPath = renamingRule(metadata, '');
|
||||
} catch (e) {
|
||||
// If renamingRule fails, virtualPath remains undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duplicateGroups.push({
|
||||
key,
|
||||
files: sortedFiles,
|
||||
virtualPath
|
||||
});
|
||||
|
||||
//alphabetical order
|
||||
const remainingFiles = files.slice(1).sort();
|
||||
//rename all problematic entries V2, V3, V4, V5
|
||||
let i = 2;
|
||||
for (const file of remainingFiles) {
|
||||
if (action === 'remove') {
|
||||
database.delete(file);
|
||||
} else if (action === 'version') {
|
||||
let definition = database.get(file);
|
||||
definition.version = "V" + i;
|
||||
if (definition.fileType !== "subtitle") {//allow multiple version of subtitle in the same folder
|
||||
definition.extra = "true";//mark as extra so it don't pollute the main list
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicateGroups;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* HashProcessingStateManager
|
||||
*
|
||||
* Tracks the state of background hash computation operations:
|
||||
* - Pending: Files queued for hash computation
|
||||
* - Computing: Files currently having hashes computed
|
||||
* - Complete: Recently completed hash computations
|
||||
*/
|
||||
|
||||
export type HashStatus = 'pending' | 'computing' | 'complete' | 'error';
|
||||
|
||||
export interface HashProcessingState {
|
||||
filePath: string;
|
||||
tempId?: string;
|
||||
hashStatus: HashStatus;
|
||||
queuedAt?: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
processingTime?: number;
|
||||
hash?: string;
|
||||
error?: string;
|
||||
virtualPath?: string;
|
||||
}
|
||||
|
||||
export interface HashProcessingStatusSnapshot {
|
||||
pending: HashProcessingState[];
|
||||
computing: HashProcessingState[];
|
||||
complete: HashProcessingState[];
|
||||
totalPending: number;
|
||||
totalComputing: number;
|
||||
totalComplete: number;
|
||||
}
|
||||
|
||||
export class HashProcessingStateManager {
|
||||
private pending: Map<string, HashProcessingState> = new Map();
|
||||
private computing: Map<string, HashProcessingState> = new Map();
|
||||
private complete: HashProcessingState[] = [];
|
||||
private maxCompleteHistory = 100; // Keep last 100 completed hashes
|
||||
|
||||
/**
|
||||
* Add a file to the pending hash queue
|
||||
*/
|
||||
addPending(filePath: string, tempId?: string, virtualPath?: string): void {
|
||||
this.pending.set(filePath, {
|
||||
filePath,
|
||||
tempId,
|
||||
virtualPath,
|
||||
hashStatus: 'pending',
|
||||
queuedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file from pending to computing
|
||||
*/
|
||||
startComputing(filePath: string): void {
|
||||
const pendingState = this.pending.get(filePath);
|
||||
this.pending.delete(filePath);
|
||||
|
||||
this.computing.set(filePath, {
|
||||
filePath,
|
||||
tempId: pendingState?.tempId,
|
||||
virtualPath: pendingState?.virtualPath,
|
||||
hashStatus: 'computing',
|
||||
queuedAt: pendingState?.queuedAt,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file from computing to complete
|
||||
*/
|
||||
completeComputing(
|
||||
filePath: string,
|
||||
processingTime: number,
|
||||
hash?: string,
|
||||
error?: string,
|
||||
virtualPath?: string
|
||||
): void {
|
||||
const computingState = this.computing.get(filePath);
|
||||
this.computing.delete(filePath);
|
||||
|
||||
const completedState: HashProcessingState = {
|
||||
filePath,
|
||||
tempId: computingState?.tempId,
|
||||
virtualPath: virtualPath || computingState?.virtualPath,
|
||||
hashStatus: error ? 'error' : 'complete',
|
||||
queuedAt: computingState?.queuedAt,
|
||||
startedAt: computingState?.startedAt,
|
||||
completedAt: Date.now(),
|
||||
processingTime,
|
||||
hash,
|
||||
error
|
||||
};
|
||||
|
||||
// Add to complete list (most recent first)
|
||||
this.complete.unshift(completedState);
|
||||
|
||||
// Trim history if needed
|
||||
if (this.complete.length > this.maxCompleteHistory) {
|
||||
this.complete = this.complete.slice(0, this.maxCompleteHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from all states (e.g., when deleted)
|
||||
*/
|
||||
removeFile(filePath: string): void {
|
||||
this.pending.delete(filePath);
|
||||
this.computing.delete(filePath);
|
||||
this.complete = this.complete.filter(f => f.filePath !== filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current hash processing state
|
||||
*/
|
||||
getSnapshot(): HashProcessingStatusSnapshot {
|
||||
return {
|
||||
pending: Array.from(this.pending.values()),
|
||||
computing: Array.from(this.computing.values()),
|
||||
complete: this.complete.slice(0, 50), // Return only last 50 for API
|
||||
totalPending: this.pending.size,
|
||||
totalComputing: this.computing.size,
|
||||
totalComplete: this.complete.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all states (useful for testing or reset)
|
||||
*/
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
this.computing.clear();
|
||||
this.complete = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of files in all states
|
||||
*/
|
||||
getTotalFileCount(): number {
|
||||
return this.pending.size + this.computing.size + this.complete.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as path from 'path';
|
||||
import {
|
||||
HashMeta,
|
||||
} from "@metazla/meta-interface";
|
||||
|
||||
type Local = HashMeta;
|
||||
|
||||
export class MetaDataToFolderStruct {
|
||||
|
||||
public renamingRule: (metamesh: Local, filepath: string) => string | null;
|
||||
|
||||
constructor(renamingRule: (metamesh: Local, filepath: string) => string | null) {
|
||||
this.renamingRule = renamingRule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute virtual filesystem structure for FUSE/WebDAV
|
||||
* Generates organized paths based on metadata without creating physical files
|
||||
* @param data Map of file paths to metadata
|
||||
* @private
|
||||
*/
|
||||
private computeVirtualStructure(data: Map<string, Local>): Map<string, string> {
|
||||
const virtualMap = new Map<string, string>();
|
||||
|
||||
for (const [filepath, metamesh] of data) {
|
||||
// Compute the virtual path for the file
|
||||
let virtualPath: string;
|
||||
try {
|
||||
virtualPath = this.renamingRule(metamesh, filepath);
|
||||
}catch (e){
|
||||
console.warn(e.message || e);
|
||||
}
|
||||
if (!virtualPath) {
|
||||
continue;
|
||||
}
|
||||
virtualPath = this.sanitizePath(virtualPath);
|
||||
virtualMap.set(filepath, virtualPath);
|
||||
}
|
||||
|
||||
console.log(`Computed virtual structure for ${virtualMap.size} files`);
|
||||
return virtualMap;
|
||||
}
|
||||
|
||||
sanitizePath(newPath: string): string {
|
||||
// First, isolate any potential Windows drive letter at the start of the path
|
||||
const driveMatch = newPath.match(/^[a-zA-Z]:/);
|
||||
const drive = driveMatch ? driveMatch[0] : '';
|
||||
|
||||
// Define illegal characters for Windows and potentially problematic ones for Unix
|
||||
const illegalChars = /[<>:"|?*]/g; // Null character added for Unix-like systems
|
||||
|
||||
// Replace illegal characters with an underscore in the path, excluding the drive part
|
||||
const pathWithoutDrive = newPath.slice(drive.length);
|
||||
const sanitizedPath = pathWithoutDrive.replace(illegalChars, '');
|
||||
|
||||
// Combine the drive (if any) and the sanitized path
|
||||
const cleanPath = drive + sanitizedPath;
|
||||
|
||||
// Normalize the clean path to handle slashes appropriately
|
||||
return path.normalize(cleanPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate virtual filesystem structure for FUSE/WebDAV
|
||||
* Returns only the computed virtual paths without checking physical filesystem
|
||||
* @param data Map of file paths to metadata
|
||||
*/
|
||||
public generateVirtualStructure(data: Map<string, Local>): Map<string, string> {
|
||||
const start = performance.now();
|
||||
const computed = this.computeVirtualStructure(data);
|
||||
console.log(`Compute virtual structure took ${performance.now() - start}ms`);
|
||||
return computed;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import yaml from 'yaml';
|
||||
const { parse, stringify } = yaml;
|
||||
/**
|
||||
* File type:
|
||||
* filename.<ext>.meta
|
||||
* filename.meta (contains multiple metadata entry "mkv", "str" etc...)
|
||||
* folder.meta
|
||||
*/
|
||||
|
||||
export class MetaMeshFormat {
|
||||
async createOrUpdate<T>(metaDataFilePath: string, data: T): Promise<void> {
|
||||
if(!metaDataFilePath.endsWith('.meta')){
|
||||
throw new Error(`File path must end with '.meta'`);
|
||||
}
|
||||
try {
|
||||
// Check if the file exists
|
||||
const fileExists = await fs.access(metaDataFilePath).then(() => true).catch(() => false);
|
||||
|
||||
if (fileExists) {
|
||||
// If the file exists, update it
|
||||
await this.update(metaDataFilePath, data);
|
||||
} else {
|
||||
// If the file does not exist, create it
|
||||
await fs.writeFile(metaDataFilePath, stringify(data), 'utf8');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in createOrUpdate: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async read<T>(metaDataFilePath: string): Promise<T> {
|
||||
if(!metaDataFilePath.endsWith('.meta')){
|
||||
throw new Error(`File path must end with '.meta'`);
|
||||
}
|
||||
try {
|
||||
const fileExists = await fs.access(metaDataFilePath).then(() => true).catch(() => false);
|
||||
|
||||
if (!fileExists) {
|
||||
return null;
|
||||
}
|
||||
// Read the YAML file
|
||||
const fileContent = await fs.readFile(metaDataFilePath, 'utf8');
|
||||
|
||||
// Parse the YAML content to a JavaScript object
|
||||
return parse(fileContent) as T;
|
||||
} catch (error) {
|
||||
console.error(`Error in read: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update<T>(metaDataFilePath: string, data: T): Promise<void> {
|
||||
if(!metaDataFilePath.endsWith('.meta')){
|
||||
throw new Error(`File path must end with '.meta'`);
|
||||
}
|
||||
try {
|
||||
// Read the existing file content
|
||||
const existingData = await this.read<any>(metaDataFilePath) || {};
|
||||
|
||||
// Merge the existing data with the new data
|
||||
const updatedData = { ...existingData, ...data };
|
||||
|
||||
// Write the updated data back to the file
|
||||
await fs.writeFile(metaDataFilePath, stringify(updatedData), 'utf8');
|
||||
} catch (error) {
|
||||
console.error(`Error in update: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async write<T>(metaDataFilePath: string, data: T): Promise<void> {
|
||||
if(!metaDataFilePath.endsWith('.meta')){
|
||||
throw new Error(`File path must end with '.meta'`);
|
||||
}
|
||||
try {
|
||||
// Write the updated data back to the file
|
||||
await fs.writeFile(metaDataFilePath, stringify(data), {
|
||||
encoding:'utf8',
|
||||
flag:'w' // Write mode : Overwrite
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error in update: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize data to YAML string (for virtual .meta files)
|
||||
*/
|
||||
serialize<T>(data: T): string {
|
||||
return stringify(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import yaml from 'yaml';
|
||||
const { stringify: yamlStringify } = yaml;
|
||||
import { Builder } from 'xml2js';
|
||||
|
||||
/**
|
||||
* Supported metadata format types
|
||||
*/
|
||||
export enum MetadataFormatType {
|
||||
META = 'meta',
|
||||
JELLYFIN = 'jellyfin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for metadata format handlers
|
||||
* Note: metadata parameter uses `any` type because it combines multiple metadata interfaces
|
||||
* (HashMeta, JellyfinMeta, VideoMeta, etc.) and TypeScript doesn't have a single type for this union
|
||||
*/
|
||||
export interface IMetadataFormatHandler {
|
||||
/**
|
||||
* The file extension for this format (e.g., '.meta', '.nfo')
|
||||
*/
|
||||
readonly extension: string;
|
||||
|
||||
/**
|
||||
* The format type identifier
|
||||
*/
|
||||
readonly formatType: MetadataFormatType;
|
||||
|
||||
/**
|
||||
* Serialize metadata to string format
|
||||
* @param metadata The metadata to serialize (combination of various metadata interfaces)
|
||||
* @returns Serialized string content
|
||||
*/
|
||||
serialize(metadata: any): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for .meta YAML format
|
||||
*/
|
||||
export class MetaFormatHandler implements IMetadataFormatHandler {
|
||||
readonly extension = '.meta';
|
||||
readonly formatType = MetadataFormatType.META;
|
||||
|
||||
serialize(metadata: any): string {
|
||||
return yamlStringify(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Jellyfin .nfo XML format
|
||||
*/
|
||||
export class JellyfinFormatHandler implements IMetadataFormatHandler {
|
||||
readonly extension = '.nfo';
|
||||
readonly formatType = MetadataFormatType.JELLYFIN;
|
||||
|
||||
serialize(metadata: any): string {
|
||||
// Only include Jellyfin-compatible fields
|
||||
const jellyfinData = this.filterJellyfinFields(metadata);
|
||||
|
||||
// Determine root element based on video type
|
||||
const rootElementName = this.isMovieOrTvShow(jellyfinData) === "tvshow" ? 'episodedetails' : "movie";
|
||||
|
||||
// Convert to XML-friendly format
|
||||
const xmlData = this.convertToJsonXml(jellyfinData);
|
||||
|
||||
// Build XML
|
||||
const obj = {
|
||||
[rootElementName]: xmlData
|
||||
};
|
||||
|
||||
const builder = new Builder({
|
||||
xmldec: { version: '1.0', encoding: 'UTF-8', standalone: true }
|
||||
});
|
||||
|
||||
return builder.buildObject(obj);
|
||||
}
|
||||
|
||||
private filterJellyfinFields(metadata: any): any {
|
||||
const jellyfinFields: any = {};
|
||||
|
||||
// Video metadata
|
||||
if (metadata.videoType) jellyfinFields.videoType = metadata.videoType;
|
||||
if (metadata.season) jellyfinFields.season = metadata.season;
|
||||
if (metadata.episode) jellyfinFields.episode = metadata.episode;
|
||||
if (metadata.originalTitle) jellyfinFields.originalTitle = metadata.originalTitle;
|
||||
if (metadata.movieYear) jellyfinFields.year = metadata.movieYear;
|
||||
|
||||
// Titles (first title or original title)
|
||||
if (metadata.titles) {
|
||||
const titleEntries = Object.entries(metadata.titles);
|
||||
if (titleEntries.length > 0) {
|
||||
jellyfinFields.title = titleEntries[0][1];
|
||||
}
|
||||
}
|
||||
|
||||
// IDs
|
||||
if (metadata.imdbid) jellyfinFields.imdbid = metadata.imdbid;
|
||||
if (metadata.tmdbid) jellyfinFields.tmdbid = metadata.tmdbid;
|
||||
if (metadata.anidbid) jellyfinFields.anidbid = metadata.anidbid;
|
||||
|
||||
// Additional fields
|
||||
if (metadata.rating) jellyfinFields.rating = metadata.rating;
|
||||
if (metadata.plot) jellyfinFields.plot = metadata.plot;
|
||||
if (metadata.studio) jellyfinFields.studio = metadata.studio;
|
||||
if (metadata.releasedate) jellyfinFields.releasedate = metadata.releasedate;
|
||||
if (metadata.mpaa) jellyfinFields.mpaa = metadata.mpaa;
|
||||
|
||||
// Sets (genres, tags, languages)
|
||||
if (metadata.genres) jellyfinFields.genre = metadata.genres;
|
||||
if (metadata.tags) jellyfinFields.tag = metadata.tags;
|
||||
if (metadata.languages) jellyfinFields.language = metadata.languages;
|
||||
|
||||
// Art
|
||||
if (metadata.art) {
|
||||
if (metadata.art.poster) jellyfinFields.poster = metadata.art.poster;
|
||||
if (metadata.art.fanart) jellyfinFields.fanart = metadata.art.fanart;
|
||||
}
|
||||
|
||||
return jellyfinFields;
|
||||
}
|
||||
|
||||
private isMovieOrTvShow(metadata: any): "tvshow" | "movie" {
|
||||
if (metadata.videoType) {
|
||||
return metadata.videoType;
|
||||
} else if (metadata.episode) {
|
||||
return "tvshow";
|
||||
} else {
|
||||
return "movie";
|
||||
}
|
||||
}
|
||||
|
||||
private convertToJsonXml(input: any): any {
|
||||
const output: any = {};
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value instanceof Set) {
|
||||
// Convert Set to array of items for XML
|
||||
const array = Array.from(value as any);
|
||||
if (array.length > 0) {
|
||||
output[key] = array;
|
||||
}
|
||||
} else if (value instanceof Array) {
|
||||
output[key] = value;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// For nested objects, recursively convert
|
||||
output[key] = [this.convertToJsonXml(value)];
|
||||
} else {
|
||||
output[key] = [value];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for metadata format handlers
|
||||
*/
|
||||
export class MetadataFormatRegistry {
|
||||
private handlers: Map<MetadataFormatType, IMetadataFormatHandler> = new Map();
|
||||
|
||||
constructor() {
|
||||
// Register default handlers
|
||||
this.registerHandler(new MetaFormatHandler());
|
||||
this.registerHandler(new JellyfinFormatHandler());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new format handler
|
||||
*/
|
||||
registerHandler(handler: IMetadataFormatHandler): void {
|
||||
this.handlers.set(handler.formatType, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler by format type
|
||||
*/
|
||||
getHandler(formatType: MetadataFormatType): IMetadataFormatHandler | undefined {
|
||||
return this.handlers.get(formatType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handler by format name (case-insensitive)
|
||||
*/
|
||||
getHandlerByName(formatName: string): IMetadataFormatHandler | undefined {
|
||||
const normalizedName = formatName.toLowerCase();
|
||||
for (const [type, handler] of this.handlers.entries()) {
|
||||
if (type.toLowerCase() === normalizedName) {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered handlers
|
||||
*/
|
||||
getAllHandlers(): IMetadataFormatHandler[] {
|
||||
return Array.from(this.handlers.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get handlers for specified format names
|
||||
*/
|
||||
getHandlersForFormats(formatNames: string[]): IMetadataFormatHandler[] {
|
||||
const handlers: IMetadataFormatHandler[] = [];
|
||||
for (const name of formatNames) {
|
||||
const handler = this.getHandlerByName(name);
|
||||
if (handler) {
|
||||
handlers.push(handler);
|
||||
} else {
|
||||
console.warn(`Unknown metadata format: ${name}. Supported formats: ${Array.from(this.handlers.keys()).join(', ')}`);
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* ProcessingStateManager
|
||||
*
|
||||
* Tracks the state of file processing operations:
|
||||
* - Pending: Files queued for processing
|
||||
* - Processing: Files currently being processed
|
||||
* - Processed: Recently completed files
|
||||
*/
|
||||
|
||||
export interface FileProcessingState {
|
||||
filePath: string;
|
||||
state: 'pending' | 'processing' | 'processed';
|
||||
queuedAt?: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
processingTime?: number;
|
||||
error?: string;
|
||||
virtualPath?: string;
|
||||
}
|
||||
|
||||
export interface ProcessingStatusSnapshot {
|
||||
pending: FileProcessingState[];
|
||||
processing: FileProcessingState[];
|
||||
processed: FileProcessingState[];
|
||||
totalPending: number;
|
||||
totalProcessing: number;
|
||||
totalProcessed: number;
|
||||
}
|
||||
|
||||
export class ProcessingStateManager {
|
||||
private pending: Map<string, FileProcessingState> = new Map();
|
||||
private processing: Map<string, FileProcessingState> = new Map();
|
||||
private processed: FileProcessingState[] = [];
|
||||
private maxProcessedHistory = 100; // Keep last 100 processed files
|
||||
|
||||
/**
|
||||
* Add a file to the pending queue
|
||||
*/
|
||||
addPending(filePath: string): void {
|
||||
this.pending.set(filePath, {
|
||||
filePath,
|
||||
state: 'pending',
|
||||
queuedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file from pending to processing
|
||||
*/
|
||||
startProcessing(filePath: string): void {
|
||||
const pendingState = this.pending.get(filePath);
|
||||
this.pending.delete(filePath);
|
||||
|
||||
this.processing.set(filePath, {
|
||||
filePath,
|
||||
state: 'processing',
|
||||
queuedAt: pendingState?.queuedAt,
|
||||
startedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file from processing to processed
|
||||
*/
|
||||
completeProcessing(filePath: string, processingTime: number, error?: string, virtualPath?: string): void {
|
||||
const processingState = this.processing.get(filePath);
|
||||
this.processing.delete(filePath);
|
||||
|
||||
const completedState: FileProcessingState = {
|
||||
filePath,
|
||||
state: 'processed',
|
||||
queuedAt: processingState?.queuedAt,
|
||||
startedAt: processingState?.startedAt,
|
||||
completedAt: Date.now(),
|
||||
processingTime,
|
||||
error,
|
||||
virtualPath
|
||||
};
|
||||
|
||||
// Add to processed list (most recent first)
|
||||
this.processed.unshift(completedState);
|
||||
|
||||
// Trim history if needed
|
||||
if (this.processed.length > this.maxProcessedHistory) {
|
||||
this.processed = this.processed.slice(0, this.maxProcessedHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from all states (e.g., when deleted)
|
||||
*/
|
||||
removeFile(filePath: string): void {
|
||||
this.pending.delete(filePath);
|
||||
this.processing.delete(filePath);
|
||||
this.processed = this.processed.filter(f => f.filePath !== filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current processing state
|
||||
*/
|
||||
getSnapshot(): ProcessingStatusSnapshot {
|
||||
return {
|
||||
pending: Array.from(this.pending.values()),
|
||||
processing: Array.from(this.processing.values()),
|
||||
processed: this.processed.slice(0, 50), // Return only last 50 for API
|
||||
totalPending: this.pending.size,
|
||||
totalProcessing: this.processing.size,
|
||||
totalProcessed: this.processed.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all states (useful for testing or reset)
|
||||
*/
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
this.processing.clear();
|
||||
this.processed = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of files in all states
|
||||
*/
|
||||
getTotalFileCount(): number {
|
||||
return this.pending.size + this.processing.size + this.processed.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* UnifiedProcessingStateManager
|
||||
*
|
||||
* Tracks files through the complete processing pipeline with 4 states:
|
||||
* 1. Pending: Files discovered in filesystem (not yet processed)
|
||||
* 2. Light Processing: Quick metadata extraction + midhash256 computation (~20ms)
|
||||
* 3. Hash Processing: SHA-256 computation for compatibility (slow, background)
|
||||
* 4. Done: Fully processed files
|
||||
*
|
||||
* Key change from old system:
|
||||
* - midhash256 (fast) is computed in lightProcessing and becomes the permanent file ID
|
||||
* - SHA-256 (slow) is computed in hashProcessing as additional metadata
|
||||
* - Files become accessible in VFS immediately after lightProcessing completes
|
||||
*/
|
||||
|
||||
import { performanceMetrics } from '../metrics/PerformanceMetrics.js';
|
||||
|
||||
export type FileState = 'pending' | 'lightProcessing' | 'hashProcessing' | 'done';
|
||||
|
||||
export interface UnifiedFileState {
|
||||
filePath: string;
|
||||
state: FileState;
|
||||
hash?: string; // midhash256 (set after lightProcessing)
|
||||
virtualPath?: string;
|
||||
error?: string;
|
||||
|
||||
// Retry tracking
|
||||
retryCount?: number;
|
||||
lastRetryAt?: number;
|
||||
|
||||
// Timestamps
|
||||
discoveredAt?: number;
|
||||
lightProcessingStartedAt?: number;
|
||||
lightProcessingCompletedAt?: number;
|
||||
hashProcessingStartedAt?: number;
|
||||
hashProcessingCompletedAt?: number;
|
||||
|
||||
// Processing times
|
||||
lightProcessingTime?: number;
|
||||
hashProcessingTime?: number;
|
||||
totalProcessingTime?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedProcessingSnapshot {
|
||||
pending: UnifiedFileState[];
|
||||
lightProcessing: UnifiedFileState[];
|
||||
hashProcessing: UnifiedFileState[];
|
||||
done: UnifiedFileState[];
|
||||
|
||||
totalPending: number;
|
||||
totalLightProcessing: number;
|
||||
totalHashProcessing: number;
|
||||
totalDone: number;
|
||||
totalFilesInVFS: number; // Total number of files accessible in the Virtual File System
|
||||
|
||||
// Configuration
|
||||
fastQueueConcurrency?: number; // Number of fast queue workers
|
||||
backgroundQueueConcurrency?: number; // Number of background queue workers
|
||||
}
|
||||
|
||||
export class UnifiedProcessingStateManager {
|
||||
private pending: Map<string, UnifiedFileState> = new Map();
|
||||
private lightProcessing: Map<string, UnifiedFileState> = new Map();
|
||||
private hashProcessing: Map<string, UnifiedFileState> = new Map();
|
||||
private done: UnifiedFileState[] = [];
|
||||
private maxDoneHistory = 100; // Keep last 100 completed files
|
||||
private totalProcessedCount = 0; // Track total number of completed files (not limited)
|
||||
|
||||
/**
|
||||
* Add a file to pending state (file discovered)
|
||||
*/
|
||||
addPending(filePath: string): void {
|
||||
this.pending.set(filePath, {
|
||||
filePath,
|
||||
state: 'pending',
|
||||
discoveredAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move file from pending to light processing
|
||||
*/
|
||||
startLightProcessing(filePath: string): void {
|
||||
const pendingState = this.pending.get(filePath);
|
||||
const existingState = this.lightProcessing.get(filePath);
|
||||
this.pending.delete(filePath);
|
||||
|
||||
this.lightProcessing.set(filePath, {
|
||||
filePath,
|
||||
state: 'lightProcessing',
|
||||
discoveredAt: pendingState?.discoveredAt || existingState?.discoveredAt,
|
||||
lightProcessingStartedAt: Date.now(),
|
||||
retryCount: existingState?.retryCount || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete light processing and move to hash processing queue
|
||||
*/
|
||||
completeLightProcessing(
|
||||
filePath: string,
|
||||
hash: string, // midhash256
|
||||
virtualPath?: string,
|
||||
error?: string
|
||||
): void {
|
||||
const lightState = this.lightProcessing.get(filePath);
|
||||
this.lightProcessing.delete(filePath);
|
||||
|
||||
const now = Date.now();
|
||||
const lightProcessingTime = lightState?.lightProcessingStartedAt
|
||||
? now - lightState.lightProcessingStartedAt
|
||||
: undefined;
|
||||
|
||||
// Record light processing time metric
|
||||
if (lightProcessingTime !== undefined && !error) {
|
||||
performanceMetrics.recordMetadataProcessing(lightProcessingTime);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// If error, mark as done with error
|
||||
this.completeHashProcessing(filePath, hash, virtualPath, error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to hash processing queue
|
||||
this.hashProcessing.set(filePath, {
|
||||
filePath,
|
||||
state: 'hashProcessing',
|
||||
hash,
|
||||
virtualPath,
|
||||
discoveredAt: lightState?.discoveredAt,
|
||||
lightProcessingStartedAt: lightState?.lightProcessingStartedAt,
|
||||
lightProcessingCompletedAt: now,
|
||||
lightProcessingTime,
|
||||
retryCount: lightState?.retryCount || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start hash processing (SHA-256 computation)
|
||||
*/
|
||||
startHashProcessing(filePath: string): void {
|
||||
const hashState = this.hashProcessing.get(filePath);
|
||||
if (hashState) {
|
||||
hashState.hashProcessingStartedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete hash processing and move to done
|
||||
*/
|
||||
completeHashProcessing(
|
||||
filePath: string,
|
||||
hash?: string,
|
||||
virtualPath?: string,
|
||||
error?: string
|
||||
): void {
|
||||
const hashState = this.hashProcessing.get(filePath);
|
||||
this.hashProcessing.delete(filePath);
|
||||
|
||||
const now = Date.now();
|
||||
const hashProcessingTime = hashState?.hashProcessingStartedAt
|
||||
? now - hashState.hashProcessingStartedAt
|
||||
: undefined;
|
||||
|
||||
const totalProcessingTime = hashState?.discoveredAt
|
||||
? now - hashState.discoveredAt
|
||||
: undefined;
|
||||
|
||||
// Record hash processing time metric
|
||||
if (hashProcessingTime !== undefined && !error) {
|
||||
performanceMetrics.recordHashProcessing(hashProcessingTime);
|
||||
}
|
||||
|
||||
const doneState: UnifiedFileState = {
|
||||
filePath,
|
||||
state: 'done',
|
||||
hash: hash || hashState?.hash,
|
||||
virtualPath: virtualPath || hashState?.virtualPath,
|
||||
error,
|
||||
discoveredAt: hashState?.discoveredAt,
|
||||
lightProcessingStartedAt: hashState?.lightProcessingStartedAt,
|
||||
lightProcessingCompletedAt: hashState?.lightProcessingCompletedAt,
|
||||
hashProcessingStartedAt: hashState?.hashProcessingStartedAt,
|
||||
hashProcessingCompletedAt: now,
|
||||
lightProcessingTime: hashState?.lightProcessingTime,
|
||||
hashProcessingTime,
|
||||
totalProcessingTime
|
||||
};
|
||||
|
||||
// Add to done list (most recent first)
|
||||
this.done.unshift(doneState);
|
||||
|
||||
// Increment total processed counter (tracks all files, not limited)
|
||||
this.totalProcessedCount++;
|
||||
|
||||
// Trim history if needed
|
||||
if (this.done.length > this.maxDoneHistory) {
|
||||
this.done = this.done.slice(0, this.maxDoneHistory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from all states (e.g., when deleted)
|
||||
*/
|
||||
removeFile(filePath: string): void {
|
||||
this.pending.delete(filePath);
|
||||
this.lightProcessing.delete(filePath);
|
||||
this.hashProcessing.delete(filePath);
|
||||
this.done = this.done.filter(f => f.filePath !== filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a snapshot of the current processing state
|
||||
* @param vfsFileCount - Optional total number of files in the Virtual File System
|
||||
* @param fastQueueConcurrency - Number of fast queue workers
|
||||
* @param backgroundQueueConcurrency - Number of background queue workers
|
||||
*/
|
||||
getSnapshot(vfsFileCount: number = 0, fastQueueConcurrency?: number, backgroundQueueConcurrency?: number): UnifiedProcessingSnapshot {
|
||||
// Limit returned arrays to prevent slow API responses with large queues
|
||||
const MAX_ITEMS_PER_STATE = 100;
|
||||
|
||||
// Efficiently extract only the needed items without creating full arrays
|
||||
const getPendingSample = (): UnifiedFileState[] => {
|
||||
const result: UnifiedFileState[] = [];
|
||||
let count = 0;
|
||||
for (const item of this.pending.values()) {
|
||||
if (count >= MAX_ITEMS_PER_STATE) break;
|
||||
result.push(item);
|
||||
count++;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getLightProcessingSample = (): UnifiedFileState[] => {
|
||||
const result: UnifiedFileState[] = [];
|
||||
let count = 0;
|
||||
for (const item of this.lightProcessing.values()) {
|
||||
if (count >= MAX_ITEMS_PER_STATE) break;
|
||||
result.push(item);
|
||||
count++;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getHashProcessingSample = (): UnifiedFileState[] => {
|
||||
const result: UnifiedFileState[] = [];
|
||||
let count = 0;
|
||||
for (const item of this.hashProcessing.values()) {
|
||||
if (count >= MAX_ITEMS_PER_STATE) break;
|
||||
result.push(item);
|
||||
count++;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
pending: getPendingSample(),
|
||||
lightProcessing: getLightProcessingSample(),
|
||||
hashProcessing: getHashProcessingSample(),
|
||||
done: this.done.slice(0, 50), // Return only last 50 for API
|
||||
|
||||
totalPending: this.pending.size,
|
||||
totalLightProcessing: this.lightProcessing.size,
|
||||
totalHashProcessing: this.hashProcessing.size,
|
||||
totalDone: this.totalProcessedCount, // Total count of all processed files (not limited to history)
|
||||
totalFilesInVFS: vfsFileCount, // Total number of files accessible in the Virtual File System
|
||||
|
||||
fastQueueConcurrency,
|
||||
backgroundQueueConcurrency
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the state of a file in any processing queue
|
||||
*/
|
||||
getFileState(filePath: string): UnifiedFileState | undefined {
|
||||
return this.pending.get(filePath) ||
|
||||
this.lightProcessing.get(filePath) ||
|
||||
this.hashProcessing.get(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all states (useful for testing or reset)
|
||||
*/
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
this.lightProcessing.clear();
|
||||
this.hashProcessing.clear();
|
||||
this.done = [];
|
||||
this.totalProcessedCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of files in all states
|
||||
*/
|
||||
getTotalFileCount(): number {
|
||||
return this.pending.size + this.lightProcessing.size + this.hashProcessing.size + this.done.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry processing for a file (move back to pending with incremented retry count)
|
||||
*/
|
||||
retryProcessing(filePath: string): boolean {
|
||||
const lightState = this.lightProcessing.get(filePath);
|
||||
const hashState = this.hashProcessing.get(filePath);
|
||||
const state = lightState || hashState;
|
||||
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const retryCount = (state.retryCount || 0) + 1;
|
||||
|
||||
this.lightProcessing.delete(filePath);
|
||||
this.hashProcessing.delete(filePath);
|
||||
|
||||
this.pending.set(filePath, {
|
||||
filePath,
|
||||
state: 'pending',
|
||||
discoveredAt: state.discoveredAt,
|
||||
retryCount,
|
||||
lastRetryAt: Date.now()
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the retry count for a file
|
||||
*/
|
||||
getRetryCount(filePath: string): number {
|
||||
const lightState = this.lightProcessing.get(filePath);
|
||||
const hashState = this.hashProcessing.get(filePath);
|
||||
const pendingState = this.pending.get(filePath);
|
||||
return lightState?.retryCount || hashState?.retryCount || pendingState?.retryCount || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files currently in light processing
|
||||
* Returns the internal Map for direct iteration
|
||||
*/
|
||||
getLightProcessingFiles(): Map<string, UnifiedFileState> {
|
||||
return this.lightProcessing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all files currently in hash processing
|
||||
* Returns the internal Map for direct iteration
|
||||
*/
|
||||
getHashProcessingFiles(): Map<string, UnifiedFileState> {
|
||||
return this.hashProcessing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import {promises as fs} from 'fs';
|
||||
import {dirname, join, parse} from 'path';
|
||||
import {config} from "../config/EnvConfig.js";
|
||||
import {FileProcessorInterface} from "@metazla/meta-hash";
|
||||
import {MetaDataToFolderStruct} from "./MetaDataToFolderStruct.js";
|
||||
import {DuplicateFinder, DuplicateResult} from "./DuplicateFinder.js";
|
||||
import {FileType} from "@metazla/filename-tools";
|
||||
import {existsAsync} from "@metazla/meta-hash";
|
||||
import {FileAnalyzerInterface} from "./fileProcessor/FileAnalyzerInterface.js";
|
||||
import {FileProcessorPiscina} from "./fileProcessor/FileProcessorPiscina.js";
|
||||
import {renamingRule} from "../config/RenamingRule.js";
|
||||
import {MetaMeshFormat} from "./MetaMeshFormat.js";
|
||||
import {JellyfinAPI} from "../jellyfin/JellyfinAPI.js";
|
||||
import {VirtualFileSystem} from "../api/VirtualFileSystem.js";
|
||||
import {performanceMetrics} from "../metrics/PerformanceMetrics.js";
|
||||
import {UnifiedProcessingStateManager} from "./UnifiedProcessingStateManager.js";
|
||||
import type {IKVClient} from "../kv/IKVClient.js";
|
||||
import PQueue from "p-queue";
|
||||
import os from 'os';
|
||||
import { TaskScheduler, createTaskScheduler, MetadataNodeKVStore } from "../plugin-engine/index.js";
|
||||
import { initializePluginManager } from "./fileProcessor/FileProcessorPiscina.js";
|
||||
import { from } from "@metazla/meta-interface";
|
||||
import { stat } from "fs/promises";
|
||||
import type { StreamingPipeline } from "./pipeline/StreamingPipeline.js";
|
||||
|
||||
export class WatchedFileProcessor implements FileProcessorInterface {
|
||||
jellyfinAPI = new JellyfinAPI();
|
||||
metaMeshFormat = new MetaMeshFormat();
|
||||
metaDataToFolderStruct = new MetaDataToFolderStruct(renamingRule);
|
||||
virtualFileSystem: VirtualFileSystem;
|
||||
unifiedStateManager = new UnifiedProcessingStateManager();
|
||||
//fileProcessor:FileAnalyzerInterface = new FileProcessor();//TODO only use for debug
|
||||
fileProcessor:FileAnalyzerInterface = new FileProcessorPiscina();
|
||||
duplicateFinder = new DuplicateFinder();
|
||||
duplicateResult: DuplicateResult | null = null;
|
||||
fileType = new FileType();
|
||||
supported = ['video','subtitle','torrent'];
|
||||
|
||||
// Task-based plugin scheduler (replaces two-queue system)
|
||||
private taskScheduler: TaskScheduler | null = null;
|
||||
|
||||
// Pre-processing queue for midhash256 computation (before plugins)
|
||||
private preProcessQueue: PQueue;
|
||||
|
||||
// Reference to StreamingPipeline for queue status
|
||||
private pipeline: StreamingPipeline | null = null;
|
||||
|
||||
constructor() {
|
||||
// Always initialize VirtualFileSystem for FUSE API
|
||||
this.virtualFileSystem = new VirtualFileSystem({
|
||||
fileMode: 0o644,
|
||||
directoryMode: config.CHMOD_FOR_NEW_FOLDER || 0o755
|
||||
});
|
||||
|
||||
// Pass the unified state manager to the fileProcessor
|
||||
if (this.fileProcessor instanceof FileProcessorPiscina) {
|
||||
this.fileProcessor.setUnifiedStateManager(this.unifiedStateManager);
|
||||
}
|
||||
|
||||
// Pre-processing queue for midhash256 computation
|
||||
const cpuCount = os.cpus().length;
|
||||
const preProcessConcurrency = config.MAX_WORKER_THREADS || cpuCount * 2;
|
||||
this.preProcessQueue = new PQueue({concurrency: preProcessConcurrency, autoStart: true});
|
||||
|
||||
console.log(`WatchedFileProcessor: TaskScheduler-based system initialized`);
|
||||
console.log(` - Pre-process queue: ${preProcessConcurrency} workers (midhash256)`);
|
||||
console.log(` - TaskScheduler handles plugin execution (fast/background queues)`);
|
||||
}
|
||||
|
||||
getVirtualFileSystem(): VirtualFileSystem {
|
||||
return this.virtualFileSystem;
|
||||
}
|
||||
|
||||
getUnifiedStateManager(): UnifiedProcessingStateManager {
|
||||
return this.unifiedStateManager;
|
||||
}
|
||||
|
||||
getDuplicateResult(): DuplicateResult | null {
|
||||
return this.duplicateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KV client
|
||||
*/
|
||||
getKVClient(): IKVClient | null {
|
||||
if (this.fileProcessor instanceof FileProcessorPiscina) {
|
||||
return this.fileProcessor.getKVClient();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the KV client
|
||||
* This is called by KVManager after successful connection
|
||||
*/
|
||||
setKVClient(client: IKVClient): void {
|
||||
if (this.fileProcessor instanceof FileProcessorPiscina) {
|
||||
this.fileProcessor.setKVClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the StreamingPipeline reference for queue status
|
||||
* This is called by index.ts after creating the pipeline
|
||||
*/
|
||||
setPipeline(pipeline: StreamingPipeline): void {
|
||||
this.pipeline = pipeline;
|
||||
console.log('[WatchedFileProcessor] Pipeline connected for queue status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current queue status for monitoring UI
|
||||
* Uses StreamingPipeline queues which do the actual processing:
|
||||
* Pipeline flow: validation → lightProcessing → hashProcessing
|
||||
*
|
||||
* Mapping to UI:
|
||||
* - Pre-Process = validation (quick extension checks, high concurrency)
|
||||
* - Fast Queue = lightProcessing (midhash256 + metadata extraction)
|
||||
* - Background Queue = hashProcessing (SHA-256 full file hash)
|
||||
*/
|
||||
getQueueStatus() {
|
||||
// Use StreamingPipeline stats if available (this is the actual processing engine)
|
||||
if (this.pipeline) {
|
||||
const pipelineStats = this.pipeline.getStats();
|
||||
const queues = pipelineStats.queues;
|
||||
|
||||
// Map StreamingPipeline queues to the 3-queue architecture UI expects
|
||||
// PQueue: .pending = currently running, .size = waiting in queue
|
||||
// API expects: .pending = running, .size = waiting
|
||||
const result = {
|
||||
preProcessQueue: {
|
||||
// validation = pre-process (quick extension checks)
|
||||
size: queues.validation.size, // waiting in queue
|
||||
pending: queues.validation.pending, // running jobs
|
||||
isPaused: false
|
||||
},
|
||||
fastQueue: {
|
||||
// fastQueue = fast queue (midhash256 + metadata)
|
||||
running: queues.fastQueue.pending, // running jobs
|
||||
pending: queues.fastQueue.size, // waiting in queue
|
||||
size: queues.fastQueue.size,
|
||||
isPaused: false
|
||||
},
|
||||
backgroundQueue: {
|
||||
// backgroundQueue = background (SHA-256 computation)
|
||||
running: queues.backgroundQueue.pending, // running jobs
|
||||
pending: queues.backgroundQueue.size, // waiting in queue
|
||||
size: queues.backgroundQueue.size,
|
||||
isPaused: false
|
||||
}
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: use TaskScheduler if available (for future plugin-based architecture)
|
||||
if (this.taskScheduler) {
|
||||
const status = this.taskScheduler.getQueueStatus();
|
||||
return {
|
||||
preProcessQueue: {
|
||||
size: this.preProcessQueue.pending,
|
||||
pending: this.preProcessQueue.size,
|
||||
isPaused: this.preProcessQueue.isPaused
|
||||
},
|
||||
fastQueue: {
|
||||
size: status.fast.ready,
|
||||
pending: status.fast.pending,
|
||||
running: status.fast.running,
|
||||
isPaused: status.fast.isPaused
|
||||
},
|
||||
backgroundQueue: {
|
||||
size: status.background.ready,
|
||||
pending: status.background.pending,
|
||||
running: status.background.running,
|
||||
isPaused: status.background.isPaused
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: empty status
|
||||
return {
|
||||
preProcessQueue: { size: 0, pending: 0, isPaused: false },
|
||||
fastQueue: { size: 0, pending: 0, running: 0, isPaused: false },
|
||||
backgroundQueue: { size: 0, pending: 0, running: 0, isPaused: false }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TaskScheduler instance
|
||||
*/
|
||||
getTaskScheduler(): TaskScheduler | null {
|
||||
return this.taskScheduler;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.fileProcessor.init();
|
||||
|
||||
// Initialize TaskScheduler with PluginManager using config values
|
||||
const pluginManager = await initializePluginManager();
|
||||
this.taskScheduler = createTaskScheduler(pluginManager, performanceMetrics, {
|
||||
fastQueueConcurrency: config.FAST_QUEUE_CONCURRENCY,
|
||||
backgroundQueueConcurrency: config.BACKGROUND_QUEUE_CONCURRENCY,
|
||||
fastThresholdMs: config.FAST_THRESHOLD_MS,
|
||||
minSamplesForMeasurement: config.MIN_SAMPLES_FOR_MEASUREMENT,
|
||||
});
|
||||
|
||||
// Set up event listeners for VFS updates
|
||||
this.setupTaskSchedulerEvents();
|
||||
|
||||
console.log(`[WatchedFileProcessor] TaskScheduler initialized with:`);
|
||||
console.log(` - Fast queue concurrency: ${config.FAST_QUEUE_CONCURRENCY}`);
|
||||
console.log(` - Background queue concurrency: ${config.BACKGROUND_QUEUE_CONCURRENCY}`);
|
||||
console.log(` - Fast threshold: ${config.FAST_THRESHOLD_MS}ms`);
|
||||
console.log(` - Min samples for measurement: ${config.MIN_SAMPLES_FOR_MEASUREMENT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up TaskScheduler event listeners for VFS integration and Redis pub/sub
|
||||
*/
|
||||
private setupTaskSchedulerEvents(): void {
|
||||
if (!this.taskScheduler) return;
|
||||
|
||||
// When a task completes, check if we should update VFS
|
||||
this.taskScheduler.on('task:complete', (event: any) => {
|
||||
const { task } = event;
|
||||
|
||||
// Update VFS when filename-parser completes (has basic metadata)
|
||||
if (task.pluginId === 'filename-parser') {
|
||||
this.updateVFSForFile(task.fileHash, task.filePath);
|
||||
}
|
||||
|
||||
// Publish plugin completion event to Redis
|
||||
this.publishPluginComplete(task.fileHash, task.pluginId, task.filePath);
|
||||
});
|
||||
|
||||
// When all tasks for a file complete
|
||||
this.taskScheduler.on('file:complete', (event: any) => {
|
||||
const { fileHash, filePath } = event;
|
||||
console.log(`[TaskScheduler] All plugins complete for ${filePath}`);
|
||||
|
||||
// Final VFS update with complete metadata
|
||||
this.updateVFSForFile(fileHash, filePath);
|
||||
|
||||
// Update unified state manager
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeHashProcessing(filePath, fileHash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish plugin completion event to Redis
|
||||
* Channel: meta-sort:plugin:complete
|
||||
*/
|
||||
private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> {
|
||||
const kvClient = this.getKVClient();
|
||||
if (!kvClient || typeof (kvClient as any).publish !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.stringify({
|
||||
fileHash,
|
||||
pluginId,
|
||||
filePath,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
await (kvClient as any).publish('meta-sort:plugin:complete', message);
|
||||
} catch (error) {
|
||||
// Silent fail - pub/sub is optional
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update VFS for a file with current metadata
|
||||
*/
|
||||
private updateVFSForFile(fileHash: string, filePath: string): void {
|
||||
const metadata = this.fileProcessor.getDatabase().get(filePath);
|
||||
if (metadata && metadata.cid_midhash256) {
|
||||
try {
|
||||
const virtualPath = this.metaDataToFolderStruct.renamingRule(metadata as any, '');
|
||||
this.virtualFileSystem.addFile(virtualPath, filePath, metadata);
|
||||
} catch (e) {
|
||||
// Renaming rule may fail for incomplete metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markPending(filePath: string): void {
|
||||
// STATE 1: Mark as pending when file is discovered (before queue)
|
||||
this.unifiedStateManager.addPending(filePath);
|
||||
}
|
||||
|
||||
async queueFile(filePath: string): Promise<void> {
|
||||
// TaskScheduler-based processing
|
||||
// Phase 1: Pre-process (midhash256 computation)
|
||||
// Phase 2: Plugin execution via TaskScheduler (fast/background queues)
|
||||
|
||||
// Debug: log queue state before adding
|
||||
console.log(`[DEBUG queueFile] Before add: size=${this.preProcessQueue.size}, pending=${this.preProcessQueue.pending}`);
|
||||
|
||||
await this.preProcessQueue.add(async () => {
|
||||
console.log(`[DEBUG queueFile] Task started: size=${this.preProcessQueue.size}, pending=${this.preProcessQueue.pending}`);
|
||||
try {
|
||||
// STATE: Start LIGHT processing
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.startLightProcessing(filePath);
|
||||
}
|
||||
|
||||
// STEP 1: Compute midhash256 (file identifier)
|
||||
const fileStats = await stat(filePath);
|
||||
const { computeMidHash256 } = await import('@metazla/meta-hash');
|
||||
|
||||
// Check cache first
|
||||
const indexLine = (this.fileProcessor as any).indexManager?.getCidForFile(
|
||||
filePath,
|
||||
fileStats.size,
|
||||
fileStats.mtime.toISOString()
|
||||
);
|
||||
|
||||
let midHash256: string;
|
||||
if (indexLine?.cid_midhash256) {
|
||||
midHash256 = indexLine.cid_midhash256;
|
||||
performanceMetrics.recordCacheHit('midhash256');
|
||||
} else {
|
||||
const computeStart = performance.now();
|
||||
midHash256 = await computeMidHash256(filePath);
|
||||
performanceMetrics.recordHashComputation('cid_midhash256', Math.ceil(performance.now() - computeStart));
|
||||
performanceMetrics.recordCacheMiss('midhash256');
|
||||
}
|
||||
|
||||
// STEP 2: Initialize metadata with midhash256
|
||||
const metadata: any = {
|
||||
cid_midhash256: midHash256,
|
||||
filePath,
|
||||
processingStatus: 'processing'
|
||||
};
|
||||
|
||||
// Store in local database
|
||||
this.fileProcessor.getDatabase().set(filePath, metadata);
|
||||
|
||||
// STEP 3: Create KV store for plugins
|
||||
const metaDataNode = from(metadata);
|
||||
const kv = new MetadataNodeKVStore(metaDataNode);
|
||||
|
||||
// STEP 4: Create and enqueue plugin tasks
|
||||
if (this.taskScheduler) {
|
||||
const tasks = this.taskScheduler.createTasksForFile(filePath, midHash256, kv);
|
||||
this.taskScheduler.enqueueTasks(tasks);
|
||||
}
|
||||
|
||||
// STATE: Light processing setup complete
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
undefined // Virtual path will be computed by plugins
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed pre-processing for ${filePath}:`, error);
|
||||
|
||||
// Mark processing as failed
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
'',
|
||||
undefined,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async processFile(current: number, queueSize: number, filePath: string): Promise<void> {
|
||||
// This method is now a wrapper for queueFile for backwards compatibility
|
||||
// In the new single-phase model, queueFile handles everything
|
||||
await this.queueFile(filePath);
|
||||
}
|
||||
|
||||
async finalize(): Promise<void> {
|
||||
try {
|
||||
console.log(`Virtual filesystem update start`);
|
||||
const start = performance.now();
|
||||
if (this.fileProcessor.getDatabase().size === 0) {
|
||||
console.log(`No files to process`);
|
||||
return;
|
||||
}
|
||||
let startTime = performance.now();
|
||||
const newDuplicateResult = await this.duplicateFinder.findDuplicates(this.fileProcessor.getDatabase());//find duplicates and removes them to avoid conflict
|
||||
const duplicateTime = Math.ceil(performance.now() - startTime);
|
||||
const totalDuplicates = newDuplicateResult.hashDuplicates.length + newDuplicateResult.titleDuplicates.length;
|
||||
console.log(`Duplicate find took ${duplicateTime}ms - found ${newDuplicateResult.hashDuplicates.length} hash groups, ${newDuplicateResult.titleDuplicates.length} title groups`);
|
||||
|
||||
// Only update duplicateResult if we found new duplicates, or if it's the first run
|
||||
if (totalDuplicates > 0 || this.duplicateResult === null) {
|
||||
this.duplicateResult = newDuplicateResult;
|
||||
}
|
||||
|
||||
performanceMetrics.recordDuplicateDetection(duplicateTime, totalDuplicates);
|
||||
|
||||
startTime = performance.now();
|
||||
console.log(`Generating virtual structure for ${this.fileProcessor.getDatabase().size} files`);
|
||||
const virtualStructure = this.metaDataToFolderStruct.generateVirtualStructure(this.fileProcessor.getDatabase());
|
||||
const virtualStructureTime = Math.ceil(performance.now() - startTime);
|
||||
console.log(`Virtual structure generation took ${virtualStructureTime}ms`);
|
||||
performanceMetrics.recordVirtualStructureGeneration(virtualStructureTime);
|
||||
|
||||
// Build VirtualFileSystem for FUSE/WebDAV API
|
||||
startTime = performance.now();
|
||||
this.virtualFileSystem.buildFromComputed(virtualStructure, this.fileProcessor.getDatabase());
|
||||
const vfsBuildTime = Math.ceil(performance.now() - startTime);
|
||||
console.log(`VirtualFileSystem build took ${vfsBuildTime}ms`);
|
||||
performanceMetrics.recordVFSUpdate(vfsBuildTime);
|
||||
const stats = this.virtualFileSystem.getStats();
|
||||
console.log(`VirtualFileSystem: ${stats.fileCount} files (${stats.metaFileCount} .meta), ${stats.directoryCount} directories, ${(stats.totalSize / 1024 / 1024).toFixed(2)} MB`);
|
||||
|
||||
if(this.jellyfinAPI.jellyApiAvailable()) {
|
||||
await this.jellyfinAPI.refreshLibrary();
|
||||
console.log(`Jellyfin refreshed`);
|
||||
}
|
||||
|
||||
console.log(`Virtual filesystem update took ${Math.ceil(performance.now() - start)}ms`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async canProcessFile(filePath: string) {
|
||||
const fileTypeResult = await this.fileType.getFileType(filePath);
|
||||
//check if filetype is one of the suported ones
|
||||
return this.supported.includes(fileTypeResult);
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string) {
|
||||
this.fileProcessor.deleteFile(filePath);
|
||||
this.unifiedStateManager.removeFile(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale entries from in-memory database for scanned directories
|
||||
*
|
||||
* This is called after discovery scans a folder. It removes files from
|
||||
* in-memory DB that belong to the scanned folder but were not seen during
|
||||
* the scan (meaning they no longer exist on disk).
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - Discovery is the source of truth for file existence
|
||||
* - Empty/missing folders → remove all child paths from memory
|
||||
* - KV keeps everything (distributed metadata archive)
|
||||
*
|
||||
* @param scannedFolders - Array of folder paths that were completely scanned
|
||||
* @param discoveredFiles - Set of file paths that were found during scan
|
||||
*/
|
||||
async cleanupStaleEntries(scannedFolders: string[], discoveredFiles: Set<string>): Promise<void> {
|
||||
console.log(`[Cleanup] Starting cleanup for ${scannedFolders.length} scanned folders...`);
|
||||
const startTime = performance.now();
|
||||
|
||||
let removedCount = 0;
|
||||
const db = this.fileProcessor.getDatabase();
|
||||
const removedHashIds: string[] = []; // Collect hashIds for Stremio notification
|
||||
|
||||
for (const folder of scannedFolders) {
|
||||
const normalizedFolder = folder.endsWith('/') ? folder : folder + '/';
|
||||
|
||||
// Find all in-memory files that belong to this folder
|
||||
const filesToCheck: string[] = [];
|
||||
for (const [filePath] of db.entries()) {
|
||||
if (filePath.startsWith(normalizedFolder) || filePath === folder) {
|
||||
filesToCheck.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove files that weren't discovered (no longer exist)
|
||||
for (const filePath of filesToCheck) {
|
||||
if (!discoveredFiles.has(filePath)) {
|
||||
// File is in memory but wasn't seen during scan → removed/offline
|
||||
const metadata = db.get(filePath);
|
||||
|
||||
// Collect hashId before deletion (for Stremio notification)
|
||||
if (metadata && metadata.cid_midhash256) {
|
||||
removedHashIds.push(metadata.cid_midhash256);
|
||||
}
|
||||
|
||||
db.delete(filePath);
|
||||
this.unifiedStateManager.removeFile(filePath);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Math.ceil(performance.now() - startTime);
|
||||
console.log(`[Cleanup] Removed ${removedCount} stale entries in ${elapsed}ms`);
|
||||
|
||||
// If we removed files, rebuild VFS to update structure
|
||||
if (removedCount > 0) {
|
||||
console.log('[Cleanup] Rebuilding VFS after cleanup...');
|
||||
await this.finalize();
|
||||
|
||||
// Notify Stremio addon to remove videos (incremental updates like FUSE)
|
||||
if (removedHashIds.length > 0) {
|
||||
console.log(`[Cleanup] Notifying Stremio to remove ${removedHashIds.length} videos...`);
|
||||
for (const hashId of removedHashIds) {
|
||||
this.notifyStremioRemove(hashId).catch(() => {
|
||||
// Silent fail - Stremio addon may not be running
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild VirtualFileSystem from KV data
|
||||
* This is called on startup to restore the VFS from persistent storage
|
||||
*
|
||||
* Fast-load strategy: Load all metadata from KV WITHOUT disk validation.
|
||||
* Discovery will later validate file existence and clean up stale entries.
|
||||
*
|
||||
* Design Philosophy:
|
||||
* - KV = distributed metadata archive (permanent record)
|
||||
* - In-memory DB = current VFS state (only present files)
|
||||
* - Discovery = source of truth (validates and cleans)
|
||||
*/
|
||||
async rebuildVFSFromKV(): Promise<void> {
|
||||
const kvClient = this.getKVClient();
|
||||
if (!kvClient) {
|
||||
console.warn('[VFS Rebuild] KV client not available, skipping VFS rebuild');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[VFS Rebuild] Starting fast-load from KV...');
|
||||
const startTime = performance.now();
|
||||
|
||||
// Get all hash IDs from KV
|
||||
const hashIds = await kvClient.getAllHashIds();
|
||||
console.log(`[VFS Rebuild] Found ${hashIds.length} files in KV`);
|
||||
|
||||
if (hashIds.length === 0) {
|
||||
console.log('[VFS Rebuild] No files in KV, VFS remains empty');
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch fetch all metadata in parallel (no disk validation)
|
||||
console.log('[VFS Rebuild] Fetching metadata in parallel...');
|
||||
const metadataList = await Promise.all(
|
||||
hashIds.map(async (hashId) => {
|
||||
try {
|
||||
return await kvClient.getMetadataFlat(hashId);
|
||||
} catch (error) {
|
||||
console.error(`[VFS Rebuild] Failed to load metadata for ${hashId}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Populate in-memory database without disk validation
|
||||
let loadedCount = 0;
|
||||
for (const metadata of metadataList) {
|
||||
if (metadata && metadata.filePath) {
|
||||
// Mark as unverified - discovery will validate later
|
||||
(metadata as any)._lastVerified = 0;
|
||||
this.fileProcessor.getDatabase().set(metadata.filePath, metadata);
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[VFS Rebuild] Loaded ${loadedCount}/${hashIds.length} files into memory (unverified)`);
|
||||
|
||||
// Build VFS immediately - may contain stale entries until discovery validates
|
||||
if (this.fileProcessor.getDatabase().size > 0) {
|
||||
await this.finalize();
|
||||
console.log(`[VFS Rebuild] VFS rebuilt in ${Math.ceil(performance.now() - startTime)}ms`);
|
||||
console.log('[VFS Rebuild] Discovery will validate file existence in background');
|
||||
} else {
|
||||
console.log('[VFS Rebuild] No files loaded, VFS remains empty');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VFS Rebuild] Error rebuilding VFS from KV:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify Stremio addon to remove video (incremental update like FUSE)
|
||||
*/
|
||||
private async notifyStremioRemove(hashId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('http://localhost:7000/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hashId }),
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Silent fail - Stremio may not be running
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Silent fail - Stremio addon may not be running or network issue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown: clean up resources
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
console.log('[WatchedFileProcessor] Shutting down...');
|
||||
|
||||
// Clear pre-process queue
|
||||
this.preProcessQueue.clear();
|
||||
|
||||
// Shutdown TaskScheduler
|
||||
if (this.taskScheduler) {
|
||||
await this.taskScheduler.shutdown();
|
||||
}
|
||||
|
||||
console.log('[WatchedFileProcessor] Shutdown complete');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {HashMeta} from "@metazla/meta-interface";
|
||||
import {FileMetadata} from "../../types/FileMetadata.js";
|
||||
|
||||
export interface FileAnalyzerInterface {
|
||||
/**
|
||||
* Full file processing - computes midhash256 and extracts all metadata
|
||||
* Since midhash256 is instant (< 1s), files appear in VFS immediately with permanent ID
|
||||
*/
|
||||
processFile(filePath: string, current: number, queueSize: number):Promise<void>;
|
||||
|
||||
/**
|
||||
* Light processing phase - fast metadata extraction + midhash256
|
||||
* File becomes accessible in VFS with permanent ID after this phase
|
||||
*/
|
||||
processLightPhase(filePath: string, current: number, queueSize: number):Promise<void>;
|
||||
|
||||
/**
|
||||
* Hash processing phase - background hash computation
|
||||
* Computes full hashes (SHA-256, SHA-1, MD5, CRC32) and updates KV
|
||||
*/
|
||||
processHashPhase(filePath: string, current: number, queueSize: number):Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the metadata database
|
||||
*/
|
||||
getDatabase<T extends FileMetadata>(): Map<string, T>;
|
||||
|
||||
/**
|
||||
* Delete a file from the database
|
||||
*/
|
||||
deleteFile(filePath: string): Promise<void> | void;
|
||||
|
||||
/**
|
||||
* Initialize the file analyzer
|
||||
*/
|
||||
init():Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {HashIndexManager} from "@metazla/meta-hash";
|
||||
import {config} from "../../config/EnvConfig.js";
|
||||
import {stat} from "fs/promises";
|
||||
import {targetHash} from "../../config/TargetHash.js";
|
||||
import {HashMeta} from "@metazla/meta-interface";
|
||||
import {FileAnalyzerInterface} from "./FileAnalyzerInterface.js";
|
||||
import {FileProcessorInternal} from "./FileProcessorInternal.js";
|
||||
import {globalHashIndex} from "./SimpleFileToCid.js";
|
||||
import {FileMetadata} from "../../types/FileMetadata.js";
|
||||
|
||||
|
||||
|
||||
export class FileProcessor implements FileAnalyzerInterface{
|
||||
database = new Map<string, FileMetadata>();//Map<filePath,FileMetadata>
|
||||
indexManager = globalHashIndex;
|
||||
fileProcessor = new FileProcessorInternal();
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.indexManager.init(true);
|
||||
console.log('Index loaded:', this.indexManager.getCacheSize(), 'entry');
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick metadata processing - extracts metadata without computing content hash
|
||||
* Note: Plugin processing is now handled by container plugins via TaskScheduler
|
||||
*/
|
||||
async processFileQuick(filePath: string): Promise<FileMetadata> {
|
||||
// Initialize metadata with processing status
|
||||
const metadata: FileMetadata = {
|
||||
processingStatus: 'processing'
|
||||
};
|
||||
|
||||
// Add to database immediately
|
||||
// Plugin processing will be done asynchronously by container plugins
|
||||
this.database.set(filePath, metadata);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
getDatabase<T>(): Map<string, T> {
|
||||
return this.database as Map<string, T>;
|
||||
}
|
||||
|
||||
async processFile(filePath: string, current: number, queueSize: number) {
|
||||
try {
|
||||
if (this.database.has(filePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
let indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
|
||||
let hash: HashMeta = {};
|
||||
if(indexLine) {
|
||||
for (const hashKey of targetHash) {
|
||||
if (indexLine[hashKey]) {
|
||||
hash[hashKey] = indexLine[hashKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// Hash logic compute and get the hash - use cache etc...
|
||||
// --------------------------------------------
|
||||
const result = await this.fileProcessor.processFile(filePath,hash);
|
||||
//const result = await this.fileProcessor.processFile(filePath,params.hash);
|
||||
|
||||
//save the metadata to the cache or disk
|
||||
this.database.set(filePath, result.metadata);
|
||||
this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), result.metadata);
|
||||
//console.log(`File [${current}/${queueSize}] ${filePath} took ${result.time}ms`);
|
||||
} catch (e) {
|
||||
console.error(`Error processing file [No Thread] ${filePath}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async processLightPhase(filePath: string, current: number, queueSize: number) {
|
||||
// Legacy FileProcessor doesn't support split phases - just use processFile
|
||||
await this.processFile(filePath, current, queueSize);
|
||||
}
|
||||
|
||||
async processHashPhase(filePath: string, current: number, queueSize: number) {
|
||||
// Legacy FileProcessor doesn't support split phases - no-op
|
||||
// (processFile already did everything)
|
||||
}
|
||||
|
||||
deleteFile(filePath: string) {
|
||||
this.database.delete(filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { HashComputerFile } from "@metazla/meta-hash";
|
||||
import { targetHash } from "../../config/TargetHash.js";
|
||||
import { DebugMeta, HashMeta } from "@metazla/meta-interface";
|
||||
import { MetaMeshFormat } from "../MetaMeshFormat.js";
|
||||
|
||||
type Local = HashMeta & DebugMeta;
|
||||
|
||||
export class FileProcessorInternal {
|
||||
hashComputer = new HashComputerFile(targetHash);
|
||||
metaMeshFormat = new MetaMeshFormat();
|
||||
|
||||
async processFile(filePath: string, hash: HashMeta): Promise<{
|
||||
time: number,
|
||||
metadata: HashMeta,
|
||||
hashTimings?: Record<string, number>,
|
||||
pluginTimings?: Record<string, number>
|
||||
}> {
|
||||
const start = performance.now();
|
||||
const hashTimings: Record<string, number> = {};
|
||||
const pluginTimings: Record<string, number> = {};
|
||||
|
||||
// --------------------------------------------
|
||||
// Hash logic compute and get the hash - use cache etc...
|
||||
// --------------------------------------------
|
||||
const metaDataPath = `${filePath}.meta`;
|
||||
let metadata: HashMeta = await this.metaMeshFormat.read(metaDataPath) || {}; // Read the reference file
|
||||
// try to update the sha values from the index
|
||||
if (hash) {
|
||||
for (let key in hash) {
|
||||
if (!metadata[key] && hash[key]) {
|
||||
metadata[key] = hash[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time consuming - compute hashes and track timing
|
||||
try {
|
||||
// Determine which hashes need to be computed
|
||||
const neededHashes = this.hashComputer['targetHash'].filter(hashName => !metadata[hashName]);
|
||||
|
||||
if (neededHashes.length > 0) {
|
||||
// Measure total time for hash computation
|
||||
const hashStart = performance.now();
|
||||
|
||||
// Compute all needed hashes at once (more efficient than individual)
|
||||
await this.hashComputer.computeMissingHash(filePath, metadata);
|
||||
|
||||
const totalHashTime = Math.ceil(performance.now() - hashStart);
|
||||
|
||||
// Distribute the time evenly among computed hashes
|
||||
// This is an approximation - actual individual hash times may vary
|
||||
const avgTimePerHash = totalHashTime / neededHashes.length;
|
||||
for (const hashAlgorithm of neededHashes) {
|
||||
hashTimings[hashAlgorithm] = Math.ceil(avgTimePerHash);
|
||||
}
|
||||
}
|
||||
} catch (hashError) {
|
||||
console.error(`[Worker] Hash computation failed for ${filePath}:`, hashError);
|
||||
throw new Error(`Hash computation failed: ${hashError instanceof Error ? hashError.message : String(hashError)}`);
|
||||
}
|
||||
|
||||
// Plugin processing is now handled by container plugins via TaskScheduler
|
||||
// This internal processor only handles hash computation
|
||||
|
||||
// Validate all required hashes are present before returning
|
||||
const missingHashesAfterCompute: string[] = [];
|
||||
for (const hashKey of this.hashComputer['targetHash']) {
|
||||
if (!metadata[hashKey]) {
|
||||
missingHashesAfterCompute.push(hashKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingHashesAfterCompute.length > 0) {
|
||||
throw new Error(`[Worker] Hash validation failed - missing hashes: ${missingHashesAfterCompute.join(', ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
time: Math.ceil(performance.now() - start),
|
||||
metadata,
|
||||
hashTimings,
|
||||
pluginTimings
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import {Piscina} from "piscina";
|
||||
import {Input, Output} from "./worker.js";
|
||||
import {HashIndexManager} from "@metazla/meta-hash";
|
||||
import {config} from "../../config/EnvConfig.js";
|
||||
import {stat} from "fs/promises";
|
||||
// Note: Full hashes are now computed by the full-hash container plugin
|
||||
import {HashMeta} from "@metazla/meta-interface";
|
||||
import {FileAnalyzerInterface} from "./FileAnalyzerInterface.js";
|
||||
import {globalHashIndex} from "./SimpleFileToCid.js";
|
||||
import {performanceMetrics} from "../../metrics/PerformanceMetrics.js";
|
||||
import {UnifiedProcessingStateManager} from "../UnifiedProcessingStateManager.js";
|
||||
import {renamingRule} from "../../config/RenamingRule.js";
|
||||
import {FileMetadata, ProcessingStatus} from "../../types/FileMetadata.js";
|
||||
import { PluginManager } from "../../plugin-engine/index.js";
|
||||
import type {IKVClient} from "../../kv/IKVClient.js";
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import os from 'os';
|
||||
|
||||
// Get the directory of this file to resolve plugin paths
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Singleton PluginManager instance
|
||||
let pluginManagerInstance: PluginManager | null = null;
|
||||
let pluginManagerInitPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Get the plugin manager instance (for API access)
|
||||
* Returns null if not yet initialized
|
||||
*/
|
||||
export function getPluginManagerInstance(): PluginManager | null {
|
||||
return pluginManagerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the plugin manager instance (called by container initialization)
|
||||
*/
|
||||
export function setPluginManagerInstance(manager: PluginManager): void {
|
||||
pluginManagerInstance = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the plugin manager on startup
|
||||
* Note: With container-based plugins, the PluginManager is initialized
|
||||
* by the ContainerManager. This function creates a basic instance
|
||||
* for state management if container plugins are not yet ready.
|
||||
*/
|
||||
export async function initializePluginManager(): Promise<PluginManager> {
|
||||
if (pluginManagerInstance) {
|
||||
return pluginManagerInstance;
|
||||
}
|
||||
|
||||
// Create basic plugin manager for state management
|
||||
const statePath = path.join(config.CACHE_FOLDER_PATH, 'plugins.json');
|
||||
const cacheDir = path.join(config.CACHE_FOLDER_PATH, 'plugin-cache');
|
||||
|
||||
console.log(`[PluginManager] Initializing with:`);
|
||||
console.log(` - State path: ${statePath}`);
|
||||
console.log(` - Cache dir: ${cacheDir}`);
|
||||
|
||||
pluginManagerInstance = new PluginManager({
|
||||
statePath,
|
||||
cacheDir,
|
||||
});
|
||||
|
||||
console.log(`[PluginManager] Basic instance created (waiting for container plugins)`);
|
||||
return pluginManagerInstance;
|
||||
}
|
||||
|
||||
export class FileProcessorPiscina implements FileAnalyzerInterface{
|
||||
private piscina: Piscina;
|
||||
database = new Map<string, FileMetadata>();//Map<filePath,FileMetadata>
|
||||
indexManager = globalHashIndex;
|
||||
private unifiedStateManager: UnifiedProcessingStateManager | null = null;
|
||||
|
||||
// Unified KV client (Redis)
|
||||
private kvClient: IKVClient | null = null;
|
||||
|
||||
// Track running hash workers for abort capability
|
||||
private runningHashTasks = new Map<string, AbortController>();
|
||||
|
||||
constructor() {
|
||||
// Construct the URL for the current module
|
||||
let distFolder = import.meta.dirname;
|
||||
distFolder = distFolder.replace('src', 'dist');
|
||||
distFolder = distFolder + "/worker.js";
|
||||
const workerUrl = new URL(distFolder,"file://").href;
|
||||
|
||||
// Set maxThreads: Smart default uses half CPU cores to avoid starving the system
|
||||
// (especially important for network storage + HDD scenarios)
|
||||
const cpuCount = os.cpus().length;
|
||||
const defaultWorkers = Math.max(1, Math.floor(cpuCount / 2));
|
||||
const maxThreads = config.MAX_WORKER_THREADS || defaultWorkers;
|
||||
|
||||
this.piscina = new Piscina({
|
||||
maxThreads,
|
||||
//maxThreads: 1,//TODO note for DEBUG must be set to 1 to avoid memory issues
|
||||
filename:workerUrl
|
||||
});
|
||||
|
||||
console.log(`Piscina worker pool initialized with ${maxThreads} threads (cpuCount=${cpuCount}, defaultWorkers=${defaultWorkers})`);
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.indexManager.init(true);
|
||||
console.log('Index loaded:', this.indexManager.getCacheSize(), 'entry');
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-phase metadata processing - computes midhash256 immediately and extracts metadata
|
||||
* Files appear in VFS instantly with their permanent midhash256 ID
|
||||
*
|
||||
* @param filePath - Path to the file to process
|
||||
* @param current - Current file number (for progress reporting)
|
||||
* @param queueSize - Total queue size (for progress reporting)
|
||||
* @returns FileMetadata with midhash256 and all metadata
|
||||
*/
|
||||
|
||||
setUnifiedStateManager(manager: UnifiedProcessingStateManager): void {
|
||||
this.unifiedStateManager = manager;
|
||||
}
|
||||
|
||||
getDatabase<T>(): Map<string, T> {
|
||||
return this.database as Map<string, T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort all running hash workers immediately (for priority enforcement)
|
||||
* @returns Number of workers aborted
|
||||
*/
|
||||
abortAllHashWorkers(): number {
|
||||
const abortedCount = this.runningHashTasks.size;
|
||||
|
||||
for (const [filePath, controller] of this.runningHashTasks.entries()) {
|
||||
controller.abort();
|
||||
console.log(`[Abort] Hash worker aborted: ${filePath}`);
|
||||
}
|
||||
|
||||
this.runningHashTasks.clear();
|
||||
return abortedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shutdown - close KV client
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.kvClient) {
|
||||
console.log('[FileProcessor] Closing KV client...');
|
||||
await this.kvClient.close();
|
||||
console.log('[FileProcessor] KV client closed');
|
||||
}
|
||||
}
|
||||
|
||||
async processFile(filePath: string, current: number, queueSize: number) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// STATE: Start LIGHT processing (fast: midhash256 + metadata extraction)
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.startLightProcessing(filePath);
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// STEP 1: Check cache for midhash256 FIRST (avoids network I/O on unchanged files)
|
||||
let midHash256: string | undefined;
|
||||
const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
|
||||
|
||||
if (indexLine && indexLine.cid_midhash256) {
|
||||
// Cache hit! Use cached midhash256 (no network I/O needed)
|
||||
midHash256 = indexLine.cid_midhash256;
|
||||
performanceMetrics.recordCacheHit('midhash256');
|
||||
} else {
|
||||
// Cache miss - compute midhash256 (< 1 second, but requires network I/O on network drives)
|
||||
const computeStart = performance.now();
|
||||
const { computeMidHash256 } = await import('@metazla/meta-hash');
|
||||
midHash256 = await computeMidHash256(filePath);
|
||||
const computeTime = Math.ceil(performance.now() - computeStart);
|
||||
|
||||
// Record midhash256 computation time (excludes cache hits)
|
||||
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
||||
performanceMetrics.recordCacheMiss('midhash256');
|
||||
}
|
||||
|
||||
// Initialize metadata with midhash256
|
||||
const metadata: FileMetadata = {
|
||||
cid_midhash256: midHash256,
|
||||
processingStatus: 'processing'
|
||||
};
|
||||
|
||||
// STEP 2: Plugin metadata extraction is now handled by container plugins
|
||||
// The TaskScheduler will dispatch tasks to container plugins asynchronously
|
||||
// Basic metadata processing continues below
|
||||
|
||||
// Generate virtualPath from metadata (needed for VFS access)
|
||||
let virtualPath: string | undefined;
|
||||
try {
|
||||
virtualPath = renamingRule(metadata as any, '');
|
||||
} catch (e) {
|
||||
// If renamingRule fails, virtualPath remains undefined
|
||||
}
|
||||
|
||||
// STEP 3: Collision detection and store preliminary metadata in KV
|
||||
let collisionDetected = false;
|
||||
if (this.kvClient) {
|
||||
try {
|
||||
const hashId = midHash256; // Use midhash256 as primary ID
|
||||
|
||||
if (hashId) {
|
||||
// COLLISION DETECTION: Check if this midhash256 already exists
|
||||
const existingFilePath = await this.kvClient.getMetadata(hashId, 'filePath');
|
||||
|
||||
if (existingFilePath && existingFilePath !== filePath) {
|
||||
// COLLISION DETECTED: Different file with same midhash256!
|
||||
collisionDetected = true;
|
||||
console.warn(`⚠️ MIDHASH256 COLLISION DETECTED!`);
|
||||
console.warn(` Hash: ${hashId}`);
|
||||
console.warn(` Existing file: ${existingFilePath}`);
|
||||
console.warn(` New file: ${filePath}`);
|
||||
console.warn(` Action: Skipping VFS addition, forcing full hash computation`);
|
||||
|
||||
// Do NOT store in KV with midhash256
|
||||
// File will be processed with full SHA-256 instead (see below)
|
||||
} else {
|
||||
// No collision or same file - safe to store
|
||||
const finalMetadata = {
|
||||
...metadata,
|
||||
filePath
|
||||
};
|
||||
|
||||
// Store using nested key architecture
|
||||
await this.kvClient.setMetadataFlat(hashId, finalMetadata, ['processingStatus']);
|
||||
}
|
||||
}
|
||||
} catch (metaError) {
|
||||
console.error(`Failed to store metadata in KV:`, metaError);
|
||||
}
|
||||
}
|
||||
|
||||
// STATE: Light processing complete, move to HASH processing queue
|
||||
// If collision detected, skip VFS addition (virtualPath = undefined)
|
||||
if (this.unifiedStateManager) {
|
||||
if (collisionDetected) {
|
||||
// Collision: Do NOT complete light processing with virtualPath
|
||||
// This prevents file from being added to VFS
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
undefined // No virtualPath = not added to VFS
|
||||
);
|
||||
} else {
|
||||
// No collision: File becomes accessible in VFS
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
virtualPath
|
||||
);
|
||||
}
|
||||
// Start hash processing phase immediately
|
||||
this.unifiedStateManager.startHashProcessing(filePath);
|
||||
}
|
||||
|
||||
// STEP 4: Full hashes (SHA-256, SHA-1, MD5, CRC32) are computed by the
|
||||
// container plugin "full-hash" in background queue - not here.
|
||||
// Only midhash256 is computed in-process for fast file identification.
|
||||
|
||||
// Mark processing as complete
|
||||
metadata.processingStatus = 'complete';
|
||||
this.database.set(filePath, metadata);
|
||||
|
||||
// STEP 5: Update metadata in KV with full hashes
|
||||
if (this.kvClient) {
|
||||
try {
|
||||
// Choose hash ID: Use SHA-256 if collision detected, otherwise midhash256
|
||||
let hashId: string;
|
||||
if (collisionDetected && metadata['cid_sha2-256']) {
|
||||
// Collision: Use full SHA-256 as primary ID
|
||||
hashId = metadata['cid_sha2-256'];
|
||||
console.log(` Using SHA-256 as primary ID for collision: ${hashId}`);
|
||||
} else {
|
||||
// Normal: Use midhash256 as primary ID
|
||||
hashId = midHash256;
|
||||
}
|
||||
|
||||
if (hashId) {
|
||||
// Prepare metadata with filePath added
|
||||
const finalMetadata = {
|
||||
...metadata,
|
||||
filePath
|
||||
};
|
||||
|
||||
// Store using nested key architecture
|
||||
await this.kvClient.setMetadataFlat(hashId, finalMetadata, ['processingStatus']);
|
||||
}
|
||||
} catch (metaError) {
|
||||
console.error(`Failed to store metadata in KV:`, metaError);
|
||||
}
|
||||
}
|
||||
|
||||
// STATE: Hash processing complete, move to done
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeHashProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
virtualPath
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error processing file ${filePath}:`, e);
|
||||
|
||||
// Mark processing as failed (complete hash processing with error)
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeHashProcessing(
|
||||
filePath,
|
||||
undefined,
|
||||
undefined,
|
||||
e instanceof Error ? e.message : String(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Light Processing Phase - Fast processing to make file accessible in VFS
|
||||
* - Compute midhash256 (< 1s)
|
||||
* - Extract metadata (filename, FFmpeg analysis)
|
||||
* - Store in KV
|
||||
* - File becomes accessible in VFS with midhash256 ID
|
||||
*
|
||||
* @deprecated Use TaskScheduler-based processing via WatchedFileProcessor.queueFile() instead.
|
||||
* This method is kept for backward compatibility with StreamingPipeline.
|
||||
*/
|
||||
async processLightPhase(filePath: string, current: number, queueSize: number) {
|
||||
try {
|
||||
// STATE: Start LIGHT processing
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.startLightProcessing(filePath);
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// STEP 1: Check cache for midhash256 FIRST (avoids network I/O on unchanged files)
|
||||
let midHash256: string | undefined;
|
||||
const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
|
||||
|
||||
if (indexLine && indexLine.cid_midhash256) {
|
||||
// Cache hit! Use cached midhash256 (no network I/O needed)
|
||||
midHash256 = indexLine.cid_midhash256;
|
||||
performanceMetrics.recordCacheHit('midhash256');
|
||||
} else {
|
||||
// Cache miss - compute midhash256 (< 1 second, but requires network I/O on network drives)
|
||||
const computeStart = performance.now();
|
||||
const { computeMidHash256 } = await import('@metazla/meta-hash');
|
||||
midHash256 = await computeMidHash256(filePath);
|
||||
const computeTime = Math.ceil(performance.now() - computeStart);
|
||||
|
||||
// Record midhash256 computation time (excludes cache hits)
|
||||
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
||||
performanceMetrics.recordCacheMiss('midhash256');
|
||||
}
|
||||
|
||||
// Initialize metadata with midhash256
|
||||
const metadata: FileMetadata = {
|
||||
cid_midhash256: midHash256,
|
||||
processingStatus: 'processing'
|
||||
};
|
||||
|
||||
// STEP 2: Plugin metadata extraction is now handled by container plugins
|
||||
// The TaskScheduler will dispatch tasks to container plugins asynchronously
|
||||
|
||||
// Generate virtualPath from metadata (needed for VFS access)
|
||||
let virtualPath: string | undefined;
|
||||
try {
|
||||
virtualPath = renamingRule(metadata as any, '');
|
||||
} catch (e) {
|
||||
// If renamingRule fails, virtualPath remains undefined
|
||||
}
|
||||
|
||||
// STEP 3: Store metadata in KV (file becomes accessible in VFS)
|
||||
if (this.kvClient) {
|
||||
try {
|
||||
const hashId = midHash256; // Use midhash256 as primary ID
|
||||
|
||||
if (hashId) {
|
||||
// Prepare metadata with filePath added
|
||||
const finalMetadata = {
|
||||
...metadata,
|
||||
filePath
|
||||
};
|
||||
|
||||
// Store using nested key architecture
|
||||
await this.kvClient.setMetadataFlat(hashId, finalMetadata, ['processingStatus']);
|
||||
}
|
||||
} catch (metaError) {
|
||||
console.error(`Failed to store metadata in KV:`, metaError);
|
||||
}
|
||||
}
|
||||
|
||||
// Store in local database
|
||||
this.database.set(filePath, metadata);
|
||||
|
||||
// STATE: Light processing complete, move to HASH processing queue
|
||||
// File is now accessible in VFS with midhash256 ID
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
virtualPath
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error in light processing for ${filePath}:`, e);
|
||||
|
||||
// Mark processing as failed
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeLightProcessing(
|
||||
filePath,
|
||||
'',
|
||||
undefined,
|
||||
e instanceof Error ? e.message : String(e)
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash Processing Phase - Background hash computation
|
||||
* - Compute full hashes (SHA-256, SHA-1, MD5, CRC32)
|
||||
* - Update KV with additional hash metadata
|
||||
* - Mark processing as complete
|
||||
* - Supports abortion for priority enforcement
|
||||
*
|
||||
* @deprecated Use the full-hash plugin via TaskScheduler instead.
|
||||
* This method is kept for backward compatibility with StreamingPipeline.
|
||||
*/
|
||||
async processHashPhase(filePath: string, current: number, queueSize: number) {
|
||||
// Create abort controller for this task
|
||||
const abortController = new AbortController();
|
||||
this.runningHashTasks.set(filePath, abortController);
|
||||
|
||||
try {
|
||||
// STATE: Start HASH processing
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.startHashProcessing(filePath);
|
||||
}
|
||||
|
||||
const stats = await stat(filePath);
|
||||
const metadata = this.database.get(filePath);
|
||||
if (!metadata) {
|
||||
throw new Error('Metadata not found - light processing may have failed');
|
||||
}
|
||||
|
||||
const midHash256 = metadata.cid_midhash256;
|
||||
if (!midHash256) {
|
||||
throw new Error('midhash256 not found in metadata');
|
||||
}
|
||||
|
||||
// Generate virtualPath (should already be done but ensure it's available)
|
||||
let virtualPath: string | undefined;
|
||||
try {
|
||||
virtualPath = renamingRule(metadata as any, '');
|
||||
} catch (e) {
|
||||
// If renamingRule fails, virtualPath remains undefined
|
||||
}
|
||||
|
||||
// Check if collision was detected during light processing
|
||||
// If the midhash256 key exists in KV with a different file path, collision occurred
|
||||
let collisionDetected = false;
|
||||
if (this.kvClient) {
|
||||
try {
|
||||
const existingFilePath = await this.kvClient.getMetadata(midHash256, 'filePath');
|
||||
if (existingFilePath && existingFilePath !== filePath) {
|
||||
collisionDetected = true;
|
||||
console.log(` Collision detected in hash phase for ${filePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors - if key doesn't exist, no collision
|
||||
}
|
||||
}
|
||||
|
||||
// Check if aborted before processing
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('AbortError');
|
||||
}
|
||||
|
||||
// STEP 4: Full hashes (SHA-256, SHA-1, MD5, CRC32) are computed by the
|
||||
// container plugin "full-hash" in background queue - not here.
|
||||
// Only midhash256 is computed in-process for fast file identification.
|
||||
|
||||
// Mark processing as complete
|
||||
metadata.processingStatus = 'complete';
|
||||
this.database.set(filePath, metadata);
|
||||
|
||||
// STEP 5: Update metadata in KV (full hashes will be added later by container plugin)
|
||||
if (this.kvClient) {
|
||||
try {
|
||||
// Use midhash256 as primary ID
|
||||
// Note: SHA-256 is computed later by the full-hash container plugin
|
||||
const hashId = midHash256;
|
||||
|
||||
if (hashId) {
|
||||
// Prepare metadata with filePath added
|
||||
const finalMetadata = {
|
||||
...metadata,
|
||||
filePath
|
||||
};
|
||||
|
||||
// Store using nested key architecture
|
||||
await this.kvClient.setMetadataFlat(hashId, finalMetadata, ['processingStatus']);
|
||||
}
|
||||
} catch (metaError) {
|
||||
console.error(`Failed to store metadata in KV:`, metaError);
|
||||
}
|
||||
}
|
||||
|
||||
// STATE: Hash processing complete, move to done
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeHashProcessing(
|
||||
filePath,
|
||||
midHash256,
|
||||
virtualPath
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle abort errors gracefully
|
||||
if (e instanceof Error && (e.message === 'AbortError' || e.name === 'AbortError')) {
|
||||
// Task was aborted for priority - re-queue it
|
||||
console.log(`[Abort] Hash processing aborted for ${filePath} - will retry when idle`);
|
||||
|
||||
// Don't mark as failed, just remove from running tasks
|
||||
// The task will remain in hash queue and retry later
|
||||
if (this.unifiedStateManager) {
|
||||
// Move back to hash processing start (will retry)
|
||||
this.unifiedStateManager.startHashProcessing(filePath);
|
||||
}
|
||||
} else {
|
||||
console.error(`Error in hash processing for ${filePath}:`, e);
|
||||
|
||||
// Mark hash processing as failed for real errors
|
||||
if (this.unifiedStateManager) {
|
||||
this.unifiedStateManager.completeHashProcessing(
|
||||
filePath,
|
||||
undefined,
|
||||
undefined,
|
||||
e instanceof Error ? e.message : String(e)
|
||||
);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
// Always remove from running tasks
|
||||
this.runningHashTasks.delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string) {
|
||||
const metadata = this.database.get(filePath);
|
||||
this.database.delete(filePath);
|
||||
|
||||
// Delete from KV if configured (using nested key architecture)
|
||||
if (this.kvClient && metadata) {
|
||||
try {
|
||||
const hashId = metadata.cid_midhash256 || metadata['cid_sha2-256'] || metadata.cid_sha1 || metadata.cid_md5;
|
||||
|
||||
// Delete all properties for this file using nested key architecture
|
||||
if (hashId) {
|
||||
const deletedCount = await this.kvClient.deleteMetadataFlat(hashId);
|
||||
console.log(`Metadata deleted from KV: /file/${hashId} (${deletedCount} keys)`);
|
||||
}
|
||||
} catch (metaError) {
|
||||
// Log error but don't fail the entire process
|
||||
console.error(`Failed to delete metadata from KV:`, metaError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the KV client
|
||||
* This is called by KVManager after successful connection
|
||||
*/
|
||||
setKVClient(client: IKVClient): void {
|
||||
this.kvClient = client;
|
||||
console.log('[FileProcessor] KV client set');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KV client
|
||||
*/
|
||||
getKVClient(): IKVClient | null {
|
||||
return this.kvClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {CID_ALGORITHM_NAMES, HashComputerFile} from "@metazla/meta-hash";
|
||||
import {HashIndexManager} from "@metazla/meta-hash";
|
||||
import {config} from "../../config/EnvConfig.js";
|
||||
import {targetHash, targetHashForIndex} from "../../config/TargetHash.js";
|
||||
import {stat} from "fs/promises";
|
||||
|
||||
|
||||
export const globalHashIndex = new HashIndexManager(config.INDEX_FOLDER_PATH, targetHashForIndex);
|
||||
export const globalHashComputer = new HashComputerFile(targetHash);
|
||||
|
||||
class SimpleFileToCid{
|
||||
|
||||
async getcid(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
let indexLine = globalHashIndex.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
|
||||
if(indexLine) {
|
||||
return indexLine[CID_ALGORITHM_NAMES.sha256];
|
||||
}else {
|
||||
let hashs = {};
|
||||
await globalHashComputer.computeMissingHash(filePath, hashs);
|
||||
globalHashIndex.addFileCid(filePath, stats.size, stats.mtime.toISOString(), hashs);
|
||||
return hashs[CID_ALGORITHM_NAMES.sha256];
|
||||
}
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const simpleFileToCid = new SimpleFileToCid();
|
||||
@@ -0,0 +1,20 @@
|
||||
import {FileProcessorInternal} from "./FileProcessorInternal.js";
|
||||
import {HashMeta} from "@metazla/meta-interface";
|
||||
|
||||
const fileProcessor = new FileProcessorInternal();
|
||||
|
||||
export interface Input {
|
||||
filePath: string;
|
||||
hash:HashMeta;
|
||||
}
|
||||
|
||||
export interface Output {
|
||||
time: number;
|
||||
metadata: HashMeta;
|
||||
hashTimings?: Record<string, number>; // Per-hash computation times in ms
|
||||
pluginTimings?: Record<string, number>; // Per-plugin processing times in ms
|
||||
}
|
||||
|
||||
export default async function processFile({ filePath,hash}: Input): Promise<Output> {
|
||||
return fileProcessor.processFile(filePath,hash);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {FileAnalyzerInterface} from "../fileProcessor/FileAnalyzerInterface.js";
|
||||
import {UnifiedProcessingStateManager} from "../UnifiedProcessingStateManager.js";
|
||||
import type {IKVClient} from "../../kv/IKVClient.js";
|
||||
import {VirtualFileSystem} from "../../api/VirtualFileSystem.js";
|
||||
import {MetaDataToFolderStruct} from "../MetaDataToFolderStruct.js";
|
||||
import type {ContainerPluginScheduler} from "../../container-plugins/ContainerPluginScheduler.js";
|
||||
|
||||
/**
|
||||
* Configuration for StreamingPipeline
|
||||
*/
|
||||
export interface PipelineConfig {
|
||||
/**
|
||||
* Supported file extensions (application-specific)
|
||||
*/
|
||||
supportedExtensions: Set<string>;
|
||||
|
||||
/**
|
||||
* Concurrency for validation stage (extension + optional MIME check)
|
||||
* Default: 32 (I/O bound - fast checks)
|
||||
*/
|
||||
validationConcurrency: number;
|
||||
|
||||
/**
|
||||
* Concurrency for fast queue stage (filename parsing, stats)
|
||||
* Default: 16 (CPU/I/O balanced)
|
||||
*/
|
||||
fastQueueConcurrency: number;
|
||||
|
||||
/**
|
||||
* Concurrency for background queue stage (hash computation, FFmpeg)
|
||||
* Default: 16 (CPU bound)
|
||||
*/
|
||||
backgroundQueueConcurrency: number;
|
||||
|
||||
/**
|
||||
* Enable strict MIME validation (reads file header)
|
||||
* Default: false (skip for performance)
|
||||
*/
|
||||
strictMimeValidation: boolean;
|
||||
|
||||
/**
|
||||
* File processor (handles light and heavy processing)
|
||||
*/
|
||||
fileProcessor: FileAnalyzerInterface;
|
||||
|
||||
/**
|
||||
* State manager (tracks processing states)
|
||||
*/
|
||||
stateManager: UnifiedProcessingStateManager;
|
||||
|
||||
/**
|
||||
* Optional KV client for metadata storage
|
||||
*/
|
||||
kvClient?: IKVClient;
|
||||
|
||||
/**
|
||||
* Virtual filesystem (for instant file visibility)
|
||||
*/
|
||||
virtualFileSystem: VirtualFileSystem;
|
||||
|
||||
/**
|
||||
* Metadata to folder structure converter (for virtual path generation)
|
||||
*/
|
||||
metaDataToFolderStruct: MetaDataToFolderStruct;
|
||||
|
||||
/**
|
||||
* Optional container plugin scheduler for dispatching tasks to containerized plugins
|
||||
*/
|
||||
containerPluginScheduler?: ContainerPluginScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default pipeline configuration
|
||||
*/
|
||||
export const DEFAULT_PIPELINE_CONFIG: Partial<PipelineConfig> = {
|
||||
validationConcurrency: 32,
|
||||
fastQueueConcurrency: 16,
|
||||
backgroundQueueConcurrency: 16,
|
||||
strictMimeValidation: false
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
import * as path from 'path';
|
||||
import PQueue from 'p-queue';
|
||||
import {PipelineConfig} from './PipelineConfig.js';
|
||||
import {withTimeoutAndRetry, TimeoutConfig} from '../../utils/TimeoutWrapper.js';
|
||||
import {performanceMetrics} from '../../metrics/PerformanceMetrics.js';
|
||||
|
||||
// Pub/sub channel for batched updates to meta-fuse
|
||||
const UPDATE_CHANNEL = 'meta-sort:file:batch';
|
||||
|
||||
// Batch update interval (5 seconds)
|
||||
const BATCH_INTERVAL_MS = 5000;
|
||||
|
||||
interface BatchChange {
|
||||
action: 'add' | 'update' | 'remove';
|
||||
hashId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming Pipeline for file processing
|
||||
* Files flow through two stages as they're discovered:
|
||||
* Discovery → Validation → Processing (midhash256 + metadata)
|
||||
*
|
||||
* With midhash256 being instant (< 1s), files appear in VFS immediately
|
||||
*
|
||||
* Publishes batched updates to meta-fuse via Redis pub/sub every 5 seconds.
|
||||
*/
|
||||
export class StreamingPipeline {
|
||||
private validationQueue: PQueue;
|
||||
private fastQueue: PQueue;
|
||||
private backgroundQueue: PQueue;
|
||||
private config: PipelineConfig;
|
||||
|
||||
private discoveredCount = 0;
|
||||
private validatedCount = 0;
|
||||
private fastProcessedCount = 0;
|
||||
private backgroundProcessedCount = 0;
|
||||
|
||||
// Batch buffer for pub/sub notifications
|
||||
private batchBuffer: BatchChange[] = [];
|
||||
private batchTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Retry configuration
|
||||
private readonly MAX_PROCESSING_RETRIES = 10;
|
||||
|
||||
// Fast queue timeout: Fast metadata extraction + midhash256
|
||||
private readonly fastQueueTimeoutConfig: TimeoutConfig = {
|
||||
baseTimeout: 120000, // 2 minutes base timeout
|
||||
timeoutMultiplier: 1.5, // Increase by 1.5x each retry
|
||||
maxTimeout: 600000 // Max 10 minutes
|
||||
};
|
||||
|
||||
// Background queue timeout: Full file hashing over NAS (can be very slow)
|
||||
private readonly backgroundQueueTimeoutConfig: TimeoutConfig = {
|
||||
baseTimeout: 7200000, // 2 hours base timeout (for NAS)
|
||||
timeoutMultiplier: 1.5, // Increase by 1.5x each retry
|
||||
maxTimeout: 14400000 // Max 4 hours
|
||||
};
|
||||
|
||||
constructor(config: PipelineConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Stage 1: Validation (high concurrency - fast I/O checks)
|
||||
this.validationQueue = new PQueue({
|
||||
concurrency: config.validationConcurrency,
|
||||
autoStart: true
|
||||
});
|
||||
|
||||
// Stage 2: Fast Queue - metadata + midhash256 (high concurrency, fast)
|
||||
this.fastQueue = new PQueue({
|
||||
concurrency: config.fastQueueConcurrency,
|
||||
autoStart: true
|
||||
});
|
||||
|
||||
// Stage 3: Background Queue - SHA-256 computation (lower concurrency, CPU-intensive)
|
||||
this.backgroundQueue = new PQueue({
|
||||
concurrency: config.backgroundQueueConcurrency,
|
||||
autoStart: true
|
||||
});
|
||||
|
||||
console.log(`[Pipeline] Initialized with concurrency: validation=${config.validationConcurrency}, fastQueue=${config.fastQueueConcurrency}, backgroundQueue=${config.backgroundQueueConcurrency}`);
|
||||
|
||||
// Start batch publish timer
|
||||
this.startBatchTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the batch publish timer
|
||||
* Publishes accumulated changes to meta-fuse every 5 seconds
|
||||
*/
|
||||
private startBatchTimer(): void {
|
||||
this.batchTimer = setInterval(() => {
|
||||
this.flushBatch().catch(err => {
|
||||
console.error('[Pipeline] Failed to flush batch:', err.message);
|
||||
});
|
||||
}, BATCH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the batch publish timer
|
||||
*/
|
||||
stopBatchTimer(): void {
|
||||
if (this.batchTimer) {
|
||||
clearInterval(this.batchTimer);
|
||||
this.batchTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a change for batched notification
|
||||
*/
|
||||
private queueChange(action: 'add' | 'update' | 'remove', hashId: string): void {
|
||||
// Deduplicate: remove existing entries for same hashId
|
||||
this.batchBuffer = this.batchBuffer.filter(c => c.hashId !== hashId);
|
||||
this.batchBuffer.push({ action, hashId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the batch buffer and publish to Redis
|
||||
*/
|
||||
private async flushBatch(): Promise<void> {
|
||||
if (this.batchBuffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = [...this.batchBuffer];
|
||||
this.batchBuffer = [];
|
||||
|
||||
try {
|
||||
const kvClient = this.config.kvClient;
|
||||
if (kvClient && typeof (kvClient as any).publish === 'function') {
|
||||
const message = JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
changes
|
||||
});
|
||||
await (kvClient as any).publish(UPDATE_CHANNEL, message);
|
||||
console.log(`[Pipeline] Published batch update: ${changes.length} changes`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Put changes back in buffer on failure
|
||||
this.batchBuffer = [...changes, ...this.batchBuffer];
|
||||
console.warn(`[Pipeline] Failed to publish batch: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing files from discovery stream
|
||||
* Processing begins immediately as files are discovered
|
||||
*
|
||||
* @param discoveryStream - Async generator yielding file paths
|
||||
*/
|
||||
async start(discoveryStream: AsyncGenerator<string>): Promise<void> {
|
||||
console.log('[Pipeline] Starting file processing stream...');
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Consume discovery stream
|
||||
for await (const filePath of discoveryStream) {
|
||||
this.discoveredCount++;
|
||||
|
||||
// Stage 1: Validation (fire and forget)
|
||||
this.validationQueue.add(() => this.validateFile(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Validation error for ${filePath}:`, err.message));
|
||||
|
||||
// Log progress every 1000 files
|
||||
if (this.discoveredCount % 1000 === 0) {
|
||||
this.logProgress();
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[Pipeline] Discovery completed: ${this.discoveredCount} files found in ${elapsed}ms`);
|
||||
console.log(`[Pipeline] Processing continues in background...`);
|
||||
this.logProgress();
|
||||
} catch (error: any) {
|
||||
console.error(`[Pipeline] Error in discovery stream:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 1: Validation
|
||||
* - Quick extension check (no file I/O)
|
||||
* - Optional MIME validation (reads file header)
|
||||
* - Mark file as pending
|
||||
*/
|
||||
private async validateFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Extension check (instant, no I/O)
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (!this.config.supportedExtensions.has(ext)) {
|
||||
// Skip unsupported file types
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional strict MIME validation
|
||||
// Note: Skipped for performance - extension check is sufficient
|
||||
// If needed, could add MIME validation via external library
|
||||
|
||||
// Mark as pending
|
||||
this.config.stateManager.addPending(filePath);
|
||||
this.validatedCount++;
|
||||
|
||||
// Stage 2: Fast Queue - metadata + midhash256 (fire and forget)
|
||||
this.fastQueue.add(() => this.processLightPhase(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Fast queue error for ${filePath}:`, err.message));
|
||||
} catch (error: any) {
|
||||
console.error(`[Pipeline] Validation failed for ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2: Fast Queue Phase
|
||||
* - Extract metadata (filename parsing, FFmpeg analysis)
|
||||
* - Compute midhash256 (< 1s)
|
||||
* - Store to KV (file becomes accessible in VFS)
|
||||
* - Queue for background processing
|
||||
* - Includes timeout and retry logic
|
||||
*/
|
||||
private async processLightPhase(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Get current retry count
|
||||
const retryCount = this.config.stateManager.getRetryCount(filePath);
|
||||
|
||||
// Call light processing with timeout
|
||||
const current = this.fastProcessedCount + 1;
|
||||
const queueSize = this.discoveredCount;
|
||||
|
||||
await withTimeoutAndRetry(
|
||||
() => this.config.fileProcessor.processLightPhase(filePath, current, queueSize),
|
||||
retryCount,
|
||||
this.fastQueueTimeoutConfig,
|
||||
`Fast queue processing file ${filePath}`
|
||||
);
|
||||
|
||||
// After light processing, add to VFS
|
||||
const metadata = this.config.fileProcessor.getDatabase().get(filePath);
|
||||
if (metadata && metadata.cid_midhash256) {
|
||||
// Generate virtual path from metadata
|
||||
const virtualPath = this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath);
|
||||
|
||||
if (virtualPath) {
|
||||
// Add to VFS immediately (file appears with permanent midhash256 ID)
|
||||
this.config.virtualFileSystem.addFile(virtualPath, filePath, metadata);
|
||||
//console.log(`✓ File added to VFS: ${filePath} → ${virtualPath} (midhash256: ${metadata.cid_midhash256})`);
|
||||
|
||||
// Queue for pub/sub notification to meta-fuse
|
||||
if (metadata.cid_midhash256) {
|
||||
this.queueChange('add', metadata.cid_midhash256);
|
||||
}
|
||||
|
||||
// Notify Stremio addon to add video (incremental update like FUSE)
|
||||
if (metadata.cid_midhash256) {
|
||||
this.notifyStremioAdd(metadata.cid_midhash256).catch(err => {
|
||||
// Silent fail - Stremio addon may not be running
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// File cannot be added to VFS (e.g., unsupported type, missing title)
|
||||
console.log(`⚠ File skipped from VFS (no virtual path): ${filePath}`);
|
||||
}
|
||||
|
||||
// Dispatch tasks to container plugins (fire and forget)
|
||||
this.dispatchContainerPluginTasks(filePath, metadata.cid_midhash256, metadata);
|
||||
|
||||
// Stage 3: Queue for background processing (SHA-256 computation)
|
||||
this.backgroundQueue.add(() => this.processHashPhase(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Background queue error for ${filePath}:`, err.message));
|
||||
}
|
||||
|
||||
this.fastProcessedCount++;
|
||||
} catch (error: any) {
|
||||
// Handle timeout or processing error
|
||||
const retryCount = this.config.stateManager.getRetryCount(filePath);
|
||||
|
||||
if (retryCount < this.MAX_PROCESSING_RETRIES) {
|
||||
// Retry: move back to pending queue with incremented retry count
|
||||
const success = this.config.stateManager.retryProcessing(filePath);
|
||||
if (success) {
|
||||
console.warn(`[Pipeline] Fast queue processing failed for ${filePath} (attempt ${retryCount + 1}/${this.MAX_PROCESSING_RETRIES}), retrying with longer timeout. Error: ${error.message}`);
|
||||
|
||||
// Re-queue for fast processing
|
||||
this.fastQueue.add(() => this.processLightPhase(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Retry failed for ${filePath}:`, err.message));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Max retries exceeded or retry failed - mark as permanently failed
|
||||
console.error(`[Pipeline] Fast queue processing permanently failed for ${filePath} after ${retryCount + 1} attempts: ${error.message}`);
|
||||
|
||||
// Record failed file in metrics
|
||||
performanceMetrics.recordFailedFile(
|
||||
filePath,
|
||||
error.message || 'Unknown error',
|
||||
retryCount + 1,
|
||||
'processing'
|
||||
);
|
||||
|
||||
// Remove from processing
|
||||
this.config.stateManager.removeFile(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: Background Queue Phase
|
||||
* - Compute full hashes (SHA-256, SHA-1, MD5, CRC32)
|
||||
* - Update KV with additional hash metadata
|
||||
* - Mark processing as complete
|
||||
* - Includes timeout and retry logic
|
||||
*/
|
||||
private async processHashPhase(filePath: string): Promise<void> {
|
||||
try {
|
||||
// Get current retry count
|
||||
const retryCount = this.config.stateManager.getRetryCount(filePath);
|
||||
|
||||
// Call hash processing with timeout
|
||||
const current = this.backgroundProcessedCount + 1;
|
||||
const queueSize = this.discoveredCount;
|
||||
|
||||
await withTimeoutAndRetry(
|
||||
() => this.config.fileProcessor.processHashPhase(filePath, current, queueSize),
|
||||
retryCount,
|
||||
this.backgroundQueueTimeoutConfig,
|
||||
`Background queue processing file ${filePath}`
|
||||
);
|
||||
|
||||
this.backgroundProcessedCount++;
|
||||
} catch (error: any) {
|
||||
// Handle timeout or processing error
|
||||
const retryCount = this.config.stateManager.getRetryCount(filePath);
|
||||
|
||||
if (retryCount < this.MAX_PROCESSING_RETRIES) {
|
||||
// Retry: move back to background queue with incremented retry count
|
||||
console.warn(`[Pipeline] Background queue processing failed for ${filePath} (attempt ${retryCount + 1}/${this.MAX_PROCESSING_RETRIES}), retrying. Error: ${error.message}`);
|
||||
|
||||
// Re-queue for background processing
|
||||
this.backgroundQueue.add(() => this.processHashPhase(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Retry failed for ${filePath}:`, err.message));
|
||||
return;
|
||||
}
|
||||
|
||||
// Max retries exceeded - mark as permanently failed
|
||||
console.error(`[Pipeline] Background queue processing permanently failed for ${filePath} after ${retryCount + 1} attempts: ${error.message}`);
|
||||
|
||||
// Record failed file in metrics
|
||||
performanceMetrics.recordFailedFile(
|
||||
filePath,
|
||||
error.message || 'Unknown error',
|
||||
retryCount + 1,
|
||||
'hash'
|
||||
);
|
||||
|
||||
// Complete with error
|
||||
this.config.stateManager.completeHashProcessing(filePath, undefined, undefined, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file added event (from chokidar)
|
||||
*/
|
||||
async handleFileAdded(filePath: string): Promise<void> {
|
||||
|
||||
// Process through validation stage
|
||||
this.validationQueue.add(() => this.validateFile(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Error processing added file ${filePath}:`, err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file changed event (from chokidar)
|
||||
*/
|
||||
async handleFileChanged(filePath: string): Promise<void> {
|
||||
|
||||
// Reprocess through validation stage
|
||||
this.config.stateManager.removeFile(filePath);
|
||||
this.validationQueue.add(() => this.validateFile(filePath))
|
||||
.catch(err => console.error(`[Pipeline] Error processing changed file ${filePath}:`, err.message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file deleted event (from chokidar)
|
||||
*/
|
||||
async handleFileDeleted(filePath: string): Promise<void> {
|
||||
// Get hashId before removal for pub/sub notification
|
||||
const metadata = this.config.fileProcessor.getDatabase().get(filePath);
|
||||
const hashId = (metadata as any)?.cid_midhash256;
|
||||
|
||||
// Remove from state and call cleanup
|
||||
if (this.config.fileProcessor.deleteFile) {
|
||||
this.config.fileProcessor.deleteFile(filePath);
|
||||
}
|
||||
this.config.stateManager.removeFile(filePath);
|
||||
|
||||
// Queue for pub/sub notification to meta-fuse
|
||||
if (hashId) {
|
||||
this.queueChange('remove', hashId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pipeline statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
discovered: this.discoveredCount,
|
||||
validated: this.validatedCount,
|
||||
fastProcessed: this.fastProcessedCount,
|
||||
backgroundProcessed: this.backgroundProcessedCount,
|
||||
queues: {
|
||||
validation: {
|
||||
pending: this.validationQueue.pending,
|
||||
size: this.validationQueue.size
|
||||
},
|
||||
fastQueue: {
|
||||
pending: this.fastQueue.pending,
|
||||
size: this.fastQueue.size
|
||||
},
|
||||
backgroundQueue: {
|
||||
pending: this.backgroundQueue.pending,
|
||||
size: this.backgroundQueue.size
|
||||
}
|
||||
},
|
||||
state: this.config.stateManager.getSnapshot()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log current pipeline progress
|
||||
*/
|
||||
private logProgress(): void {
|
||||
const stats = this.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify Stremio addon to add video (incremental update like FUSE)
|
||||
*/
|
||||
private async notifyStremioAdd(hashId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('http://localhost:7000/add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hashId }),
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Silent fail - Stremio may not be running
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Silent fail - Stremio addon may not be running or network issue
|
||||
// This is expected during development when Stremio isn't started
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify Stremio addon to remove video (incremental update like FUSE)
|
||||
*/
|
||||
private async notifyStremioRemove(hashId: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('http://localhost:7000/remove', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hashId }),
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Silent fail - Stremio may not be running
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Silent fail - Stremio addon may not be running or network issue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the container plugin scheduler (called after initialization)
|
||||
*/
|
||||
setContainerPluginScheduler(scheduler: import('../../container-plugins/ContainerPluginScheduler.js').ContainerPluginScheduler): void {
|
||||
(this.config as any).containerPluginScheduler = scheduler;
|
||||
console.log('[Pipeline] Container plugin scheduler connected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch tasks to container plugins for metadata extraction
|
||||
* This is fire-and-forget - container plugins write results directly to meta-core
|
||||
*/
|
||||
private dispatchContainerPluginTasks(filePath: string, hashId: string, existingMeta: Record<string, any>): void {
|
||||
const scheduler = this.config.containerPluginScheduler;
|
||||
if (!scheduler) {
|
||||
return; // Container plugins not configured
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert metadata to flat string map for plugin API
|
||||
const metaFlat: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(existingMeta)) {
|
||||
if (value != null) {
|
||||
metaFlat[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch tasks to all enabled plugins
|
||||
scheduler.dispatchAllPlugins(hashId, filePath, metaFlat)
|
||||
.catch(err => {
|
||||
console.error(`[Pipeline] Failed to dispatch container plugin tasks for ${filePath}:`, err.message);
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[Pipeline] Error dispatching container plugin tasks for ${filePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StreamingPipeline } from './StreamingPipeline.js';
|
||||
export { PipelineConfig, DEFAULT_PIPELINE_CONFIG } from './PipelineConfig.js';
|
||||
@@ -0,0 +1,36 @@
|
||||
import lodash from 'lodash';
|
||||
import {HashMeta} from "@metazla/meta-interface";
|
||||
|
||||
/**
|
||||
* Merge the new metadata into the old metadata
|
||||
* @param existingMetadata
|
||||
* @param newMetadata
|
||||
* @param id
|
||||
* @returns {boolean} true if the metadata has changed
|
||||
*/
|
||||
export function mergeMetadata(existingMetadata: any, newMetadata: any, id?:string): boolean{
|
||||
let hasChanged = false;
|
||||
const mergeLogic = (existingVal:any, newVal:any, key:string) => {
|
||||
if ((lodash.isObject(existingVal) || lodash.isObject(newVal)) ||
|
||||
(lodash.isArray(existingVal) || lodash.isArray(newVal))) {
|
||||
// Recursively merge objects and arrays
|
||||
return lodash.mergeWith(existingVal, newVal, mergeLogic);
|
||||
}
|
||||
|
||||
if (existingVal) {
|
||||
if (!lodash.isEqual(""+existingVal, ""+newVal)){
|
||||
//conflict on primitive types
|
||||
console.warn(`Conflict found for '${id}'. Existing value: '${existingVal}', New value: '${newVal}'.`);
|
||||
}
|
||||
// Keep the original value (especially in case of conflict)
|
||||
return existingVal;
|
||||
}else {
|
||||
if(newVal !== undefined) {
|
||||
hasChanged = true;
|
||||
}
|
||||
return newVal;
|
||||
}
|
||||
};
|
||||
lodash.mergeWith(existingMetadata, newMetadata, mergeLogic);
|
||||
return hasChanged;
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
/**
|
||||
* Performance metrics tracker for meta-mesh
|
||||
* Tracks detailed performance statistics and timing information
|
||||
*/
|
||||
|
||||
export interface MetricEntry {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FailedFileEntry {
|
||||
filePath: string;
|
||||
reason: string;
|
||||
timestamp: number;
|
||||
retryCount: number;
|
||||
stage: 'metadata' | 'hash' | 'processing';
|
||||
}
|
||||
|
||||
export interface HashTimingStats {
|
||||
total: number;
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface PluginTimingStats {
|
||||
total: number;
|
||||
count: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface PerformanceMetricsData {
|
||||
// File processing metrics
|
||||
totalFilesProcessed: number;
|
||||
averageFileProcessingTime: number;
|
||||
minFileProcessingTime: number;
|
||||
maxFileProcessingTime: number;
|
||||
recentFileProcessingTimes: MetricEntry[];
|
||||
|
||||
// Metadata (light processing) metrics
|
||||
totalMetadataProcessed: number;
|
||||
averageMetadataProcessingTime: number;
|
||||
minMetadataProcessingTime: number;
|
||||
maxMetadataProcessingTime: number;
|
||||
|
||||
// Hash processing metrics
|
||||
totalHashProcessed: number;
|
||||
averageHashProcessingTime: number;
|
||||
minHashProcessingTime: number;
|
||||
maxHashProcessingTime: number;
|
||||
|
||||
// Per-hash-algorithm timing metrics (excludes cache hits)
|
||||
hashComputationTimes: Record<string, HashTimingStats>;
|
||||
|
||||
// Per-plugin timing metrics
|
||||
pluginProcessingTimes: Record<string, PluginTimingStats>;
|
||||
|
||||
// VFS update metrics
|
||||
totalVFSUpdates: number;
|
||||
averageVFSUpdateTime: number;
|
||||
lastVFSUpdateTime: number;
|
||||
recentVFSUpdateTimes: MetricEntry[];
|
||||
|
||||
// Duplicate detection metrics
|
||||
averageDuplicateDetectionTime: number;
|
||||
lastDuplicateDetectionTime: number;
|
||||
totalDuplicatesFound: number;
|
||||
|
||||
// Virtual structure generation metrics
|
||||
averageVirtualStructureTime: number;
|
||||
lastVirtualStructureTime: number;
|
||||
|
||||
// Failed files tracking
|
||||
failedFiles: FailedFileEntry[];
|
||||
totalFailedFiles: number;
|
||||
totalMetadataFailures: number;
|
||||
totalHashFailures: number;
|
||||
|
||||
// System metrics
|
||||
uptime: number;
|
||||
startTime: number;
|
||||
lastActivityTime: number;
|
||||
|
||||
// Memory and performance
|
||||
memoryUsage?: NodeJS.MemoryUsage;
|
||||
|
||||
// Processing rate
|
||||
filesPerSecond: number;
|
||||
totalProcessingTime: number;
|
||||
|
||||
// Cache metrics
|
||||
cacheHits: Record<string, number>;
|
||||
cacheMisses: Record<string, number>;
|
||||
cacheHitRate: Record<string, number>;
|
||||
}
|
||||
|
||||
export class PerformanceMetrics {
|
||||
private startTime: number;
|
||||
private lastActivityTime: number;
|
||||
|
||||
// File processing tracking - running statistics
|
||||
private recentFileProcessing: MetricEntry[] = [];
|
||||
private totalFilesProcessed: number = 0;
|
||||
private fileProcessingSum: number = 0;
|
||||
private fileProcessingMin: number = Infinity;
|
||||
private fileProcessingMax: number = 0;
|
||||
|
||||
// Metadata (light processing) tracking - running statistics
|
||||
private totalMetadataProcessed: number = 0;
|
||||
private metadataProcessingSum: number = 0;
|
||||
private metadataProcessingMin: number = Infinity;
|
||||
private metadataProcessingMax: number = 0;
|
||||
|
||||
// Hash processing tracking - running statistics
|
||||
private totalHashProcessed: number = 0;
|
||||
private hashProcessingSum: number = 0;
|
||||
private hashProcessingMin: number = Infinity;
|
||||
private hashProcessingMax: number = 0;
|
||||
|
||||
// VFS update tracking - running statistics
|
||||
private recentVFSUpdates: MetricEntry[] = [];
|
||||
private totalVFSUpdates: number = 0;
|
||||
private vfsUpdateSum: number = 0;
|
||||
private lastVFSUpdateTime: number = 0;
|
||||
|
||||
// Duplicate detection tracking - running statistics
|
||||
private totalDuplicatesFound: number = 0;
|
||||
private duplicateDetectionSum: number = 0;
|
||||
private duplicateDetectionCount: number = 0;
|
||||
private lastDuplicateDetectionTime: number = 0;
|
||||
|
||||
// Virtual structure generation tracking - running statistics
|
||||
private virtualStructureSum: number = 0;
|
||||
private virtualStructureCount: number = 0;
|
||||
private lastVirtualStructureTime: number = 0;
|
||||
|
||||
// Failed files tracking
|
||||
private failedFiles: FailedFileEntry[] = [];
|
||||
private totalMetadataFailures: number = 0;
|
||||
private totalHashFailures: number = 0;
|
||||
|
||||
// Cache metrics tracking
|
||||
private cacheHits: Record<string, number> = {};
|
||||
private cacheMisses: Record<string, number> = {};
|
||||
|
||||
// Per-hash-algorithm timing tracking (excludes cache hits)
|
||||
private hashTimings: Record<string, {
|
||||
sum: number;
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}> = {};
|
||||
|
||||
// Per-plugin timing tracking
|
||||
private pluginTimings: Record<string, {
|
||||
sum: number;
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}> = {};
|
||||
|
||||
// Configuration
|
||||
private maxRecentEntries: number = 100;
|
||||
private maxFailedFilesHistory: number = 50;
|
||||
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.lastActivityTime = this.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a file processing event
|
||||
*/
|
||||
recordFileProcessing(filePath: string, timeMs: number): void {
|
||||
// Update running statistics
|
||||
this.fileProcessingSum += timeMs;
|
||||
this.fileProcessingMin = Math.min(this.fileProcessingMin, timeMs);
|
||||
this.fileProcessingMax = Math.max(this.fileProcessingMax, timeMs);
|
||||
this.totalFilesProcessed++;
|
||||
|
||||
// Keep recent list for display only
|
||||
this.recentFileProcessing.push({
|
||||
timestamp: Date.now(),
|
||||
value: timeMs,
|
||||
label: filePath
|
||||
});
|
||||
|
||||
if (this.recentFileProcessing.length > this.maxRecentEntries) {
|
||||
this.recentFileProcessing.shift();
|
||||
}
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a metadata (light processing) event
|
||||
*/
|
||||
recordMetadataProcessing(timeMs: number): void {
|
||||
// Update running statistics
|
||||
this.metadataProcessingSum += timeMs;
|
||||
this.metadataProcessingMin = Math.min(this.metadataProcessingMin, timeMs);
|
||||
this.metadataProcessingMax = Math.max(this.metadataProcessingMax, timeMs);
|
||||
this.totalMetadataProcessed++;
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a hash processing event
|
||||
*/
|
||||
recordHashProcessing(timeMs: number): void {
|
||||
// Update running statistics
|
||||
this.hashProcessingSum += timeMs;
|
||||
this.hashProcessingMin = Math.min(this.hashProcessingMin, timeMs);
|
||||
this.hashProcessingMax = Math.max(this.hashProcessingMax, timeMs);
|
||||
this.totalHashProcessed++;
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a VFS update event
|
||||
*/
|
||||
recordVFSUpdate(timeMs: number): void {
|
||||
// Update running statistics
|
||||
this.vfsUpdateSum += timeMs;
|
||||
this.lastVFSUpdateTime = timeMs;
|
||||
this.totalVFSUpdates++;
|
||||
|
||||
// Keep recent list for display only
|
||||
this.recentVFSUpdates.push({
|
||||
timestamp: Date.now(),
|
||||
value: timeMs
|
||||
});
|
||||
|
||||
if (this.recentVFSUpdates.length > this.maxRecentEntries) {
|
||||
this.recentVFSUpdates.shift();
|
||||
}
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a duplicate detection event
|
||||
*/
|
||||
recordDuplicateDetection(timeMs: number, duplicatesFound: number): void {
|
||||
// Update running statistics
|
||||
this.duplicateDetectionSum += timeMs;
|
||||
this.duplicateDetectionCount++;
|
||||
this.lastDuplicateDetectionTime = timeMs;
|
||||
this.totalDuplicatesFound += duplicatesFound;
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a virtual structure generation event
|
||||
*/
|
||||
recordVirtualStructureGeneration(timeMs: number): void {
|
||||
// Update running statistics
|
||||
this.virtualStructureSum += timeMs;
|
||||
this.virtualStructureCount++;
|
||||
this.lastVirtualStructureTime = timeMs;
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cache hit event
|
||||
*/
|
||||
recordCacheHit(cacheType: string): void {
|
||||
if (!this.cacheHits[cacheType]) {
|
||||
this.cacheHits[cacheType] = 0;
|
||||
}
|
||||
this.cacheHits[cacheType]++;
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a cache miss event
|
||||
*/
|
||||
recordCacheMiss(cacheType: string): void {
|
||||
if (!this.cacheMisses[cacheType]) {
|
||||
this.cacheMisses[cacheType] = 0;
|
||||
}
|
||||
this.cacheMisses[cacheType]++;
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record hash computation time for a specific algorithm (excludes cache hits)
|
||||
* @param hashAlgorithm - Hash algorithm name (e.g., 'cid_sha2-256', 'cid_midhash256')
|
||||
* @param timeMs - Time taken to compute the hash in milliseconds
|
||||
*/
|
||||
recordHashComputation(hashAlgorithm: string, timeMs: number): void {
|
||||
if (!this.hashTimings[hashAlgorithm]) {
|
||||
this.hashTimings[hashAlgorithm] = {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
min: Infinity,
|
||||
max: 0
|
||||
};
|
||||
}
|
||||
|
||||
const stats = this.hashTimings[hashAlgorithm];
|
||||
stats.sum += timeMs;
|
||||
stats.count++;
|
||||
stats.min = Math.min(stats.min, timeMs);
|
||||
stats.max = Math.max(stats.max, timeMs);
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record plugin processing time
|
||||
* @param pluginName - Name of the plugin
|
||||
* @param timeMs - Time taken by the plugin in milliseconds
|
||||
*/
|
||||
recordPluginProcessing(pluginName: string, timeMs: number): void {
|
||||
if (!this.pluginTimings[pluginName]) {
|
||||
this.pluginTimings[pluginName] = {
|
||||
sum: 0,
|
||||
count: 0,
|
||||
min: Infinity,
|
||||
max: 0
|
||||
};
|
||||
}
|
||||
|
||||
const stats = this.pluginTimings[pluginName];
|
||||
stats.sum += timeMs;
|
||||
stats.count++;
|
||||
stats.min = Math.min(stats.min, timeMs);
|
||||
stats.max = Math.max(stats.max, timeMs);
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed file processing event
|
||||
*/
|
||||
recordFailedFile(filePath: string, reason: string, retryCount: number, stage: 'metadata' | 'hash' | 'processing'): void {
|
||||
const failedEntry: FailedFileEntry = {
|
||||
filePath,
|
||||
reason,
|
||||
timestamp: Date.now(),
|
||||
retryCount,
|
||||
stage
|
||||
};
|
||||
|
||||
// Add to failed files list (most recent first)
|
||||
this.failedFiles.unshift(failedEntry);
|
||||
|
||||
// Trim history if needed
|
||||
if (this.failedFiles.length > this.maxFailedFilesHistory) {
|
||||
this.failedFiles = this.failedFiles.slice(0, this.maxFailedFilesHistory);
|
||||
}
|
||||
|
||||
// Update failure counters
|
||||
if (stage === 'metadata') {
|
||||
this.totalMetadataFailures++;
|
||||
} else {
|
||||
this.totalHashFailures++;
|
||||
}
|
||||
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics data - O(1) complexity using cached running statistics
|
||||
*/
|
||||
getMetrics(): PerformanceMetricsData & {
|
||||
// UI compatibility aliases
|
||||
totalFilesDiscovered: number;
|
||||
totalFilesFailed: number;
|
||||
averageLightProcessingMs: number;
|
||||
averageHashProcessingMs: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const uptime = now - this.startTime;
|
||||
const totalFailedFiles = this.totalMetadataFailures + this.totalHashFailures;
|
||||
const avgMetadata = this.totalMetadataProcessed > 0
|
||||
? this.metadataProcessingSum / this.totalMetadataProcessed
|
||||
: 0;
|
||||
const avgHash = this.totalHashProcessed > 0
|
||||
? this.hashProcessingSum / this.totalHashProcessed
|
||||
: 0;
|
||||
|
||||
return {
|
||||
// File processing metrics - O(1) from running statistics
|
||||
totalFilesProcessed: this.totalFilesProcessed,
|
||||
|
||||
// UI compatibility aliases
|
||||
totalFilesDiscovered: this.totalFilesProcessed + totalFailedFiles,
|
||||
totalFilesFailed: totalFailedFiles,
|
||||
averageLightProcessingMs: avgMetadata,
|
||||
averageHashProcessingMs: avgHash,
|
||||
averageFileProcessingTime: this.totalFilesProcessed > 0
|
||||
? this.fileProcessingSum / this.totalFilesProcessed
|
||||
: 0,
|
||||
minFileProcessingTime: this.fileProcessingMin === Infinity ? 0 : this.fileProcessingMin,
|
||||
maxFileProcessingTime: this.fileProcessingMax,
|
||||
recentFileProcessingTimes: this.recentFileProcessing.slice(-20), // Last 20 files
|
||||
|
||||
// Metadata (light processing) metrics - O(1) from running statistics
|
||||
totalMetadataProcessed: this.totalMetadataProcessed,
|
||||
averageMetadataProcessingTime: this.totalMetadataProcessed > 0
|
||||
? this.metadataProcessingSum / this.totalMetadataProcessed
|
||||
: 0,
|
||||
minMetadataProcessingTime: this.metadataProcessingMin === Infinity ? 0 : this.metadataProcessingMin,
|
||||
maxMetadataProcessingTime: this.metadataProcessingMax,
|
||||
|
||||
// Hash processing metrics - O(1) from running statistics
|
||||
totalHashProcessed: this.totalHashProcessed,
|
||||
averageHashProcessingTime: this.totalHashProcessed > 0
|
||||
? this.hashProcessingSum / this.totalHashProcessed
|
||||
: 0,
|
||||
minHashProcessingTime: this.hashProcessingMin === Infinity ? 0 : this.hashProcessingMin,
|
||||
maxHashProcessingTime: this.hashProcessingMax,
|
||||
|
||||
// Per-hash-algorithm timing metrics (excludes cache hits)
|
||||
hashComputationTimes: Object.keys(this.hashTimings).reduce((acc, hashAlgorithm) => {
|
||||
const stats = this.hashTimings[hashAlgorithm];
|
||||
acc[hashAlgorithm] = {
|
||||
total: stats.sum,
|
||||
count: stats.count,
|
||||
average: stats.count > 0 ? stats.sum / stats.count : 0,
|
||||
min: stats.min === Infinity ? 0 : stats.min,
|
||||
max: stats.max
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, HashTimingStats>),
|
||||
|
||||
// Per-plugin timing metrics
|
||||
pluginProcessingTimes: Object.keys(this.pluginTimings).reduce((acc, pluginName) => {
|
||||
const stats = this.pluginTimings[pluginName];
|
||||
acc[pluginName] = {
|
||||
total: stats.sum,
|
||||
count: stats.count,
|
||||
average: stats.count > 0 ? stats.sum / stats.count : 0,
|
||||
min: stats.min === Infinity ? 0 : stats.min,
|
||||
max: stats.max
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, PluginTimingStats>),
|
||||
|
||||
// VFS update metrics - O(1) from running statistics
|
||||
totalVFSUpdates: this.totalVFSUpdates,
|
||||
averageVFSUpdateTime: this.totalVFSUpdates > 0
|
||||
? this.vfsUpdateSum / this.totalVFSUpdates
|
||||
: 0,
|
||||
lastVFSUpdateTime: this.lastVFSUpdateTime,
|
||||
recentVFSUpdateTimes: this.recentVFSUpdates.slice(-10), // Last 10 updates
|
||||
|
||||
// Duplicate detection metrics - O(1) from running statistics
|
||||
averageDuplicateDetectionTime: this.duplicateDetectionCount > 0
|
||||
? this.duplicateDetectionSum / this.duplicateDetectionCount
|
||||
: 0,
|
||||
lastDuplicateDetectionTime: this.lastDuplicateDetectionTime,
|
||||
totalDuplicatesFound: this.totalDuplicatesFound,
|
||||
|
||||
// Virtual structure generation metrics - O(1) from running statistics
|
||||
averageVirtualStructureTime: this.virtualStructureCount > 0
|
||||
? this.virtualStructureSum / this.virtualStructureCount
|
||||
: 0,
|
||||
lastVirtualStructureTime: this.lastVirtualStructureTime,
|
||||
|
||||
// Failed files tracking
|
||||
failedFiles: this.failedFiles,
|
||||
totalFailedFiles: this.totalMetadataFailures + this.totalHashFailures,
|
||||
totalMetadataFailures: this.totalMetadataFailures,
|
||||
totalHashFailures: this.totalHashFailures,
|
||||
|
||||
// System metrics
|
||||
uptime: uptime,
|
||||
startTime: this.startTime,
|
||||
lastActivityTime: this.lastActivityTime,
|
||||
|
||||
// Memory usage
|
||||
memoryUsage: process.memoryUsage(),
|
||||
|
||||
// Processing rate
|
||||
filesPerSecond: uptime > 0 ? (this.totalFilesProcessed / (uptime / 1000)) : 0,
|
||||
totalProcessingTime: this.fileProcessingSum,
|
||||
|
||||
// Cache metrics
|
||||
cacheHits: { ...this.cacheHits },
|
||||
cacheMisses: { ...this.cacheMisses },
|
||||
cacheHitRate: Object.keys(this.cacheHits).reduce((acc, key) => {
|
||||
const hits = this.cacheHits[key] || 0;
|
||||
const misses = this.cacheMisses[key] || 0;
|
||||
const total = hits + misses;
|
||||
acc[key] = total > 0 ? (hits / total) * 100 : 0;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timing stats for a specific plugin
|
||||
* @param pluginId - Plugin identifier
|
||||
* @returns Plugin timing stats or null if no data
|
||||
*/
|
||||
getPluginTiming(pluginId: string): PluginTimingStats | null {
|
||||
const stats = this.pluginTimings[pluginId];
|
||||
if (!stats || stats.count === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
total: stats.sum,
|
||||
count: stats.count,
|
||||
average: stats.sum / stats.count,
|
||||
min: stats.min === Infinity ? 0 : stats.min,
|
||||
max: stats.max
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
reset(): void {
|
||||
// Reset file processing metrics
|
||||
this.recentFileProcessing = [];
|
||||
this.totalFilesProcessed = 0;
|
||||
this.fileProcessingSum = 0;
|
||||
this.fileProcessingMin = Infinity;
|
||||
this.fileProcessingMax = 0;
|
||||
|
||||
// Reset metadata processing metrics
|
||||
this.totalMetadataProcessed = 0;
|
||||
this.metadataProcessingSum = 0;
|
||||
this.metadataProcessingMin = Infinity;
|
||||
this.metadataProcessingMax = 0;
|
||||
|
||||
// Reset hash processing metrics
|
||||
this.totalHashProcessed = 0;
|
||||
this.hashProcessingSum = 0;
|
||||
this.hashProcessingMin = Infinity;
|
||||
this.hashProcessingMax = 0;
|
||||
|
||||
// Reset VFS update metrics
|
||||
this.recentVFSUpdates = [];
|
||||
this.totalVFSUpdates = 0;
|
||||
this.vfsUpdateSum = 0;
|
||||
this.lastVFSUpdateTime = 0;
|
||||
|
||||
// Reset duplicate detection metrics
|
||||
this.totalDuplicatesFound = 0;
|
||||
this.duplicateDetectionSum = 0;
|
||||
this.duplicateDetectionCount = 0;
|
||||
this.lastDuplicateDetectionTime = 0;
|
||||
|
||||
// Reset virtual structure generation metrics
|
||||
this.virtualStructureSum = 0;
|
||||
this.virtualStructureCount = 0;
|
||||
this.lastVirtualStructureTime = 0;
|
||||
|
||||
// Reset failed files tracking
|
||||
this.failedFiles = [];
|
||||
this.totalMetadataFailures = 0;
|
||||
this.totalHashFailures = 0;
|
||||
|
||||
// Reset cache metrics
|
||||
this.cacheHits = {};
|
||||
this.cacheMisses = {};
|
||||
|
||||
// Reset hash computation timing
|
||||
this.hashTimings = {};
|
||||
|
||||
// Reset plugin processing timing
|
||||
this.pluginTimings = {};
|
||||
|
||||
this.startTime = Date.now();
|
||||
this.lastActivityTime = this.startTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const performanceMetrics = new PerformanceMetrics();
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Plugin Context Implementation
|
||||
*
|
||||
* Provides KV store, cache, and logger implementations for plugins.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import type { MetadataNode } from '@metazla/meta-interface';
|
||||
import type {
|
||||
PluginKVStore,
|
||||
PluginCache,
|
||||
PluginLogger,
|
||||
PluginContext,
|
||||
PluginLifecycleContext,
|
||||
} from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// KV Store Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* In-memory KV store implementation with path-style keys
|
||||
*/
|
||||
export class KVStore implements PluginKVStore {
|
||||
private data: Map<string, string> = new Map();
|
||||
|
||||
constructor(initialData?: Map<string, string> | Record<string, string>) {
|
||||
if (initialData) {
|
||||
if (initialData instanceof Map) {
|
||||
this.data = new Map(initialData);
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(initialData)) {
|
||||
if (typeof value === 'string') {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
return this.data.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (key === undefined || key === null || key === '') {
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
this.data.set(key, String(value));
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
|
||||
keys(prefix?: string): string[] {
|
||||
const allKeys = Array.from(this.data.keys());
|
||||
if (!prefix) {
|
||||
return allKeys;
|
||||
}
|
||||
return allKeys.filter(k => k.startsWith(prefix));
|
||||
}
|
||||
|
||||
entries(prefix?: string): Array<[string, string]> {
|
||||
const allEntries = Array.from(this.data.entries());
|
||||
if (!prefix) {
|
||||
return allEntries;
|
||||
}
|
||||
return allEntries.filter(([k]) => k.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all data as a plain object (for serialization)
|
||||
*/
|
||||
toObject(): Record<string, string> {
|
||||
const obj: Record<string, string> = {};
|
||||
this.data.forEach((value, key) => {
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Map
|
||||
*/
|
||||
toMap(): Map<string, string> {
|
||||
return new Map(this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data
|
||||
*/
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MetadataNode KV Store Adapter
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* KV store that wraps MetadataNode from @metazla/meta-interface.
|
||||
* This ensures plugins output the same nested object format as the old processors.
|
||||
*
|
||||
* Path conversion:
|
||||
* - 'fileType' -> metadata.at('fileType')
|
||||
* - 'fileinfo/duration' -> metadata.at('fileinfo').at('duration')
|
||||
* - 'fileinfo/streamdetails/video/0/codec' -> metadata.at('fileinfo').at('streamdetails').at('video').at('0').at('codec')
|
||||
*/
|
||||
export class MetadataNodeKVStore implements PluginKVStore {
|
||||
private metadata: MetadataNode<any>;
|
||||
|
||||
constructor(metadata: MetadataNode<any>) {
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a nested node using path segments
|
||||
*/
|
||||
private navigate(key: string): MetadataNode<any> {
|
||||
const parts = key.split('/');
|
||||
let node = this.metadata;
|
||||
for (const part of parts) {
|
||||
node = node.at(part);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
try {
|
||||
const value = this.navigate(key).get();
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
// Return undefined for objects (indicates path to nested data)
|
||||
return undefined;
|
||||
}
|
||||
return String(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
if (key === undefined || key === null || key === '') {
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.navigate(key).set(String(value));
|
||||
} catch (e) {
|
||||
console.error(`[MetadataNodeKVStore] Error setting ${key}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
// MetadataNode doesn't have a delete method, so we set to undefined
|
||||
try {
|
||||
this.navigate(key).set(undefined as any);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys is not easily supported by MetadataNode.
|
||||
* This returns an empty array - plugins should track their own keys if needed.
|
||||
*/
|
||||
keys(prefix?: string): string[] {
|
||||
// MetadataNode doesn't expose iteration over keys
|
||||
// Plugins that need to iterate should use the metadata object directly
|
||||
console.warn('[MetadataNodeKVStore] keys() not supported - use metadata.get() for object inspection');
|
||||
return [];
|
||||
}
|
||||
|
||||
entries(prefix?: string): Array<[string, string]> {
|
||||
console.warn('[MetadataNodeKVStore] entries() not supported - use metadata.get() for object inspection');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MetadataNode
|
||||
*/
|
||||
getMetadataNode(): MetadataNode<any> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a value to a set (for RecordSet fields like genres, tags)
|
||||
*/
|
||||
add(key: string, value: string): void {
|
||||
if (key === undefined || key === null || key === '') {
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.navigate(key).add(value);
|
||||
} catch (e) {
|
||||
console.error(`[MetadataNodeKVStore] Error adding to ${key}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full object at a path (for inspecting nested structures)
|
||||
*/
|
||||
getObject(key: string): any {
|
||||
try {
|
||||
return this.navigate(key).get();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cache Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* File-based cache implementation for plugins
|
||||
*/
|
||||
export class PluginCacheImpl implements PluginCache {
|
||||
private cacheDir: string;
|
||||
private initialized: boolean = false;
|
||||
|
||||
constructor(cacheDir: string) {
|
||||
this.cacheDir = cacheDir;
|
||||
}
|
||||
|
||||
private ensureDir(): void {
|
||||
if (!this.initialized) {
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
getPath(filename: string): string {
|
||||
this.ensureDir();
|
||||
return path.join(this.cacheDir, filename);
|
||||
}
|
||||
|
||||
async readJson<T>(filename: string): Promise<T | null> {
|
||||
try {
|
||||
const filePath = this.getPath(filename);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(data) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async writeJson(filename: string, data: unknown): Promise<void> {
|
||||
this.ensureDir();
|
||||
const filePath = this.getPath(filename);
|
||||
const dir = path.dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async exists(filename: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.getPath(filename));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(filename: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.getPath(filename));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
try {
|
||||
const files = await fs.readdir(this.cacheDir);
|
||||
await Promise.all(
|
||||
files.map(file => fs.unlink(path.join(this.cacheDir, file)).catch(() => {}))
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Logger Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Scoped logger implementation for plugins
|
||||
*/
|
||||
export class PluginLoggerImpl implements PluginLogger {
|
||||
private prefix: string;
|
||||
|
||||
constructor(pluginId: string) {
|
||||
this.prefix = `[plugin:${pluginId}]`;
|
||||
}
|
||||
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
console.debug(this.prefix, message, ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
console.info(this.prefix, message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
console.warn(this.prefix, message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
console.error(this.prefix, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context Factory
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a lifecycle context for a plugin
|
||||
*/
|
||||
export function createLifecycleContext(
|
||||
pluginId: string,
|
||||
config: Record<string, unknown>,
|
||||
cacheDir: string
|
||||
): PluginLifecycleContext {
|
||||
return {
|
||||
config,
|
||||
cacheDir,
|
||||
log: new PluginLoggerImpl(pluginId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full plugin context for file processing
|
||||
*/
|
||||
export function createPluginContext(
|
||||
pluginId: string,
|
||||
filePath: string,
|
||||
kv: PluginKVStore,
|
||||
config: Record<string, unknown>,
|
||||
cacheDir: string,
|
||||
metadata?: any
|
||||
): PluginContext {
|
||||
const ctx: PluginContext = {
|
||||
kv,
|
||||
filePath,
|
||||
config,
|
||||
cacheDir,
|
||||
cache: new PluginCacheImpl(cacheDir),
|
||||
log: new PluginLoggerImpl(pluginId),
|
||||
};
|
||||
|
||||
// If kv is a MetadataNodeKVStore, extract the MetadataNode
|
||||
if (metadata) {
|
||||
ctx.metadata = metadata;
|
||||
} else if (kv instanceof MetadataNodeKVStore) {
|
||||
ctx.metadata = kv.getMetadataNode();
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Plugin Executor
|
||||
*
|
||||
* Handles dependency resolution for the container-based plugin system.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from './types.js';
|
||||
|
||||
// =============================================================================
|
||||
// Dependency Resolution
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of dependency resolution
|
||||
*/
|
||||
export interface DependencyResolution {
|
||||
order: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform topological sort on plugins based on dependencies.
|
||||
* Returns execution order (plugins that must run first come first).
|
||||
*
|
||||
* @param plugins Map of plugin id to manifest
|
||||
* @param activePluginIds Set of active plugin ids to include in the order
|
||||
*/
|
||||
export function resolveDependencyOrder(
|
||||
plugins: Map<string, PluginManifest>,
|
||||
activePluginIds: Set<string>
|
||||
): DependencyResolution {
|
||||
const errors: string[] = [];
|
||||
const order: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>(); // For cycle detection
|
||||
|
||||
// Filter to only active plugins
|
||||
const activePlugins = new Map<string, PluginManifest>();
|
||||
activePluginIds.forEach(pluginId => {
|
||||
const manifest = plugins.get(pluginId);
|
||||
if (manifest) {
|
||||
activePlugins.set(pluginId, manifest);
|
||||
}
|
||||
});
|
||||
|
||||
// DFS visit function
|
||||
function visit(pluginId: string): boolean {
|
||||
if (visited.has(pluginId)) {
|
||||
return true;
|
||||
}
|
||||
if (visiting.has(pluginId)) {
|
||||
errors.push(`Circular dependency detected involving plugin '${pluginId}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const manifest = activePlugins.get(pluginId);
|
||||
if (!manifest) {
|
||||
// Plugin not found or not active
|
||||
return true;
|
||||
}
|
||||
|
||||
visiting.add(pluginId);
|
||||
|
||||
// Visit dependencies first
|
||||
for (const depId of manifest.dependencies || []) {
|
||||
if (!activePlugins.has(depId)) {
|
||||
// Dependency is not active
|
||||
if (plugins.has(depId)) {
|
||||
errors.push(`Plugin '${pluginId}' depends on '${depId}' which is not active`);
|
||||
} else {
|
||||
errors.push(`Plugin '${pluginId}' depends on '${depId}' which does not exist`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!visit(depId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(pluginId);
|
||||
visited.add(pluginId);
|
||||
order.push(pluginId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Visit all active plugins
|
||||
Array.from(activePluginIds).forEach(pluginId => {
|
||||
if (!visited.has(pluginId)) {
|
||||
visit(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
return { order, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin can be activated (all dependencies are active)
|
||||
*/
|
||||
export function canActivate(
|
||||
pluginId: string,
|
||||
plugins: Map<string, PluginManifest>,
|
||||
activePluginIds: Set<string>
|
||||
): { canActivate: boolean; missingDeps: string[] } {
|
||||
const manifest = plugins.get(pluginId);
|
||||
if (!manifest) {
|
||||
return { canActivate: false, missingDeps: [] };
|
||||
}
|
||||
|
||||
const missingDeps: string[] = [];
|
||||
for (const depId of manifest.dependencies || []) {
|
||||
if (!activePluginIds.has(depId)) {
|
||||
missingDeps.push(depId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canActivate: missingDeps.length === 0,
|
||||
missingDeps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin can be deactivated (no active plugins depend on it)
|
||||
*/
|
||||
export function canDeactivate(
|
||||
pluginId: string,
|
||||
plugins: Map<string, PluginManifest>,
|
||||
activePluginIds: Set<string>
|
||||
): { canDeactivate: boolean; dependents: string[] } {
|
||||
const dependents: string[] = [];
|
||||
|
||||
activePluginIds.forEach(activeId => {
|
||||
if (activeId === pluginId) return;
|
||||
|
||||
const manifest = plugins.get(activeId);
|
||||
if (manifest?.dependencies?.includes(pluginId)) {
|
||||
dependents.push(activeId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
canDeactivate: dependents.length === 0,
|
||||
dependents,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Plugin Manager
|
||||
*
|
||||
* Main orchestrator for the container-based plugin system.
|
||||
* Handles plugin manifests, lifecycle management, and state persistence.
|
||||
* All plugins run as Docker containers - there is no local plugin execution.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
import type {
|
||||
PluginManifest,
|
||||
PluginState,
|
||||
PluginStateEntry,
|
||||
PluginInfo,
|
||||
LoadedPlugin,
|
||||
PluginManagerEvent,
|
||||
PluginKVStore,
|
||||
} from './types.js';
|
||||
import { KVStore } from './PluginContext.js';
|
||||
import {
|
||||
resolveDependencyOrder,
|
||||
canActivate,
|
||||
canDeactivate,
|
||||
} from './PluginExecutor.js';
|
||||
import type { ContainerManager, ContainerPluginManifest } from '../container-plugins/index.js';
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manager
|
||||
// =============================================================================
|
||||
|
||||
export interface PluginManagerOptions {
|
||||
/** Path to plugins.json state file */
|
||||
statePath: string;
|
||||
/** Base directory for plugin caches */
|
||||
cacheDir: string;
|
||||
/** Container manager for containerized plugins (required) */
|
||||
containerManager?: ContainerManager;
|
||||
}
|
||||
|
||||
export class PluginManager extends EventEmitter {
|
||||
private statePath: string;
|
||||
private cacheDir: string;
|
||||
|
||||
// Runtime state
|
||||
private manifests: Map<string, PluginManifest> = new Map();
|
||||
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
||||
private activePluginIds: Set<string> = new Set();
|
||||
private configs: Map<string, Record<string, unknown>> = new Map();
|
||||
private executionOrder: string[] = [];
|
||||
|
||||
// Container plugins (all plugins are container-based)
|
||||
private containerManager: ContainerManager | null = null;
|
||||
|
||||
constructor(options: PluginManagerOptions) {
|
||||
super();
|
||||
this.statePath = options.statePath;
|
||||
this.cacheDir = options.cacheDir;
|
||||
this.containerManager = options.containerManager || null;
|
||||
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
mkdirSync(this.cacheDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialization
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Initialize the plugin manager with container plugins.
|
||||
* This loads plugin manifests from the ContainerManager and sets up the execution order.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.containerManager) {
|
||||
console.warn('[PluginManager] No container manager set, no plugins will be available');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadContainerPlugins();
|
||||
console.log(`[PluginManager] Initialized with ${this.manifests.size} container plugins`);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Lifecycle Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Activate a plugin
|
||||
*/
|
||||
async activate(pluginId: string): Promise<void> {
|
||||
const manifest = this.manifests.get(pluginId);
|
||||
if (!manifest) {
|
||||
throw new Error(`Plugin '${pluginId}' not found`);
|
||||
}
|
||||
if (this.activePluginIds.has(pluginId)) {
|
||||
return; // Already active
|
||||
}
|
||||
|
||||
// Check dependencies
|
||||
const { canActivate: can, missingDeps } = canActivate(
|
||||
pluginId,
|
||||
this.manifests,
|
||||
this.activePluginIds
|
||||
);
|
||||
if (!can) {
|
||||
throw new Error(
|
||||
`Cannot activate '${pluginId}': missing dependencies: ${missingDeps.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as active
|
||||
this.activePluginIds.add(pluginId);
|
||||
this.rebuildExecutionOrder();
|
||||
|
||||
// Save state
|
||||
await this.saveState();
|
||||
|
||||
// Emit event
|
||||
this.emit('plugin:activated', { pluginId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate a plugin
|
||||
*/
|
||||
async deactivate(pluginId: string): Promise<void> {
|
||||
if (!this.activePluginIds.has(pluginId)) {
|
||||
return; // Already inactive
|
||||
}
|
||||
|
||||
// Check if other plugins depend on this one
|
||||
const { canDeactivate: can, dependents } = canDeactivate(
|
||||
pluginId,
|
||||
this.manifests,
|
||||
this.activePluginIds
|
||||
);
|
||||
if (!can) {
|
||||
throw new Error(
|
||||
`Cannot deactivate '${pluginId}': other plugins depend on it: ${dependents.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as inactive
|
||||
this.activePluginIds.delete(pluginId);
|
||||
this.rebuildExecutionOrder();
|
||||
|
||||
// Save state
|
||||
await this.saveState();
|
||||
|
||||
// Emit event
|
||||
this.emit('plugin:deactivated', { pluginId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plugin configuration
|
||||
*/
|
||||
async updateConfig(pluginId: string, config: Record<string, unknown>): Promise<void> {
|
||||
const manifest = this.manifests.get(pluginId);
|
||||
if (!manifest) {
|
||||
throw new Error(`Plugin '${pluginId}' not found`);
|
||||
}
|
||||
|
||||
// Store the config
|
||||
this.configs.set(pluginId, config);
|
||||
|
||||
// Save state
|
||||
await this.saveState();
|
||||
|
||||
// Emit event
|
||||
this.emit('plugin:config-changed', { pluginId, config });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Container Plugin Support
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Set the container manager (can be set after construction)
|
||||
*/
|
||||
setContainerManager(containerManager: ContainerManager): void {
|
||||
this.containerManager = containerManager;
|
||||
console.log('[PluginManager] Container manager set');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin is a containerized plugin (all plugins are container-based)
|
||||
*/
|
||||
isContainerPlugin(pluginId: string): boolean {
|
||||
return this.manifests.has(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugin manifests from ContainerManager
|
||||
* Should be called after container plugins are initialized
|
||||
*/
|
||||
async loadContainerPlugins(): Promise<void> {
|
||||
if (!this.containerManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = this.containerManager.getStatus();
|
||||
if (!status.initialized) {
|
||||
console.warn('[PluginManager] Container manager not initialized, skipping plugins');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load state for plugin active status
|
||||
const state = await this.loadState();
|
||||
|
||||
for (const pluginStatus of status.plugins) {
|
||||
const manifest = this.containerManager.getPluginManifest(pluginStatus.pluginId);
|
||||
if (!manifest) continue;
|
||||
|
||||
const pluginId = pluginStatus.pluginId;
|
||||
|
||||
// Convert container manifest to local manifest format
|
||||
const localManifest: PluginManifest = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description || '',
|
||||
author: manifest.author,
|
||||
dependencies: manifest.dependencies || [],
|
||||
schema: manifest.schema || {},
|
||||
config: manifest.config,
|
||||
priority: manifest.priority || 100,
|
||||
color: manifest.color,
|
||||
defaultQueue: manifest.defaultQueue || 'background',
|
||||
};
|
||||
|
||||
this.manifests.set(pluginId, localManifest);
|
||||
|
||||
// Container plugins are always "loaded" but execution is remote
|
||||
this.loadedPlugins.set(pluginId, {
|
||||
manifest: localManifest,
|
||||
instance: null as any, // No local instance for container plugins
|
||||
status: 'loaded',
|
||||
pluginDir: '', // No local dir
|
||||
});
|
||||
|
||||
// Check if should be active
|
||||
const stateEntry = state.plugins[pluginId];
|
||||
if (stateEntry?.active !== false) {
|
||||
// Default to active for plugins
|
||||
this.activePluginIds.add(pluginId);
|
||||
}
|
||||
|
||||
// Store config
|
||||
const config = stateEntry?.config || {};
|
||||
this.configs.set(pluginId, config);
|
||||
|
||||
console.log(`[PluginManager] Loaded plugin: ${pluginId}`);
|
||||
}
|
||||
|
||||
// Rebuild execution order
|
||||
this.rebuildExecutionOrder();
|
||||
await this.saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container manager instance
|
||||
*/
|
||||
getContainerManager(): ContainerManager | null {
|
||||
return this.containerManager;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get all discovered plugins
|
||||
*/
|
||||
getPlugins(): PluginInfo[] {
|
||||
const plugins: PluginInfo[] = [];
|
||||
|
||||
this.manifests.forEach((manifest, pluginId) => {
|
||||
const loaded = this.loadedPlugins.get(pluginId);
|
||||
const config = this.configs.get(pluginId) || {};
|
||||
|
||||
plugins.push({
|
||||
id: pluginId,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
priority: manifest.priority,
|
||||
color: manifest.color,
|
||||
dependencies: manifest.dependencies || [],
|
||||
active: this.activePluginIds.has(pluginId),
|
||||
config,
|
||||
configSchema: manifest.config,
|
||||
metadataSchema: manifest.schema,
|
||||
status: loaded?.status || 'unloaded',
|
||||
error: loaded?.error,
|
||||
isContainer: true, // All plugins are container-based
|
||||
});
|
||||
});
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only active plugins
|
||||
*/
|
||||
getActivePlugins(): PluginInfo[] {
|
||||
return this.getPlugins().filter(p => p.active);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution order (dependency-sorted active plugin IDs)
|
||||
*/
|
||||
getExecutionOrder(): string[] {
|
||||
return [...this.executionOrder];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new KV store for file processing
|
||||
*/
|
||||
createKVStore(initialData?: Record<string, string>): KVStore {
|
||||
return new KVStore(initialData);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shutdown
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Shutdown: clear plugin state
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
this.manifests.clear();
|
||||
this.loadedPlugins.clear();
|
||||
this.activePluginIds.clear();
|
||||
this.executionOrder = [];
|
||||
console.log('[PluginManager] Shutdown complete');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helpers
|
||||
// =========================================================================
|
||||
|
||||
private rebuildExecutionOrder(): void {
|
||||
const { order, errors } = resolveDependencyOrder(
|
||||
this.manifests,
|
||||
this.activePluginIds
|
||||
);
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
console.warn(`Dependency resolution warning: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.executionOrder = order;
|
||||
}
|
||||
|
||||
private async loadState(): Promise<PluginState> {
|
||||
try {
|
||||
if (existsSync(this.statePath)) {
|
||||
const content = await fs.readFile(this.statePath, 'utf8');
|
||||
return JSON.parse(content) as PluginState;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load plugin state:', error);
|
||||
}
|
||||
return { plugins: {} };
|
||||
}
|
||||
|
||||
private async saveState(): Promise<void> {
|
||||
const state: PluginState = { plugins: {} };
|
||||
|
||||
this.manifests.forEach((_, pluginId) => {
|
||||
state.plugins[pluginId] = {
|
||||
active: this.activePluginIds.has(pluginId),
|
||||
config: this.configs.get(pluginId) || {},
|
||||
};
|
||||
});
|
||||
|
||||
const dir = path.dirname(this.statePath);
|
||||
if (!existsSync(dir)) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await fs.writeFile(this.statePath, JSON.stringify(state, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a plugin manager with default paths based on config
|
||||
*/
|
||||
export function createPluginManager(
|
||||
statePath: string,
|
||||
cacheDir: string
|
||||
): PluginManager {
|
||||
return new PluginManager({
|
||||
statePath,
|
||||
cacheDir,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Task Scheduler
|
||||
*
|
||||
* Manages plugin task execution across fast and background queues.
|
||||
* All plugins run as Docker containers - task execution is delegated to ContainerPluginScheduler.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import PQueue from 'p-queue';
|
||||
import type {
|
||||
PluginTask,
|
||||
PluginTaskStatus,
|
||||
PluginTaskResult,
|
||||
TaskQueueType,
|
||||
QueueStatus,
|
||||
TaskSchedulerEvent,
|
||||
PluginManifest,
|
||||
PluginKVStore,
|
||||
} from './types.js';
|
||||
import type { PluginManager } from './PluginManager.js';
|
||||
import type { PerformanceMetrics, PluginTimingStats } from '../metrics/PerformanceMetrics.js';
|
||||
import type { ContainerPluginScheduler } from '../container-plugins/index.js';
|
||||
import { KVStore } from './PluginContext.js';
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
export interface TaskSchedulerConfig {
|
||||
/** Concurrency for fast queue (default: 32) */
|
||||
fastQueueConcurrency?: number;
|
||||
/** Concurrency for background queue (default: 8) */
|
||||
backgroundQueueConcurrency?: number;
|
||||
/** Threshold in ms for fast classification (default: 1000) */
|
||||
fastThresholdMs?: number;
|
||||
/** Minimum samples before using measured timing (default: 10) */
|
||||
minSamplesForMeasurement?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: Required<TaskSchedulerConfig> = {
|
||||
fastQueueConcurrency: 32,
|
||||
backgroundQueueConcurrency: 8,
|
||||
fastThresholdMs: 1000,
|
||||
minSamplesForMeasurement: 10,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Task Scheduler
|
||||
// =============================================================================
|
||||
|
||||
export class TaskScheduler extends EventEmitter {
|
||||
private pluginManager: PluginManager;
|
||||
private performanceMetrics: PerformanceMetrics;
|
||||
private config: Required<TaskSchedulerConfig>;
|
||||
|
||||
// Queues
|
||||
private fastQueue: PQueue;
|
||||
private backgroundQueue: PQueue;
|
||||
|
||||
// Task tracking
|
||||
private pendingTasks: Map<string, PluginTask> = new Map();
|
||||
private completedTasks: Set<string> = new Set();
|
||||
private fileTasks: Map<string, Set<string>> = new Map(); // fileHash -> taskIds
|
||||
private fileKVStores: Map<string, PluginKVStore> = new Map(); // fileHash -> KV store
|
||||
|
||||
// Container plugin support
|
||||
private containerPluginScheduler: ContainerPluginScheduler | null = null;
|
||||
|
||||
// Statistics
|
||||
private totalTasksCreated: number = 0;
|
||||
private totalTasksCompleted: number = 0;
|
||||
private totalTasksFailed: number = 0;
|
||||
|
||||
constructor(
|
||||
pluginManager: PluginManager,
|
||||
performanceMetrics: PerformanceMetrics,
|
||||
config: TaskSchedulerConfig = {}
|
||||
) {
|
||||
super();
|
||||
this.pluginManager = pluginManager;
|
||||
this.performanceMetrics = performanceMetrics;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
// Initialize fast queue
|
||||
this.fastQueue = new PQueue({
|
||||
concurrency: this.config.fastQueueConcurrency,
|
||||
});
|
||||
|
||||
// Initialize background queue (starts paused)
|
||||
this.backgroundQueue = new PQueue({
|
||||
concurrency: this.config.backgroundQueueConcurrency,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
// When fast queue becomes idle, start background queue
|
||||
this.fastQueue.on('idle', () => {
|
||||
this.onFastQueueIdle();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the container plugin scheduler (called after initialization)
|
||||
*/
|
||||
setContainerPluginScheduler(scheduler: ContainerPluginScheduler): void {
|
||||
this.containerPluginScheduler = scheduler;
|
||||
|
||||
// Forward container scheduler events to this scheduler
|
||||
scheduler.on('task:completed', ({ task }) => {
|
||||
this.onContainerTaskComplete(task.id, task.fileHash, true);
|
||||
});
|
||||
scheduler.on('task:failed', ({ task }) => {
|
||||
this.onContainerTaskComplete(task.id, task.fileHash, false);
|
||||
});
|
||||
scheduler.on('file:complete', ({ fileHash, filePath }) => {
|
||||
// Container file completion - check if local tasks are also done
|
||||
this.checkFileComplete(fileHash);
|
||||
});
|
||||
|
||||
console.log('[TaskScheduler] Container plugin scheduler set');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle completion of a container plugin task
|
||||
*/
|
||||
private onContainerTaskComplete(taskId: string, fileHash: string, success: boolean): void {
|
||||
// Move from pending to completed
|
||||
this.pendingTasks.delete(taskId);
|
||||
this.completedTasks.add(taskId);
|
||||
|
||||
if (success) {
|
||||
this.totalTasksCompleted++;
|
||||
} else {
|
||||
this.totalTasksFailed++;
|
||||
}
|
||||
|
||||
// Schedule dependent tasks
|
||||
const task = { id: taskId, fileHash, pluginId: taskId.split(':')[1] } as PluginTask;
|
||||
this.scheduleDependentTasks(task);
|
||||
|
||||
// Check if file is complete
|
||||
this.checkFileComplete(fileHash);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Task Creation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create tasks for all active plugins for a file
|
||||
* @param filePath - Absolute path to the file
|
||||
* @param fileHash - File identity hash (midhash256)
|
||||
* @param kv - KV store for the file (will be shared across all plugins)
|
||||
*/
|
||||
createTasksForFile(filePath: string, fileHash: string, kv?: PluginKVStore): PluginTask[] {
|
||||
const tasks: PluginTask[] = [];
|
||||
const executionOrder = this.pluginManager.getExecutionOrder();
|
||||
const plugins = this.pluginManager.getPlugins();
|
||||
const pluginMap = new Map(plugins.map(p => [p.id, p]));
|
||||
|
||||
// Initialize file task tracking
|
||||
if (!this.fileTasks.has(fileHash)) {
|
||||
this.fileTasks.set(fileHash, new Set());
|
||||
}
|
||||
const fileTaskIds = this.fileTasks.get(fileHash)!;
|
||||
|
||||
// Store KV for this file (create new if not provided)
|
||||
if (!this.fileKVStores.has(fileHash)) {
|
||||
this.fileKVStores.set(fileHash, kv || new KVStore());
|
||||
}
|
||||
|
||||
for (const pluginId of executionOrder) {
|
||||
const plugin = pluginMap.get(pluginId);
|
||||
if (!plugin || !plugin.active) continue;
|
||||
|
||||
const taskId = `${fileHash}:${pluginId}`;
|
||||
|
||||
// Skip if task already exists
|
||||
if (this.pendingTasks.has(taskId) || this.completedTasks.has(taskId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timing = this.performanceMetrics.getPluginTiming(pluginId);
|
||||
const queue = this.classifyPlugin(pluginId);
|
||||
|
||||
const task: PluginTask = {
|
||||
id: taskId,
|
||||
fileHash,
|
||||
filePath,
|
||||
pluginId,
|
||||
dependencies: plugin.dependencies || [],
|
||||
queue,
|
||||
priority: timing?.average || 0,
|
||||
estimatedTimeMs: timing?.average || 0,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
fileTaskIds.add(taskId);
|
||||
this.totalTasksCreated++;
|
||||
|
||||
this.emitEvent({ type: 'task:created', task });
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a task for a specific plugin on a single file
|
||||
* Used for recompute scenarios where we want to run just one plugin
|
||||
* @param pluginId - Plugin to execute
|
||||
* @param filePath - Absolute path to the file
|
||||
* @param fileHash - File identity hash (midhash256)
|
||||
* @param options - Runtime options merged into plugin config (e.g., { forceRecompute: true })
|
||||
* @param kvData - Optional initial KV data (flattened metadata from Redis)
|
||||
* @returns The created task, or null if plugin not found/not active
|
||||
*/
|
||||
createTaskForPlugin(
|
||||
pluginId: string,
|
||||
filePath: string,
|
||||
fileHash: string,
|
||||
options?: Record<string, unknown>,
|
||||
kvData?: Record<string, string>
|
||||
): PluginTask | null {
|
||||
const plugins = this.pluginManager.getPlugins();
|
||||
const plugin = plugins.find(p => p.id === pluginId);
|
||||
|
||||
if (!plugin || !plugin.active) {
|
||||
console.warn(`[TaskScheduler] Plugin '${pluginId}' not found or not active`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use a unique task ID for recompute tasks to avoid conflicts
|
||||
const taskId = options?.forceRecompute
|
||||
? `${fileHash}:${pluginId}:recompute:${Date.now()}`
|
||||
: `${fileHash}:${pluginId}`;
|
||||
|
||||
// Skip if non-recompute task already exists
|
||||
if (!options?.forceRecompute && (this.pendingTasks.has(taskId) || this.completedTasks.has(taskId))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize file task tracking if needed
|
||||
if (!this.fileTasks.has(fileHash)) {
|
||||
this.fileTasks.set(fileHash, new Set());
|
||||
}
|
||||
const fileTaskIds = this.fileTasks.get(fileHash)!;
|
||||
|
||||
// Initialize or update KV store for this file
|
||||
// For recompute, we want to load existing metadata so plugin can see current state
|
||||
if (!this.fileKVStores.has(fileHash)) {
|
||||
this.fileKVStores.set(fileHash, new KVStore(kvData));
|
||||
} else if (kvData) {
|
||||
// Update existing KV store with provided data
|
||||
const existingKv = this.fileKVStores.get(fileHash)!;
|
||||
for (const [key, value] of Object.entries(kvData)) {
|
||||
existingKv.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const timing = this.performanceMetrics.getPluginTiming(pluginId);
|
||||
const queue = this.classifyPlugin(pluginId);
|
||||
|
||||
const task: PluginTask = {
|
||||
id: taskId,
|
||||
fileHash,
|
||||
filePath,
|
||||
pluginId,
|
||||
dependencies: plugin.dependencies || [],
|
||||
queue,
|
||||
priority: timing?.average || 0,
|
||||
estimatedTimeMs: timing?.average || 0,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
options,
|
||||
};
|
||||
|
||||
fileTaskIds.add(taskId);
|
||||
this.totalTasksCreated++;
|
||||
this.emitEvent({ type: 'task:created', task });
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tasks for a specific plugin on multiple files
|
||||
* Used for "recompute plugin on all files" feature
|
||||
* @param pluginId - Plugin to execute
|
||||
* @param files - Array of { filePath, fileHash, kvData? } objects
|
||||
* @param options - Runtime options merged into plugin config
|
||||
* @returns Array of created tasks
|
||||
*/
|
||||
createTasksForPluginOnFiles(
|
||||
pluginId: string,
|
||||
files: Array<{ filePath: string; fileHash: string; kvData?: Record<string, string> }>,
|
||||
options?: Record<string, unknown>
|
||||
): PluginTask[] {
|
||||
const tasks: PluginTask[] = [];
|
||||
|
||||
for (const { filePath, fileHash, kvData } of files) {
|
||||
const task = this.createTaskForPlugin(pluginId, filePath, fileHash, options, kvData);
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[TaskScheduler] Created ${tasks.length} tasks for plugin '${pluginId}'`);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a task for execution
|
||||
* All plugins are container-based, so all tasks go to the container scheduler
|
||||
*/
|
||||
enqueueTask(task: PluginTask): void {
|
||||
this.pendingTasks.set(task.id, task);
|
||||
this.enqueueContainerTask(task);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue multiple tasks
|
||||
*/
|
||||
enqueueTasks(tasks: PluginTask[]): void {
|
||||
for (const task of tasks) {
|
||||
this.enqueueTask(task);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a task to the container plugin scheduler
|
||||
*/
|
||||
private enqueueContainerTask(task: PluginTask): void {
|
||||
if (!this.containerPluginScheduler) {
|
||||
console.warn(
|
||||
`[TaskScheduler] Container plugin task ${task.id} cannot be scheduled: no container scheduler`
|
||||
);
|
||||
task.status = 'failed';
|
||||
task.result = {
|
||||
success: false,
|
||||
timeMs: 0,
|
||||
error: 'Container plugin scheduler not available',
|
||||
};
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.completedTasks.add(task.id);
|
||||
this.totalTasksFailed++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create container task and enqueue
|
||||
const containerTask = this.containerPluginScheduler.createTask(
|
||||
task.pluginId,
|
||||
task.fileHash,
|
||||
task.filePath,
|
||||
task.dependencies,
|
||||
task.queue
|
||||
);
|
||||
|
||||
// Map the local task ID to the container task ID
|
||||
// Update our pending tasks map with the container task ID
|
||||
this.pendingTasks.delete(task.id);
|
||||
this.pendingTasks.set(containerTask.id, {
|
||||
...task,
|
||||
id: containerTask.id, // Use container task ID
|
||||
});
|
||||
|
||||
// Update file task tracking
|
||||
const fileTaskIds = this.fileTasks.get(task.fileHash);
|
||||
if (fileTaskIds) {
|
||||
fileTaskIds.delete(task.id);
|
||||
fileTaskIds.add(containerTask.id);
|
||||
}
|
||||
|
||||
// Enqueue to container scheduler
|
||||
this.containerPluginScheduler.enqueueTask(containerTask);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Classification
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Classify a plugin as fast or background based on timing data and manifest
|
||||
*/
|
||||
classifyPlugin(pluginId: string): TaskQueueType {
|
||||
const plugins = this.pluginManager.getPlugins();
|
||||
const plugin = plugins.find(p => p.id === pluginId);
|
||||
const timing = this.performanceMetrics.getPluginTiming(pluginId);
|
||||
|
||||
// Get manifest default queue (need to access via plugin manager internals)
|
||||
// For now, we'll use a workaround - check if plugin has defaultQueue in manifest
|
||||
const manifest = this.getPluginManifest(pluginId);
|
||||
const defaultQueue = manifest?.defaultQueue || 'fast';
|
||||
|
||||
// If we have enough samples, use measured timing
|
||||
if (timing && timing.count >= this.config.minSamplesForMeasurement) {
|
||||
return timing.average < this.config.fastThresholdMs ? 'fast' : 'background';
|
||||
}
|
||||
|
||||
// Otherwise use manifest default
|
||||
return defaultQueue;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Task Completion
|
||||
// =========================================================================
|
||||
|
||||
private scheduleDependentTasks(completedTask: PluginTask): void {
|
||||
// Container scheduler handles dependency resolution
|
||||
// This method is kept for compatibility but doesn't need to do anything
|
||||
}
|
||||
|
||||
private checkFileComplete(fileHash: string): void {
|
||||
const fileTaskIds = this.fileTasks.get(fileHash);
|
||||
if (!fileTaskIds) return;
|
||||
|
||||
// Check if all tasks for this file are completed
|
||||
let filePath = '';
|
||||
for (const taskId of fileTaskIds) {
|
||||
if (!this.completedTasks.has(taskId)) {
|
||||
return; // Still have pending/running tasks
|
||||
}
|
||||
// Try to get filePath from any task
|
||||
if (!filePath) {
|
||||
const task = this.pendingTasks.get(taskId);
|
||||
if (task) filePath = task.filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// All tasks complete
|
||||
this.emitEvent({
|
||||
type: 'file:complete',
|
||||
fileHash,
|
||||
filePath,
|
||||
});
|
||||
|
||||
// Clean up file tracking
|
||||
this.fileTasks.delete(fileHash);
|
||||
this.fileKVStores.delete(fileHash);
|
||||
|
||||
// Clean up completed task IDs for this file to prevent memory leak
|
||||
for (const taskId of fileTaskIds) {
|
||||
this.completedTasks.delete(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Queue Control
|
||||
// =========================================================================
|
||||
|
||||
private onFastQueueIdle(): void {
|
||||
this.emitEvent({ type: 'queue:fast:idle' });
|
||||
|
||||
// Start background queue if it has pending work
|
||||
if (this.backgroundQueue.size > 0 || this.backgroundQueue.pending > 0) {
|
||||
console.log('[TaskScheduler] Fast queue idle, starting background queue');
|
||||
this.backgroundQueue.start();
|
||||
this.emitEvent({ type: 'queue:background:started' });
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status & Metrics
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Get status of both queues
|
||||
*/
|
||||
getQueueStatus(): { fast: QueueStatus; background: QueueStatus } {
|
||||
return {
|
||||
fast: this.buildQueueStatus('fast'),
|
||||
background: this.buildQueueStatus('background'),
|
||||
};
|
||||
}
|
||||
|
||||
private buildQueueStatus(queueType: TaskQueueType): QueueStatus {
|
||||
const queue = queueType === 'fast' ? this.fastQueue : this.backgroundQueue;
|
||||
|
||||
let pending = 0;
|
||||
let ready = 0;
|
||||
let running = 0;
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Count pending tasks for this queue type
|
||||
for (const [, task] of this.pendingTasks) {
|
||||
if (task.queue === queueType) {
|
||||
if (task.status === 'pending') pending++;
|
||||
else if (task.status === 'ready') ready++;
|
||||
else if (task.status === 'running') running++;
|
||||
}
|
||||
}
|
||||
|
||||
// Count completed tasks
|
||||
for (const taskId of this.completedTasks) {
|
||||
const [, pluginId] = taskId.split(':');
|
||||
if (this.classifyPlugin(pluginId) === queueType) {
|
||||
completed++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pending,
|
||||
ready: queue.size, // Tasks in queue waiting to run
|
||||
running: queue.pending, // Tasks currently executing
|
||||
completed,
|
||||
failed, // Would need separate tracking
|
||||
isPaused: queue.isPaused,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending tasks for a specific file
|
||||
*/
|
||||
getPendingTasksForFile(fileHash: string): PluginTask[] {
|
||||
const tasks: PluginTask[] = [];
|
||||
const fileTaskIds = this.fileTasks.get(fileHash);
|
||||
|
||||
if (fileTaskIds) {
|
||||
for (const taskId of fileTaskIds) {
|
||||
const task = this.pendingTasks.get(taskId);
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the KV store for a file
|
||||
*/
|
||||
getFileKVStore(fileHash: string): PluginKVStore | undefined {
|
||||
return this.fileKVStores.get(fileHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel processing for a file (removes all pending tasks)
|
||||
*/
|
||||
cancelFile(fileHash: string): void {
|
||||
const fileTaskIds = this.fileTasks.get(fileHash);
|
||||
if (!fileTaskIds) return;
|
||||
|
||||
// Remove pending tasks
|
||||
for (const taskId of fileTaskIds) {
|
||||
this.pendingTasks.delete(taskId);
|
||||
}
|
||||
|
||||
// Clean up file tracking
|
||||
this.fileTasks.delete(fileHash);
|
||||
this.fileKVStores.delete(fileHash);
|
||||
|
||||
console.log(`[TaskScheduler] Cancelled processing for file ${fileHash}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler statistics
|
||||
*/
|
||||
getStats(): {
|
||||
totalTasksCreated: number;
|
||||
totalTasksCompleted: number;
|
||||
totalTasksFailed: number;
|
||||
pendingTaskCount: number;
|
||||
completedTaskCount: number;
|
||||
filesInProgress: number;
|
||||
} {
|
||||
return {
|
||||
totalTasksCreated: this.totalTasksCreated,
|
||||
totalTasksCompleted: this.totalTasksCompleted,
|
||||
totalTasksFailed: this.totalTasksFailed,
|
||||
pendingTaskCount: this.pendingTasks.size,
|
||||
completedTaskCount: this.completedTasks.size,
|
||||
filesInProgress: this.fileTasks.size,
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shutdown
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Gracefully shutdown the scheduler
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
console.log('[TaskScheduler] Shutting down...');
|
||||
|
||||
// Clear queues
|
||||
this.fastQueue.clear();
|
||||
this.backgroundQueue.clear();
|
||||
|
||||
// Wait for in-progress tasks to complete
|
||||
await Promise.all([
|
||||
this.fastQueue.onIdle(),
|
||||
this.backgroundQueue.onIdle(),
|
||||
]);
|
||||
|
||||
// Clear tracking
|
||||
this.pendingTasks.clear();
|
||||
this.completedTasks.clear();
|
||||
this.fileTasks.clear();
|
||||
this.fileKVStores.clear();
|
||||
|
||||
console.log('[TaskScheduler] Shutdown complete');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private getPluginManifest(pluginId: string): PluginManifest | undefined {
|
||||
// Access manifests from PluginManager
|
||||
const manifests = (this.pluginManager as any).manifests as Map<string, PluginManifest>;
|
||||
return manifests.get(pluginId);
|
||||
}
|
||||
|
||||
private emitEvent(event: TaskSchedulerEvent): void {
|
||||
this.emit(event.type, event);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Factory Function
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create a task scheduler instance
|
||||
*/
|
||||
export function createTaskScheduler(
|
||||
pluginManager: PluginManager,
|
||||
performanceMetrics: PerformanceMetrics,
|
||||
config?: TaskSchedulerConfig
|
||||
): TaskScheduler {
|
||||
return new TaskScheduler(pluginManager, performanceMetrics, config);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Plugin System
|
||||
*
|
||||
* Main entry point for the meta-sort container-based plugin system.
|
||||
* All plugins run as Docker containers - there is no local plugin execution.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
// Manifest types
|
||||
ConfigFieldType,
|
||||
SchemaFieldType,
|
||||
SelectOption,
|
||||
ConfigField,
|
||||
SchemaField,
|
||||
PluginManifest,
|
||||
TaskQueueType,
|
||||
// State types
|
||||
PluginStateEntry,
|
||||
PluginState,
|
||||
PluginStatus,
|
||||
PluginInfo,
|
||||
// Context types
|
||||
PluginKVStore,
|
||||
PluginCache,
|
||||
PluginLogger,
|
||||
PluginLifecycleContext,
|
||||
PluginContext,
|
||||
// Plugin interface
|
||||
Plugin,
|
||||
// Internal types
|
||||
LoadedPlugin,
|
||||
PluginManagerEvent,
|
||||
// Task types
|
||||
PluginTaskStatus,
|
||||
PluginTaskResult,
|
||||
PluginTask,
|
||||
QueueStatus,
|
||||
TaskSchedulerEvent,
|
||||
} from './types.js';
|
||||
|
||||
// Context implementations (KVStore is still used for local state)
|
||||
export { KVStore, MetadataNodeKVStore } from './PluginContext.js';
|
||||
|
||||
// Dependency resolution utilities
|
||||
export {
|
||||
resolveDependencyOrder,
|
||||
canActivate,
|
||||
canDeactivate,
|
||||
} from './PluginExecutor.js';
|
||||
export type { DependencyResolution } from './PluginExecutor.js';
|
||||
|
||||
// Plugin Manager
|
||||
export { PluginManager, createPluginManager } from './PluginManager.js';
|
||||
export type { PluginManagerOptions } from './PluginManager.js';
|
||||
|
||||
// Task Scheduler
|
||||
export { TaskScheduler, createTaskScheduler } from './TaskScheduler.js';
|
||||
export type { TaskSchedulerConfig } from './TaskScheduler.js';
|
||||
@@ -0,0 +1,667 @@
|
||||
/**
|
||||
* Plugin System Unit Tests
|
||||
*/
|
||||
|
||||
import { expect } from 'chai';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { existsSync, mkdirSync, rmSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import {
|
||||
KVStore,
|
||||
PluginCacheImpl,
|
||||
PluginLoggerImpl,
|
||||
parseManifest,
|
||||
validateManifest,
|
||||
resolveDependencyOrder,
|
||||
canActivate,
|
||||
canDeactivate,
|
||||
PluginManager,
|
||||
} from './index.js';
|
||||
import type { PluginManifest, Plugin, PluginContext } from './types.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_DIR = path.join(__dirname, '../../test-plugins');
|
||||
const TEST_CACHE_DIR = path.join(__dirname, '../../test-cache');
|
||||
const TEST_STATE_PATH = path.join(__dirname, '../../test-plugins.json');
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
async function setupTestDir() {
|
||||
// Clean up
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
if (existsSync(TEST_STATE_PATH)) {
|
||||
rmSync(TEST_STATE_PATH);
|
||||
}
|
||||
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function cleanupTestDir() {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true });
|
||||
}
|
||||
if (existsSync(TEST_CACHE_DIR)) {
|
||||
rmSync(TEST_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
if (existsSync(TEST_STATE_PATH)) {
|
||||
rmSync(TEST_STATE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestPlugin(
|
||||
id: string,
|
||||
manifest: Partial<PluginManifest>,
|
||||
processCode: string = 'ctx.kv.set("test/processed", "true");'
|
||||
): Promise<string> {
|
||||
const pluginDir = path.join(TEST_DIR, id);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
// Write manifest
|
||||
const fullManifest = {
|
||||
id,
|
||||
name: manifest.name || id,
|
||||
version: manifest.version || '1.0.0',
|
||||
...manifest,
|
||||
};
|
||||
|
||||
const manifestYaml = `id: ${fullManifest.id}
|
||||
name: ${fullManifest.name}
|
||||
version: ${fullManifest.version}
|
||||
${fullManifest.description ? `description: ${fullManifest.description}` : ''}
|
||||
${fullManifest.dependencies?.length ? `dependencies:\n${fullManifest.dependencies.map(d => ` - ${d}`).join('\n')}` : ''}
|
||||
`;
|
||||
|
||||
await fs.writeFile(path.join(pluginDir, 'manifest.yml'), manifestYaml);
|
||||
|
||||
// Write plugin code
|
||||
const pluginCode = `
|
||||
export default {
|
||||
async process(ctx) {
|
||||
${processCode}
|
||||
}
|
||||
};
|
||||
`;
|
||||
await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode);
|
||||
|
||||
return pluginDir;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// KVStore Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('KVStore', () => {
|
||||
it('should set and get values', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
expect(kv.get('file/name')).to.equal('test.mkv');
|
||||
});
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
const kv = new KVStore();
|
||||
expect(kv.get('missing/key')).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should delete keys', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.delete('file/name');
|
||||
expect(kv.get('file/name')).to.be.undefined;
|
||||
});
|
||||
|
||||
it('should list all keys', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.set('video/codec', 'h264');
|
||||
kv.set('audio/0/codec', 'aac');
|
||||
|
||||
const keys = kv.keys();
|
||||
expect(keys).to.have.lengthOf(3);
|
||||
expect(keys).to.include('file/name');
|
||||
expect(keys).to.include('video/codec');
|
||||
expect(keys).to.include('audio/0/codec');
|
||||
});
|
||||
|
||||
it('should filter keys by prefix', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.set('audio/0/codec', 'aac');
|
||||
kv.set('audio/0/language', 'eng');
|
||||
kv.set('audio/1/codec', 'dts');
|
||||
|
||||
const audioKeys = kv.keys('audio/');
|
||||
expect(audioKeys).to.have.lengthOf(3);
|
||||
expect(audioKeys).to.include('audio/0/codec');
|
||||
expect(audioKeys).to.include('audio/0/language');
|
||||
expect(audioKeys).to.include('audio/1/codec');
|
||||
});
|
||||
|
||||
it('should return entries', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.set('video/codec', 'h264');
|
||||
|
||||
const entries = kv.entries();
|
||||
expect(entries).to.have.lengthOf(2);
|
||||
expect(entries).to.deep.include(['file/name', 'test.mkv']);
|
||||
expect(entries).to.deep.include(['video/codec', 'h264']);
|
||||
});
|
||||
|
||||
it('should filter entries by prefix', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.set('audio/0/codec', 'aac');
|
||||
kv.set('audio/1/codec', 'dts');
|
||||
|
||||
const audioEntries = kv.entries('audio/');
|
||||
expect(audioEntries).to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
it('should initialize with data', () => {
|
||||
const kv = new KVStore({ 'file/name': 'test.mkv', 'video/codec': 'h264' });
|
||||
expect(kv.get('file/name')).to.equal('test.mkv');
|
||||
expect(kv.get('video/codec')).to.equal('h264');
|
||||
});
|
||||
|
||||
it('should convert to object', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
kv.set('video/codec', 'h264');
|
||||
|
||||
const obj = kv.toObject();
|
||||
expect(obj).to.deep.equal({
|
||||
'file/name': 'test.mkv',
|
||||
'video/codec': 'h264',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore null/undefined values', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('test', null as any);
|
||||
kv.set('test2', undefined as any);
|
||||
expect(kv.keys()).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should ignore empty keys', () => {
|
||||
const kv = new KVStore();
|
||||
kv.set('', 'value');
|
||||
expect(kv.keys()).to.have.lengthOf(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// PluginCache Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('PluginCache', () => {
|
||||
const cacheDir = path.join(TEST_CACHE_DIR, 'test-plugin');
|
||||
|
||||
beforeEach(async () => {
|
||||
if (existsSync(cacheDir)) {
|
||||
rmSync(cacheDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (existsSync(cacheDir)) {
|
||||
rmSync(cacheDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should write and read JSON', async () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
const data = { foo: 'bar', num: 42 };
|
||||
|
||||
await cache.writeJson('test.json', data);
|
||||
const result = await cache.readJson<typeof data>('test.json');
|
||||
|
||||
expect(result).to.deep.equal(data);
|
||||
});
|
||||
|
||||
it('should return null for missing files', async () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
const result = await cache.readJson('missing.json');
|
||||
expect(result).to.be.null;
|
||||
});
|
||||
|
||||
it('should check file existence', async () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
|
||||
expect(await cache.exists('test.json')).to.be.false;
|
||||
|
||||
await cache.writeJson('test.json', { foo: 'bar' });
|
||||
expect(await cache.exists('test.json')).to.be.true;
|
||||
});
|
||||
|
||||
it('should delete files', async () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
await cache.writeJson('test.json', { foo: 'bar' });
|
||||
|
||||
await cache.delete('test.json');
|
||||
expect(await cache.exists('test.json')).to.be.false;
|
||||
});
|
||||
|
||||
it('should clear all files', async () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
await cache.writeJson('test1.json', { a: 1 });
|
||||
await cache.writeJson('test2.json', { b: 2 });
|
||||
|
||||
await cache.clear();
|
||||
|
||||
expect(await cache.exists('test1.json')).to.be.false;
|
||||
expect(await cache.exists('test2.json')).to.be.false;
|
||||
});
|
||||
|
||||
it('should get full path', () => {
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
const fullPath = cache.getPath('test.json');
|
||||
expect(fullPath).to.equal(path.join(cacheDir, 'test.json'));
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Manifest Parsing Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Manifest Parsing', () => {
|
||||
beforeEach(setupTestDir);
|
||||
afterEach(cleanupTestDir);
|
||||
|
||||
it('should parse valid manifest', async () => {
|
||||
const manifestPath = path.join(TEST_DIR, 'manifest.yml');
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
`id: test-plugin
|
||||
name: Test Plugin
|
||||
version: 1.0.0
|
||||
description: A test plugin
|
||||
dependencies:
|
||||
- other-plugin
|
||||
config:
|
||||
apiKey:
|
||||
type: string
|
||||
required: true
|
||||
secret: true
|
||||
language:
|
||||
type: string
|
||||
default: en-US
|
||||
`
|
||||
);
|
||||
|
||||
const manifest = await parseManifest(manifestPath);
|
||||
|
||||
expect(manifest.id).to.equal('test-plugin');
|
||||
expect(manifest.name).to.equal('Test Plugin');
|
||||
expect(manifest.version).to.equal('1.0.0');
|
||||
expect(manifest.description).to.equal('A test plugin');
|
||||
expect(manifest.dependencies).to.deep.equal(['other-plugin']);
|
||||
expect(manifest.config?.apiKey?.type).to.equal('string');
|
||||
expect(manifest.config?.apiKey?.secret).to.be.true;
|
||||
expect(manifest.config?.language?.default).to.equal('en-US');
|
||||
});
|
||||
|
||||
it('should validate manifest - valid', () => {
|
||||
const manifest: PluginManifest = {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const errors = validateManifest(manifest);
|
||||
expect(errors).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should validate manifest - invalid id', () => {
|
||||
const manifest: PluginManifest = {
|
||||
id: 'Test_Plugin',
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const errors = validateManifest(manifest);
|
||||
expect(errors).to.have.lengthOf(1);
|
||||
expect(errors[0]).to.include('Invalid plugin id');
|
||||
});
|
||||
|
||||
it('should validate manifest - invalid version', () => {
|
||||
const manifest: PluginManifest = {
|
||||
id: 'test-plugin',
|
||||
name: 'Test Plugin',
|
||||
version: 'invalid',
|
||||
};
|
||||
|
||||
const errors = validateManifest(manifest);
|
||||
expect(errors).to.have.lengthOf(1);
|
||||
expect(errors[0]).to.include('Invalid version');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Dependency Resolution Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Dependency Resolution', () => {
|
||||
it('should resolve simple dependency chain', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
['c', { id: 'c', name: 'C', version: '1.0.0', dependencies: ['b'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b', 'c']);
|
||||
const { order, errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
expect(errors).to.have.lengthOf(0);
|
||||
expect(order.indexOf('a')).to.be.lessThan(order.indexOf('b'));
|
||||
expect(order.indexOf('b')).to.be.lessThan(order.indexOf('c'));
|
||||
});
|
||||
|
||||
it('should handle diamond dependency', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
['c', { id: 'c', name: 'C', version: '1.0.0', dependencies: ['a'] }],
|
||||
['d', { id: 'd', name: 'D', version: '1.0.0', dependencies: ['b', 'c'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b', 'c', 'd']);
|
||||
const { order, errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
expect(errors).to.have.lengthOf(0);
|
||||
expect(order.indexOf('a')).to.be.lessThan(order.indexOf('b'));
|
||||
expect(order.indexOf('a')).to.be.lessThan(order.indexOf('c'));
|
||||
expect(order.indexOf('b')).to.be.lessThan(order.indexOf('d'));
|
||||
expect(order.indexOf('c')).to.be.lessThan(order.indexOf('d'));
|
||||
});
|
||||
|
||||
it('should detect circular dependencies', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: ['c'] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
['c', { id: 'c', name: 'C', version: '1.0.0', dependencies: ['b'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b', 'c']);
|
||||
const { errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
expect(errors.length).to.be.greaterThan(0);
|
||||
expect(errors[0]).to.include('Circular dependency');
|
||||
});
|
||||
|
||||
it('should report missing dependencies', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: ['missing'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a']);
|
||||
const { errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
expect(errors.length).to.be.greaterThan(0);
|
||||
expect(errors[0]).to.include('does not exist');
|
||||
});
|
||||
|
||||
it('should report inactive dependencies', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['b']); // 'a' is not active
|
||||
const { errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
expect(errors.length).to.be.greaterThan(0);
|
||||
expect(errors[0]).to.include('not active');
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Activation/Deactivation Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('Activation Checks', () => {
|
||||
it('should allow activation when dependencies are met', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a']);
|
||||
const { canActivate: can, missingDeps } = canActivate('b', plugins, active);
|
||||
|
||||
expect(can).to.be.true;
|
||||
expect(missingDeps).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should prevent activation when dependencies are missing', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
]);
|
||||
|
||||
const active = new Set<string>(); // 'a' is not active
|
||||
const { canActivate: can, missingDeps } = canActivate('b', plugins, active);
|
||||
|
||||
expect(can).to.be.false;
|
||||
expect(missingDeps).to.deep.equal(['a']);
|
||||
});
|
||||
|
||||
it('should allow deactivation when no dependents', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b']);
|
||||
const { canDeactivate: can, dependents } = canDeactivate('b', plugins, active);
|
||||
|
||||
expect(can).to.be.true;
|
||||
expect(dependents).to.have.lengthOf(0);
|
||||
});
|
||||
|
||||
it('should prevent deactivation when has dependents', () => {
|
||||
const plugins = new Map<string, PluginManifest>([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b']);
|
||||
const { canDeactivate: can, dependents } = canDeactivate('a', plugins, active);
|
||||
|
||||
expect(can).to.be.false;
|
||||
expect(dependents).to.deep.equal(['b']);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manager Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe('PluginManager', () => {
|
||||
beforeEach(setupTestDir);
|
||||
afterEach(async () => {
|
||||
await cleanupTestDir();
|
||||
});
|
||||
|
||||
it('should scan and discover plugins', async () => {
|
||||
await createTestPlugin('test-plugin', {
|
||||
name: 'Test Plugin',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
const plugins = await manager.scanPlugins();
|
||||
|
||||
expect(plugins).to.have.lengthOf(1);
|
||||
expect(plugins[0].id).to.equal('test-plugin');
|
||||
expect(plugins[0].name).to.equal('Test Plugin');
|
||||
});
|
||||
|
||||
it('should load and activate plugins', async () => {
|
||||
await createTestPlugin('plugin-a', { name: 'Plugin A' });
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager.scanPlugins();
|
||||
await manager.loadAll();
|
||||
|
||||
// Activate
|
||||
await manager.activate('plugin-a');
|
||||
|
||||
const plugins = manager.getPlugins();
|
||||
expect(plugins[0].active).to.be.true;
|
||||
|
||||
const order = manager.getExecutionOrder();
|
||||
expect(order).to.include('plugin-a');
|
||||
});
|
||||
|
||||
it('should respect dependency order in execution', async () => {
|
||||
await createTestPlugin('plugin-a', { name: 'Plugin A' }, 'ctx.kv.set("order/a", Date.now().toString());');
|
||||
await createTestPlugin(
|
||||
'plugin-b',
|
||||
{ name: 'Plugin B', dependencies: ['plugin-a'] },
|
||||
'ctx.kv.set("order/b", Date.now().toString());'
|
||||
);
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager.scanPlugins();
|
||||
await manager.loadAll();
|
||||
await manager.activate('plugin-a');
|
||||
await manager.activate('plugin-b');
|
||||
|
||||
const order = manager.getExecutionOrder();
|
||||
expect(order.indexOf('plugin-a')).to.be.lessThan(order.indexOf('plugin-b'));
|
||||
});
|
||||
|
||||
it('should process files through plugins', async () => {
|
||||
await createTestPlugin(
|
||||
'test-plugin',
|
||||
{ name: 'Test Plugin' },
|
||||
`
|
||||
ctx.kv.set('file/processed', 'true');
|
||||
ctx.kv.set('file/path', ctx.filePath);
|
||||
`
|
||||
);
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager.scanPlugins();
|
||||
await manager.loadAll();
|
||||
await manager.activate('test-plugin');
|
||||
|
||||
const kv = manager.createKVStore();
|
||||
const result = await manager.processFile('/test/file.mkv', kv);
|
||||
|
||||
expect(result.success).to.be.true;
|
||||
expect(kv.get('file/processed')).to.equal('true');
|
||||
expect(kv.get('file/path')).to.equal('/test/file.mkv');
|
||||
});
|
||||
|
||||
it('should persist state across restarts', async () => {
|
||||
await createTestPlugin('test-plugin', { name: 'Test Plugin' });
|
||||
|
||||
// First run
|
||||
const manager1 = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager1.scanPlugins();
|
||||
await manager1.loadAll();
|
||||
await manager1.activate('test-plugin');
|
||||
await manager1.updateConfig('test-plugin', { setting: 'value' });
|
||||
await manager1.shutdown();
|
||||
|
||||
// Second run
|
||||
const manager2 = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager2.scanPlugins();
|
||||
await manager2.loadAll();
|
||||
|
||||
const plugins = manager2.getPlugins();
|
||||
expect(plugins[0].active).to.be.true;
|
||||
expect(plugins[0].config.setting).to.equal('value');
|
||||
|
||||
await manager2.shutdown();
|
||||
});
|
||||
|
||||
it('should prevent deactivation of plugin with dependents', async () => {
|
||||
await createTestPlugin('plugin-a', { name: 'Plugin A' });
|
||||
await createTestPlugin('plugin-b', { name: 'Plugin B', dependencies: ['plugin-a'] });
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
await manager.scanPlugins();
|
||||
await manager.loadAll();
|
||||
await manager.activate('plugin-a');
|
||||
await manager.activate('plugin-b');
|
||||
|
||||
try {
|
||||
await manager.deactivate('plugin-a');
|
||||
expect.fail('Should have thrown');
|
||||
} catch (error) {
|
||||
expect((error as Error).message).to.include('other plugins depend on it');
|
||||
}
|
||||
|
||||
await manager.shutdown();
|
||||
});
|
||||
|
||||
it('should emit events on activation/deactivation', async () => {
|
||||
await createTestPlugin('test-plugin', { name: 'Test Plugin' });
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
const events: string[] = [];
|
||||
manager.on('plugin:activated', (e) => events.push(`activated:${e.pluginId}`));
|
||||
manager.on('plugin:deactivated', (e) => events.push(`deactivated:${e.pluginId}`));
|
||||
|
||||
await manager.scanPlugins();
|
||||
await manager.loadAll();
|
||||
await manager.activate('test-plugin');
|
||||
await manager.deactivate('test-plugin');
|
||||
|
||||
expect(events).to.deep.equal(['activated:test-plugin', 'deactivated:test-plugin']);
|
||||
|
||||
await manager.shutdown();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Plugin System Type Definitions
|
||||
*
|
||||
* This module defines all types for the meta-sort plugin system.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manifest Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Configuration field type in manifest
|
||||
*/
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select';
|
||||
|
||||
/**
|
||||
* Schema field type for editor UI
|
||||
*/
|
||||
export type SchemaFieldType = 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid' | 'languageString';
|
||||
|
||||
/**
|
||||
* Select option for config/schema fields
|
||||
*/
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration field definition in manifest
|
||||
*/
|
||||
export interface ConfigField {
|
||||
type: ConfigFieldType;
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
default?: unknown;
|
||||
secret?: boolean;
|
||||
options?: SelectOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata schema field definition (for editor UI)
|
||||
*/
|
||||
export interface SchemaField {
|
||||
/** Display label */
|
||||
label: string;
|
||||
/** Field type for form control */
|
||||
type?: SchemaFieldType;
|
||||
/** Read-only field (not editable) */
|
||||
readonly?: boolean;
|
||||
/** Whether field is indexed (has {n} pattern) */
|
||||
indexed?: boolean;
|
||||
/** Options for select type */
|
||||
options?: SelectOption[];
|
||||
/** Hint text shown below field */
|
||||
hint?: string;
|
||||
/** URL template for clickable values (use {value} as placeholder) */
|
||||
link?: string;
|
||||
/** Pattern for JSON type to aggregate matching keys */
|
||||
pattern?: string;
|
||||
/** Minimum value for number type */
|
||||
min?: number;
|
||||
/** Maximum value for number type */
|
||||
max?: number;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task queue type for plugin classification
|
||||
*/
|
||||
export type TaskQueueType = 'fast' | 'background';
|
||||
|
||||
/**
|
||||
* Plugin manifest (parsed from manifest.yml)
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
/** Plugin author */
|
||||
author?: string;
|
||||
/** Display priority (lower = higher priority) */
|
||||
priority?: number;
|
||||
/** Color for UI display */
|
||||
color?: string;
|
||||
dependencies?: string[];
|
||||
config?: Record<string, ConfigField>;
|
||||
schema?: Record<string, SchemaField>;
|
||||
/**
|
||||
* Default queue for this plugin.
|
||||
* - 'fast': High concurrency queue for quick operations (<1s)
|
||||
* - 'background': Low concurrency queue for slow operations (>=1s)
|
||||
* Default: 'fast'
|
||||
*/
|
||||
defaultQueue?: TaskQueueType;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin State Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Plugin state entry (stored in plugins.json)
|
||||
*/
|
||||
export interface PluginStateEntry {
|
||||
active: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete plugin state file structure
|
||||
*/
|
||||
export interface PluginState {
|
||||
plugins: Record<string, PluginStateEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin status
|
||||
*/
|
||||
export type PluginStatus = 'loaded' | 'error' | 'unloaded';
|
||||
|
||||
/**
|
||||
* Plugin info (runtime representation)
|
||||
*/
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
/** Display priority (lower = higher priority) */
|
||||
priority?: number;
|
||||
/** Color for UI display */
|
||||
color?: string;
|
||||
dependencies: string[];
|
||||
active: boolean;
|
||||
config: Record<string, unknown>;
|
||||
configSchema?: Record<string, ConfigField>;
|
||||
metadataSchema?: Record<string, SchemaField>;
|
||||
status: PluginStatus;
|
||||
error?: string;
|
||||
/** True if this is a containerized plugin */
|
||||
isContainer?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Context Types (injected into plugins)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Key-value store for file metadata
|
||||
*/
|
||||
export interface PluginKVStore {
|
||||
/**
|
||||
* Get a value by key
|
||||
* @param key Path-style key, e.g., "video/codec" or "audio/0/language"
|
||||
*/
|
||||
get(key: string): string | undefined;
|
||||
|
||||
/**
|
||||
* Set a value. Only string values are supported.
|
||||
* @param key Path-style key
|
||||
* @param value String value
|
||||
*/
|
||||
set(key: string, value: string): void;
|
||||
|
||||
/**
|
||||
* Delete a key
|
||||
*/
|
||||
delete(key: string): void;
|
||||
|
||||
/**
|
||||
* Get all keys, optionally filtered by prefix
|
||||
* @param prefix Optional prefix filter, e.g., "audio/"
|
||||
*/
|
||||
keys(prefix?: string): string[];
|
||||
|
||||
/**
|
||||
* Get all key-value pairs, optionally filtered by prefix
|
||||
*/
|
||||
entries(prefix?: string): Array<[string, string]>;
|
||||
|
||||
/**
|
||||
* Add a value to a set (for RecordSet fields like genres, tags).
|
||||
* Optional - only available with MetadataNodeKVStore.
|
||||
*/
|
||||
add?(key: string, value: string): void;
|
||||
|
||||
/**
|
||||
* Get the full object at a path (for inspecting nested structures).
|
||||
* Optional - only available with MetadataNodeKVStore.
|
||||
*/
|
||||
getObject?(key: string): any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache utilities for persistent plugin storage
|
||||
*/
|
||||
export interface PluginCache {
|
||||
/**
|
||||
* Get full path for a cache file
|
||||
* @param filename Relative filename within cache directory
|
||||
*/
|
||||
getPath(filename: string): string;
|
||||
|
||||
/**
|
||||
* Read and parse a JSON cache file
|
||||
* @returns Parsed data or null if not found
|
||||
*/
|
||||
readJson<T>(filename: string): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Write data as JSON to cache file
|
||||
*/
|
||||
writeJson(filename: string, data: unknown): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if a cache file exists
|
||||
*/
|
||||
exists(filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Delete a cache file
|
||||
*/
|
||||
delete(filename: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clear all files in the plugin's cache directory
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for plugins
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
debug(message: string, ...args: unknown[]): void;
|
||||
info(message: string, ...args: unknown[]): void;
|
||||
warn(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to plugin lifecycle hooks
|
||||
*/
|
||||
export interface PluginLifecycleContext {
|
||||
/** Plugin configuration from plugins.json */
|
||||
config: Record<string, unknown>;
|
||||
/** Absolute path to plugin's cache directory */
|
||||
cacheDir: string;
|
||||
/** Scoped logger */
|
||||
log: PluginLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to plugin process function
|
||||
*/
|
||||
export interface PluginContext extends PluginLifecycleContext {
|
||||
/** Key-value storage for the current file's metadata */
|
||||
kv: PluginKVStore;
|
||||
/** Absolute path to the file being processed */
|
||||
filePath: string;
|
||||
/** Cache utilities for persistent storage */
|
||||
cache: PluginCache;
|
||||
/**
|
||||
* Direct access to MetadataNode for plugins that need it.
|
||||
* Use this for compatibility with existing libraries that expect MetadataNode.
|
||||
* Only available when using MetadataNodeKVStore.
|
||||
*/
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Plugin interface that all plugins must implement
|
||||
*/
|
||||
export interface Plugin {
|
||||
/**
|
||||
* Called once when the plugin is loaded at startup.
|
||||
* Use for one-time initialization.
|
||||
*/
|
||||
onLoad?(ctx: PluginLifecycleContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the plugin is activated.
|
||||
* Triggered at startup (if previously active) or from dashboard.
|
||||
*/
|
||||
onActivate?(ctx: PluginLifecycleContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the plugin is deactivated from the dashboard.
|
||||
* Use for cleanup, releasing resources.
|
||||
*/
|
||||
onDeactivate?(ctx: PluginLifecycleContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the plugin is unloaded at shutdown.
|
||||
*/
|
||||
onUnload?(ctx: PluginLifecycleContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process a single file. Called for each file in the library.
|
||||
* Must be implemented by all plugins.
|
||||
*/
|
||||
process(ctx: PluginContext): Promise<void>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Manager Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Loaded plugin internal representation
|
||||
*/
|
||||
export interface LoadedPlugin {
|
||||
manifest: PluginManifest;
|
||||
instance: Plugin;
|
||||
status: PluginStatus;
|
||||
error?: string;
|
||||
pluginDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processing result for a single file
|
||||
*/
|
||||
export interface ProcessingResult {
|
||||
success: boolean;
|
||||
timings: Record<string, number>;
|
||||
errors: Array<{ pluginId: string; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin manager events
|
||||
*/
|
||||
export type PluginManagerEvent =
|
||||
| { type: 'plugin:activated'; pluginId: string }
|
||||
| { type: 'plugin:deactivated'; pluginId: string }
|
||||
| { type: 'plugin:config-changed'; pluginId: string; config: Record<string, unknown> }
|
||||
| { type: 'plugin:error'; pluginId: string; error: Error };
|
||||
|
||||
// =============================================================================
|
||||
// Plugin Task Types (for TaskScheduler)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Status of a plugin task
|
||||
*/
|
||||
export type PluginTaskStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* Result of a plugin task execution
|
||||
*/
|
||||
export interface PluginTaskResult {
|
||||
success: boolean;
|
||||
timeMs: number;
|
||||
/** KV updates to apply (key-value pairs) */
|
||||
kvUpdates?: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin task representing a single plugin execution for a file
|
||||
*/
|
||||
export interface PluginTask {
|
||||
/** Unique task ID: `${fileHash}:${pluginId}` */
|
||||
id: string;
|
||||
/** File identity hash (midhash256) */
|
||||
fileHash: string;
|
||||
/** Absolute path to the file */
|
||||
filePath: string;
|
||||
/** Plugin to execute */
|
||||
pluginId: string;
|
||||
/** Plugin IDs this task depends on */
|
||||
dependencies: string[];
|
||||
/** Target queue (from classification) */
|
||||
queue: TaskQueueType;
|
||||
/** Priority based on avg execution time (lower = higher priority) */
|
||||
priority: number;
|
||||
/** Estimated execution time in ms (from performanceMetrics) */
|
||||
estimatedTimeMs: number;
|
||||
/** Current task status */
|
||||
status: PluginTaskStatus;
|
||||
/** Timestamp when task was created */
|
||||
createdAt: number;
|
||||
/** Timestamp when task started executing */
|
||||
startedAt?: number;
|
||||
/** Timestamp when task completed */
|
||||
completedAt?: number;
|
||||
/** Execution result */
|
||||
result?: PluginTaskResult;
|
||||
/** Runtime options merged into plugin config (e.g., forceRecompute) */
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue status information
|
||||
*/
|
||||
export interface QueueStatus {
|
||||
/** Number of tasks pending (waiting for dependencies) */
|
||||
pending: number;
|
||||
/** Number of tasks ready (in queue, waiting for worker) */
|
||||
ready: number;
|
||||
/** Number of tasks currently running */
|
||||
running: number;
|
||||
/** Number of tasks completed */
|
||||
completed: number;
|
||||
/** Number of tasks failed */
|
||||
failed: number;
|
||||
/** Whether the queue is currently paused */
|
||||
isPaused: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task scheduler events
|
||||
*/
|
||||
export type TaskSchedulerEvent =
|
||||
| { type: 'task:created'; task: PluginTask }
|
||||
| { type: 'task:ready'; task: PluginTask }
|
||||
| { type: 'task:started'; task: PluginTask }
|
||||
| { type: 'task:complete'; task: PluginTask }
|
||||
| { type: 'task:failed'; task: PluginTask; error: string }
|
||||
| { type: 'file:complete'; fileHash: string; filePath: string }
|
||||
| { type: 'queue:fast:idle' }
|
||||
| { type: 'queue:background:started' }
|
||||
| { type: 'queue:background:paused' };
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Plugin System Validation Script
|
||||
*
|
||||
* Simple validation to ensure the plugin system works correctly.
|
||||
* Run with: node dist/plugin2/validate.js
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { existsSync, mkdirSync, rmSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import {
|
||||
KVStore,
|
||||
PluginCacheImpl,
|
||||
resolveDependencyOrder,
|
||||
canActivate,
|
||||
canDeactivate,
|
||||
PluginManager,
|
||||
} from './index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_DIR = path.join(__dirname, '../../test-plugins-validation');
|
||||
const TEST_CACHE_DIR = path.join(__dirname, '../../test-cache-validation');
|
||||
const TEST_STATE_PATH = path.join(__dirname, '../../test-plugins-validation.json');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
console.log(` ✓ ${message}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` ✗ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
|
||||
if (existsSync(TEST_CACHE_DIR)) rmSync(TEST_CACHE_DIR, { recursive: true });
|
||||
if (existsSync(TEST_STATE_PATH)) rmSync(TEST_STATE_PATH);
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
await cleanup();
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
mkdirSync(TEST_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function createTestPlugin(id, deps = []) {
|
||||
const pluginDir = path.join(TEST_DIR, id);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
const manifestYaml = `id: ${id}
|
||||
name: ${id}
|
||||
version: 1.0.0
|
||||
${deps.length ? `dependencies:\n${deps.map(d => ` - ${d}`).join('\n')}` : ''}
|
||||
`;
|
||||
await fs.writeFile(path.join(pluginDir, 'manifest.yml'), manifestYaml);
|
||||
|
||||
const pluginCode = `
|
||||
export default {
|
||||
async process(ctx) {
|
||||
ctx.kv.set("${id}/processed", "true");
|
||||
}
|
||||
};
|
||||
`;
|
||||
await fs.writeFile(path.join(pluginDir, 'index.js'), pluginCode);
|
||||
}
|
||||
|
||||
async function testKVStore() {
|
||||
console.log('\nTesting KVStore...');
|
||||
|
||||
const kv = new KVStore();
|
||||
kv.set('file/name', 'test.mkv');
|
||||
assert(kv.get('file/name') === 'test.mkv', 'set and get value');
|
||||
|
||||
assert(kv.get('missing/key') === undefined, 'missing key returns undefined');
|
||||
|
||||
kv.set('audio/0/codec', 'aac');
|
||||
kv.set('audio/0/language', 'eng');
|
||||
kv.set('audio/1/codec', 'dts');
|
||||
const audioKeys = kv.keys('audio/');
|
||||
assert(audioKeys.length === 3, 'keys with prefix filters correctly');
|
||||
|
||||
kv.delete('file/name');
|
||||
assert(kv.get('file/name') === undefined, 'delete removes key');
|
||||
|
||||
const obj = kv.toObject();
|
||||
assert(typeof obj === 'object', 'toObject returns object');
|
||||
}
|
||||
|
||||
async function testPluginCache() {
|
||||
console.log('\nTesting PluginCache...');
|
||||
|
||||
const cacheDir = path.join(TEST_CACHE_DIR, 'test-cache');
|
||||
const cache = new PluginCacheImpl(cacheDir);
|
||||
|
||||
await cache.writeJson('test.json', { foo: 'bar' });
|
||||
const data = await cache.readJson('test.json');
|
||||
assert(data?.foo === 'bar', 'writeJson and readJson work');
|
||||
|
||||
const exists = await cache.exists('test.json');
|
||||
assert(exists === true, 'exists returns true for existing file');
|
||||
|
||||
const notExists = await cache.exists('missing.json');
|
||||
assert(notExists === false, 'exists returns false for missing file');
|
||||
|
||||
await cache.delete('test.json');
|
||||
const afterDelete = await cache.exists('test.json');
|
||||
assert(afterDelete === false, 'delete removes file');
|
||||
}
|
||||
|
||||
async function testDependencyResolution() {
|
||||
console.log('\nTesting Dependency Resolution...');
|
||||
|
||||
const plugins = new Map([
|
||||
['a', { id: 'a', name: 'A', version: '1.0.0', dependencies: [] }],
|
||||
['b', { id: 'b', name: 'B', version: '1.0.0', dependencies: ['a'] }],
|
||||
['c', { id: 'c', name: 'C', version: '1.0.0', dependencies: ['b'] }],
|
||||
]);
|
||||
|
||||
const active = new Set(['a', 'b', 'c']);
|
||||
const { order, errors } = resolveDependencyOrder(plugins, active);
|
||||
|
||||
assert(errors.length === 0, 'no errors for valid dependency chain');
|
||||
assert(order.indexOf('a') < order.indexOf('b'), 'a comes before b');
|
||||
assert(order.indexOf('b') < order.indexOf('c'), 'b comes before c');
|
||||
|
||||
// Test can activate
|
||||
const { canActivate: can, missingDeps } = canActivate('b', plugins, new Set(['a']));
|
||||
assert(can === true, 'can activate when dependencies met');
|
||||
|
||||
const { canActivate: cannot, missingDeps: missing } = canActivate('b', plugins, new Set());
|
||||
assert(cannot === false, 'cannot activate when dependencies missing');
|
||||
assert(missing.includes('a'), 'reports missing dependency');
|
||||
|
||||
// Test can deactivate
|
||||
const { canDeactivate: canDeact } = canDeactivate('c', plugins, active);
|
||||
assert(canDeact === true, 'can deactivate when no dependents');
|
||||
|
||||
const { canDeactivate: cannotDeact, dependents } = canDeactivate('a', plugins, active);
|
||||
assert(cannotDeact === false, 'cannot deactivate when has dependents');
|
||||
assert(dependents.includes('b'), 'reports dependent plugin');
|
||||
}
|
||||
|
||||
async function testPluginManager() {
|
||||
console.log('\nTesting PluginManager...');
|
||||
|
||||
await createTestPlugin('plugin-a');
|
||||
await createTestPlugin('plugin-b', ['plugin-a']);
|
||||
|
||||
const manager = new PluginManager({
|
||||
pluginsDir: TEST_DIR,
|
||||
statePath: TEST_STATE_PATH,
|
||||
cacheDir: TEST_CACHE_DIR,
|
||||
});
|
||||
|
||||
const plugins = await manager.scanPlugins();
|
||||
assert(plugins.length === 2, 'scans and discovers plugins');
|
||||
|
||||
await manager.loadAll();
|
||||
assert(manager.getPlugins().length === 2, 'loads all plugins');
|
||||
|
||||
await manager.activate('plugin-a');
|
||||
await manager.activate('plugin-b');
|
||||
assert(manager.getActivePlugins().length === 2, 'activates plugins');
|
||||
|
||||
const order = manager.getExecutionOrder();
|
||||
assert(order.indexOf('plugin-a') < order.indexOf('plugin-b'), 'respects dependency order');
|
||||
|
||||
const kv = manager.createKVStore();
|
||||
const result = await manager.processFile('/test/file.mkv', kv);
|
||||
assert(result.success === true, 'processes file successfully');
|
||||
assert(kv.get('plugin-a/processed') === 'true', 'plugin-a executed');
|
||||
assert(kv.get('plugin-b/processed') === 'true', 'plugin-b executed');
|
||||
|
||||
await manager.deactivate('plugin-b');
|
||||
assert(manager.getActivePlugins().length === 1, 'deactivates plugin');
|
||||
|
||||
await manager.shutdown();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('Plugin System Validation\n========================');
|
||||
|
||||
try {
|
||||
await setup();
|
||||
await testKVStore();
|
||||
await testPluginCache();
|
||||
await testDependencyResolution();
|
||||
await testPluginManager();
|
||||
await cleanup();
|
||||
|
||||
console.log(`\n========================`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('\nValidation error:', error);
|
||||
await cleanup();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* FileMetadata - Extended metadata for file processing
|
||||
*
|
||||
* Wraps HashMeta with application-specific fields for tracking
|
||||
* file processing state.
|
||||
*/
|
||||
|
||||
import { HashMeta } from '@metazla/meta-interface';
|
||||
|
||||
export type ProcessingStatus = 'pending' | 'processing' | 'complete' | 'error';
|
||||
|
||||
/**
|
||||
* Extended file metadata that includes processing state
|
||||
* Extends the generic HashMeta with meta-mesh specific fields
|
||||
*
|
||||
* Note: No longer uses tempId - midhash256 is computed immediately and serves as permanent ID
|
||||
*/
|
||||
export interface FileMetadata extends HashMeta {
|
||||
/** Current status of file processing */
|
||||
processingStatus?: ProcessingStatus;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* TempIdGenerator
|
||||
*
|
||||
* Generates temporary IDs for files before content hash is computed.
|
||||
* Uses a deterministic approach based on file path, size, and mtime.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { Stats } from 'fs';
|
||||
|
||||
/**
|
||||
* Generate a temporary ID for a file based on its path and stats
|
||||
* This is deterministic - same file will always get same temp ID
|
||||
*
|
||||
* @param filePath - Absolute path to the file
|
||||
* @param stats - File stats from fs.stat()
|
||||
* @returns Temporary ID as MD5 hash (without prefix - prefix added by storage layer)
|
||||
*/
|
||||
export function generateTempId(filePath: string, stats: Stats): string {
|
||||
// Create deterministic input from file metadata (NOT content)
|
||||
const input = `${filePath}:${stats.size}:${stats.mtimeMs}`;
|
||||
|
||||
// Fast MD5 hash of the input
|
||||
const hash = createHash('md5').update(input).digest('hex');
|
||||
|
||||
// Return hash without prefix - storage layer adds "tmp:" prefix
|
||||
return hash;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Timeout Wrapper Utility
|
||||
*
|
||||
* Provides timeout functionality for async operations with configurable retry logic
|
||||
*/
|
||||
|
||||
export interface TimeoutConfig {
|
||||
/** Base timeout in milliseconds (default: 30000ms = 30s) */
|
||||
baseTimeout: number;
|
||||
/** Timeout multiplier for each retry (default: 1.5x) */
|
||||
timeoutMultiplier: number;
|
||||
/** Maximum timeout in milliseconds (default: 600000ms = 10min) */
|
||||
maxTimeout: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: TimeoutConfig = {
|
||||
baseTimeout: 30000, // 30 seconds
|
||||
timeoutMultiplier: 1.5,
|
||||
maxTimeout: 600000 // 10 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate timeout for a specific retry attempt
|
||||
*/
|
||||
export function calculateTimeout(retryCount: number, config: TimeoutConfig = DEFAULT_CONFIG): number {
|
||||
const timeout = config.baseTimeout * Math.pow(config.timeoutMultiplier, retryCount);
|
||||
return Math.min(timeout, config.maxTimeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async function with timeout
|
||||
* @param fn - Function to execute
|
||||
* @param timeoutMs - Timeout in milliseconds
|
||||
* @param label - Label for error messages
|
||||
* @returns Promise that rejects if timeout is exceeded
|
||||
*/
|
||||
export async function withTimeout<T>(
|
||||
fn: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string = 'Operation'
|
||||
): Promise<T> {
|
||||
let timeoutHandle: NodeJS.Timeout;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([fn(), timeoutPromise]);
|
||||
clearTimeout(timeoutHandle!);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutHandle!);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async function with timeout and retry logic
|
||||
* @param fn - Function to execute
|
||||
* @param retryCount - Current retry count (0-based)
|
||||
* @param config - Timeout configuration
|
||||
* @param label - Label for error messages
|
||||
* @returns Promise that resolves with result or rejects with timeout error
|
||||
*/
|
||||
export async function withTimeoutAndRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retryCount: number,
|
||||
config: TimeoutConfig = DEFAULT_CONFIG,
|
||||
label: string = 'Operation'
|
||||
): Promise<T> {
|
||||
const timeout = calculateTimeout(retryCount, config);
|
||||
return withTimeout(fn, timeout, label);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": false,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
# @metazla/meta-editor
|
||||
|
||||
User-friendly metadata editor for MetaMesh. Search and edit file metadata directly through a web interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **Search**: Find files by title, filename, or hash ID
|
||||
- **Edit**: Modify all metadata fields including title, season, episode, year, Jellyfin metadata, and more
|
||||
- **Batch Operations**: Update multiple files simultaneously
|
||||
- **Real-time**: Direct integration with etcd via REST API
|
||||
- **User-friendly**: Clean, intuitive interface with form validation
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 21.6.2+
|
||||
- pnpm 10.19.0+
|
||||
- Meta-mesh API server running (provides the `/api/metadata/*` endpoints)
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# From the root directory
|
||||
pnpm run start:editor
|
||||
|
||||
# Or from the package directory
|
||||
cd packages/meta-editor
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
The editor will be available at `http://localhost:5173` with API proxying to `http://localhost:3000`.
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# From the root directory
|
||||
pnpm run build:editor
|
||||
|
||||
# Or from the package directory
|
||||
cd packages/meta-editor
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
The built files will be in `dist/` directory.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
In the Docker development environment, the editor is automatically available at:
|
||||
|
||||
```
|
||||
http://localhost/editor
|
||||
```
|
||||
|
||||
The nginx configuration serves the built files from `/app/packages/meta-editor/dist/`.
|
||||
|
||||
## API Integration
|
||||
|
||||
The editor communicates with the UnifiedAPIServer (meta-mesh) via REST API:
|
||||
|
||||
- `POST /api/metadata/search` - Search for files
|
||||
- `GET /api/metadata/:hashId` - Get file metadata
|
||||
- `PUT /api/metadata/:hashId` - Update complete metadata
|
||||
- `PUT /api/metadata/:hashId/property` - Update specific property
|
||||
- `POST /api/metadata/batch` - Batch update multiple files
|
||||
|
||||
See `src/api/metadataApi.ts` for the API client implementation.
|
||||
|
||||
## Usage
|
||||
|
||||
### Searching for Files
|
||||
|
||||
1. Select search type: "Search by Title/Filename" or "Search by Hash ID"
|
||||
2. Enter your search query
|
||||
3. Click "Search"
|
||||
4. Results will appear in the left sidebar
|
||||
|
||||
### Editing Metadata
|
||||
|
||||
1. Select a file from the search results
|
||||
2. Modify any fields in the editor form
|
||||
3. Click "Save Changes" to persist updates
|
||||
4. Use "Reset" to discard changes
|
||||
|
||||
### Supported Metadata Fields
|
||||
|
||||
**Basic Information:**
|
||||
- Title, Original Title, Show Title
|
||||
- Video Type (Movie, TV, Anime, Other)
|
||||
- Year, Season, Episode
|
||||
|
||||
**Jellyfin Metadata:**
|
||||
- Plot, IMDb ID, TMDb ID, AniDB ID
|
||||
- Director, Studio, Rating, Runtime
|
||||
- Genres, Premiered Date, Release Date
|
||||
|
||||
**File Information:**
|
||||
- Filename, File Size, MIME Type
|
||||
- Hash values (SHA-256, MD5, etc.) - read-only
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/meta-editor/
|
||||
├── src/
|
||||
│ ├── api/ # API client for metadata operations
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── SearchBar.tsx # Search interface
|
||||
│ │ ├── FileList.tsx # Search results list
|
||||
│ │ └── MetadataEditor.tsx # Main editing form
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── App.tsx # Main application component
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Global styles
|
||||
├── index.html
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **React 18** - UI framework
|
||||
- **TypeScript 5.7** - Type safety
|
||||
- **Vite 6** - Build tool and dev server
|
||||
- **Native CSS** - Styling (no CSS framework)
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. Add type definitions to `src/types/index.ts`
|
||||
2. Update API client in `src/api/metadataApi.ts`
|
||||
3. Create/update components as needed
|
||||
4. Update this README with new features
|
||||
|
||||
## License
|
||||
|
||||
Part of the MetaMesh project.
|
||||
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MetaMesh Metadata Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1570
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@meta-sort/editor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"iso-639-3": "^3.0.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.app-description {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app-error {
|
||||
padding: 1rem;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.app-welcome {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.app-welcome h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.app-welcome-features {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.app-kv-browser {
|
||||
margin-top: 1.5rem;
|
||||
text-align: left;
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.app-editor {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.app-no-selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: #646cff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.app-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.app-editor {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header,
|
||||
.app-main,
|
||||
.app-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.app-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.app-description {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.app-error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border-color: rgba(255, 0, 0, 0.3);
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.app-welcome {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.app-welcome h2 {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.app-welcome-features {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.app-no-selection {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
border-top-color: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SearchBar } from './components/SearchBar';
|
||||
import { FileList } from './components/FileList';
|
||||
import { MetadataEditor } from './components/MetadataEditor';
|
||||
import { BulkMetadataEditor } from './components/BulkMetadataEditor';
|
||||
import { KVTreeView } from './components/KVTreeView';
|
||||
import { MetadataAPI } from './api/metadataApi';
|
||||
import { SearchResult } from './types';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [selectedHashId, setSelectedHashId] = useState<string | undefined>();
|
||||
const [selectedHashIds, setSelectedHashIds] = useState<Set<string>>(new Set());
|
||||
const [bulkEditMode, setBulkEditMode] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isLoadingDirectHash, setIsLoadingDirectHash] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Extract hash from URL path (supports /editor/file/hash or /editor/?hash=...)
|
||||
const getHashFromUrl = (): string | null => {
|
||||
const path = window.location.pathname;
|
||||
const fileMatch = path.match(/\/file\/([a-z0-9]+)$/i);
|
||||
if (fileMatch) {
|
||||
return fileMatch[1];
|
||||
}
|
||||
// Fallback to query param for backwards compatibility
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('hash');
|
||||
};
|
||||
|
||||
// Update URL when file is selected
|
||||
const updateUrlForFile = (hashId: string | undefined) => {
|
||||
const basePath = window.location.pathname.replace(/\/file\/[a-z0-9]+$/i, '').replace(/\/$/, '');
|
||||
if (hashId) {
|
||||
const newPath = `${basePath}/file/${hashId}`;
|
||||
window.history.pushState({ hashId }, '', newPath);
|
||||
} else {
|
||||
window.history.pushState({}, '', basePath || '/editor/');
|
||||
}
|
||||
};
|
||||
|
||||
// Check for hash parameter in URL on component mount
|
||||
useEffect(() => {
|
||||
const loadHashFromUrl = async () => {
|
||||
const hashParam = getHashFromUrl();
|
||||
|
||||
if (hashParam) {
|
||||
try {
|
||||
setIsLoadingDirectHash(true);
|
||||
setError(null);
|
||||
|
||||
// Try to get metadata for this hash
|
||||
const metadata = await MetadataAPI.getMetadata(hashParam);
|
||||
|
||||
// Create a search result from the metadata
|
||||
const searchResult: SearchResult = {
|
||||
hashId: hashParam,
|
||||
metadata: metadata,
|
||||
score: 1.0,
|
||||
};
|
||||
|
||||
// Set the search results and select this file
|
||||
setSearchResults([searchResult]);
|
||||
setSelectedHashId(hashParam);
|
||||
} catch (err: any) {
|
||||
setError(`Failed to load file with hash ${hashParam}: ${err.message}`);
|
||||
setSearchResults([]);
|
||||
setSelectedHashId(undefined);
|
||||
} finally {
|
||||
setIsLoadingDirectHash(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadHashFromUrl();
|
||||
|
||||
// Handle browser back/forward
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
const hashId = event.state?.hashId || getHashFromUrl();
|
||||
if (hashId) {
|
||||
setSelectedHashId(hashId);
|
||||
} else {
|
||||
setSelectedHashId(undefined);
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
try {
|
||||
setIsSearching(true);
|
||||
setError(null);
|
||||
|
||||
// Unified search - searches across all fields
|
||||
const response = await MetadataAPI.search({ query });
|
||||
|
||||
setSearchResults(response.results);
|
||||
|
||||
// Auto-select first result if available
|
||||
if (response.results.length > 0) {
|
||||
setSelectedHashId(response.results[0].hashId);
|
||||
} else {
|
||||
setSelectedHashId(undefined);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setSearchResults([]);
|
||||
setSelectedHashId(undefined);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFile = (hashId: string) => {
|
||||
if (!bulkEditMode) {
|
||||
setSelectedHashId(hashId);
|
||||
setSelectedHashIds(new Set());
|
||||
updateUrlForFile(hashId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSelection = (hashId: string) => {
|
||||
const newSelection = new Set(selectedHashIds);
|
||||
if (newSelection.has(hashId)) {
|
||||
newSelection.delete(hashId);
|
||||
} else {
|
||||
newSelection.add(hashId);
|
||||
}
|
||||
setSelectedHashIds(newSelection);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allHashIds = new Set(searchResults.map(r => r.hashId));
|
||||
setSelectedHashIds(allHashIds);
|
||||
};
|
||||
|
||||
const handleClearSelection = () => {
|
||||
setSelectedHashIds(new Set());
|
||||
setBulkEditMode(false);
|
||||
};
|
||||
|
||||
const handleBulkEditMode = (enabled: boolean) => {
|
||||
setBulkEditMode(enabled);
|
||||
if (enabled) {
|
||||
setSelectedHashId(undefined);
|
||||
} else {
|
||||
setSelectedHashIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleMetadataSaved = () => {
|
||||
// Optionally refresh the search results to reflect changes
|
||||
console.log('Metadata saved successfully');
|
||||
};
|
||||
|
||||
const handleBulkMetadataSaved = () => {
|
||||
console.log('Bulk metadata saved successfully');
|
||||
handleClearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>MetaMesh Metadata Editor</h1>
|
||||
<p className="app-description">
|
||||
Search and edit metadata for your media files
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<SearchBar onSearch={handleSearch} isLoading={isSearching} />
|
||||
|
||||
{error && <div className="app-error">{error}</div>}
|
||||
|
||||
{isLoadingDirectHash && (
|
||||
<div className="app-loading">Loading file metadata...</div>
|
||||
)}
|
||||
|
||||
{!isSearching && !isLoadingDirectHash && searchResults.length === 0 && !error && (
|
||||
<div className="app-welcome">
|
||||
<h2>Welcome to MetaMesh Metadata Editor</h2>
|
||||
<p>Search for files by title, filename, file path, or hash ID to get started.</p>
|
||||
<p className="app-welcome-features">
|
||||
Unified search across all fields | Single file editing | Bulk editing | Direct access via URL
|
||||
</p>
|
||||
<div className="app-kv-browser">
|
||||
<KVTreeView />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchResults.length > 0 && (
|
||||
<div className="app-content">
|
||||
<div className="app-sidebar">
|
||||
<FileList
|
||||
results={searchResults}
|
||||
selectedHashId={selectedHashId}
|
||||
selectedHashIds={selectedHashIds}
|
||||
bulkEditMode={bulkEditMode}
|
||||
onSelectFile={handleSelectFile}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onSelectAll={handleSelectAll}
|
||||
onClearSelection={handleClearSelection}
|
||||
onBulkEditMode={handleBulkEditMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="app-editor">
|
||||
{bulkEditMode && selectedHashIds.size > 0 ? (
|
||||
<BulkMetadataEditor
|
||||
hashIds={Array.from(selectedHashIds)}
|
||||
onSaved={handleBulkMetadataSaved}
|
||||
onCancel={handleClearSelection}
|
||||
/>
|
||||
) : selectedHashId ? (
|
||||
<MetadataEditor
|
||||
key={selectedHashId}
|
||||
hashId={selectedHashId}
|
||||
onSaved={handleMetadataSaved}
|
||||
/>
|
||||
) : (
|
||||
<div className="app-no-selection">
|
||||
{bulkEditMode
|
||||
? 'Select files from the list to bulk edit metadata'
|
||||
: 'Select a file from the list to edit its metadata'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
MetaMesh Metadata Editor | Part of{' '}
|
||||
<a href="/" target="_blank">
|
||||
MetaMesh
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
FileMetadata,
|
||||
SearchResponse,
|
||||
UpdateResult,
|
||||
BatchUpdateResponse,
|
||||
} from '../types';
|
||||
|
||||
const API_BASE = '/api/metadata';
|
||||
|
||||
export class MetadataAPI {
|
||||
/**
|
||||
* Search for files by query, hashId, or property
|
||||
*/
|
||||
static async search(params: {
|
||||
query?: string;
|
||||
hashId?: string;
|
||||
property?: string;
|
||||
propertyValue?: string;
|
||||
limit?: number;
|
||||
}): Promise<SearchResponse> {
|
||||
const response = await fetch(`${API_BASE}/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Search failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata for a specific file by hash ID
|
||||
*/
|
||||
static async getMetadata(hashId: string): Promise<FileMetadata> {
|
||||
const response = await fetch(`${API_BASE}/${hashId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to get metadata');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update complete metadata for a file
|
||||
*/
|
||||
static async updateMetadata(
|
||||
hashId: string,
|
||||
metadata: FileMetadata
|
||||
): Promise<UpdateResult> {
|
||||
const response = await fetch(`${API_BASE}/${hashId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(metadata),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update metadata');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific property for a file
|
||||
*/
|
||||
static async updateProperty(
|
||||
hashId: string,
|
||||
property: string,
|
||||
value: string
|
||||
): Promise<UpdateResult> {
|
||||
const response = await fetch(`${API_BASE}/${hashId}/property`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ property, value }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to update property');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update multiple files
|
||||
*/
|
||||
static async batchUpdate(
|
||||
updates: Array<{
|
||||
hashId: string;
|
||||
metadata?: FileMetadata;
|
||||
properties?: Record<string, string>;
|
||||
}>
|
||||
): Promise<BatchUpdateResponse> {
|
||||
const response = await fetch(`${API_BASE}/batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ updates }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Batch update failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete metadata for a file
|
||||
*/
|
||||
static async deleteMetadata(hashId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${hashId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete metadata');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hash IDs
|
||||
*/
|
||||
static async getAllHashIds(): Promise<{ hashIds: string[]; count: number }> {
|
||||
const response = await fetch(`${API_BASE}/hash-ids`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to get hash IDs');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
.bulk-metadata-editor {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-header h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.bulk-info {
|
||||
margin: 0 0 1rem 0;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-actions button.primary {
|
||||
background-color: #646cff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-actions button.primary:hover:not(:disabled) {
|
||||
background-color: #535bf2;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-message {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-message.success {
|
||||
background-color: rgba(0, 200, 0, 0.1);
|
||||
border: 1px solid rgba(0, 200, 0, 0.3);
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-message.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-section {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-left: 4px solid #646cff;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.bulk-metadata-section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bulk-metadata-section-header h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.section-hint-bulk {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Plugin-based bulk fields */
|
||||
.bulk-fields-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bulk-field-wrapper {
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bulk-field-wrapper.has-value {
|
||||
border-color: rgba(100, 108, 255, 0.3);
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
}
|
||||
|
||||
.bulk-field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.field-key {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: lowercase;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.bulk-field-wrapper.has-value .field-key {
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.bulk-field-input {
|
||||
/* Plugin component will render inside this */
|
||||
}
|
||||
|
||||
/* Legacy bulk fields (for compatibility) */
|
||||
.bulk-metadata-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-header label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.clear-field-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
color: #ff6b6b;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clear-field-btn:hover {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
border-color: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.bulk-metadata-field input,
|
||||
.bulk-metadata-field textarea,
|
||||
.bulk-metadata-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-metadata-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-field-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Modified fields summary */
|
||||
.modified-fields {
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
border-color: rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.modified-fields h3 {
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.modified-fields-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modified-field-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
border: 1px solid rgba(100, 108, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-weight: 600;
|
||||
color: #646cff;
|
||||
min-width: 150px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.remove-field-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||
color: #ff6b6b;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.remove-field-btn:hover {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
border-color: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bulk-metadata-editor-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bulk-metadata-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modified-field-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.remove-field-btn {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.bulk-metadata-editor {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bulk-info {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.bulk-metadata-section {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.bulk-metadata-section-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.section-hint-bulk {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.bulk-field-wrapper {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.bulk-field-wrapper.has-value {
|
||||
border-color: rgba(100, 108, 255, 0.3);
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
}
|
||||
|
||||
.field-key {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.bulk-field-wrapper.has-value .field-key {
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.field-header label {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-message.success {
|
||||
background-color: rgba(0, 200, 0, 0.1);
|
||||
border-color: rgba(0, 200, 0, 0.3);
|
||||
color: #008800;
|
||||
}
|
||||
|
||||
.bulk-metadata-editor-message.error {
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border-color: rgba(255, 0, 0, 0.3);
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
.modified-fields {
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
border-color: rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.modified-field-item {
|
||||
background-color: rgba(100, 108, 255, 0.05);
|
||||
border-color: rgba(100, 108, 255, 0.2);
|
||||
}
|
||||
|
||||
.clear-field-btn {
|
||||
border-color: rgba(255, 0, 0, 0.3);
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
.clear-field-btn:hover {
|
||||
background-color: rgba(255, 0, 0, 0.2);
|
||||
border-color: rgba(255, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileMetadata, PluginSchema } from '../types';
|
||||
import { MetadataAPI } from '../api/metadataApi';
|
||||
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
||||
import './BulkMetadataEditor.css';
|
||||
|
||||
interface PluginsResponse {
|
||||
plugins: PluginSchema[];
|
||||
}
|
||||
|
||||
interface BulkMetadataEditorProps {
|
||||
hashIds: string[];
|
||||
onSaved?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function BulkMetadataEditor({ hashIds, onSaved, onCancel }: BulkMetadataEditorProps) {
|
||||
const [plugins, setPlugins] = useState<PluginSchema[]>([]);
|
||||
const [commonValues, setCommonValues] = useState<Record<string, any>>({});
|
||||
const [editedFields, setEditedFields] = useState<Partial<FileMetadata>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Load plugins and metadata on mount
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch plugins
|
||||
const pluginsResponse = await fetch('/api/plugins');
|
||||
if (pluginsResponse.ok) {
|
||||
const data: PluginsResponse = await pluginsResponse.json();
|
||||
// Sort by priority
|
||||
const sorted = [...data.plugins].sort((a, b) =>
|
||||
(a.priority ?? 100) - (b.priority ?? 100)
|
||||
);
|
||||
setPlugins(sorted);
|
||||
}
|
||||
|
||||
// Load metadata for all selected files
|
||||
const metadataPromises = hashIds.map((hashId) => MetadataAPI.getMetadata(hashId));
|
||||
const metadata = await Promise.all(metadataPromises);
|
||||
|
||||
// Calculate common values
|
||||
const commons: Record<string, any> = {};
|
||||
|
||||
// Get all editable fields from all plugins
|
||||
const allFields: string[] = [];
|
||||
plugins.forEach((plugin) => {
|
||||
if (plugin.metadataSchema) {
|
||||
Object.entries(plugin.metadataSchema).forEach(([key, field]) => {
|
||||
if (!field.readonly) {
|
||||
allFields.push(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
allFields.forEach((field) => {
|
||||
const values = metadata.map((m) => m[field as keyof FileMetadata]);
|
||||
|
||||
// Filter out undefined and null values
|
||||
const definedValues = values.filter((v) => v !== undefined && v !== null);
|
||||
|
||||
if (definedValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Safely stringify values
|
||||
const uniqueValues = Array.from(
|
||||
new Set(
|
||||
definedValues.map((v) => {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (uniqueValues.length === 1) {
|
||||
try {
|
||||
commons[field] = JSON.parse(uniqueValues[0]);
|
||||
} catch {
|
||||
commons[field] = uniqueValues[0];
|
||||
}
|
||||
} else {
|
||||
commons[field] = '*';
|
||||
}
|
||||
});
|
||||
|
||||
setCommonValues(commons);
|
||||
} catch (err: any) {
|
||||
setError(`Failed to load data: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [hashIds]);
|
||||
|
||||
const handleFieldChange = (field: string, value: any) => {
|
||||
setEditedFields({
|
||||
...editedFields,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearField = (field: string) => {
|
||||
const newFields = { ...editedFields };
|
||||
delete newFields[field];
|
||||
setEditedFields(newFields);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (Object.keys(editedFields).length === 0) {
|
||||
setError('No fields have been edited');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
// Build batch update payload
|
||||
const updates = hashIds.map((hashId) => ({
|
||||
hashId,
|
||||
metadata: editedFields,
|
||||
}));
|
||||
|
||||
const result = await MetadataAPI.batchUpdate(updates);
|
||||
|
||||
if (result.errors > 0) {
|
||||
setError(
|
||||
`Updated ${result.success} files successfully, but ${result.errors} failed. Check console for details.`
|
||||
);
|
||||
console.error('Batch update errors:', result.results.filter((r) => r.error));
|
||||
} else {
|
||||
setSuccessMessage(`Successfully updated ${result.success} files!`);
|
||||
}
|
||||
|
||||
if (onSaved) {
|
||||
onSaved();
|
||||
}
|
||||
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = Object.keys(editedFields).length > 0;
|
||||
|
||||
// Filter to only editable plugins (those with non-readonly fields)
|
||||
const editablePlugins = plugins.filter((plugin) => {
|
||||
if (!plugin.metadataSchema) return false;
|
||||
return Object.values(plugin.metadataSchema).some((field) => !field.readonly);
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bulk-metadata-editor">
|
||||
<div className="bulk-metadata-editor-loading">
|
||||
Loading metadata for {hashIds.length} files...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build display metadata (edited values or common values)
|
||||
const displayMetadata: Record<string, any> = { ...commonValues, ...editedFields };
|
||||
|
||||
return (
|
||||
<div className="bulk-metadata-editor">
|
||||
<div className="bulk-metadata-editor-header">
|
||||
<h2>Bulk Edit Metadata</h2>
|
||||
<p className="bulk-info">
|
||||
Editing {hashIds.length} file{hashIds.length !== 1 ? 's' : ''} - Only modified fields will
|
||||
be updated. Fields showing "*" have different values across files.
|
||||
</p>
|
||||
<div className="bulk-metadata-editor-actions">
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleSave} disabled={isSaving || !hasChanges} className="primary">
|
||||
{isSaving ? 'Saving...' : `Update ${hashIds.length} File${hashIds.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="bulk-metadata-editor-message success">{successMessage}</div>
|
||||
)}
|
||||
{error && <div className="bulk-metadata-editor-message error">{error}</div>}
|
||||
|
||||
<div className="bulk-metadata-editor-form">
|
||||
{editablePlugins.map((plugin) => {
|
||||
if (!plugin.metadataSchema) return null;
|
||||
|
||||
// Get editable fields for this plugin
|
||||
const editableFields = Object.entries(plugin.metadataSchema).filter(
|
||||
([, field]) => !field.readonly
|
||||
);
|
||||
|
||||
if (editableFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={plugin.id}
|
||||
className="bulk-metadata-section"
|
||||
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
||||
>
|
||||
<div className="bulk-metadata-section-header">
|
||||
<h3>{plugin.name}</h3>
|
||||
{plugin.description && (
|
||||
<p className="section-hint">{plugin.description}</p>
|
||||
)}
|
||||
<p className="section-hint-bulk">
|
||||
Common values shown. "*" indicates different values. Edit to update all files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="plugin-fields">
|
||||
{editableFields.map(([key, field]) => (
|
||||
<DynamicFieldRenderer
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
schema={field}
|
||||
metadata={displayMetadata as FileMetadata}
|
||||
onChange={handleFieldChange}
|
||||
readonly={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Modified Fields Summary */}
|
||||
{hasChanges && (
|
||||
<section className="bulk-metadata-section modified-fields">
|
||||
<h3>Fields to be Updated ({Object.keys(editedFields).length})</h3>
|
||||
<div className="modified-fields-list">
|
||||
{Object.entries(editedFields).map(([key, value]) => (
|
||||
<div key={key} className="modified-field-item">
|
||||
<span className="field-name">{key}:</span>
|
||||
<span className="field-value">
|
||||
{Array.isArray(value)
|
||||
? value.join(', ')
|
||||
: typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value || '(empty)')}
|
||||
</span>
|
||||
<button
|
||||
className="remove-field-btn"
|
||||
onClick={() => handleClearField(key)}
|
||||
title="Remove this field"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.dynamic-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dynamic-field label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.dynamic-field input[type="text"],
|
||||
.dynamic-field input[type="number"],
|
||||
.dynamic-field select,
|
||||
.dynamic-field textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #fff;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.dynamic-field input:focus,
|
||||
.dynamic-field select:focus,
|
||||
.dynamic-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.dynamic-field input:disabled,
|
||||
.dynamic-field select:disabled,
|
||||
.dynamic-field textarea:disabled {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dynamic-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: normal;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
accent-color: #646cff;
|
||||
}
|
||||
|
||||
.checkbox-label input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-link {
|
||||
color: #646cff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.field-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* CID field styling */
|
||||
.cid-field .cid-value-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cid-field .cid-value {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #a0d8ef;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cid-field .cid-value:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-color: #646cff;
|
||||
}
|
||||
|
||||
.cid-field .cid-open-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #646cff;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cid-field .cid-open-button:hover {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
/* JSON field styling */
|
||||
.json-field .json-viewer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: #d4d4d4;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Language string field styling */
|
||||
.language-string-field .language-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-string-field .language-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-string-field .lang-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.language-string-field .lang-code {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(100, 108, 255, 0.2);
|
||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #a0d8ef;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.language-string-field .lang-text {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-string-field .lang-remove-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 100, 100, 0.2);
|
||||
border: 1px solid rgba(255, 100, 100, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #ff6b6b;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.language-string-field .lang-remove-btn:hover {
|
||||
background: rgba(255, 100, 100, 0.3);
|
||||
border-color: rgba(255, 100, 100, 0.5);
|
||||
}
|
||||
|
||||
.language-string-field .lang-add-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: rgba(100, 108, 255, 0.2);
|
||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
color: #646cff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.language-string-field .lang-add-btn:hover {
|
||||
background: rgba(100, 108, 255, 0.3);
|
||||
border-color: rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.dynamic-field label {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.dynamic-field input[type="text"],
|
||||
.dynamic-field input[type="number"],
|
||||
.dynamic-field select,
|
||||
.dynamic-field textarea {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dynamic-field input:disabled,
|
||||
.dynamic-field select:disabled,
|
||||
.dynamic-field textarea:disabled {
|
||||
background: #f5f5f5;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.readonly-value {
|
||||
background: #f8f9fa;
|
||||
border-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.json-field .json-viewer {
|
||||
background: #1e1e1e;
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.language-string-field .lang-code {
|
||||
background: rgba(100, 108, 255, 0.1);
|
||||
color: #4a50c8;
|
||||
}
|
||||
|
||||
.language-string-field .lang-text {
|
||||
background: #f8f9fa;
|
||||
border-color: #e9ecef;
|
||||
color: #495057;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { SchemaField, FileMetadata } from '../types';
|
||||
import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox';
|
||||
import './DynamicFieldRenderer.css';
|
||||
|
||||
interface DynamicFieldRendererProps {
|
||||
fieldKey: string;
|
||||
schema: SchemaField;
|
||||
metadata: FileMetadata;
|
||||
onChange: (key: string, value: any) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single metadata field based on its schema definition
|
||||
*/
|
||||
export function DynamicFieldRenderer({
|
||||
fieldKey,
|
||||
schema,
|
||||
metadata,
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const isReadonly = readonly || schema.readonly;
|
||||
const fieldType = schema.type || 'string';
|
||||
|
||||
// Get value from metadata using path-style key
|
||||
const getValue = (): any => {
|
||||
// Handle path-style keys like "stream/duration" or "nfo/title"
|
||||
const parts = fieldKey.split('/');
|
||||
let value: any = metadata;
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
value = value[part];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Set value in metadata using path-style key
|
||||
const setValue = (newValue: any) => {
|
||||
onChange(fieldKey, newValue);
|
||||
};
|
||||
|
||||
const value = getValue();
|
||||
|
||||
// Render link if configured
|
||||
const renderValueWithLink = (displayValue: string) => {
|
||||
if (schema.link && displayValue) {
|
||||
const url = schema.link.replace('{value}', encodeURIComponent(displayValue));
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className="field-link">
|
||||
{displayValue}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return displayValue;
|
||||
};
|
||||
|
||||
// Render based on field type
|
||||
switch (fieldType) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => setValue(e.target.checked)}
|
||||
disabled={isReadonly}
|
||||
/>
|
||||
<span>{schema.label}</span>
|
||||
</label>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value ? Number(e.target.value) : undefined)}
|
||||
disabled={isReadonly}
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{schema.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<textarea
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
disabled={isReadonly}
|
||||
placeholder={schema.placeholder}
|
||||
rows={4}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'array':
|
||||
// Convert RecordSet to comma-separated string for display
|
||||
const arrayValue = (() => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
return Object.values(value).join(', ');
|
||||
}
|
||||
return '';
|
||||
})();
|
||||
|
||||
// Convert comma-separated string back to RecordSet
|
||||
const handleArrayChange = (str: string) => {
|
||||
const items = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (items.length === 0) {
|
||||
setValue(undefined);
|
||||
return;
|
||||
}
|
||||
const recordSet: Record<string, string> = {};
|
||||
items.forEach((item, idx) => {
|
||||
recordSet[idx.toString()] = item;
|
||||
});
|
||||
setValue(recordSet);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={arrayValue}
|
||||
onChange={(e) => handleArrayChange(e.target.value)}
|
||||
disabled={isReadonly}
|
||||
placeholder={schema.placeholder || 'Comma-separated values'}
|
||||
/>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
// Aggregate matching keys based on pattern
|
||||
const jsonData = (() => {
|
||||
if (!schema.pattern) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Pattern like "stream/video/{n}/*" - aggregate all matching keys
|
||||
const patternPrefix = schema.pattern.replace(/\{n\}.*$/, '');
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
// Recursively find all matching keys
|
||||
const findMatching = (obj: any, currentPath: string = '') => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||
if (newPath.startsWith(patternPrefix.replace(/\/$/, ''))) {
|
||||
// Store in result with relative path
|
||||
const relativePath = newPath.substring(patternPrefix.length - 1);
|
||||
if (relativePath) {
|
||||
setNestedValue(result, relativePath, val);
|
||||
}
|
||||
}
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
findMatching(val, newPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
findMatching(metadata);
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
})();
|
||||
|
||||
if (!jsonData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field json-field">
|
||||
<label>{schema.label}</label>
|
||||
<pre className="json-viewer">
|
||||
{JSON.stringify(jsonData, null, 2)}
|
||||
</pre>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'cid':
|
||||
// CID field type - displays CID with "Open in Editor" button
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cidValue = String(value);
|
||||
const editorUrl = `/editor/file/${cidValue}`;
|
||||
|
||||
return (
|
||||
<div className="dynamic-field cid-field">
|
||||
<label>{schema.label}</label>
|
||||
<div className="cid-value-container">
|
||||
<code
|
||||
className="cid-value"
|
||||
onClick={() => navigator.clipboard.writeText(cidValue)}
|
||||
title="Click to copy"
|
||||
>
|
||||
{cidValue}
|
||||
</code>
|
||||
<a
|
||||
href={editorUrl}
|
||||
className="cid-open-button"
|
||||
title="Open in Editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
↗ Open
|
||||
</a>
|
||||
</div>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'languageString':
|
||||
// Language-keyed string object: { "eng": "English Title", "jpn": "日本語タイトル" }
|
||||
const langEntries: Array<[string, string]> = value && typeof value === 'object'
|
||||
? Object.entries(value).map(([k, v]) => [k, String(v)])
|
||||
: [];
|
||||
|
||||
const handleLangCodeChange = (oldLang: string, newLang: string) => {
|
||||
if (oldLang === newLang) return;
|
||||
const newValue = { ...value };
|
||||
const text = newValue[oldLang];
|
||||
delete newValue[oldLang];
|
||||
newValue[newLang] = text;
|
||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||
};
|
||||
|
||||
const handleLangTextChange = (lang: string, text: string) => {
|
||||
const newValue = { ...value, [lang]: text };
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const handleRemoveLang = (lang: string) => {
|
||||
const newValue = { ...value };
|
||||
delete newValue[lang];
|
||||
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||
};
|
||||
|
||||
const handleAddLang = () => {
|
||||
// Find a language code not already used, default to 'eng'
|
||||
const usedLangs = new Set(langEntries.map(([k]) => k));
|
||||
const commonDefaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa'];
|
||||
const defaultLang = commonDefaults.find(l => !usedLangs.has(l)) || 'eng';
|
||||
setValue({ ...value, [defaultLang]: '' });
|
||||
};
|
||||
|
||||
if (isReadonly && langEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field language-string-field">
|
||||
<label>{schema.label}</label>
|
||||
<div className="language-entries">
|
||||
{langEntries.map(([lang, text]) => (
|
||||
<div key={lang} className="language-entry">
|
||||
{isReadonly ? (
|
||||
<>
|
||||
<span className="lang-code" title={formatLanguageOption(lang)}>{lang}</span>
|
||||
<span className="lang-text">{text}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LanguageCombobox
|
||||
value={lang}
|
||||
onChange={(newLang) => handleLangCodeChange(lang, newLang)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="lang-input"
|
||||
value={text}
|
||||
onChange={(e) => handleLangTextChange(lang, e.target.value)}
|
||||
placeholder={schema.placeholder || 'Text...'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="lang-remove-btn"
|
||||
onClick={() => handleRemoveLang(lang)}
|
||||
title="Remove"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!isReadonly && (
|
||||
<button
|
||||
type="button"
|
||||
className="lang-add-btn"
|
||||
onClick={handleAddLang}
|
||||
>
|
||||
+ Add Language
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
default:
|
||||
// Show nothing if no value and readonly
|
||||
if (isReadonly && !value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dynamic-field">
|
||||
<label>{schema.label}</label>
|
||||
{isReadonly ? (
|
||||
<div className="readonly-value">
|
||||
{renderValueWithLink(String(value ?? ''))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => setValue(e.target.value || undefined)}
|
||||
placeholder={schema.placeholder}
|
||||
/>
|
||||
)}
|
||||
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to set nested value in object
|
||||
function setNestedValue(obj: any, path: string, value: any) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let current = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!current[parts[i]]) {
|
||||
current[parts[i]] = {};
|
||||
}
|
||||
current = current[parts[i]];
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
current[parts[parts.length - 1]] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.plugin-section {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-left: 4px solid #646cff;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.plugin-section-header {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.plugin-section-header h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #646cff;
|
||||
}
|
||||
|
||||
.plugin-section-header .plugin-description {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.plugin-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive grid for fields */
|
||||
@media (min-width: 768px) {
|
||||
.plugin-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Make text and json fields span full width */
|
||||
.plugin-fields .dynamic-field:has(textarea),
|
||||
.plugin-fields .json-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.plugin-section {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.plugin-section-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.plugin-section-header .plugin-description {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { PluginSchema, FileMetadata, SchemaField } from '../types';
|
||||
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
||||
import './DynamicPluginSection.css';
|
||||
|
||||
interface DynamicPluginSectionProps {
|
||||
plugin: PluginSchema;
|
||||
metadata: FileMetadata;
|
||||
onChange: (key: string, value: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a plugin section with all its fields based on schema
|
||||
*/
|
||||
export function DynamicPluginSection({
|
||||
plugin,
|
||||
metadata,
|
||||
onChange,
|
||||
}: DynamicPluginSectionProps) {
|
||||
const schema = plugin.metadataSchema;
|
||||
|
||||
if (!schema || Object.keys(schema).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if plugin has any data in metadata
|
||||
const hasData = checkPluginHasData(schema, metadata);
|
||||
if (!hasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort fields: non-readonly first, then by key
|
||||
const sortedFields = Object.entries(schema).sort(([keyA, a], [keyB, b]) => {
|
||||
// Non-readonly fields first
|
||||
if (a.readonly !== b.readonly) {
|
||||
return a.readonly ? 1 : -1;
|
||||
}
|
||||
// Then alphabetically
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
className="plugin-section"
|
||||
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
||||
>
|
||||
<div className="plugin-section-header">
|
||||
<h3>{plugin.name}</h3>
|
||||
{plugin.description && (
|
||||
<p className="plugin-description">{plugin.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="plugin-fields">
|
||||
{sortedFields.map(([fieldKey, fieldSchema]) => (
|
||||
<DynamicFieldRenderer
|
||||
key={fieldKey}
|
||||
fieldKey={fieldKey}
|
||||
schema={fieldSchema}
|
||||
metadata={metadata}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin has any data in the metadata
|
||||
*/
|
||||
function checkPluginHasData(
|
||||
schema: Record<string, SchemaField>,
|
||||
metadata: FileMetadata
|
||||
): boolean {
|
||||
for (const fieldKey of Object.keys(schema)) {
|
||||
const fieldSchema = schema[fieldKey];
|
||||
|
||||
// For JSON type with pattern, check if any matching keys exist
|
||||
if (fieldSchema.type === 'json' && fieldSchema.pattern) {
|
||||
const patternPrefix = fieldSchema.pattern.replace(/\{n\}.*$/, '').replace(/\/$/, '');
|
||||
if (hasKeyWithPrefix(metadata, patternPrefix)) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// For regular fields, check if value exists
|
||||
if (getNestedValue(metadata, fieldKey) !== undefined) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested value from object using path-style key
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
const parts = path.split('/');
|
||||
let value = obj;
|
||||
for (const part of parts) {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
value = value[part];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object has any key starting with prefix
|
||||
*/
|
||||
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||
if (newPath.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
if (hasKeyWithPrefix(obj[key], prefix, newPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
.file-list {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 0.9em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.bulk-edit-toggle {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85em;
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||
color: #646cff;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.bulk-edit-toggle:hover {
|
||||
background-color: rgba(100, 108, 255, 0.2);
|
||||
border-color: rgba(100, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
.bulk-edit-toggle.active {
|
||||
background-color: rgba(100, 108, 255, 0.3);
|
||||
border-color: #646cff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-list-bulk-actions {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85em;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.select-all-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
font-size: 0.9em;
|
||||
color: #646cff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-list-items {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.file-list-item-checkbox {
|
||||
padding-top: 0.2rem;
|
||||
}
|
||||
|
||||
.file-list-item-checkbox input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-list-item-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-list-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.file-list-item.selected {
|
||||
background-color: rgba(100, 108, 255, 0.2);
|
||||
border-left: 3px solid #646cff;
|
||||
}
|
||||
|
||||
.file-list-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-list-item-details {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.file-list-item-hash {
|
||||
font-size: 0.8em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.file-list-empty {
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.file-list {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-count {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.file-list-item {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.file-list-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.file-list-item.selected {
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.file-list-item-details {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.file-list-item-hash {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.file-list-empty {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { SearchResult } from '../types';
|
||||
import { getDisplayName, truncateHash, formatBytes } from '../utils/formatting';
|
||||
import './FileList.css';
|
||||
|
||||
interface FileListProps {
|
||||
results: SearchResult[];
|
||||
selectedHashId?: string;
|
||||
selectedHashIds: Set<string>;
|
||||
bulkEditMode: boolean;
|
||||
onSelectFile: (hashId: string) => void;
|
||||
onToggleSelection: (hashId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onBulkEditMode: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function FileList({
|
||||
results,
|
||||
selectedHashId,
|
||||
selectedHashIds,
|
||||
bulkEditMode,
|
||||
onSelectFile,
|
||||
onToggleSelection,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onBulkEditMode,
|
||||
}: FileListProps) {
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="file-list-empty">
|
||||
<p>No files found. Try searching for a title, filename, or hash ID.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const allSelected = results.length > 0 && results.every(r => selectedHashIds.has(r.hashId));
|
||||
|
||||
// Sort results by file path
|
||||
const sortedResults = [...results].sort((a, b) => {
|
||||
const pathA = a.metadata.filePath || a.metadata.privateFilePath || '';
|
||||
const pathB = b.metadata.filePath || b.metadata.privateFilePath || '';
|
||||
return pathA.localeCompare(pathB);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="file-list">
|
||||
<div className="file-list-header">
|
||||
<span className="file-count">{results.length} files found</span>
|
||||
<button
|
||||
className={`bulk-edit-toggle ${bulkEditMode ? 'active' : ''}`}
|
||||
onClick={() => onBulkEditMode(!bulkEditMode)}
|
||||
title={bulkEditMode ? 'Exit bulk edit mode' : 'Enter bulk edit mode'}
|
||||
>
|
||||
{bulkEditMode ? '✓ Bulk Edit' : 'Bulk Edit'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bulkEditMode && (
|
||||
<div className="file-list-bulk-actions">
|
||||
<button onClick={allSelected ? onClearSelection : onSelectAll} className="select-all-btn">
|
||||
{allSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
{selectedHashIds.size > 0 && (
|
||||
<span className="selection-count">
|
||||
{selectedHashIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="file-list-items">
|
||||
{sortedResults.map((result) => {
|
||||
const isSelected = bulkEditMode
|
||||
? selectedHashIds.has(result.hashId)
|
||||
: selectedHashId === result.hashId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.hashId}
|
||||
className={`file-list-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
if (bulkEditMode) {
|
||||
onToggleSelection(result.hashId);
|
||||
} else {
|
||||
onSelectFile(result.hashId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{bulkEditMode && (
|
||||
<div className="file-list-item-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHashIds.has(result.hashId)}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(result.hashId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="file-list-item-content">
|
||||
<div className="file-list-item-title">
|
||||
{getDisplayName(result.metadata)}
|
||||
</div>
|
||||
<div className="file-list-item-details">
|
||||
<span className="file-list-item-type">
|
||||
{result.metadata.videoType || 'Unknown'}
|
||||
</span>
|
||||
<span className="file-list-item-size">
|
||||
{formatBytes(result.metadata.byteSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="file-list-item-hash">
|
||||
Hash: {truncateHash(result.hashId)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
.kv-tree {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kv-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.kv-tree-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.kv-tree-info {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.kv-tree-refresh {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
background-color: rgba(100, 108, 255, 0.2);
|
||||
color: #646cff;
|
||||
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.kv-tree-refresh:hover:not(:disabled) {
|
||||
background-color: rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.kv-tree-refresh:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kv-tree-error {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
color: #ff6b6b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.kv-tree-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.kv-tree-node {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kv-tree-node:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.kv-tree-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.kv-tree-key:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kv-tree-key.expanded {
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.kv-tree-toggle {
|
||||
width: 1rem;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.kv-tree-key-name {
|
||||
flex: 1;
|
||||
color: #88c0d0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kv-tree-type {
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.kv-tree-value {
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.kv-tree-fields {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.kv-tree-fields tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.kv-field-name {
|
||||
padding: 0.25rem 0.5rem 0.25rem 0;
|
||||
color: #a3be8c;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.kv-field-value {
|
||||
padding: 0.25rem 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.kv-field-value pre {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.kv-tree-load-more {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
background-color: transparent;
|
||||
color: #646cff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.kv-tree-load-more:hover:not(:disabled) {
|
||||
background-color: rgba(100, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.kv-tree-load-more:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kv-tree-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Light mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.kv-tree {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.kv-tree-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.kv-tree-header h3 {
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.kv-tree-info {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.kv-tree-error {
|
||||
background-color: rgba(255, 0, 0, 0.08);
|
||||
color: #cc0000;
|
||||
}
|
||||
|
||||
.kv-tree-node {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.kv-tree-key:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.kv-tree-key.expanded {
|
||||
background-color: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.kv-tree-toggle {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.kv-tree-key-name {
|
||||
color: #0077aa;
|
||||
}
|
||||
|
||||
.kv-tree-type {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.kv-tree-value {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-top-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.kv-tree-fields tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.kv-field-name {
|
||||
color: #228822;
|
||||
}
|
||||
|
||||
.kv-field-value {
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.kv-tree-load-more:hover:not(:disabled) {
|
||||
background-color: rgba(100, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.kv-tree-empty {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user