initial commit

This commit is contained in:
Pierre
2026-01-11 17:26:07 +01:00
commit ff4fbf136f
49 changed files with 13118 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Dependencies
node_modules
**/node_modules
**/.pnpm-store
# Build outputs
dist
**/dist
# Development files
.git
.gitignore
*.md
.env*
.vscode
.idea
# Test files
**/*.test.ts
**/*.spec.ts
coverage
# OS files
.DS_Store
Thumbs.db
+11
View File
@@ -0,0 +1,11 @@
# meta-fuse Environment Configuration
# Data path (shared with meta-sort)
DATA_PATH=/path/to/shared/data
# User/Group IDs for file permissions
PUID=1000
PGID=1000
# VFS refresh interval (milliseconds)
VFS_REFRESH_INTERVAL=30000
+66
View File
@@ -0,0 +1,66 @@
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
- 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
+27
View File
@@ -0,0 +1,27 @@
# Dependencies
node_modules/
# Build outputs
dist/
target/
# Environment files
.env
.env.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Runtime data
data/
+146
View File
@@ -0,0 +1,146 @@
# meta-fuse standalone Docker image
# Includes: Redis, nginx, FUSE driver, WebDAV, Node.js backend, React UI
#
# Build standalone:
# docker build -t meta-fuse .
#
# Build with custom meta-core (for development):
# docker build --build-arg META_CORE_IMAGE=meta-core:local -t meta-fuse .
# 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-fuse-ui/package.json ./
RUN npm install
# Copy UI source and build
COPY packages/meta-fuse-ui/ ./
RUN npm run build
# Stage 2: Build Backend
FROM node:21-alpine AS backend-builder
WORKDIR /build
# Copy backend package and install
COPY packages/meta-fuse-core/package.json ./
RUN npm install
# Copy backend source and build
COPY packages/meta-fuse-core/tsconfig.json ./
COPY packages/meta-fuse-core/src/ ./src/
RUN npm run build
# Stage 3: Build FUSE Driver
FROM rust:1.83-bookworm AS fuse-builder
WORKDIR /build
# Install FUSE development libraries
RUN apt-get update && apt-get install -y libfuse3-dev pkg-config
# Copy Rust project
COPY packages/meta-fuse-driver/Cargo.toml ./
COPY packages/meta-fuse-driver/src/ ./src/
# Build release binary
RUN cargo build --release
# Stage 4: Runtime
FROM ubuntu:22.04
# Container registry metadata
LABEL org.opencontainers.image.source=https://github.com/worph/meta-fuse
LABEL org.opencontainers.image.description="MetaMesh virtual filesystem via FUSE/WebDAV"
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 \
fuse3 \
python3 \
python3-pip \
&& 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 WsgiDAV
RUN pip3 install wsgidav cheroot
# Create directories
RUN mkdir -p \
/app/backend \
/app/ui \
/app/welcome \
/app/fuse-driver \
/app/docker \
/data/watch \
/data/redis \
/mnt/virtual \
/var/log/supervisor \
/var/log/nginx
# Configure FUSE
RUN echo "user_allow_other" >> /etc/fuse.conf
# Copy built UI
COPY --from=ui-builder /build/dist /app/ui
RUN chmod -R 755 /app/ui
# Copy built backend
COPY --from=backend-builder /build/dist /app/backend/dist
COPY --from=backend-builder /build/node_modules /app/backend/node_modules
COPY --from=backend-builder /build/package.json /app/backend/
# Copy built FUSE driver
COPY --from=fuse-builder /build/target/release/meta-fuse-driver /app/fuse-driver/
RUN chmod +x /app/fuse-driver/meta-fuse-driver
# 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/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/wsgidav.yaml /app/docker/
COPY docker/welcome.html /app/welcome/
COPY docker/start-fuse-driver.sh /app/docker/
RUN chmod 644 /app/welcome/welcome.html && \
chmod +x /app/docker/start-fuse-driver.sh
# Environment variables
ENV NODE_ENV=production \
REDIS_URL=redis://127.0.0.1:6379 \
REDIS_PREFIX=meta-sort: \
API_HOST=0.0.0.0 \
API_PORT=3000 \
PUID=1000 \
PGID=1000
# 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, fuse-driver, wsgidav, nginx)
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
+604
View File
@@ -0,0 +1,604 @@
# Meta-Fuse
Standalone virtual filesystem service that exposes metadata-organized content via FUSE and WebDAV without duplicating files.
## Overview
Meta-Fuse is a read-only process in the MetaMesh ecosystem that:
1. **Connects to shared KV storage** - Reads metadata from Redis/compatible KV database managed by meta-sort
2. **Mounts virtual filesystem** - Rust-based FUSE driver creates an organized view of media files
3. **Serves WebDAV** - Network-accessible file sharing for Windows/Mac/Linux clients
4. **Zero file duplication** - All content points to original files in the shared DATA volume
5. **Leader-aware KV access** - Discovers and connects to the active KV database via lock file
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MetaMesh Ecosystem │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ DATA Volume (Shared) KV Database (Leader Election) │
│ ┌────────────────────┐ ┌────────────────────────────┐ │
│ │ /data/watch/ │ │ Redis/Compatible DB │ │
│ │ /data/output/ │◄──────┐│ - metadata storage │ │
│ │ /mnt/remote/ │ ││ - file paths & attributes │ │
│ └────────────────────┘ │└────────────────────────────┘ │
│ ▲ │ ▲ │
│ │ │ │ reads │
│ │ │ │ │
│ ┌────────┴───────────────────┴─────────────┴──────────────────┐ │
│ │ META-FUSE │ │
│ │ ┌─────────────────────────────────────────────────────────┐│ │
│ │ │ KV Client Wrapper ││ │
│ │ │ - Reads lock file for leader discovery ││ │
│ │ │ - Connects to active KV database ││ │
│ │ │ - Reconnect loop for failure handling ││ │
│ │ └─────────────────────────────────────────────────────────┘│ │
│ │ │ │ │
│ │ ┌─────────────┴─────────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌───────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ FUSE API Server │ │ WebDAV Server │ │ │
│ │ │ (Node.js/Fastify) │ │ (WsgiDAV) │ │ │
│ │ │ Port 3000 │ │ Port 8080 │ │ │
│ │ └───────────┬───────────┘ └───────────┬─────────────┘ │ │
│ │ │ │ │ │
│ │ ▼ │ │ │
│ │ ┌───────────────────────┐ │ │ │
│ │ │ FUSE Driver │◄──────────────┘ │ │
│ │ │ (Rust) │ │ │
│ │ │ /mnt/virtual │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ nginx Reverse Proxy │ │
│ │ / → UI │ │
│ │ /webdav → WebDAV │ │
│ │ /api/fuse → API │ │
│ └─────────────────────────┘ │
│ │ │
└────────────────────────────┼────────────────────────────────────────────────┘
▼
External Clients
(Windows/Mac/Linux)
```
### Service Role in MetaMesh
```
┌────────────────────────────────────────────────────────────────────────────┐
│ SERVICE ROLES │
├────────────────────────────────────────────────────────────────────────────┤
│ │
│ [1] meta-sort (PROCESS-WRITE) │
│ └─► Writes metadata to KV, manages remote mounts │
│ │
│ [2] meta-fuse (PROCESS-READ) ◄── YOU ARE HERE │
│ └─► Reads metadata from KV, exposes virtual filesystem │
│ │
│ [3] meta-stremio (PROCESS-READ) │
│ └─► Reads metadata from KV, streams media content │
│ │
│ [4] meta-orbit (SHARING-READ-WRITE) │
│ └─► P2P metadata sync across network │
│ │
└────────────────────────────────────────────────────────────────────────────┘
```
### KV Leader Election
All services in MetaMesh use a shared KV wrapper with leader election via flock(2):
```
/meta-core/locks/
└── kv-leader.lock # flock-based leader election + endpoint info
Lock File Format (JSON):
{
"host": "meta-sort-instance-1",
"api": "redis://10.0.1.50:6379",
"http": "http://10.0.1.50:3000",
"timestamp": 1703808000000,
"pid": 12345
}
Leader Election Flow:
1. meta-sort attempts exclusive flock on kv-leader.lock
2. Winner (leader) spawns Redis, writes endpoint to lock file
3. meta-fuse reads lock file to discover Redis endpoint
4. meta-fuse connects as read-only client
5. On leader failure, flock auto-releases, new leader elected
```
**Note**: meta-fuse never becomes leader - it only reads the lock file to discover the active Redis endpoint managed by meta-sort.
## Core Features
### Virtual Filesystem (FUSE)
- **Organized view**: Files appear in categorized folders (Movies, TV, Anime, etc.)
- **Path-to-inode mapping**: Translates filesystem paths to FUSE inode numbers
- **Attribute caching**: 1-second TTL for file attributes
- **Directory caching**: 30-second TTL for directory listings
- **Error resilience**: Virtual ERROR.txt shown when backend unavailable
### WebDAV Server
- **Network file sharing**: Mount as network drive on any OS
- **Read-only access**: Prevents accidental modifications
- **Basic authentication**: Username/password protection
- **Directory browsing**: Web-based file browser
### KV Client Wrapper
- **Leader discovery**: Reads lock file to find active database
- **Auto-reconnect**: Handles leader failover gracefully
- **Connection pooling**: Efficient database connections
- **Read-only operations**: No writes to KV database
## Package Structure
```
meta-fuse/
├── packages/
│ ├── meta-fuse-core/ # Core service (@meta-fuse/core)
│ │ ├── src/
│ │ │ ├── api/ # Fastify REST API (APIServer)
│ │ │ │ └── APIServer.ts
│ │ │ ├── kv/ # KV client wrapper (FOLLOWER mode only)
│ │ │ │ ├── IKVClient.ts # Read-only interface
│ │ │ │ ├── KVManager.ts # Leader discovery, connection management
│ │ │ │ ├── LeaderDiscovery.ts # Lock file reading
│ │ │ │ └── RedisClient.ts # Redis connection with pub/sub
│ │ │ ├── vfs/ # Virtual filesystem logic
│ │ │ │ ├── VirtualFileSystem.ts # In-memory VFS representation
│ │ │ │ ├── MetaDataToFolderStruct.ts # Folder organization
│ │ │ │ └── RenamingRule.ts # Virtual path rules
│ │ │ └── index.ts # Entry point
│ │ ├── package.json
│ │ └── tsconfig.json
│ │
│ ├── meta-fuse-driver/ # Rust FUSE driver
│ │ ├── src/
│ │ │ ├── main.rs # Entry point
│ │ │ ├── api_client.rs # HTTP client to API server
│ │ │ └── inode_mapper.rs # Path-to-inode mapping
│ │ └── Cargo.toml
│ │
│ └── meta-fuse-ui/ # Monitoring dashboard (optional)
│ ├── src/
│ └── package.json
│
├── docker/
│ ├── nginx.conf # Reverse proxy config
│ ├── wsgidav.yaml # WebDAV server config
│ ├── supervisord.conf # Process management
│ └── entrypoint.sh # Service orchestration
│
├── Dockerfile
├── docker-compose.yml
├── package.json # Workspace root
├── pnpm-workspace.yaml
└── README.md
```
### Key Components (meta-fuse-core)
| Component | File | Purpose |
|-----------|------|---------|
| Entry Point | `src/index.ts` | Initializes KV manager, VFS, API server, pub/sub listener |
| API Server | `src/api/APIServer.ts` | Fastify REST API for FUSE operations (readdir, getattr, read) |
| KV Manager | `src/kv/KVManager.ts` | **FOLLOWER-only**: leader discovery, Redis connection, reconnection loop |
| Leader Discovery | `src/kv/LeaderDiscovery.ts` | Reads `/meta-core/locks/kv-leader.lock` to find active Redis |
| Redis Client | `src/kv/RedisClient.ts` | Wrapper around ioredis with pub/sub support |
| KV Interface | `src/kv/IKVClient.ts` | Read-only interface (get, scan, subscribe) |
| Virtual FS | `src/vfs/VirtualFileSystem.ts` | In-memory VFS representation with caching |
| Folder Organizer | `src/vfs/MetaDataToFolderStruct.ts` | Converts flat metadata to organized folder structure |
| Renaming Rules | `src/vfs/RenamingRule.ts` | Rules for virtual path organization |
**Note**: Unlike meta-sort, meta-fuse's KVManager is simplified:
- **Never spawns Redis** (always FOLLOWER)
- **Only reads metadata** (read-only interface)
- **Discovers leader** via lock file at `/meta-core/locks/kv-leader.lock`
- **No service discovery registration** (minimal footprint)
## Configuration
### Environment Variables
```bash
# Volume Paths
META_CORE_PATH=/meta-core # Infrastructure volume (locks, services)
FILES_VOLUME=/files # Shared media volume
# KV Connection
REDIS_URL= # Direct Redis URL (optional, skips discovery)
REDIS_PREFIX=meta-sort: # Key prefix in Redis
# API Server
API_PORT=3000 # API server port
API_HOST=0.0.0.0 # API bind address
VFS_REFRESH_INTERVAL=30000 # VFS cache refresh interval (ms)
# FUSE Driver
FUSE_MOUNT_POINT=/mnt/virtual # Virtual filesystem mount
FUSE_API_URL=http://localhost:3000 # API server URL
FUSE_FILE_MODE=644 # File permissions (octal)
FUSE_DIR_MODE=755 # Directory permissions (octal)
FUSE_ALLOW_OTHER=true # Allow other users
# User/Group
PUID=1000 # User ID for files
PGID=1000 # Group ID for files
# WebDAV
WEBDAV_PORT=8080 # WebDAV server port
WEBDAV_USERNAME=metamesh # WebDAV username
WEBDAV_PASSWORD=metamesh # WebDAV password
WEBDAV_READONLY=true # Read-only mode
```
### Docker Compose
```yaml
version: '3.8'
services:
meta-fuse:
build: .
container_name: meta-fuse
restart: unless-stopped
privileged: true # Required for FUSE
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse
ports:
- "80:80" # nginx (WebDAV, API)
volumes:
# Infrastructure volume (read-only for followers)
- ${META_CORE_PATH:-./data/meta-core}:/meta-core:ro
# Shared media volume (read-only)
- ${FILES_PATH:-./data/files}:/files:ro
# FUSE mount output (if exposing to host)
- /mnt/metamesh:/mnt/virtual:rw,shared
environment:
- META_CORE_PATH=/meta-core
- FILES_VOLUME=/files
- FUSE_MOUNT_POINT=/mnt/virtual
- REDIS_PREFIX=meta-sort:
```
## API Endpoints
### FUSE API (Port 3000)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/fuse/health` | Health check |
| GET | `/api/fuse/stats` | Filesystem statistics |
| POST | `/api/fuse/readdir` | List directory contents |
| POST | `/api/fuse/getattr` | Get file/directory attributes |
| POST | `/api/fuse/read` | Read file content |
| POST | `/api/fuse/exists` | Check path existence |
### Example Responses
**GET /api/fuse/stats**
```json
{
"totalFiles": 1523,
"totalDirectories": 48,
"totalSize": 1847392847362,
"categories": {
"Movies": 523,
"TV": 412,
"Anime": 588
},
"kvConnection": "connected",
"fuseMount": "mounted"
}
```
**POST /api/fuse/readdir**
```json
// Request
{ "path": "/Movies" }
// Response
{
"entries": [
"Action",
"Comedy",
"Drama",
"Sci-Fi"
]
}
```
**POST /api/fuse/getattr**
```json
// Request
{ "path": "/Movies/Action/Movie.mkv" }
// Response
{
"size": 4831838208,
"mode": "file",
"permissions": 644,
"atime": "2024-01-15T10:30:00Z",
"mtime": "2024-01-15T10:30:00Z",
"ctime": "2024-01-15T10:30:00Z",
"sourcePath": "/data/watch/downloads/Movie.2024.1080p.mkv"
}
```
## Usage
### Docker (Recommended)
```bash
# Start meta-fuse service
docker-compose up -d
# View logs
docker logs -f meta-fuse
# Check health
curl http://localhost/api/fuse/health
```
### Mount WebDAV (Windows)
```powershell
# Map network drive
net use Z: http://localhost/webdav /user:metamesh metamesh
# Access files
dir Z:\Movies
```
### Mount WebDAV (Linux)
```bash
# Install davfs2
sudo apt-get install davfs2
# Create mount point
mkdir -p ~/meta-fuse
# Mount
sudo mount -t davfs http://localhost/webdav ~/meta-fuse
# Enter credentials: metamesh / metamesh
# Access files
ls ~/meta-fuse/Movies
```
### Mount WebDAV (macOS)
```bash
# Using Finder: Go → Connect to Server
# Enter: http://localhost/webdav
# Credentials: metamesh / metamesh
```
## Development
### Prerequisites
- Node.js 21.6.2+
- pnpm 10.19.0+
- Rust 1.90+ (for FUSE driver)
- Docker & Docker Compose
### Local Development
```bash
# Install dependencies
pnpm install
# Build all packages
pnpm run build
# Start development mode
pnpm run dev
# Run tests
pnpm run test
# Lint
pnpm run lint
```
### Building FUSE Driver
```bash
cd packages/meta-fuse-driver
cargo build --release
# Run driver
./target/release/meta-fuse-driver \
--mount-point /mnt/virtual \
--api-url http://localhost:3000
```
### Project Scripts
| Command | Description |
|---------|-------------|
| `pnpm run build` | Build all packages |
| `pnpm run dev` | Development mode with hot reload |
| `pnpm run start:core` | Start core service |
| `pnpm run start:driver` | Start FUSE driver |
| `pnpm run test` | Run all tests |
| `pnpm run lint` | Lint all packages |
## How It Works
### Virtual Filesystem Flow
```
1. Client opens /mnt/virtual/Movies/Action/Movie.mkv
│
▼
2. FUSE driver receives read request
- Converts path to inode
- Calls API: POST /api/fuse/getattr
│
▼
3. API server queries KV database
- Looks up metadata by virtual path
- Returns sourcePath pointing to actual file
│
▼
4. FUSE driver reads from sourcePath
- /data/watch/downloads/Movie.2024.1080p.mkv
│
▼
5. Content streamed to client
- No file duplication
- Direct read from original location
```
### KV Data Structure
meta-fuse reads metadata stored by meta-sort using the `meta-sort:` prefix:
```
# File metadata stored by meta-sort (hash-based keys)
meta-sort:file:{midhash256}:title → "Inception"
meta-sort:file:{midhash256}:year → "2010"
meta-sort:file:{midhash256}:filePath → "media1/Movies/Inception (2010)/Inception.mkv"
meta-sort:file:{midhash256}:size → 4831838208
meta-sort:file:{midhash256}:video/codec → "h265"
# VFS paths are computed dynamically from metadata
# meta-fuse builds virtual paths like:
# /Movies/Inception (2010)/Inception.mkv
# → resolves to sourcePath: /files/media1/Movies/Inception (2010)/Inception.mkv
```
**Note**: File paths in Redis are **relative to FILES_VOLUME** (`/files`). meta-fuse prepends the FILES_VOLUME path when resolving actual file locations.
### Real-Time Updates via Pub/Sub
meta-fuse subscribes to Redis pub/sub channel `meta-sort:file:batch` for real-time metadata updates from meta-sort:
```typescript
interface BatchUpdateMessage {
timestamp: number;
changes: Array<{
action: 'add' | 'update' | 'remove';
hashId: string;
}>;
}
```
When meta-sort adds/updates/removes files, it publishes batch updates. meta-fuse processes these to:
1. Invalidate affected caches
2. Rebuild virtual paths for modified files
3. Update directory listings without full refresh
This ensures the virtual filesystem stays synchronized with metadata changes in near real-time.
---
## Troubleshooting
### FUSE Mount Not Working
```bash
# Check if FUSE is available
ls -la /dev/fuse
# Verify mount
mount | grep virtual
# Check API health
curl http://localhost:3000/api/fuse/health
# View driver logs
journalctl -u meta-fuse-driver -f
```
### KV Connection Failed
```bash
# Check lock file exists
cat /data/apps/meta-core/db/redis.lock
# Verify redis is running (on leader)
redis-cli ping
# Check meta-fuse logs
docker logs meta-fuse | grep "KV"
```
### WebDAV Not Accessible
```bash
# Check WebDAV health
curl -u metamesh:metamesh http://localhost/webdav/
# Verify nginx is proxying
curl -I http://localhost/webdav
# Check WsgiDAV logs
docker logs meta-fuse | grep "wsgidav"
```
### Files Not Appearing
```bash
# Check KV has data
redis-cli keys "meta-fuse:vfs:*"
# Verify source files exist
ls -la /data/watch/
# Check API stats
curl http://localhost:3000/api/fuse/stats
```
## Technology Stack
| Component | Technology | Purpose |
|-----------|------------|---------|
| Core Service | Node.js + TypeScript | API server, VFS logic |
| HTTP Framework | Fastify 5.x | REST API |
| FUSE Driver | Rust + fuser | Filesystem interface |
| WebDAV | WsgiDAV (Python) | Network file sharing |
| KV Database | Redis (via ioredis) | Metadata storage |
| Reverse Proxy | nginx | Request routing |
| Containerization | Docker | Deployment |
## Integration with MetaMesh
Meta-Fuse is designed to work seamlessly with other MetaMesh services:
- **meta-sort**: Provides metadata in KV database
- **meta-stremio**: Can stream files exposed by meta-fuse
- **meta-orbit**: Syncs metadata across P2P network
### Connecting to meta-sort
Ensure meta-sort is running and has processed files:
```bash
# On meta-sort host
curl http://localhost:3000/api/processing/status
# Verify KV has metadata
redis-cli keys "meta-sort:file:*"
```
Meta-fuse will automatically discover the KV database through the shared lock file.
## License
MIT License - See [LICENSE](LICENSE) for details.
+37
View File
@@ -0,0 +1,37 @@
version: '3.8'
services:
meta-fuse:
build: .
container_name: meta-fuse
restart: unless-stopped
privileged: true
cap_add:
- SYS_ADMIN
devices:
- /dev/fuse
ports:
- "80:80"
volumes:
# Shared data volume (read-only for meta-fuse)
- ${DATA_PATH:-./data}:/data:ro
# Redis data (needs write access)
- meta-fuse-redis:/data/redis
environment:
- NODE_ENV=production
- REDIS_URL=redis://127.0.0.1:6379
- REDIS_PREFIX=meta-sort:
- API_HOST=0.0.0.0
- API_PORT=3000
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- VFS_REFRESH_INTERVAL=${VFS_REFRESH_INTERVAL:-30000}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
volumes:
meta-fuse-redis:
+101
View File
@@ -0,0 +1,101 @@
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 webdav {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name _;
# Welcome page at root
location = / {
root /app/welcome;
try_files /welcome.html =404;
}
# Redirect /stats to /stats/
location = /stats {
return 301 /stats/;
}
# VFS Statistics dashboard (React SPA)
location /stats/ {
alias /app/ui/;
index index.html;
try_files $uri $uri/ /stats/index.html;
}
# Static assets for stats dashboard
location /assets/ {
alias /app/ui/assets/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# 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;
}
# WebDAV endpoint
location /webdav {
proxy_pass http://webdav/webdav;
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 Destination $http_destination;
proxy_set_header Depth $http_depth;
proxy_buffering off;
client_max_body_size 0;
# WebDAV methods
dav_methods PUT DELETE MKCOL COPY MOVE;
# Timeouts for large file operations
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
}
}
}
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Wait for meta-fuse API to be ready before starting fuse-driver
API_URL="http://127.0.0.1:3000/api/fuse/health"
MAX_WAIT=60
WAIT_INTERVAL=2
echo "[start-fuse-driver] Waiting for meta-fuse API at $API_URL..."
waited=0
while [ $waited -lt $MAX_WAIT ]; do
if curl -sf "$API_URL" > /dev/null 2>&1; then
echo "[start-fuse-driver] API is ready after ${waited}s"
break
fi
sleep $WAIT_INTERVAL
waited=$((waited + WAIT_INTERVAL))
echo "[start-fuse-driver] Waiting... (${waited}s/${MAX_WAIT}s)"
done
if [ $waited -ge $MAX_WAIT ]; then
echo "[start-fuse-driver] ERROR: API not ready after ${MAX_WAIT}s, starting anyway..."
fi
echo "[start-fuse-driver] Starting meta-fuse-driver..."
exec /app/fuse-driver/meta-fuse-driver /mnt/virtual http://127.0.0.1:3000
+75
View File
@@ -0,0 +1,75 @@
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
loglevel=info
[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-fuse",
SERVICE_VERSION="1.0.0",
META_CORE_HTTP_PORT="9000",
LEADER_ELECTION_ENABLED="false"
[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:meta-fuse]
command=/usr/bin/node /app/backend/dist/index.js
directory=/app/backend
priority=30
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
startsecs=5
environment=
NODE_ENV="production",
REDIS_PREFIX="",
API_HOST="0.0.0.0",
API_PORT="3000"
[program:fuse-driver]
command=/app/docker/start-fuse-driver.sh
priority=40
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/fuse-driver.log
stderr_logfile=/var/log/supervisor/fuse-driver_error.log
startsecs=10
startretries=3
environment=
RUST_LOG="info",
PUID="1000",
PGID="1000",
FUSE_FILE_PERM="755",
FUSE_DIR_PERM="755"
[program:wsgidav]
command=/usr/local/bin/wsgidav --config /app/docker/wsgidav.yaml
priority=50
autostart=true
autorestart=true
stdout_logfile=/var/log/supervisor/wsgidav.log
stderr_logfile=/var/log/supervisor/wsgidav_error.log
startsecs=5
+518
View File
@@ -0,0 +1,518 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>meta-fuse - Virtual Filesystem</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 50px;
}
h1 {
font-size: 3em;
background: linear-gradient(135deg, #4ecdc4, #44a08d);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 12px;
}
.subtitle {
color: #a0a0a0;
font-size: 1.2em;
}
.status-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
margin-top: 20px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.05);
border-radius: 30px;
display: inline-flex;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4ecdc4;
animation: pulse 2s infinite;
}
.status-dot.disconnected {
background: #ff6b6b;
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.description {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 30px;
margin-bottom: 40px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.description h2 {
color: #4ecdc4;
margin-bottom: 16px;
font-size: 1.4em;
}
.description p {
line-height: 1.8;
color: #b0b0b0;
}
.description ul {
margin: 16px 0 0 24px;
line-height: 2;
color: #b0b0b0;
}
.services {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.service-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 28px;
text-decoration: none;
color: inherit;
transition: all 0.3s ease;
}
.service-card:hover {
transform: translateY(-4px);
border-color: #4ecdc4;
box-shadow: 0 8px 30px rgba(78, 205, 196, 0.2);
}
.service-card .icon {
font-size: 2.5em;
margin-bottom: 16px;
}
.service-card h3 {
color: #fff;
font-size: 1.3em;
margin-bottom: 10px;
}
.service-card p {
color: #a0a0a0;
line-height: 1.6;
margin-bottom: 16px;
}
.service-url {
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #4ecdc4;
background: rgba(78, 205, 196, 0.1);
padding: 6px 12px;
border-radius: 6px;
display: inline-block;
}
/* Collapsible Setup Section */
.setup-section {
margin-bottom: 40px;
}
.setup-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 18px 24px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
transition: all 0.3s ease;
user-select: none;
}
.setup-header:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(78, 205, 196, 0.3);
}
.setup-header h3 {
color: #a0a0a0;
font-size: 1.1em;
margin: 0;
}
.toggle-icon {
font-size: 1.2em;
color: #606060;
transition: transform 0.3s ease;
}
.toggle-icon.open {
transform: rotate(180deg);
}
.setup-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.setup-content.open {
max-height: 2000px;
}
.setup-inner {
padding-top: 20px;
}
.info-section {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
}
.info-section h4 {
color: #4ecdc4;
margin-bottom: 12px;
font-size: 1em;
}
.info-section p {
color: #808080;
font-size: 0.9em;
margin-bottom: 16px;
line-height: 1.6;
}
.command {
background: #0d1117;
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 10px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-x: auto;
}
.command:last-child {
margin-bottom: 0;
}
.command strong {
color: #4ecdc4;
display: inline-block;
min-width: 80px;
}
.command code {
color: #e0e0e0;
}
footer {
text-align: center;
padding-top: 30px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
color: #606060;
font-size: 0.9em;
}
/* Services Navigation */
.services-nav {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 0.5rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.services-nav-label {
color: #888;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
color: #e8e8e8;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.service-link:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(78, 205, 196, 0.5);
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.3), rgba(68, 160, 141, 0.3));
border-color: rgba(78, 205, 196, 0.5);
}
.service-link .service-icon {
font-size: 1.1rem;
}
.service-link .service-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: #4ecdc4;
margin-left: 0.25rem;
}
.leader-badge {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.8rem;
background: rgba(78, 205, 196, 0.15);
border: 1px solid rgba(78, 205, 196, 0.3);
border-radius: 16px;
font-size: 0.75rem;
color: #4ecdc4;
margin-left: auto;
}
.leader-badge-icon {
font-size: 0.9rem;
}
</style>
<script>
function toggleSetup() {
const content = document.getElementById('setupContent');
const icon = document.getElementById('toggleIcon');
content.classList.toggle('open');
icon.classList.toggle('open');
}
</script>
</head>
<body>
<div class="container">
<!-- Services Navigation -->
<nav class="services-nav" id="servicesNav" style="display: none;">
<span class="services-nav-label">Services:</span>
<div class="services-nav-items" id="servicesNavItems">
<!-- Populated by JavaScript -->
</div>
</nav>
<header>
<h1>meta-fuse</h1>
<p class="subtitle">Virtual Filesystem Service</p>
<div class="status-bar">
<div class="status-item">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Checking...</span>
</div>
</div>
</header>
<div class="description">
<h2>What is meta-fuse?</h2>
<p>
<strong>meta-fuse</strong> is a virtual filesystem that presents your media files
in an organized structure based on metadata. It reads file information from the
KV leader (via Redis) and creates a virtual view of your library without duplicating any files.
</p>
<ul>
<li>Access files organized by title, year, series, etc.</li>
<li>Mount via FUSE (native) or WebDAV (network drive)</li>
<li>No file duplication - virtual paths point to real files</li>
<li>Automatically updates when new files are processed</li>
</ul>
</div>
<div class="services">
<a href="/webdav" class="service-card">
<div class="icon">📁</div>
<h3>WebDAV Files</h3>
<p>Browse and access your organized media library. Mount as a network drive on any device.</p>
<span class="service-url">/webdav</span>
</a>
<a href="/stats/" class="service-card">
<div class="icon">📊</div>
<h3>VFS Statistics</h3>
<p>View file counts, directory structure, connection status, and refresh metadata.</p>
<span class="service-url">/stats</span>
</a>
</div>
<div class="setup-section">
<div class="setup-header" onclick="toggleSetup()">
<h3>⚙️ Setup & Configuration</h3>
<span class="toggle-icon" id="toggleIcon">▼</span>
</div>
<div class="setup-content" id="setupContent">
<div class="setup-inner">
<div class="info-section">
<h4>🗂️ FUSE Mount (Docker)</h4>
<p>Mount the virtual filesystem directly from the container. Add this volume to your docker-compose.yml to access the organized files on your host:</p>
<div class="command">
<code>volumes:<br>&nbsp;&nbsp;- /path/on/host:/mnt/virtual:rw,shared</code>
</div>
<p style="margin-top: 12px; margin-bottom: 0;">The virtual filesystem is available inside the container at <code style="background: #0d1117; padding: 2px 6px; border-radius: 4px;">/mnt/virtual</code></p>
</div>
<div class="info-section">
<h4>🌐 WebDAV Network Drive</h4>
<p>Mount the virtual filesystem as a network drive on any device:</p>
<div class="command">
<strong>Windows:</strong>
<code>net use Z: http://&lt;host&gt;/webdav</code>
</div>
<div class="command">
<strong>macOS:</strong>
<code>Finder → Go → Connect to Server → http://&lt;host&gt;/webdav</code>
</div>
<div class="command">
<strong>Linux:</strong>
<code>sudo mount -t davfs http://&lt;host&gt;/webdav /mnt/vfs</code>
</div>
</div>
</div>
</div>
</div>
<footer>
<p>Part of the <strong>MetaMesh</strong> project</p>
<p>Discovers KV leader via shared volume and connects to Redis automatically</p>
</footer>
</div>
<script>
async function checkStatus() {
const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText');
try {
const res = await fetch('/health');
const data = await res.json();
if (data.status === 'ok') {
dot.classList.remove('disconnected');
text.textContent = 'Connected to Redis';
} else {
dot.classList.add('disconnected');
text.textContent = 'Disconnected';
}
} catch (e) {
dot.classList.add('disconnected');
text.textContent = 'Service unavailable';
}
}
// Service icons mapping
const serviceIcons = {
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name) {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
async function fetchServices() {
try {
const res = await fetch('/api/services');
const data = await res.json();
const navEl = document.getElementById('servicesNav');
const itemsEl = document.getElementById('servicesNavItems');
const leaderBadge = document.getElementById('leaderBadge');
const leaderBadgeText = document.getElementById('leaderBadgeText');
if (data.services && data.services.length > 0) {
const services = data.services.sort((a, b) => a.name.localeCompare(b.name));
itemsEl.innerHTML = services.map(svc => {
const icon = serviceIcons[svc.name] || serviceIcons.default;
const isActive = svc.name === 'meta-fuse';
const activeClass = isActive ? ' active' : '';
const href = isActive ? '#' : svc.url;
return `
<a href="${href}" class="service-link${activeClass}">
<span class="service-icon">${icon}</span>
<span>${formatServiceName(svc.name)}</span>
<span class="service-status"></span>
</a>
`;
}).join('');
navEl.style.display = 'flex';
} else {
navEl.style.display = 'none';
}
} catch (e) {
console.error('Failed to fetch services:', e);
document.getElementById('servicesNav').style.display = 'none';
}
}
checkStatus();
fetchServices();
setInterval(checkStatus, 5000);
setInterval(fetchServices, 30000);
</script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
# WsgiDAV Configuration for meta-fuse Virtual Filesystem
host: 0.0.0.0
port: 8080
# Simple user authentication
simple_dc:
user_mapping:
"/":
metamesh:
password: metamesh
roles: ["editor"]
"/webdav":
metamesh:
password: metamesh
roles: ["editor"]
# Mount the virtual filesystem
provider_mapping:
"/webdav":
root: /mnt/virtual
readonly: true
# Logging
verbose: 1
logging:
enable_loggers: []
# Server configuration
server: cheroot
server_args:
numthreads: 10
# Authentication settings
accept_basic: true
accept_digest: false
default_to_digest: false
# HTTP configuration
add_header_MS_Author_Via: false
# Hotfixes for path encoding
hotfixes:
unquote_path_info: false
# Directory browsing
dir_browser:
enable: true
response_trailer: ""
davmount: false
ms_sharepoint_support: false
ms_sharepoint_urls: false
+16
View File
@@ -0,0 +1,16 @@
{
"name": "meta-fuse",
"version": "1.0.0",
"private": true,
"description": "Standalone virtual filesystem service with FUSE and WebDAV",
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r --parallel dev",
"start": "pnpm --filter @meta-fuse/core start",
"start:core": "pnpm --filter @meta-fuse/core start",
"start:ui": "pnpm --filter @meta-fuse/ui dev",
"test": "pnpm --filter @meta-fuse/core test",
"lint": "pnpm -r lint"
},
"license": "MIT"
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@meta-fuse/core",
"version": "1.0.0",
"description": "Core FUSE API server for meta-fuse",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"test": "mocha",
"lint": "eslint src --ext ts"
},
"license": "MIT",
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/static": "^8.0.0",
"dotenv": "^16.4.1",
"fastify": "^5.6.1",
"ioredis": "^5.4.1",
"tslog": "^4.9.2"
},
"devDependencies": {
"@types/node": "^20.11.16",
"typescript": "^5.3.3"
}
}
+856
View File
@@ -0,0 +1,856 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@fastify/cors':
specifier: ^11.1.0
version: 11.2.0
'@fastify/static':
specifier: ^8.0.0
version: 8.3.0
dotenv:
specifier: ^16.4.1
version: 16.6.1
fastify:
specifier: ^5.6.1
version: 5.6.2
ioredis:
specifier: ^5.4.1
version: 5.8.2
tslog:
specifier: ^4.9.2
version: 4.10.2
devDependencies:
'@types/node':
specifier: ^20.11.16
version: 20.19.27
typescript:
specifier: ^5.3.3
version: 5.9.3
packages:
'@fastify/[email protected]':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@fastify/[email protected]':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/[email protected]':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
'@fastify/[email protected]':
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
'@fastify/[email protected]':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/[email protected]':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
'@ioredis/[email protected]':
resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==}
'@isaacs/[email protected]':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/[email protected]':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/[email protected]':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@lukeed/[email protected]':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@pinojs/[email protected]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@types/[email protected]':
resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==}
[email protected]:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
[email protected]:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
[email protected]:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
[email protected]:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
[email protected]:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
[email protected]:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
[email protected]:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
[email protected]:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
[email protected]:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
[email protected]:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
[email protected]:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
[email protected]:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
[email protected]:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
[email protected]:
resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==}
[email protected]:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
[email protected]:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
[email protected]:
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
[email protected]:
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
[email protected]:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
[email protected]:
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
hasBin: true
[email protected]:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
[email protected]:
resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==}
engines: {node: '>=12.22.0'}
[email protected]:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
[email protected]:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
[email protected]:
resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
[email protected]:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
[email protected]:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
[email protected]:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
[email protected]:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
[email protected]:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
engines: {node: '>=10.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
[email protected]:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
[email protected]:
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
[email protected]:
resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
[email protected]:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
[email protected]:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
[email protected]:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
[email protected]:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
[email protected]:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
[email protected]:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
[email protected]:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
[email protected]:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
[email protected]:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
[email protected]:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
[email protected]:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
[email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
[email protected]:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
[email protected]:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
[email protected]:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
[email protected]:
resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==}
engines: {node: '>=16'}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
[email protected]:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
snapshots:
'@fastify/[email protected]': {}
'@fastify/[email protected]':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1([email protected])
fast-uri: 3.1.0
'@fastify/[email protected]':
dependencies:
fastify-plugin: 5.1.0
toad-cache: 3.7.0
'@fastify/[email protected]': {}
'@fastify/[email protected]':
dependencies:
fast-json-stringify: 6.1.1
'@fastify/[email protected]': {}
'@fastify/[email protected]':
dependencies:
dequal: 2.0.3
'@fastify/[email protected]':
dependencies:
'@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0
'@fastify/[email protected]':
dependencies:
'@lukeed/ms': 2.0.2
escape-html: 1.0.3
fast-decode-uri-component: 1.0.1
http-errors: 2.0.1
mime: 3.0.0
'@fastify/[email protected]':
dependencies:
'@fastify/accept-negotiator': 2.0.1
'@fastify/send': 4.1.0
content-disposition: 0.5.4
fastify-plugin: 5.1.0
fastq: 1.20.1
glob: 11.1.0
'@ioredis/[email protected]': {}
'@isaacs/[email protected]': {}
'@isaacs/[email protected]':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/[email protected]':
dependencies:
string-width: 5.1.2
string-width-cjs: [email protected]
strip-ansi: 7.1.2
strip-ansi-cjs: [email protected]
wrap-ansi: 8.1.0
wrap-ansi-cjs: [email protected]
'@lukeed/[email protected]': {}
'@pinojs/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 6.21.0
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
ajv: 8.17.1
[email protected]:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
color-convert: 2.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@fastify/error': 4.2.0
fastq: 1.20.1
[email protected]: {}
[email protected]:
dependencies:
color-name: 1.1.4
[email protected]: {}
[email protected]:
dependencies:
safe-buffer: 5.2.1
[email protected]: {}
[email protected]:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
[email protected]:
dependencies:
ms: 2.1.3
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.17.1
ajv-formats: 3.0.1([email protected])
fast-uri: 3.1.0
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
[email protected]:
dependencies:
fast-decode-uri-component: 1.0.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0
'@fastify/fast-json-stringify-compiler': 5.0.3
'@fastify/proxy-addr': 5.1.0
abstract-logging: 2.0.1
avvio: 9.1.0
fast-json-stringify: 6.1.1
find-my-way: 9.3.0
light-my-request: 6.6.0
pino: 10.1.0
process-warning: 5.0.0
rfdc: 1.4.1
secure-json-parse: 4.1.0
semver: 7.7.3
toad-cache: 3.7.0
[email protected]:
dependencies:
reusify: 1.1.0
[email protected]:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 5.0.0
[email protected]:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
[email protected]:
dependencies:
foreground-child: 3.3.1
jackspeak: 4.1.1
minimatch: 10.1.1
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 2.0.1
[email protected]:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
[email protected]: {}
[email protected]:
dependencies:
'@ioredis/commands': 1.4.0
cluster-key-slot: 1.1.2
debug: 4.4.3
denque: 2.1.0
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@isaacs/cliui': 8.0.2
[email protected]:
dependencies:
dequal: 2.0.3
[email protected]: {}
[email protected]:
dependencies:
cookie: 1.1.1
process-warning: 4.0.1
set-cookie-parser: 2.7.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@isaacs/brace-expansion': 5.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
lru-cache: 11.2.4
minipass: 7.1.2
[email protected]:
dependencies:
split2: 4.2.0
[email protected]: {}
[email protected]:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 2.0.0
pino-std-serializers: 7.0.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0
thread-stream: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
redis-errors: 1.2.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
ret: 0.5.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
shebang-regex: 3.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
atomic-sleep: 1.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
[email protected]:
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.2
[email protected]:
dependencies:
ansi-regex: 5.0.1
[email protected]:
dependencies:
ansi-regex: 6.2.2
[email protected]:
dependencies:
real-require: 0.2.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
isexe: 2.0.0
[email protected]:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
[email protected]:
dependencies:
ansi-styles: 6.2.3
string-width: 5.1.2
strip-ansi: 7.1.2
@@ -0,0 +1,459 @@
/**
* Fastify API Server for FUSE operations
*
* Exposes the VirtualFileSystem over HTTP for consumption by the FUSE driver
*/
import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import cors from '@fastify/cors';
import { Logger } from 'tslog';
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js';
import type { KVManager } from '../kv/KVManager.js';
import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types/RenamingRuleTypes.js';
import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js';
import { TemplateEngine } from '../vfs/template/TemplateEngine.js';
import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js';
const logger = new Logger({ name: 'APIServer' });
export interface APIServerConfig {
port?: number;
host?: string;
}
interface PathBody {
path: string;
}
export class APIServer {
private app: FastifyInstance;
private vfs: VirtualFileSystem;
private kvManager: KVManager | null = null;
private config: Required<APIServerConfig>;
constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) {
this.vfs = vfs;
this.kvManager = kvManager || null;
this.config = {
port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10),
host: config.host ?? process.env.API_HOST ?? '0.0.0.0',
};
this.app = Fastify({
logger: false,
});
this.setupRoutes();
}
/**
* Setup API routes
*/
private setupRoutes(): void {
// CORS
this.app.register(cors, {
origin: true,
methods: ['GET', 'POST', 'OPTIONS'],
});
// Health check
this.app.get('/health', this.handleHealth.bind(this));
this.app.get('/api/health', this.handleHealth.bind(this));
this.app.get('/api/fuse/health', this.handleHealth.bind(this));
// Stats
this.app.get('/api/fuse/stats', this.handleStats.bind(this));
// FUSE operations
this.app.post('/api/fuse/readdir', this.handleReaddir.bind(this));
this.app.post('/api/fuse/getattr', this.handleGetattr.bind(this));
this.app.post('/api/fuse/exists', this.handleExists.bind(this));
this.app.post('/api/fuse/read', this.handleRead.bind(this));
this.app.post('/api/fuse/metadata', this.handleMetadata.bind(this));
// Directory listing endpoints
this.app.get('/api/fuse/files', this.handleFiles.bind(this));
this.app.get('/api/fuse/directories', this.handleDirectories.bind(this));
// Refresh
this.app.post('/api/fuse/refresh', this.handleRefresh.bind(this));
// Renaming rules configuration
this.app.get('/api/fuse/rules', this.handleGetRules.bind(this));
this.app.put('/api/fuse/rules', this.handleUpdateRules.bind(this));
this.app.post('/api/fuse/rules/preview', this.handlePreviewRules.bind(this));
this.app.post('/api/fuse/rules/validate', this.handleValidateRule.bind(this));
this.app.get('/api/fuse/rules/variables', this.handleGetVariables.bind(this));
// Service discovery (for inter-service navigation)
if (this.kvManager) {
this.app.get('/api/services', this.handleServices.bind(this));
}
}
/**
* Start the server
*/
async start(): Promise<void> {
try {
await this.app.listen({
port: this.config.port,
host: this.config.host,
});
logger.info(`API Server listening on http://${this.config.host}:${this.config.port}`);
} catch (error) {
logger.error('Failed to start API server:', error);
throw error;
}
}
/**
* Stop the server
*/
async stop(): Promise<void> {
await this.app.close();
logger.info('API Server stopped');
}
// Route handlers
private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({
status: 'ok',
timestamp: new Date().toISOString(),
service: 'meta-fuse',
});
}
private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const stats = this.vfs.getStats();
reply.send(stats);
}
private async handleReaddir(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const entries = this.vfs.readdir(path);
if (entries === null) {
reply.status(404).send({ error: 'Directory not found' });
return;
}
reply.send({ entries });
}
private async handleGetattr(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const attrs = this.vfs.getattr(path);
if (attrs === null) {
reply.status(404).send({ error: 'Path not found' });
return;
}
reply.send(attrs);
}
private async handleExists(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const exists = this.vfs.exists(path);
reply.send({ exists });
}
private async handleRead(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const result = this.vfs.read(path);
if (result === null) {
reply.status(404).send({ error: 'File not found' });
return;
}
// Return source path for FUSE driver to read from
const response: Record<string, unknown> = {
sourcePath: result.sourcePath,
size: result.size,
};
// If content is provided (virtual file), encode as base64
if (result.content !== null) {
response.content = result.content.toString('base64');
response.contentEncoding = 'base64';
}
reply.send(response);
}
private async handleMetadata(
req: FastifyRequest<{ Body: PathBody }>,
reply: FastifyReply
): Promise<void> {
const { path } = req.body;
if (typeof path !== 'string') {
reply.status(400).send({ error: 'Missing or invalid "path" parameter' });
return;
}
const metadata = this.vfs.getMetadata(path);
if (metadata === null) {
reply.status(404).send({ error: 'Metadata not found' });
return;
}
reply.send(metadata);
}
private async handleFiles(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const files = this.vfs.getAllFiles();
reply.send({ files });
}
private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
const directories = this.vfs.getAllDirectories();
reply.send({ directories });
}
private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
await this.vfs.refresh();
reply.send({ status: 'ok' });
}
private async handleServices(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
interface ServiceResponse {
name: string;
url: string;
api: string;
status: string;
capabilities: string[];
version: string;
}
const services: ServiceResponse[] = [];
let leaderInfo: { host: string; api: string; http: string } | null = null;
try {
const serviceDiscovery = this.kvManager?.getServiceDiscovery();
if (serviceDiscovery) {
const allServices = await serviceDiscovery.discoverAllServices();
for (const svc of allServices) {
// Build dashboard URL from API URL
const apiUrl = svc.api || '';
const dashboardPath = svc.endpoints?.dashboard || '/';
services.push({
name: svc.name || 'Unknown',
url: apiUrl + dashboardPath,
api: apiUrl,
status: svc.status || 'unknown',
capabilities: svc.capabilities || [],
version: svc.version || '',
});
}
}
} catch (error) {
logger.error('Error discovering services:', error);
}
// Get leader info
if (this.kvManager) {
const info = this.kvManager.getLeaderInfo();
if (info) {
leaderInfo = {
host: info.host,
api: info.api,
http: info.http,
};
}
}
reply.send({
services,
current: 'meta-fuse',
leader: leaderInfo,
isLeader: false, // meta-fuse is never the leader
});
}
// ============================================
// Renaming Rules API Handlers
// ============================================
/**
* Get current renaming rules configuration
*/
private async handleGetRules(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const config = this.vfs.getRulesConfig();
reply.send({
config,
lastModified: config.lastModified || new Date().toISOString(),
});
} catch (error: any) {
logger.error('Failed to get rules:', error);
reply.status(500).send({ error: 'Failed to get rules configuration' });
}
}
/**
* Update renaming rules configuration
*/
private async handleUpdateRules(
req: FastifyRequest<{ Body: { config: RenamingConfig } }>,
reply: FastifyReply
): Promise<void> {
try {
const { config } = req.body;
if (!config || typeof config !== 'object') {
reply.status(400).send({ error: 'Missing or invalid config in request body' });
return;
}
// Validate rules
const errors: string[] = [];
const templateEngine = new TemplateEngine();
const conditionEvaluator = new ConditionEvaluator();
for (const rule of config.rules) {
// Validate template
const templateValidation = templateEngine.validate(rule.template);
if (!templateValidation.valid) {
errors.push(`Rule "${rule.name}": ${templateValidation.errors.join(', ')}`);
}
// Validate conditions
const conditionValidation = conditionEvaluator.validate(rule.conditions);
if (!conditionValidation.valid) {
errors.push(`Rule "${rule.name}": ${conditionValidation.errors.join(', ')}`);
}
}
if (errors.length > 0) {
reply.status(400).send({ success: false, errors });
return;
}
// Save configuration
this.vfs.saveRulesConfig(config);
// Trigger immediate VFS refresh
await this.vfs.refresh();
reply.send({ success: true, refreshed: true });
} catch (error: any) {
logger.error('Failed to update rules:', error);
reply.status(500).send({ error: 'Failed to update rules configuration' });
}
}
/**
* Preview how files would be renamed with given rules
*/
private async handlePreviewRules(
req: FastifyRequest<{ Body: { rules?: RenamingRule[]; limit?: number } }>,
reply: FastifyReply
): Promise<void> {
try {
const { rules, limit = 100 } = req.body || {};
const preview = await this.vfs.previewRules(rules, limit);
reply.send(preview);
} catch (error: any) {
logger.error('Failed to preview rules:', error);
reply.status(500).send({ error: 'Failed to generate preview' });
}
}
/**
* Validate a single rule
*/
private async handleValidateRule(
req: FastifyRequest<{ Body: { rule: RenamingRule; sampleMetadata?: Record<string, unknown> } }>,
reply: FastifyReply
): Promise<void> {
try {
const { rule, sampleMetadata } = req.body;
if (!rule || typeof rule !== 'object') {
reply.status(400).send({ error: 'Missing or invalid rule in request body' });
return;
}
const templateEngine = new TemplateEngine();
const conditionEvaluator = new ConditionEvaluator();
const errors: string[] = [];
const warnings: string[] = [];
// Validate template
const templateValidation = templateEngine.validate(rule.template);
errors.push(...templateValidation.errors);
warnings.push(...templateValidation.warnings);
// Validate conditions
const conditionValidation = conditionEvaluator.validate(rule.conditions);
errors.push(...conditionValidation.errors);
// Generate sample output if metadata provided
let sampleOutput: string | undefined;
if (sampleMetadata && errors.length === 0) {
sampleOutput = templateEngine.interpolate(rule.template, sampleMetadata) || undefined;
}
reply.send({
valid: errors.length === 0,
errors,
warnings,
sampleOutput,
});
} catch (error: any) {
logger.error('Failed to validate rule:', error);
reply.status(500).send({ error: 'Failed to validate rule' });
}
}
/**
* Get list of available template variables
*/
private async handleGetVariables(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
reply.send({ variables: TEMPLATE_VARIABLES });
}
}
@@ -0,0 +1,171 @@
/**
* ConfigStorage - File-based configuration storage
*
* Stores configuration (like renaming rules) in JSON files
* instead of Redis, which should only be used for metadata.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from 'tslog';
import type { RenamingConfig } from '../vfs/types/RenamingRuleTypes.js';
import { getDefaultRulesConfig } from '../vfs/defaults/defaultRules.js';
const logger = new Logger({ name: 'ConfigStorage' });
export interface ConfigStorageOptions {
configDir: string; // Directory to store config files
}
export class ConfigStorage {
private configDir: string;
private rulesFile: string;
constructor(options: ConfigStorageOptions) {
this.configDir = options.configDir;
this.rulesFile = path.join(this.configDir, 'renaming-rules.json');
// Ensure config directory exists
this.ensureConfigDir();
}
/**
* Ensure the config directory exists
*/
private ensureConfigDir(): void {
try {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
logger.info(`Created config directory: ${this.configDir}`);
}
} catch (error) {
logger.error(`Failed to create config directory: ${this.configDir}`, error);
}
}
/**
* Get renaming rules configuration from file
* If no config file exists, creates one with default rules
*
* @returns The stored RenamingConfig
*/
getRulesConfig(): RenamingConfig {
try {
if (!fs.existsSync(this.rulesFile)) {
logger.info('No renaming rules config file found, creating default');
const defaultConfig = this.createDefaultConfig();
return defaultConfig;
}
const data = fs.readFileSync(this.rulesFile, 'utf-8');
const config = JSON.parse(data) as RenamingConfig;
logger.debug(`Loaded renaming rules config with ${config.rules.length} rules`);
return config;
} catch (error) {
logger.error('Failed to read rules config, using defaults:', error);
return this.createDefaultConfig();
}
}
/**
* Create and save the default configuration file
*/
private createDefaultConfig(): RenamingConfig {
const defaultConfig = getDefaultRulesConfig();
// Mark as default configuration
const configWithDefault: RenamingConfig = {
...defaultConfig,
isDefault: true,
lastModified: new Date().toISOString(),
};
// Save to file so user can see/edit it
try {
fs.writeFileSync(
this.rulesFile,
JSON.stringify(configWithDefault, null, 2),
'utf-8'
);
logger.info('Created default renaming rules config file');
} catch (error) {
logger.error('Failed to create default config file:', error);
}
return configWithDefault;
}
/**
* Save renaming rules configuration to file
* Removes the isDefault field since this is now user-customized
*
* @param config The RenamingConfig to save
*/
saveRulesConfig(config: RenamingConfig): void {
try {
// Create backup of current config if it exists
if (fs.existsSync(this.rulesFile)) {
const backupFile = path.join(
this.configDir,
`renaming-rules.backup.${Date.now()}.json`
);
fs.copyFileSync(this.rulesFile, backupFile);
logger.debug(`Created backup: ${backupFile}`);
// Clean up old backups (keep last 5)
this.cleanupBackups();
}
// Remove isDefault flag (user is customizing) and update timestamp
const { isDefault, ...configWithoutDefault } = config;
const configToSave: RenamingConfig = {
...configWithoutDefault,
lastModified: new Date().toISOString(),
};
// Save new config with pretty formatting
fs.writeFileSync(
this.rulesFile,
JSON.stringify(configToSave, null, 2),
'utf-8'
);
logger.info(`Saved renaming rules config with ${config.rules.length} rules`);
} catch (error) {
logger.error('Failed to save rules config:', error);
throw error;
}
}
/**
* Clean up old backup files, keeping only the most recent ones
*/
private cleanupBackups(keepCount: number = 5): void {
try {
const files = fs.readdirSync(this.configDir);
const backups = files
.filter(f => f.startsWith('renaming-rules.backup.') && f.endsWith('.json'))
.map(f => ({
name: f,
path: path.join(this.configDir, f),
time: parseInt(f.replace('renaming-rules.backup.', '').replace('.json', ''), 10),
}))
.sort((a, b) => b.time - a.time);
// Remove old backups beyond keepCount
for (let i = keepCount; i < backups.length; i++) {
fs.unlinkSync(backups[i].path);
logger.debug(`Removed old backup: ${backups[i].name}`);
}
} catch (error) {
logger.warn('Failed to cleanup backups:', error);
}
}
/**
* Get the config directory path
*/
getConfigDir(): string {
return this.configDir;
}
}
+168
View File
@@ -0,0 +1,168 @@
/**
* meta-fuse-core entry point
*
* Standalone FUSE API server that reads metadata from Redis,
* computes virtual paths, and exposes organized folder structure.
*/
import 'dotenv/config';
import { Logger } from 'tslog';
import { KVManager } from './kv/KVManager.js';
import { RedisClient } from './kv/RedisClient.js';
import { VirtualFileSystem } from './vfs/VirtualFileSystem.js';
import { APIServer } from './api/APIServer.js';
const logger = new Logger({ name: 'meta-fuse' });
// Pub/sub channel for batched updates from meta-sort
const UPDATE_CHANNEL = 'meta-sort:file:batch';
interface BatchUpdateMessage {
timestamp: number;
changes: Array<{
action: 'add' | 'update' | 'remove';
hashId: string;
}>;
}
async function main(): Promise<void> {
logger.info('Starting meta-fuse...');
logger.info(`Node.js ${process.version}`);
// Configuration
const config = {
metaCorePath: process.env.META_CORE_PATH ?? '/meta-core',
filesPath: process.env.FILES_VOLUME ?? '/files',
redisUrl: process.env.REDIS_URL,
redisPrefix: process.env.REDIS_PREFIX ?? 'meta-sort:',
apiPort: parseInt(process.env.API_PORT ?? '3000', 10),
apiHost: process.env.API_HOST ?? '0.0.0.0',
vfsRefreshInterval: parseInt(process.env.VFS_REFRESH_INTERVAL ?? '30000', 10),
baseUrl: process.env.BASE_URL,
serviceVersion: process.env.SERVICE_VERSION ?? '1.0.0',
};
logger.info(`Config: META_CORE_PATH=${config.metaCorePath}, FILES_VOLUME=${config.filesPath}`);
// Initialize KV Manager (handles leader discovery)
const kvManager = new KVManager({
metaCorePath: config.metaCorePath,
filesPath: config.filesPath,
serviceName: 'meta-fuse',
version: config.serviceVersion,
apiPort: config.apiPort,
redisUrl: config.redisUrl,
redisPrefix: config.redisPrefix,
baseUrl: config.baseUrl,
capabilities: ['read', 'vfs'],
});
let redisClient: RedisClient | null = null;
let vfs: VirtualFileSystem | null = null;
let apiServer: APIServer | null = null;
// Graceful shutdown
const shutdown = async (signal: string): Promise<void> => {
logger.info(`Received ${signal}, shutting down...`);
try {
if (apiServer) await apiServer.stop();
if (vfs) await vfs.stop();
await kvManager.stop();
logger.info('Shutdown complete');
process.exit(0);
} catch (error) {
logger.error('Error during shutdown:', error);
process.exit(1);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle ready event from KV manager
kvManager.onReady(async () => {
logger.info('KV Manager ready, initializing VFS...');
try {
// Get Redis client from KV manager
redisClient = kvManager.getClient();
if (!redisClient) {
logger.error('Redis client not available');
return;
}
// Initialize VFS
vfs = new VirtualFileSystem(redisClient, {
fileMode: parseInt(process.env.FUSE_FILE_MODE ?? '644', 8),
directoryMode: parseInt(process.env.FUSE_DIR_MODE ?? '755', 8),
uid: parseInt(process.env.PUID ?? '1000', 10),
gid: parseInt(process.env.PGID ?? '1000', 10),
refreshInterval: config.vfsRefreshInterval,
configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config',
});
// Start VFS
logger.info('Starting VirtualFileSystem...');
await vfs.start();
// Subscribe to batch updates from meta-sort
logger.info(`Subscribing to ${UPDATE_CHANNEL}...`);
await redisClient.subscribe(UPDATE_CHANNEL, async (message: string) => {
try {
const batch: BatchUpdateMessage = JSON.parse(message);
logger.debug(`Received batch update: ${batch.changes.length} changes`);
// Process each change incrementally
for (const change of batch.changes) {
await vfs!.onFileUpdate(change.hashId, change.action);
}
} catch (error: any) {
logger.error(`Error processing batch update: ${error.message}`);
}
});
// Initialize API server (with kvManager for service discovery)
apiServer = new APIServer(vfs, {
port: config.apiPort,
host: config.apiHost,
}, kvManager);
// Start API server
logger.info('Starting API server...');
await apiServer.start();
logger.info('meta-fuse is ready!');
logger.info(`API: http://${config.apiHost}:${config.apiPort}`);
// Log initial stats
const stats = vfs.getStats();
logger.info(`VFS: ${stats.fileCount} files, ${stats.directoryCount} directories`);
} catch (error) {
logger.error('Failed to initialize after KV ready:', error);
}
});
// Handle disconnect event
kvManager.onDisconnect(() => {
logger.warn('Redis disconnected, VFS will use cached data');
});
// Start KV Manager (initiates leader discovery)
try {
logger.info('Starting KV Manager...');
await kvManager.start();
// Wait for ready with timeout
await kvManager.waitForReady(60000);
} catch (error) {
logger.error('Failed to start meta-fuse:', error);
process.exit(1);
}
}
main().catch((error) => {
logger.error('Fatal error:', error);
process.exit(1);
});
+113
View File
@@ -0,0 +1,113 @@
/**
* KV Client Interface - Read-only abstraction for meta-fuse
*
* meta-fuse is a FOLLOWER service that only reads metadata from Redis.
* This interface provides the subset of operations needed for reading.
*/
export interface KeyValuePair {
key: string;
value: string;
}
export interface IKVClient {
/**
* Get a value by key (value is JSON deserialized)
*/
get(key: string): Promise<any | null>;
/**
* Count keys with a given prefix
*/
countKeysWithPrefix(prefix: string): Promise<number>;
/**
* Health check - verifies KV store is accessible
*/
health(): Promise<boolean>;
/**
* Get all key-value pairs with a given prefix
*/
getRange(prefix: string): Promise<KeyValuePair[]>;
/**
* Retrieve file metadata - reconstructs from individual property keys
*/
getMetadataFlat(hashId: string): Promise<any | null>;
/**
* Get all unique hash IDs (files) stored in KV
*/
getAllHashIds(): Promise<string[]>;
/**
* Subscribe to Redis pub/sub channel
*/
subscribe(channel: string, callback: (message: string) => void): Promise<void>;
/**
* Unsubscribe from Redis pub/sub channel
*/
unsubscribe(channel: string): Promise<void>;
/**
* Close connections and cleanup resources
*/
close(): Promise<void>;
}
/**
* 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;
/** Timestamp when leadership was acquired */
timestamp: number;
/** Process ID of the leader */
pid: number;
}
/**
* Service registration info stored in JSON file
*/
export interface ServiceInfo {
/** Service name */
name: string;
/** Service version */
version: string;
/** HTTP API URL (e.g., http://10.0.1.50:3000) */
api: string;
/** Service status */
status: 'starting' | 'running' | 'stopping' | 'stopped';
/** Process ID */
pid: number;
/** Hostname */
hostname: string;
/** Service start time (ISO string) */
startedAt: string;
/** Last heartbeat time (ISO string) */
lastHeartbeat: string;
/** Service capabilities */
capabilities: string[];
/** Named endpoints */
endpoints: Record<string, string>;
}
+338
View File
@@ -0,0 +1,338 @@
/**
* KV Manager for meta-fuse (FOLLOWER-only)
*
* Simplified KV manager that:
* 1. Discovers the leader via lock file
* 2. Creates Redis client connection
* 3. Handles reconnection on leader failure
*
* Unlike meta-sort's KVManager, this version never spawns Redis -
* it only connects as a client.
*/
import { Logger } from 'tslog';
import { LeaderDiscovery } from './LeaderDiscovery.js';
import { ServiceDiscovery } from './ServiceDiscovery.js';
import { RedisClient } from './RedisClient.js';
import type { LeaderLockInfo } from './IKVClient.js';
const logger = new Logger({ name: 'KVManager' });
interface KVManagerConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Path to FILES_VOLUME (e.g., /files) */
filesPath: string;
/** Service name (for logging) */
serviceName?: string;
/** Service version */
version?: string;
/** HTTP API port */
apiPort?: number;
/** Override Redis URL (skip leader discovery if provided) */
redisUrl?: string;
/** Redis key prefix (default: 'meta-sort:') */
redisPrefix?: string;
/** Base URL for service discovery (overrides auto-detected URL) */
baseUrl?: string;
/** Service capabilities */
capabilities?: string[];
}
export class KVManager {
private config: Required<KVManagerConfig>;
private leaderDiscovery: LeaderDiscovery | null = null;
private serviceDiscovery: ServiceDiscovery | null = null;
private redisClient: RedisClient | null = null;
private isStarted = false;
private isShuttingDown = false;
// Event callbacks
private onReadyCallbacks: (() => void)[] = [];
private onDisconnectCallbacks: (() => void)[] = [];
constructor(config: KVManagerConfig) {
this.config = {
serviceName: 'meta-fuse',
version: '1.0.0',
apiPort: 3000,
redisUrl: '',
redisPrefix: 'meta-sort:',
baseUrl: '',
capabilities: ['read', 'vfs'],
...config
};
}
/**
* Start the KV manager
*/
async start(): Promise<void> {
if (this.isStarted) {
logger.warn('KVManager already started');
return;
}
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
// Initialize service discovery
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,
baseUrl: this.config.baseUrl || undefined,
capabilities: this.config.capabilities,
endpoints: {
health: '/api/fuse/health',
dashboard: '/',
stats: '/api/fuse/stats',
}
});
// Start service discovery
await this.serviceDiscovery.start();
// If direct Redis URL provided, skip leader discovery
if (this.config.redisUrl) {
logger.info(`Using direct Redis URL: ${this.config.redisUrl}`);
await this.connectToRedis(this.config.redisUrl);
this.isStarted = true;
return;
}
// Use leader discovery
this.leaderDiscovery = new LeaderDiscovery({
metaCorePath: this.config.metaCorePath
});
// Set up leader discovery callbacks
this.leaderDiscovery.onLeaderFound(async (info: LeaderLockInfo) => {
logger.info(`Connecting to leader at ${info.api}...`);
await this.connectToRedis(info.api);
});
this.leaderDiscovery.onLeaderLost(async () => {
logger.warn('Leader lost, disconnecting...');
await this.disconnectRedis();
this.notifyDisconnect();
});
// Start leader discovery
await this.leaderDiscovery.start();
this.isStarted = true;
}
/**
* Stop the KV manager
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
logger.info('Stopping KVManager...');
this.isShuttingDown = true;
// Stop service discovery
if (this.serviceDiscovery) {
await this.serviceDiscovery.stop();
this.serviceDiscovery = null;
}
// Stop leader discovery
if (this.leaderDiscovery) {
await this.leaderDiscovery.stop();
this.leaderDiscovery = null;
}
// Disconnect Redis
await this.disconnectRedis();
this.isStarted = false;
logger.info('KVManager stopped');
}
/**
* Connect to Redis
*/
private async connectToRedis(url: string): Promise<void> {
try {
// Disconnect existing client if any
await this.disconnectRedis();
// Create new client
this.redisClient = new RedisClient({
url,
prefix: this.config.redisPrefix,
filesVolume: this.config.filesPath
});
await this.redisClient.connect();
logger.info('Connected to Redis');
// Notify ready
this.notifyReady();
} catch (error: any) {
logger.error(`Failed to connect to Redis: ${error.message}`);
this.redisClient = null;
}
}
/**
* Disconnect from Redis
*/
private async disconnectRedis(): Promise<void> {
if (this.redisClient) {
try {
await this.redisClient.disconnect();
} catch (error: any) {
logger.warn(`Error disconnecting Redis: ${error.message}`);
}
this.redisClient = null;
}
}
/**
* Notify ready callbacks
*/
private notifyReady(): void {
for (const callback of this.onReadyCallbacks) {
try {
callback();
} catch (error: any) {
logger.error(`Error in ready callback: ${error.message}`);
}
}
}
/**
* Notify disconnect callbacks
*/
private notifyDisconnect(): void {
for (const callback of this.onDisconnectCallbacks) {
try {
callback();
} catch (error: any) {
logger.error(`Error in disconnect callback: ${error.message}`);
}
}
}
// ========================================================================
// Event Registration
// ========================================================================
/**
* Register callback for when KV client is ready
*/
onReady(callback: () => void): this {
this.onReadyCallbacks.push(callback);
// If already ready, call immediately
if (this.redisClient?.connected) {
callback();
}
return this;
}
/**
* Register callback for disconnection
*/
onDisconnect(callback: () => void): this {
this.onDisconnectCallbacks.push(callback);
return this;
}
/**
* Wait for KV client to be ready
*/
async waitForReady(timeoutMs: number = 30000): Promise<void> {
if (this.redisClient?.connected) 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 Redis client (may be null if not connected)
*/
getClient(): RedisClient | null {
return this.redisClient;
}
/**
* Get the Redis client (throws if not connected)
*/
requireClient(): RedisClient {
if (!this.redisClient) {
throw new Error('Redis client not available. Ensure start() was called and leader is available.');
}
return this.redisClient;
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.redisClient?.connected ?? false;
}
/**
* Health check
*/
async isHealthy(): Promise<boolean> {
if (!this.redisClient) return false;
try {
const stats = await this.redisClient.getStats();
return stats.connected;
} catch {
return false;
}
}
/**
* Get leader info (if using leader discovery)
*/
getLeaderInfo(): LeaderLockInfo | null {
return this.leaderDiscovery?.getLeaderInfo() ?? null;
}
/**
* Get service discovery (for inter-service navigation)
*/
getServiceDiscovery(): ServiceDiscovery | null {
return this.serviceDiscovery;
}
/**
* Get configured paths
*/
getPaths() {
return {
metaCorePath: this.config.metaCorePath,
filesPath: this.config.filesPath
};
}
}
@@ -0,0 +1,276 @@
/**
* Leader Discovery for meta-fuse (FOLLOWER-only)
*
* This is a simplified version of leader election that only discovers
* the existing leader. meta-fuse never spawns Redis - it only connects
* to the leader's Redis instance.
*
* Architecture:
* - Leader holds flock on META_CORE_PATH/locks/kv-leader.lock
* - Leader writes info to META_CORE_PATH/locks/kv-leader.info
* - This class reads the info file to discover the leader
* - Watches for leader changes via health checks
* - Triggers reconnection callbacks on leader failure/change
*/
import { promises as fs, watch } from 'fs';
import { dirname } from 'path';
import { Logger } from 'tslog';
import type { LeaderLockInfo } from './IKVClient.js';
const logger = new Logger({ name: 'LeaderDiscovery' });
interface LeaderDiscoveryConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Health check interval in ms (default: 5000) */
healthCheckInterval?: number;
/** Max consecutive failures before marking leader as lost (default: 3) */
maxFailures?: number;
}
export class LeaderDiscovery {
private config: Required<LeaderDiscoveryConfig>;
private lockFilePath: string;
private leaderInfo: LeaderLockInfo | null = null;
private healthCheckTimer: NodeJS.Timeout | null = null;
private fileWatcher: ReturnType<typeof watch> | null = null;
private isStarted = false;
private consecutiveFailures = 0;
// Event callbacks
private onLeaderFoundCallback?: (info: LeaderLockInfo) => void;
private onLeaderLostCallback?: () => void;
constructor(config: LeaderDiscoveryConfig) {
this.config = {
healthCheckInterval: 5000,
maxFailures: 3,
...config
};
this.lockFilePath = `${this.config.metaCorePath}/locks/kv-leader.info`;
}
/**
* Start watching for leader
*/
async start(): Promise<void> {
if (this.isStarted) {
logger.warn('LeaderDiscovery already started');
return;
}
logger.info(`Starting leader discovery, lock file: ${this.lockFilePath}`);
// Ensure lock directory exists (for file watching)
await this.ensureLockDir();
// Try to read existing leader info
await this.checkForLeader();
// Start file watcher for immediate detection of leader changes
this.startFileWatcher();
// Start health check for ongoing monitoring
this.startHealthCheck();
this.isStarted = true;
}
/**
* Stop watching
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
logger.info('Stopping leader discovery');
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
if (this.fileWatcher) {
this.fileWatcher.close();
this.fileWatcher = null;
}
this.isStarted = false;
}
/**
* Ensure lock directory exists
*/
private async ensureLockDir(): Promise<void> {
const lockDir = dirname(this.lockFilePath);
try {
await fs.mkdir(lockDir, { recursive: true });
} catch (error: any) {
if (error.code !== 'EEXIST') {
logger.warn(`Could not create lock directory: ${error.message}`);
}
}
}
/**
* Check for leader by reading lock file
*/
private async checkForLeader(): Promise<void> {
try {
const content = await fs.readFile(this.lockFilePath, 'utf-8');
const info = JSON.parse(content) as LeaderLockInfo;
// Validate leader info
if (info && info.api && info.http) {
const isNew = !this.leaderInfo || this.leaderInfo.api !== info.api;
this.leaderInfo = info;
this.consecutiveFailures = 0;
if (isNew) {
logger.info(`Leader found: ${info.host} at ${info.api}`);
this.onLeaderFoundCallback?.(info);
}
}
} catch (error: any) {
if (error.code === 'ENOENT') {
// Lock file doesn't exist - no leader yet
if (this.leaderInfo) {
logger.warn('Lock file removed, leader lost');
this.handleLeaderLost();
}
} else {
logger.warn(`Error reading lock file: ${error.message}`);
}
}
}
/**
* Check if current leader is still healthy
*/
private async isLeaderHealthy(): Promise<boolean> {
if (!this.leaderInfo) return false;
try {
const response = await fetch(`${this.leaderInfo.http}/health`, {
signal: AbortSignal.timeout(5000)
});
return response.ok;
} catch {
return false;
}
}
/**
* Handle leader being lost
*/
private handleLeaderLost(): void {
this.leaderInfo = null;
this.onLeaderLostCallback?.();
}
/**
* Start watching lock file for changes
*/
private startFileWatcher(): void {
try {
const lockDir = dirname(this.lockFilePath);
this.fileWatcher = watch(lockDir, async (eventType, filename) => {
if (filename === 'kv-leader.info') {
logger.debug(`Lock file ${eventType}, checking for leader...`);
await this.checkForLeader();
}
});
this.fileWatcher.on('error', (error) => {
logger.warn(`File watcher error: ${error.message}`);
});
} catch (error: any) {
logger.warn(`Could not start file watcher: ${error.message}`);
}
}
/**
* Start health check loop
*/
private startHealthCheck(): void {
this.healthCheckTimer = setInterval(async () => {
if (!this.leaderInfo) {
// No leader known, try to find one
await this.checkForLeader();
return;
}
// Check if current leader is healthy
const healthy = await this.isLeaderHealthy();
if (healthy) {
this.consecutiveFailures = 0;
} else {
this.consecutiveFailures++;
logger.warn(`Leader health check failed (${this.consecutiveFailures}/${this.config.maxFailures})`);
if (this.consecutiveFailures >= this.config.maxFailures) {
logger.warn('Leader appears dead, marking as lost');
this.handleLeaderLost();
// Try to find new leader
await this.checkForLeader();
}
}
}, this.config.healthCheckInterval);
}
// ========================================================================
// Event Registration
// ========================================================================
/**
* Register callback for when leader is found/changed
*/
onLeaderFound(callback: (info: LeaderLockInfo) => void): this {
this.onLeaderFoundCallback = callback;
// If we already have leader info, call immediately
if (this.leaderInfo) {
callback(this.leaderInfo);
}
return this;
}
/**
* Register callback for when leader is lost
*/
onLeaderLost(callback: () => void): this {
this.onLeaderLostCallback = callback;
return this;
}
// ========================================================================
// Getters
// ========================================================================
/**
* Get current leader info
*/
getLeaderInfo(): LeaderLockInfo | null {
return this.leaderInfo;
}
/**
* Get Redis URL of current leader
*/
getRedisUrl(): string | null {
return this.leaderInfo?.api ?? null;
}
/**
* Check if a leader is known
*/
hasLeader(): boolean {
return this.leaderInfo !== null;
}
}
@@ -0,0 +1,511 @@
/**
* Redis Client for meta-fuse
* Reads metadata from Redis (written by meta-sort)
*
* Key features:
* - FILES_VOLUME path resolution (prepends /files/ to relative paths)
* - Parses meta-sort hash format into structured metadata
* - Redis pub/sub support for real-time updates
*/
import { Redis } from 'ioredis';
import { Logger } from 'tslog';
import type { IKVClient, KeyValuePair } from './IKVClient.js';
const logger = new Logger({ name: 'RedisClient' });
/**
* Full metadata structure including fields needed for virtual path computation
*/
export interface FileMetadata {
// Path information
sourcePath: string; // Full path: /files/media1/Movies/...
originalPath: string; // Relative path from meta-sort: media1/Movies/...
// File stats
size: number;
mtime: number;
ctime: number;
// Identification
hashId?: string; // midhash256 hash ID
// Title information (for folder organization)
title?: string;
titles?: { eng?: string; [key: string]: string | undefined };
originalTitle?: string;
fileName?: string;
// Series metadata
season?: number;
episode?: number;
extra?: boolean;
// Movie metadata
movieYear?: number;
year?: number;
// File type
fileType?: string; // 'video', 'subtitle', 'torrent', etc.
extension?: string;
// Version/variant info
version?: string;
subtitleLanguage?: string;
// Generic metadata container
[key: string]: unknown;
}
export interface RedisClientConfig {
url?: string;
prefix?: string;
filesVolume?: string;
reconnectInterval?: number;
}
export class RedisClient implements Partial<IKVClient> {
private client: Redis | null = null;
private subscriber: Redis | null = null;
private config: Required<RedisClientConfig>;
private isConnected = false;
private reconnectTimer: NodeJS.Timeout | null = null;
private subscriptions: Map<string, (message: string) => void> = new Map();
constructor(config: RedisClientConfig = {}) {
this.config = {
url: config.url ?? process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
prefix: config.prefix ?? process.env.REDIS_PREFIX ?? 'meta-sort:',
filesVolume: config.filesVolume ?? process.env.FILES_VOLUME ?? '/files',
reconnectInterval: config.reconnectInterval ?? 5000,
};
}
/**
* Connect to Redis
*/
async connect(): Promise<void> {
if (this.client && this.isConnected) {
return;
}
try {
this.client = new Redis(this.config.url, {
retryStrategy: (times: number) => {
if (times > 10) {
logger.warn('Max reconnection attempts reached');
return null;
}
return Math.min(times * 500, 5000);
},
maxRetriesPerRequest: 3,
});
this.client.on('connect', () => {
logger.info('Connected to Redis');
this.isConnected = true;
});
this.client.on('error', (err: Error) => {
logger.error('Redis error:', err.message);
this.isConnected = false;
});
this.client.on('close', () => {
logger.warn('Redis connection closed');
this.isConnected = false;
this.scheduleReconnect();
});
// Wait for connection
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Redis connection timeout'));
}, 10000);
this.client!.once('ready', () => {
clearTimeout(timeout);
this.isConnected = true;
resolve();
});
this.client!.once('error', (err: Error) => {
clearTimeout(timeout);
reject(err);
});
});
} catch (error) {
logger.error('Failed to connect to Redis:', error);
this.scheduleReconnect();
throw error;
}
}
/**
* Schedule reconnection attempt
*/
private scheduleReconnect(): void {
if (this.reconnectTimer) {
return;
}
this.reconnectTimer = setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
} catch {
// Will retry via scheduleReconnect
}
}, this.config.reconnectInterval);
}
/**
* Disconnect from Redis
*/
async disconnect(): Promise<void> {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.subscriber) {
await this.subscriber.quit();
this.subscriber = null;
}
if (this.client) {
await this.client.quit();
this.client = null;
this.isConnected = false;
}
this.subscriptions.clear();
}
/**
* Check if connected
*/
get connected(): boolean {
return this.isConnected;
}
/**
* Get all file metadata from Redis
*/
async getAllFiles(): Promise<Map<string, FileMetadata>> {
const files = new Map<string, FileMetadata>();
if (!this.client || !this.isConnected) {
logger.warn('Redis not connected, returning empty file list');
return files;
}
try {
// Get all file keys using the index
const indexKey = `${this.config.prefix}file:__index__`;
const hashIds = await this.client.smembers(indexKey);
if (hashIds.length === 0) {
// Fallback: scan for file keys directly
const pattern = `${this.config.prefix}file:*`;
const keys = await this.scanKeys(pattern);
logger.debug(`Found ${keys.length} file keys in Redis (via scan)`);
for (const key of keys) {
if (key.includes('__index__')) continue;
try {
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const hashId = key.replace(`${this.config.prefix}file:`, '');
const metadata = this.parseMetadata(data, hashId);
files.set(metadata.sourcePath, metadata);
}
} catch (err) {
logger.warn(`Failed to get metadata for key ${key}:`, err);
}
}
} else {
logger.debug(`Found ${hashIds.length} files in index`);
// Fetch metadata for each hash ID
for (const hashId of hashIds) {
try {
const key = `${this.config.prefix}file:${hashId}`;
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
const metadata = this.parseMetadata(data, hashId);
files.set(metadata.sourcePath, metadata);
}
} catch (err) {
logger.warn(`Failed to get metadata for hashId ${hashId}:`, err);
}
}
}
logger.info(`Loaded ${files.size} files from Redis`);
} catch (error) {
logger.error('Failed to get files from Redis:', error);
}
return files;
}
/**
* Get file metadata by hash ID
*/
async getFileByHashId(hashId: string): Promise<FileMetadata | null> {
if (!this.client || !this.isConnected) {
return null;
}
try {
const key = `${this.config.prefix}file:${hashId}`;
const data = await this.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
return this.parseMetadata(data, hashId);
}
return null;
} catch (error) {
logger.error(`Failed to get file ${hashId}:`, error);
return null;
}
}
/**
* Get all hash IDs from Redis index
*/
async getAllHashIds(): Promise<string[]> {
if (!this.client || !this.isConnected) {
return [];
}
try {
const indexKey = `${this.config.prefix}file:__index__`;
return await this.client.smembers(indexKey);
} catch (error) {
logger.error('Failed to get hash IDs:', error);
return [];
}
}
/**
* Scan Redis keys matching pattern
*/
private async scanKeys(pattern: string): Promise<string[]> {
if (!this.client) return [];
const keys: string[] = [];
let cursor = '0';
do {
const [newCursor, batch] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
cursor = newCursor;
keys.push(...batch);
} while (cursor !== '0');
return keys;
}
/**
* Parse metadata from Redis hash
* Handles meta-sort format and resolves paths with FILES_VOLUME
*/
private parseMetadata(data: Record<string, string>, hashId?: string): FileMetadata {
// Get the original path (relative to FILES_VOLUME)
const originalPath = data.filePath ?? data.sourcePath ?? '';
// Resolve full source path by prepending FILES_VOLUME
let sourcePath = originalPath;
if (originalPath && !originalPath.startsWith('/')) {
// Relative path - prepend FILES_VOLUME
sourcePath = `${this.config.filesVolume}/${originalPath}`;
} else if (!originalPath.startsWith(this.config.filesVolume) && originalPath.startsWith('/')) {
// Absolute path but not in FILES_VOLUME - still prepend
sourcePath = `${this.config.filesVolume}${originalPath}`;
}
// Parse size
const size = parseInt(data.fileSize ?? data.size ?? data.sizeByte ?? '0', 10);
// Parse timestamps
let mtime = 0;
if (data.mtime) {
const parsed = Date.parse(data.mtime);
mtime = isNaN(parsed) ? parseFloat(data.mtime) : parsed;
}
// Parse titles - can be nested object or simple string
let titles: { eng?: string; [key: string]: string | undefined } | undefined;
if (data['titles/eng']) {
titles = { eng: data['titles/eng'] };
}
// Parse season/episode (can be 0 for specials)
const season = data.season !== undefined ? parseInt(data.season, 10) : undefined;
const episode = data.episode !== undefined ? parseInt(data.episode, 10) : undefined;
// Parse year
const movieYear = data.movieYear ? parseInt(data.movieYear, 10) : undefined;
const year = data.year ? parseInt(data.year, 10) : movieYear;
return {
// Path information
sourcePath,
originalPath,
// File stats
size,
mtime,
ctime: data.ctime ? parseFloat(data.ctime) : mtime,
// Identification
hashId: hashId ?? data.cid_midhash256 ?? data.hashId,
// Title information
title: data.title,
titles,
originalTitle: data.originalTitle,
fileName: data.fileName,
// Series metadata
season,
episode,
extra: data.extra === 'true',
// Movie metadata
movieYear,
year,
// File type
fileType: data.fileType,
extension: data.extension,
// Version/variant
version: data.version,
subtitleLanguage: data.subtitleLanguage,
};
}
/**
* Subscribe to Redis pub/sub channel
*/
async subscribe(channel: string, callback: (message: string) => void): Promise<void> {
if (!this.client) {
throw new Error('Redis not connected');
}
// Create subscriber connection if not exists
if (!this.subscriber) {
this.subscriber = this.client.duplicate();
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Subscriber connection timeout')), 10000);
this.subscriber!.once('ready', () => {
clearTimeout(timeout);
resolve();
});
this.subscriber!.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// Set up message handler
this.subscriber.on('message', (ch: string, message: string) => {
const handler = this.subscriptions.get(ch);
if (handler) {
try {
handler(message);
} catch (error: any) {
logger.error(`Error in subscription handler for ${ch}:`, error.message);
}
}
});
}
// Store callback and subscribe
this.subscriptions.set(channel, callback);
await this.subscriber.subscribe(channel);
logger.info(`Subscribed to channel: ${channel}`);
}
/**
* Unsubscribe from Redis pub/sub channel
*/
async unsubscribe(channel: string): Promise<void> {
if (this.subscriber) {
await this.subscriber.unsubscribe(channel);
this.subscriptions.delete(channel);
logger.info(`Unsubscribed from channel: ${channel}`);
}
}
/**
* Get stats about Redis data
*/
async getStats(): Promise<{ totalKeys: number; fileCount: number; connected: boolean }> {
if (!this.client || !this.isConnected) {
return { totalKeys: 0, fileCount: 0, connected: false };
}
try {
// Try index first
const indexKey = `${this.config.prefix}file:__index__`;
const count = await this.client.scard(indexKey);
if (count > 0) {
return {
totalKeys: count,
fileCount: count,
connected: true,
};
}
// Fallback to scanning
const filePattern = `${this.config.prefix}file:*`;
const fileKeys = await this.scanKeys(filePattern);
const actualFileKeys = fileKeys.filter(k => !k.includes('__index__'));
return {
totalKeys: actualFileKeys.length,
fileCount: actualFileKeys.length,
connected: true,
};
} catch {
return { totalKeys: 0, fileCount: 0, connected: false };
}
}
/**
* Health check
*/
async health(): Promise<boolean> {
if (!this.client || !this.isConnected) {
return false;
}
try {
const result = await this.client.ping();
return result === 'PONG';
} catch {
return false;
}
}
/**
* Close connections
*/
async close(): Promise<void> {
await this.disconnect();
}
/**
* Get the raw ioredis client for direct access if needed
* Use with caution - prefer using the typed methods above
*/
getRawClient(): Redis | null {
return this.client;
}
}
@@ -0,0 +1,306 @@
/**
* 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 { Logger } from 'tslog';
import type { ServiceInfo } from './IKVClient.js';
const logger = new Logger({ name: 'ServiceDiscovery' });
interface ServiceDiscoveryConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Service name */
serviceName: string;
/** Service version */
version: string;
/** HTTP API URL (can be overridden by baseUrl) */
apiUrl?: string;
/** Base URL for service discovery (overrides auto-detected URL) */
baseUrl?: 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;
private isStarted = 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.baseUrl || 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));
logger.info(`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) {
logger.error('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: any) {
// If file was deleted, re-register
if (error.code === 'ENOENT') {
await this.register();
await this.updateStatus('running');
} else {
logger.error('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');
logger.info(`Unregistered ${this.config.serviceName}`);
} catch (error) {
logger.error('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) {
logger.warn(`Service ${name} appears stale (last heartbeat: ${service.lastHeartbeat})`);
return null;
}
return service;
} catch (error: any) {
if (error.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: any) {
if (error.code === 'ENOENT') {
return []; // Services directory doesn't exist yet
}
throw error;
}
}
/**
* 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> {
if (this.isStarted) return;
await this.register();
await this.updateStatus('running');
this.startHeartbeat();
this.isStarted = true;
logger.info(`Started ${this.config.serviceName} at ${this.config.baseUrl || this.config.apiUrl}`);
}
/**
* Full shutdown sequence
*/
async stop(): Promise<void> {
if (!this.isStarted) return;
await this.unregister();
this.isStarted = false;
}
// ========================================================================
// Getters
// ========================================================================
getServiceFilePath(): string {
return this.serviceFilePath;
}
getServicesDir(): string {
return this.servicesDir;
}
getIsStarted(): boolean {
return this.isStarted;
}
}
@@ -0,0 +1,94 @@
/**
* MetaDataToFolderStruct - Generates virtual filesystem structure from metadata
*
* Migrated from meta-mesh to meta-fuse.
* Takes a collection of file metadata and generates organized virtual paths.
*/
import { renamingRule, sanitizePath } from './RenamingRule.js';
import { Logger } from 'tslog';
const logger = new Logger({ name: 'MetaDataToFolderStruct' });
type RenamingRuleFunction = (metadata: any, filepath: string) => string | null;
export class MetaDataToFolderStruct {
public renamingRule: RenamingRuleFunction;
constructor(customRenamingRule?: RenamingRuleFunction) {
this.renamingRule = customRenamingRule ?? renamingRule;
}
/**
* Compute virtual filesystem structure for FUSE/WebDAV
* Generates organized paths based on metadata without creating physical files
*
* @param data Map of source paths to metadata
* @returns Map of source paths to virtual paths
*/
private computeVirtualStructure(data: Map<string, any>): Map<string, string> {
const virtualMap = new Map<string, string>();
for (const [filepath, metadata] of data) {
// Compute the virtual path for the file
let virtualPath: string | null = null;
try {
virtualPath = this.renamingRule(metadata, filepath);
} catch (e: any) {
logger.warn(`Failed to compute virtual path for ${filepath}: ${e.message || e}`);
}
if (!virtualPath) {
// Fallback: use filename directly if renaming rule fails
const filename = filepath.split('/').pop() || filepath.split('\\').pop() || filepath;
virtualPath = `Unsorted/${filename}`;
}
virtualPath = sanitizePath(virtualPath);
virtualMap.set(filepath, virtualPath);
}
logger.debug(`Computed virtual structure for ${virtualMap.size} files`);
return virtualMap;
}
/**
* Generate virtual filesystem structure for FUSE/WebDAV
* Returns only the computed virtual paths without checking physical filesystem
*
* @param data Map of source paths to metadata
* @returns Map of source paths to virtual paths
*/
public generateVirtualStructure(data: Map<string, any>): Map<string, string> {
const start = performance.now();
const computed = this.computeVirtualStructure(data);
logger.debug(`Compute virtual structure took ${Math.ceil(performance.now() - start)}ms`);
return computed;
}
/**
* Generate virtual path for a single file
*
* @param sourcePath The source file path
* @param metadata The file metadata
* @returns Virtual path or fallback path
*/
public generateSingleVirtualPath(sourcePath: string, metadata: any): string {
let virtualPath: string | null = null;
try {
virtualPath = this.renamingRule(metadata, sourcePath);
} catch (e: any) {
logger.warn(`Failed to compute virtual path for ${sourcePath}: ${e.message || e}`);
}
if (!virtualPath) {
// Fallback: use filename directly if renaming rule fails
const filename = sourcePath.split('/').pop() || sourcePath.split('\\').pop() || sourcePath;
virtualPath = `Unsorted/${filename}`;
}
return sanitizePath(virtualPath);
}
}
@@ -0,0 +1,125 @@
/**
* Renaming Rule - Defines folder organization logic
*
* Migrated from meta-mesh to meta-fuse for virtual path computation.
* Organizes files into structured folders based on metadata:
* - TV Shows/{title}/S01/{filename} for series
* - Movies/{title} (year)/{filename} for movies
*/
/**
* Minimal metadata interface for folder organization
* Contains only the fields needed for virtual path computation
*/
export interface MediaMeta {
// Title information
titles?: { eng?: string; [key: string]: string | undefined };
originalTitle?: string;
title?: string;
// Series metadata
season?: number;
episode?: number;
extra?: boolean;
// Movie metadata
movieYear?: number;
year?: number;
// File type info
fileType?: string; // 'video', 'subtitle', 'torrent'
extension?: string;
// Version/variant
version?: string;
subtitleLanguage?: string;
// Allow additional fields
[key: string]: unknown;
}
/**
* Compute the virtual path for a file based on its metadata
* Returns the path relative to the VFS root
*
* @param metadata File metadata with title, season, episode, year, etc.
* @param filepath Original file path (used for warnings)
* @returns Virtual path (e.g., "TV Shows/Show Name/S01/Episode.mkv") or null if unsupported
*/
export function renamingRule(metadata: Partial<MediaMeta>, filepath?: string): string | null {
const supported = ['video', 'subtitle', 'torrent'];
// Check if filetype is one of the supported ones
if (!metadata.fileType || !supported.includes(metadata.fileType)) {
return null;
}
// Extract the file extension
const extension = metadata.extension;
if (!extension) {
console.warn(`[RenamingRule] No extension found for file: ${filepath}`);
return null;
}
// Get title - try various fields
const title = metadata.titles?.eng || metadata.originalTitle;
if (!title) {
if (metadata.fileType !== 'torrent') {
// Don't warn for torrent files because they don't always have a title
console.warn(`[RenamingRule] No title found for file: ${filepath}`);
}
return null;
}
let newPath = '';
// Build filename components
const season = metadata.season ? ('S' + String(metadata.season).padStart(2, '0')) : '';
const seasonSpace = metadata.season ? ` ${season}` : '';
const episode = metadata.episode ? ('E' + String(metadata.episode).padStart(2, '0')) : '';
const version = metadata.version ? ` ${metadata.version}` : '';
const year = metadata.movieYear ? ` (${metadata.movieYear})` : '';
const subtitle = metadata.fileType === 'subtitle' && metadata.subtitleLanguage
? `.${metadata.subtitleLanguage}`
: '';
const fileName = `${title}${seasonSpace}${episode}${year}${subtitle}${version}.${extension}`;
// Determine structure: TV Shows for series, Movies for standalone content
// Note: season can be 0 (special episodes), so check for null/undefined explicitly
const hasSeason = metadata.season !== null && metadata.season !== undefined;
const hasEpisode = metadata.episode !== null && metadata.episode !== undefined;
if (metadata.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;
}
/**
* Sanitize a path to remove illegal characters
* Works for both Windows and Unix-like systems
*/
export function sanitizePath(newPath: string): string {
// Isolate any potential Windows drive letter at the start
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;
// Replace illegal characters 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 to forward slashes (works cross-platform)
return cleanPath.replace(/\\/g, '/');
}
@@ -0,0 +1,737 @@
/**
* VirtualFileSystem - In-memory representation of the virtual filesystem
*
* Reads metadata from Redis and computes organized virtual paths
* using MetaDataToFolderStruct for folder structure organization.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Logger } from 'tslog';
import { RedisClient, FileMetadata } from '../kv/RedisClient.js';
import { MetaDataToFolderStruct } from './MetaDataToFolderStruct.js';
import { sanitizePath } from './RenamingRule.js';
import { TemplateEngine } from './template/TemplateEngine.js';
import { ConditionEvaluator } from './template/ConditionEvaluator.js';
import { ConfigStorage } from '../config/ConfigStorage.js';
import type { RenamingConfig, RenamingRule, PreviewItem, PreviewResponse } from './types/RenamingRuleTypes.js';
const logger = new Logger({ name: 'VirtualFileSystem' });
export interface FileAttributes {
size: number;
mode: number;
mtime: number;
atime: number;
ctime: number;
nlink: number;
uid: number;
gid: number;
}
export interface ReadResult {
sourcePath: string | null;
content: Buffer | null;
size: number;
}
interface VFSNode {
type: 'file' | 'directory';
name: string;
parent: string | null;
sourcePath?: string;
size?: number;
mtime?: Date;
ctime?: Date;
metadata?: FileMetadata;
children?: Set<string>;
}
export interface VFSConfig {
fileMode?: number;
directoryMode?: number;
uid?: number;
gid?: number;
refreshInterval?: number;
configDir?: string; // Directory for config files (renaming rules, etc.)
}
export class VirtualFileSystem {
private nodes: Map<string, VFSNode> = new Map();
private sourcePathToVirtualPath: Map<string, string> = new Map();
private redisClient: RedisClient;
private configStorage: ConfigStorage;
private metaDataToFolderStruct: MetaDataToFolderStruct;
private templateEngine: TemplateEngine;
private conditionEvaluator: ConditionEvaluator;
private rulesConfig: RenamingConfig | null = null;
private config: Required<VFSConfig>;
private refreshTimer: NodeJS.Timeout | null = null;
private lastRefresh: Date | null = null;
private cachedStats = {
fileCount: 0,
directoryCount: 0,
totalSize: 0,
};
constructor(redisClient: RedisClient, config: VFSConfig = {}) {
this.redisClient = redisClient;
this.metaDataToFolderStruct = new MetaDataToFolderStruct();
this.templateEngine = new TemplateEngine();
this.conditionEvaluator = new ConditionEvaluator();
// Default config directory
const configDir = config.configDir ?? '/meta-fuse/config';
this.configStorage = new ConfigStorage({ configDir });
this.config = {
fileMode: config.fileMode ?? 0o644,
directoryMode: config.directoryMode ?? 0o755,
uid: config.uid ?? 1000,
gid: config.gid ?? 1000,
refreshInterval: config.refreshInterval ?? 30000, // 30 seconds
configDir,
};
// Initialize root directory
this.initRoot();
}
/**
* Initialize root directory
*/
private initRoot(): void {
this.nodes.clear();
this.sourcePathToVirtualPath.clear();
this.nodes.set('/', {
type: 'directory',
name: '',
parent: null,
children: new Set(),
});
this.cachedStats = { fileCount: 0, directoryCount: 1, totalSize: 0 };
}
/**
* Start the VFS (connect to Redis and load data)
*/
async start(): Promise<void> {
logger.info('Starting VirtualFileSystem');
// Initial load
await this.refresh();
// Start periodic refresh
this.refreshTimer = setInterval(async () => {
try {
await this.refresh();
} catch (error) {
logger.error('Failed to refresh VFS:', error);
}
}, this.config.refreshInterval);
logger.info('VirtualFileSystem started');
}
/**
* Stop the VFS
*/
async stop(): Promise<void> {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
logger.info('VirtualFileSystem stopped');
}
/**
* Refresh the VFS from Redis
* Uses template-based rules if configured, otherwise falls back to MetaDataToFolderStruct
*/
async refresh(): Promise<void> {
logger.debug('Refreshing VFS from Redis');
try {
// Load rules configuration
this.loadRulesConfig();
// Get all files from Redis (keyed by sourcePath)
const files = await this.redisClient.getAllFiles();
if (files.size === 0) {
logger.info('No files found in Redis');
this.initRoot();
return;
}
// Rebuild VFS with computed paths
this.initRoot();
for (const [sourcePath, metadata] of files) {
// Compute virtual path using rules or fallback
const virtualPath = this.computeVirtualPath(metadata, sourcePath);
if (virtualPath) {
this.addFile(virtualPath, metadata);
this.sourcePathToVirtualPath.set(sourcePath, virtualPath);
}
}
this.lastRefresh = new Date();
logger.info(`VFS refreshed: ${this.cachedStats.fileCount} files, ${this.cachedStats.directoryCount} directories`);
} catch (error) {
logger.error('Failed to refresh VFS:', error);
}
}
/**
* Load rules configuration from file
* ConfigStorage creates the default config file if it doesn't exist
*/
private loadRulesConfig(): void {
this.rulesConfig = this.configStorage.getRulesConfig();
const isDefault = this.rulesConfig.isDefault ? ' (default)' : '';
logger.debug(`Loaded ${this.rulesConfig.rules.length} renaming rules${isDefault}`);
}
/**
* Compute virtual path for a file using template rules
*/
private computeVirtualPath(metadata: FileMetadata, sourcePath: string): string {
if (!this.rulesConfig) {
// Fallback to old behavior
return this.metaDataToFolderStruct.generateSingleVirtualPath(sourcePath, metadata);
}
// Sort rules by priority (descending)
const sortedRules = [...this.rulesConfig.rules]
.filter(r => r.enabled)
.sort((a, b) => b.priority - a.priority);
// Find first matching rule
for (const rule of sortedRules) {
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
if (result) {
return sanitizePath(result);
}
if (rule.fallbackToUnsorted) {
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
return `Unsorted/${filename}`;
}
}
}
// Apply default rule if no rules matched
if (this.rulesConfig.defaultRule) {
const result = this.templateEngine.interpolate(
this.rulesConfig.defaultRule.template,
metadata as Record<string, unknown>
);
if (result) {
return sanitizePath(result);
}
}
// Ultimate fallback
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
return `Unsorted/${filename}`;
}
/**
* Handle incremental file update from pub/sub
* Adds or updates a single file without full rebuild
*/
async onFileUpdate(hashId: string, action: 'add' | 'update' | 'remove'): Promise<void> {
if (action === 'remove') {
// Find and remove file by hashId
for (const [vPath, node] of this.nodes) {
if (node.type === 'file' && node.metadata?.hashId === hashId) {
this.removeFile(vPath);
break;
}
}
return;
}
// Add or update: fetch metadata and compute virtual path
const metadata = await this.redisClient.getFileByHashId(hashId);
if (!metadata) {
logger.warn(`File not found in Redis: ${hashId}`);
return;
}
// Compute virtual path for this single file
const virtualPath = this.metaDataToFolderStruct.generateSingleVirtualPath(
metadata.sourcePath,
metadata
);
// If updating, remove old entry first
if (action === 'update') {
const oldVirtualPath = this.sourcePathToVirtualPath.get(metadata.sourcePath);
if (oldVirtualPath && oldVirtualPath !== virtualPath) {
this.removeFile(oldVirtualPath);
}
}
// Add/update file
this.addFile(virtualPath, metadata);
this.sourcePathToVirtualPath.set(metadata.sourcePath, virtualPath);
logger.debug(`File ${action}ed: ${virtualPath}`);
}
/**
* Add a file to the VFS
*/
private addFile(virtualPath: string, metadata: FileMetadata): void {
virtualPath = this.normalizePath(virtualPath);
// Skip if file already exists at this path
if (this.nodes.has(virtualPath) && this.nodes.get(virtualPath)!.type === 'file') {
// Update existing file
const node = this.nodes.get(virtualPath)!;
const oldSize = node.size ?? 0;
this.cachedStats.totalSize -= oldSize;
this.cachedStats.fileCount--;
}
// Create parent directories
const dirname = path.dirname(virtualPath);
this.ensureDirectory(dirname);
// Get file stats
let size = metadata.size;
let mtime = new Date(metadata.mtime || Date.now());
let ctime = new Date(metadata.ctime || metadata.mtime || Date.now());
// Fallback: stat the source file if metadata is incomplete
if (!size && metadata.sourcePath) {
try {
const stats = fs.statSync(metadata.sourcePath);
size = stats.size;
mtime = stats.mtime;
ctime = stats.ctime;
} catch {
logger.warn(`Cannot stat source file: ${metadata.sourcePath}`);
return;
}
}
// Add file node
const filename = path.basename(virtualPath);
this.nodes.set(virtualPath, {
type: 'file',
name: filename,
parent: dirname,
sourcePath: metadata.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 stats
this.cachedStats.fileCount++;
this.cachedStats.totalSize += size;
}
/**
* Remove a file from the VFS
*/
private removeFile(virtualPath: string): void {
virtualPath = this.normalizePath(virtualPath);
const node = this.nodes.get(virtualPath);
if (!node || node.type !== 'file') {
return;
}
// Remove from parent's children
if (node.parent) {
const parentNode = this.nodes.get(node.parent);
if (parentNode && parentNode.type === 'directory') {
parentNode.children!.delete(node.name);
}
}
// Update stats
this.cachedStats.fileCount--;
this.cachedStats.totalSize -= node.size ?? 0;
// Remove node
this.nodes.delete(virtualPath);
// Remove from source path mapping
if (node.sourcePath) {
this.sourcePathToVirtualPath.delete(node.sourcePath);
}
// Clean up empty parent directories
this.cleanEmptyDirectories(node.parent);
}
/**
* Clean up empty directories
*/
private cleanEmptyDirectories(dirPath: string | null): void {
if (!dirPath || dirPath === '/') {
return;
}
const node = this.nodes.get(dirPath);
if (!node || node.type !== 'directory') {
return;
}
// Only remove if empty
if (node.children!.size === 0) {
// Remove from parent
if (node.parent) {
const parentNode = this.nodes.get(node.parent);
if (parentNode && parentNode.type === 'directory') {
parentNode.children!.delete(node.name);
}
}
// Remove this directory
this.nodes.delete(dirPath);
this.cachedStats.directoryCount--;
// Recursively clean parent
this.cleanEmptyDirectories(node.parent);
}
}
/**
* Ensure a directory exists in the VFS
*/
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);
}
this.cachedStats.directoryCount++;
}
/**
* Normalize path
*/
private normalizePath(p: string): string {
p = p.split(path.sep).join('/');
if (!p.startsWith('/')) {
p = '/' + p;
}
if (p !== '/' && p.endsWith('/')) {
p = p.slice(0, -1);
}
return p;
}
// FUSE API Methods
/**
* List directory contents
*/
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!);
}
/**
* Get file attributes
*/
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,
mode: 0o040000 | this.config.directoryMode,
mtime: Date.now() / 1000,
atime: Date.now() / 1000,
ctime: Date.now() / 1000,
nlink: 2,
uid: this.config.uid,
gid: this.config.gid,
};
} else {
return {
size: node.size!,
mode: 0o100000 | this.config.fileMode,
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,
};
}
}
/**
* Check if path exists
*/
exists(filepath: string): boolean {
filepath = this.normalizePath(filepath);
return this.nodes.has(filepath);
}
/**
* Read file (returns source path for actual reading)
*/
read(filepath: string): ReadResult | null {
filepath = this.normalizePath(filepath);
const node = this.nodes.get(filepath);
if (!node || node.type !== 'file') {
return null;
}
return {
sourcePath: node.sourcePath!,
content: null,
size: node.size!,
};
}
/**
* Get file metadata
*/
getMetadata(filepath: string): FileMetadata | null {
filepath = this.normalizePath(filepath);
const node = this.nodes.get(filepath);
if (!node || node.type !== 'file') {
return null;
}
return node.metadata ?? null;
}
/**
* Get all file paths
*/
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
*/
getAllDirectories(): string[] {
const dirs: string[] = [];
for (const [dirpath, node] of this.nodes.entries()) {
if (node.type === 'directory') {
dirs.push(dirpath);
}
}
return dirs;
}
/**
* Get VFS statistics
*/
getStats(): {
fileCount: number;
directoryCount: number;
totalSize: number;
lastRefresh: string | null;
redisConnected: boolean;
} {
return {
...this.cachedStats,
lastRefresh: this.lastRefresh?.toISOString() ?? null,
redisConnected: this.redisClient.connected,
};
}
/**
* Get virtual path for a source path
*/
getVirtualPath(sourcePath: string): string | null {
return this.sourcePathToVirtualPath.get(sourcePath) ?? null;
}
// ============================================
// Rules Configuration API
// ============================================
/**
* Get current rules configuration
*/
getRulesConfig(): RenamingConfig {
if (!this.rulesConfig) {
this.loadRulesConfig();
}
return this.rulesConfig!;
}
/**
* Save rules configuration to file
*/
saveRulesConfig(config: RenamingConfig): void {
this.configStorage.saveRulesConfig(config);
this.rulesConfig = config;
logger.info(`Saved rules configuration with ${config.rules.length} rules`);
}
/**
* Preview how files would be renamed with given rules
*/
async previewRules(testRules?: RenamingRule[], limit: number = 100): Promise<PreviewResponse> {
const files = await this.redisClient.getAllFiles();
const previews: PreviewItem[] = [];
// Use test rules or current config
const rulesToTest = testRules || this.rulesConfig?.rules || [];
const defaultRule = this.rulesConfig?.defaultRule;
let count = 0;
for (const [sourcePath, metadata] of files) {
if (count >= limit) break;
const currentVirtualPath = this.sourcePathToVirtualPath.get(sourcePath) || 'Unknown';
try {
// Compute new path with test rules
const newVirtualPath = this.computeVirtualPathWithRules(
metadata,
sourcePath,
rulesToTest,
defaultRule
);
const matchedRule = this.findMatchingRule(metadata, rulesToTest);
previews.push({
sourcePath,
currentVirtualPath,
newVirtualPath,
matchedRule: matchedRule?.id || null,
});
} catch (error: any) {
previews.push({
sourcePath,
currentVirtualPath,
newVirtualPath: null,
matchedRule: null,
error: error.message,
});
}
count++;
}
return {
previews,
total: files.size,
limited: files.size > limit,
};
}
/**
* Compute virtual path with specific rules (for preview)
*/
private computeVirtualPathWithRules(
metadata: FileMetadata,
sourcePath: string,
rules: RenamingRule[],
defaultRule?: RenamingRule
): string {
// Sort rules by priority (descending)
const sortedRules = [...rules]
.filter(r => r.enabled)
.sort((a, b) => b.priority - a.priority);
// Find first matching rule
for (const rule of sortedRules) {
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
const result = this.templateEngine.interpolate(rule.template, metadata as Record<string, unknown>);
if (result) {
return sanitizePath(result);
}
if (rule.fallbackToUnsorted) {
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
return `Unsorted/${filename}`;
}
}
}
// Apply default rule if no rules matched
if (defaultRule) {
const result = this.templateEngine.interpolate(
defaultRule.template,
metadata as Record<string, unknown>
);
if (result) {
return sanitizePath(result);
}
}
// Ultimate fallback
const filename = metadata.fileName || sourcePath.split('/').pop() || 'unknown';
return `Unsorted/${filename}`;
}
/**
* Find which rule matches a file
*/
private findMatchingRule(metadata: FileMetadata, rules: RenamingRule[]): RenamingRule | null {
const sortedRules = [...rules]
.filter(r => r.enabled)
.sort((a, b) => b.priority - a.priority);
for (const rule of sortedRules) {
if (this.conditionEvaluator.evaluateGroup(rule.conditions, metadata as Record<string, unknown>)) {
return rule;
}
}
return null;
}
}
@@ -0,0 +1,232 @@
/**
* Default Renaming Rules
*
* These rules match the current hardcoded behavior in RenamingRule.ts.
* They serve as the initial configuration when no custom rules are set.
*
* Old behavior:
* - Only processes video, subtitle, torrent files
* - Requires extension
* - Title from titles.eng || originalTitle
* - Files without title/extension are hidden (not in Unsorted)
*/
import type { RenamingConfig, RenamingRule } from '../types/RenamingRuleTypes.js';
/**
* Supported file types condition (video, subtitle, torrent)
* Used by all rules to filter out unsupported file types
*/
const supportedFileTypeCondition = {
operator: 'OR' as const,
conditions: [
{ type: 'EQUALS' as const, field: 'fileType', value: 'video' },
{ type: 'EQUALS' as const, field: 'fileType', value: 'subtitle' },
{ type: 'EQUALS' as const, field: 'fileType', value: 'torrent' },
],
};
/**
* TV Series - Extras rule
* Matches files marked as extra/special content
*/
const tvExtrasRule: RenamingRule = {
id: 'tv-extras',
name: 'TV Series - Extras',
description: 'Special episodes, behind the scenes, extras',
enabled: true,
priority: 100,
conditions: {
operator: 'AND',
conditions: [
supportedFileTypeCondition,
{ type: 'EXISTS', field: 'extension' },
{ type: 'EQUALS', field: 'extra', value: true },
],
},
// Old format: TV Shows/{title}/extra/{title}{seasonSpace}{episode}{subtitle}{version}.{extension}
template: 'TV Shows/{title|originalTitle}/extra/{title|originalTitle}{season?( S{season:pad2})}{episode?(E{episode:pad2})}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
};
/**
* TV Series rule
* Matches files with both season and episode numbers
*/
const tvSeriesRule: RenamingRule = {
id: 'tv-series',
name: 'TV Series',
description: 'Regular TV show episodes with season and episode numbers',
enabled: true,
priority: 90,
conditions: {
operator: 'AND',
conditions: [
supportedFileTypeCondition,
{ type: 'EXISTS', field: 'extension' },
{ type: 'EXISTS', field: 'season' },
{ type: 'EXISTS', field: 'episode' },
],
},
// Old format: TV Shows/{title}/S{season}/{title} S{season}E{episode}{subtitle}{version}.{extension}
template: 'TV Shows/{title|originalTitle}/S{season:pad2}/{title|originalTitle} S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
fallbackToUnsorted: true, // If template fails (no title), put in Unsorted
};
/**
* Movies rule
* Matches files with a title but no season number (assumed to be movies)
*/
const moviesRule: RenamingRule = {
id: 'movies',
name: 'Movies',
description: 'Standalone movies without season/episode numbers',
enabled: true,
priority: 80,
conditions: {
operator: 'AND',
conditions: [
supportedFileTypeCondition,
{ type: 'EXISTS', field: 'extension' },
{
operator: 'OR',
conditions: [
{ type: 'EXISTS', field: 'title' },
{ type: 'EXISTS', field: 'originalTitle' },
],
},
{ type: 'NOT_EXISTS', field: 'season' },
],
},
// Old format: Movies/{title}{year}/{title}{year}{subtitle}{version}.{extension}
template: 'Movies/{title|originalTitle}{movieYear?( ({movieYear}))}/{title|originalTitle}{movieYear?( ({movieYear}))}{subtitleLanguage?(.{subtitleLanguage})}{version?( {version})}.{extension}',
fallbackToUnsorted: true, // If template fails, put in Unsorted
};
/**
* Images rule
* Matches image files (posters, backdrops, etc.)
* Note: Poster images are classified as 'document' type
*/
const imagesRule: RenamingRule = {
id: 'images',
name: 'Images',
description: 'Image files like posters and backdrops',
enabled: true,
priority: 70,
conditions: {
operator: 'AND',
conditions: [
{
operator: 'OR',
conditions: [
{ type: 'EQUALS', field: 'fileType', value: 'image' },
{ type: 'EQUALS', field: 'fileType', value: 'document' },
],
},
],
},
template: 'Images/{fileName}',
fallbackToUnsorted: true,
};
/**
* Default fallback rule
* Used when no other rules match - places files in Unsorted folder
* Note: In old behavior, unsupported files were hidden. This rule provides visibility.
*/
const fallbackRule: RenamingRule = {
id: 'fallback-unsorted',
name: 'Unsorted',
description: 'Files that do not match any other rule',
enabled: true,
priority: 0,
conditions: {
operator: 'AND',
conditions: [], // Always matches
},
template: 'Unsorted/{fileName}',
fallbackToUnsorted: false,
};
/**
* Get the default renaming configuration
*
* @returns The default RenamingConfig matching current hardcoded behavior
*/
export function getDefaultRulesConfig(): RenamingConfig {
return {
version: 1,
rules: [
tvExtrasRule,
tvSeriesRule,
moviesRule,
imagesRule,
],
defaultRule: fallbackRule,
lastModified: new Date().toISOString(),
};
}
/**
* Plex-style naming preset
* Uses Plex's preferred naming conventions
*/
export function getPlexPreset(): RenamingConfig {
return {
version: 1,
rules: [
{
...tvSeriesRule,
id: 'plex-tv',
name: 'TV Series (Plex)',
template: 'TV Shows/{title|originalTitle}/Season {season:pad2}/{title|originalTitle} - S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
},
{
...moviesRule,
id: 'plex-movies',
name: 'Movies (Plex)',
template: 'Movies/{title|originalTitle} ({movieYear|Unknown})/{title|originalTitle} ({movieYear|Unknown}){subtitleLanguage?(.{subtitleLanguage})}{version?( - {version})}.{extension}',
},
],
defaultRule: fallbackRule,
lastModified: new Date().toISOString(),
};
}
/**
* Jellyfin-style naming preset
* Uses Jellyfin's preferred naming conventions
*/
export function getJellyfinPreset(): RenamingConfig {
return {
version: 1,
rules: [
{
...tvSeriesRule,
id: 'jellyfin-tv',
name: 'TV Series (Jellyfin)',
template: 'Shows/{title|originalTitle}{year?( ({year}))}/Season {season:pad2}/S{season:pad2}E{episode:pad2}{subtitleLanguage?(.{subtitleLanguage})}.{extension}',
},
{
...moviesRule,
id: 'jellyfin-movies',
name: 'Movies (Jellyfin)',
template: 'Movies/{title|originalTitle} ({movieYear})/{title|originalTitle} ({movieYear}){subtitleLanguage?(.{subtitleLanguage})}.{extension}',
},
],
defaultRule: fallbackRule,
lastModified: new Date().toISOString(),
};
}
/**
* Available presets
*/
export const PRESETS = {
default: getDefaultRulesConfig,
plex: getPlexPreset,
jellyfin: getJellyfinPreset,
} as const;
export type PresetName = keyof typeof PRESETS;
@@ -0,0 +1,171 @@
/**
* Condition Evaluator
*
* Evaluates conditions and condition groups against metadata
* to determine which renaming rule should be applied.
*/
import { Logger } from 'tslog';
import type { Condition, ConditionGroup } from '../types/RenamingRuleTypes.js';
import { isConditionGroup } from '../types/RenamingRuleTypes.js';
import { getNestedValue } from './TemplateEngine.js';
const logger = new Logger({ name: 'ConditionEvaluator' });
/**
* Condition Evaluator class
*/
export class ConditionEvaluator {
/**
* Evaluate a single condition against metadata
*
* @param condition The condition to evaluate
* @param metadata The metadata object to check against
* @returns true if the condition is satisfied
*/
evaluateCondition(condition: Condition, metadata: Record<string, unknown>): boolean {
const value = getNestedValue(metadata, condition.field);
switch (condition.type) {
case 'EXISTS':
return value !== null && value !== undefined;
case 'NOT_EXISTS':
return value === null || value === undefined;
case 'EQUALS':
// Handle type coercion for common cases
if (typeof condition.value === 'boolean') {
return value === condition.value;
}
if (typeof condition.value === 'number') {
return Number(value) === condition.value;
}
return String(value) === String(condition.value);
case 'NOT_EQUALS':
if (typeof condition.value === 'boolean') {
return value !== condition.value;
}
if (typeof condition.value === 'number') {
return Number(value) !== condition.value;
}
return String(value) !== String(condition.value);
case 'CONTAINS':
if (value === null || value === undefined) {
return false;
}
return String(value).includes(String(condition.value));
case 'MATCHES':
if (value === null || value === undefined) {
return false;
}
try {
const regex = new RegExp(String(condition.value));
return regex.test(String(value));
} catch (error) {
logger.warn(`Invalid regex in condition: ${condition.value}`);
return false;
}
default:
logger.warn(`Unknown condition type: ${(condition as Condition).type}`);
return false;
}
}
/**
* Evaluate a condition group (AND/OR of multiple conditions)
*
* @param group The condition group to evaluate
* @param metadata The metadata object to check against
* @returns true if the group condition is satisfied
*/
evaluateGroup(group: ConditionGroup, metadata: Record<string, unknown>): boolean {
// Empty conditions means always match
if (group.conditions.length === 0) {
return true;
}
const results = group.conditions.map(condition => {
if (isConditionGroup(condition)) {
return this.evaluateGroup(condition, metadata);
}
return this.evaluateCondition(condition, metadata);
});
if (group.operator === 'AND') {
return results.every(r => r);
} else {
return results.some(r => r);
}
}
/**
* Evaluate conditions - convenience method that handles both single conditions and groups
*
* @param conditions A single condition or condition group
* @param metadata The metadata object to check against
* @returns true if the conditions are satisfied
*/
evaluate(conditions: Condition | ConditionGroup, metadata: Record<string, unknown>): boolean {
if (isConditionGroup(conditions)) {
return this.evaluateGroup(conditions, metadata);
}
return this.evaluateCondition(conditions, metadata);
}
/**
* Validate a condition or condition group
*
* @param conditions The conditions to validate
* @returns Validation result with any errors
*/
validate(conditions: Condition | ConditionGroup): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (isConditionGroup(conditions)) {
// Validate group
if (!['AND', 'OR'].includes(conditions.operator)) {
errors.push(`Invalid operator: ${conditions.operator}`);
}
for (const condition of conditions.conditions) {
const result = this.validate(condition);
errors.push(...result.errors);
}
} else {
// Validate single condition
const validTypes = ['EXISTS', 'NOT_EXISTS', 'EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
if (!validTypes.includes(conditions.type)) {
errors.push(`Invalid condition type: ${conditions.type}`);
}
if (!conditions.field || typeof conditions.field !== 'string') {
errors.push('Condition must have a valid field');
}
// Check if value is required
const requiresValue = ['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'MATCHES'];
if (requiresValue.includes(conditions.type) && conditions.value === undefined) {
errors.push(`Condition type ${conditions.type} requires a value`);
}
// Validate regex for MATCHES
if (conditions.type === 'MATCHES' && conditions.value !== undefined) {
try {
new RegExp(String(conditions.value));
} catch (error) {
errors.push(`Invalid regex: ${conditions.value}`);
}
}
}
return {
valid: errors.length === 0,
errors,
};
}
}
@@ -0,0 +1,358 @@
/**
* Template Engine
*
* Parses and interpolates template strings for renaming rules.
*
* Supported syntax:
* - {var} - Basic variable interpolation
* - {var:pad2} - Zero-pad to 2 digits
* - {var:pad3} - Zero-pad to 3 digits
* - {var?} - Optional: omit if missing
* - {var?(text)} - Conditional section: include text only if var exists
* - {var|default} - Use default value if var is missing
* - {a.b} - Nested object access
*/
import { Logger } from 'tslog';
const logger = new Logger({ name: 'TemplateEngine' });
/**
* Token types for template parsing
*/
type TokenType = 'literal' | 'variable' | 'optional' | 'conditional' | 'default';
interface Token {
type: TokenType;
value: string;
field?: string;
format?: string;
defaultValue?: string;
innerTemplate?: string;
}
/**
* Get a nested value from an object using dot notation
* e.g., getNestedValue({titles: {eng: 'Test'}}, 'titles.eng') => 'Test'
*/
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
/**
* Apply a format to a value
*/
function applyFormat(value: unknown, format: string): string {
if (value === null || value === undefined) {
return '';
}
const strValue = String(value);
switch (format) {
case 'pad2':
return strValue.padStart(2, '0');
case 'pad3':
return strValue.padStart(3, '0');
case 'pad4':
return strValue.padStart(4, '0');
case 'upper':
case 'uppercase':
return strValue.toUpperCase();
case 'lower':
case 'lowercase':
return strValue.toLowerCase();
default:
// Check for custom padding: padN where N is a number
const padMatch = format.match(/^pad(\d+)$/);
if (padMatch) {
return strValue.padStart(parseInt(padMatch[1], 10), '0');
}
return strValue;
}
}
/**
* Parse a template string into tokens
*/
function tokenize(template: string): Token[] {
const tokens: Token[] = [];
let i = 0;
let literalStart = 0;
while (i < template.length) {
if (template[i] === '{') {
// Save any literal text before this
if (i > literalStart) {
tokens.push({
type: 'literal',
value: template.slice(literalStart, i),
});
}
// Find the matching closing brace
const start = i + 1;
let depth = 1;
let end = start;
while (end < template.length && depth > 0) {
if (template[end] === '{') depth++;
if (template[end] === '}') depth--;
if (depth > 0) end++;
}
if (depth !== 0) {
// Unclosed brace, treat as literal
tokens.push({
type: 'literal',
value: template.slice(i, end),
});
} else {
const content = template.slice(start, end);
tokens.push(parseVariable(content));
}
i = end + 1;
literalStart = i;
} else {
i++;
}
}
// Add any remaining literal text
if (literalStart < template.length) {
tokens.push({
type: 'literal',
value: template.slice(literalStart),
});
}
return tokens;
}
/**
* Parse a variable expression inside braces
*/
function parseVariable(content: string): Token {
// Check for conditional section: var?(inner template)
const conditionalMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\?\((.*)\)$/s);
if (conditionalMatch) {
return {
type: 'conditional',
value: content,
field: conditionalMatch[1],
innerTemplate: conditionalMatch[2],
};
}
// Check for optional variable: var?
if (content.endsWith('?')) {
const field = content.slice(0, -1);
// Check for format: var:format?
const formatMatch = field.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
if (formatMatch) {
return {
type: 'optional',
value: content,
field: formatMatch[1],
format: formatMatch[2],
};
}
return {
type: 'optional',
value: content,
field: field,
};
}
// Check for default value: var|default
const defaultMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*)\|(.+)$/);
if (defaultMatch) {
return {
type: 'default',
value: content,
field: defaultMatch[1],
defaultValue: defaultMatch[2],
};
}
// Check for format: var:format
const formatMatch = content.match(/^([a-zA-Z_][a-zA-Z0-9_.]*):([a-z0-9]+)$/);
if (formatMatch) {
return {
type: 'variable',
value: content,
field: formatMatch[1],
format: formatMatch[2],
};
}
// Simple variable
return {
type: 'variable',
value: content,
field: content,
};
}
/**
* Template Engine class for interpolating templates
*/
export class TemplateEngine {
/**
* Interpolate a template string with metadata
*
* @param template The template string with variables
* @param metadata The metadata object to get values from
* @returns The interpolated string, or null if a required variable is missing
*/
interpolate(template: string, metadata: Record<string, unknown>): string | null {
const tokens = tokenize(template);
const result: string[] = [];
for (const token of tokens) {
switch (token.type) {
case 'literal':
result.push(token.value);
break;
case 'variable': {
const value = getNestedValue(metadata, token.field!);
if (value === null || value === undefined) {
// Required variable is missing
logger.debug(`Missing required variable: ${token.field}`);
return null;
}
const formatted = token.format ? applyFormat(value, token.format) : String(value);
result.push(formatted);
break;
}
case 'optional': {
const value = getNestedValue(metadata, token.field!);
if (value !== null && value !== undefined) {
const formatted = token.format ? applyFormat(value, token.format) : String(value);
result.push(formatted);
}
// If missing, just skip (don't add anything)
break;
}
case 'conditional': {
const value = getNestedValue(metadata, token.field!);
if (value !== null && value !== undefined) {
// Variable exists, interpolate the inner template
const innerResult = this.interpolate(token.innerTemplate!, metadata);
if (innerResult !== null) {
result.push(innerResult);
}
}
// If missing, skip the entire conditional section
break;
}
case 'default': {
const value = getNestedValue(metadata, token.field!);
if (value !== null && value !== undefined) {
result.push(String(value));
} else {
// Check if defaultValue looks like a field name (for field fallback)
// Field names: start with letter/underscore, contain alphanumeric/dot/underscore
const fieldPattern = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
if (fieldPattern.test(token.defaultValue!)) {
// Try to get value from fallback field
const fallbackValue = getNestedValue(metadata, token.defaultValue!);
if (fallbackValue !== null && fallbackValue !== undefined) {
result.push(String(fallbackValue));
} else {
// Neither field exists - treat as missing required variable
// Don't use the field name as a literal string
logger.debug(`Missing both primary field '${token.field}' and fallback field '${token.defaultValue}'`);
return null;
}
} else {
// defaultValue is not a field name, use as literal
result.push(token.defaultValue!);
}
}
break;
}
}
}
return result.join('');
}
/**
* Validate a template string for syntax errors
*
* @param template The template to validate
* @returns An object with valid flag and any errors/warnings
*/
validate(template: string): { valid: boolean; errors: string[]; warnings: string[] } {
const errors: string[] = [];
const warnings: string[] = [];
try {
const tokens = tokenize(template);
for (const token of tokens) {
if (token.type !== 'literal' && !token.field) {
errors.push(`Invalid variable syntax: {${token.value}}`);
}
// Check for nested conditional templates
if (token.type === 'conditional' && token.innerTemplate) {
const innerValidation = this.validate(token.innerTemplate);
errors.push(...innerValidation.errors.map(e => `In conditional {${token.field}?()}: ${e}`));
warnings.push(...innerValidation.warnings.map(w => `In conditional {${token.field}?()}: ${w}`));
}
}
} catch (error) {
errors.push(`Template parsing error: ${error}`);
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Extract all variable names from a template
*
* @param template The template to analyze
* @returns Array of variable field paths used in the template
*/
extractVariables(template: string): string[] {
const variables: Set<string> = new Set();
const tokens = tokenize(template);
for (const token of tokens) {
if (token.field) {
variables.add(token.field);
}
// Recursively extract from conditional templates
if (token.type === 'conditional' && token.innerTemplate) {
const innerVars = this.extractVariables(token.innerTemplate);
innerVars.forEach(v => variables.add(v));
}
}
return Array.from(variables);
}
}
@@ -0,0 +1,6 @@
/**
* Template module exports
*/
export { TemplateEngine, getNestedValue } from './TemplateEngine.js';
export { ConditionEvaluator } from './ConditionEvaluator.js';
@@ -0,0 +1,116 @@
/**
* Renaming Rule Types
*
* Defines the schema for configurable renaming rules that replace
* the hardcoded renamingRule() function.
*/
/**
* Condition operators for evaluating metadata fields
*/
export type ConditionOperator =
| 'EXISTS' // Field exists and is not null/undefined
| 'NOT_EXISTS' // Field is null/undefined
| 'EQUALS' // Field equals specified value
| 'NOT_EQUALS' // Field does not equal specified value
| 'CONTAINS' // String field contains specified substring
| 'MATCHES'; // String field matches specified regex
/**
* A single condition that evaluates a metadata field
*/
export interface Condition {
type: ConditionOperator;
field: string; // Metadata field path, e.g., "titles.eng", "season", "fileType"
value?: string | number | boolean; // Required for EQUALS, NOT_EQUALS, CONTAINS, MATCHES
}
/**
* A group of conditions combined with AND or OR logic
*/
export interface ConditionGroup {
operator: 'AND' | 'OR';
conditions: (Condition | ConditionGroup)[];
}
/**
* A single renaming rule with conditions and a template
*/
export interface RenamingRule {
id: string; // Unique identifier (UUID)
name: string; // Human-readable name
description?: string; // Optional description
enabled: boolean; // Can disable without deleting
priority: number; // Higher = evaluated first (default: 0)
conditions: ConditionGroup;
template: string; // Template string with variables
fallbackToUnsorted: boolean; // If template fails, put in Unsorted/
}
/**
* Full renaming configuration with all rules
*/
export interface RenamingConfig {
version: number; // Schema version for migrations
rules: RenamingRule[];
defaultRule?: RenamingRule; // Catch-all rule when no conditions match
lastModified?: string; // ISO timestamp of last modification
isDefault?: boolean; // True if this is the default config (not user-customized)
}
/**
* Preview item showing how a file would be renamed
*/
export interface PreviewItem {
sourcePath: string;
currentVirtualPath: string;
newVirtualPath: string | null;
matchedRule: string | null; // Rule ID that matched
error?: string;
}
/**
* Response for preview endpoint
*/
export interface PreviewResponse {
previews: PreviewItem[];
total: number;
limited: boolean;
}
/**
* Available template variable metadata for UI
*/
export interface TemplateVariable {
name: string; // Display name
path: string; // Field path (e.g., "titles.eng")
type: 'string' | 'number' | 'boolean';
description: string;
example?: string;
}
/**
* List of all available template variables
*/
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
{ name: 'English Title', path: 'titles.eng', type: 'string', description: 'English title from metadata', example: 'Breaking Bad' },
{ name: 'Original Title', path: 'originalTitle', type: 'string', description: 'Original/native title', example: 'Breaking Bad' },
{ name: 'Title', path: 'title', type: 'string', description: 'Simple title field', example: 'Breaking Bad' },
{ name: 'Season', path: 'season', type: 'number', description: 'Season number', example: '1' },
{ name: 'Episode', path: 'episode', type: 'number', description: 'Episode number', example: '1' },
{ name: 'Is Extra', path: 'extra', type: 'boolean', description: 'Whether this is a special/extra', example: 'false' },
{ name: 'Movie Year', path: 'movieYear', type: 'number', description: 'Movie release year', example: '2010' },
{ name: 'Year', path: 'year', type: 'number', description: 'General year field', example: '2010' },
{ name: 'File Type', path: 'fileType', type: 'string', description: 'Type: video, subtitle, torrent', example: 'video' },
{ name: 'Extension', path: 'extension', type: 'string', description: 'File extension', example: 'mkv' },
{ name: 'Version', path: 'version', type: 'string', description: 'Quality/release version', example: '1080p BluRay' },
{ name: 'Subtitle Language', path: 'subtitleLanguage', type: 'string', description: 'Language code for subtitles', example: 'eng' },
{ name: 'File Name', path: 'fileName', type: 'string', description: 'Original filename', example: 'Breaking.Bad.S01E01.mkv' },
];
/**
* Type guard to check if a condition is a ConditionGroup
*/
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
return 'operator' in condition && 'conditions' in condition;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "meta-fuse-driver"
version = "1.0.0"
edition = "2021"
description = "Rust FUSE driver for meta-fuse virtual filesystem"
[dependencies]
fuser = "0.14"
libc = "0.2"
env_logger = "0.11"
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
base64 = "0.22"
[[bin]]
name = "meta-fuse-driver"
path = "src/main.rs"
+121
View File
@@ -0,0 +1,121 @@
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ApiClient {
base_url: String,
client: Client,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FileAttributes {
pub size: u64,
pub mode: u32,
pub mtime: f64,
pub atime: f64,
pub ctime: f64,
pub nlink: u32,
pub uid: u32,
pub gid: u32,
}
#[derive(Debug, Deserialize)]
pub struct ReadResult {
#[serde(rename = "sourcePath")]
pub source_path: Option<String>,
pub content: Option<String>,
#[serde(rename = "contentEncoding")]
pub content_encoding: Option<String>,
pub size: u64,
}
#[derive(Debug, Deserialize)]
pub struct ReaddirResponse {
pub entries: Vec<String>,
}
#[derive(Debug, Serialize)]
struct PathRequest {
path: String,
}
impl ApiClient {
pub fn new(base_url: String) -> Result<Self, Box<dyn std::error::Error>> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
Ok(ApiClient { base_url, client })
}
pub fn readdir(&self, path: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let url = format!("{}/api/fuse/readdir", self.base_url);
let request = PathRequest {
path: path.to_string(),
};
let response = self.client.post(&url).json(&request).send()?;
if response.status().is_success() {
let result: ReaddirResponse = response.json()?;
Ok(result.entries)
} else {
Err(format!("API error: {}", response.status()).into())
}
}
pub fn getattr(&self, path: &str) -> Result<FileAttributes, Box<dyn std::error::Error>> {
let url = format!("{}/api/fuse/getattr", self.base_url);
let request = PathRequest {
path: path.to_string(),
};
let response = self.client.post(&url).json(&request).send()?;
if response.status().is_success() {
let attrs: FileAttributes = response.json()?;
Ok(attrs)
} else {
Err(format!("API error: {}", response.status()).into())
}
}
pub fn exists(&self, path: &str) -> Result<bool, Box<dyn std::error::Error>> {
let url = format!("{}/api/fuse/exists", self.base_url);
let request = PathRequest {
path: path.to_string(),
};
let response = self.client.post(&url).json(&request).send()?;
if response.status().is_success() {
let result: HashMap<String, bool> = response.json()?;
Ok(result.get("exists").copied().unwrap_or(false))
} else {
Err(format!("API error: {}", response.status()).into())
}
}
pub fn read(&self, path: &str) -> Result<ReadResult, Box<dyn std::error::Error>> {
let url = format!("{}/api/fuse/read", self.base_url);
let request = PathRequest {
path: path.to_string(),
};
let response = self.client.post(&url).json(&request).send()?;
if response.status().is_success() {
let result: ReadResult = response.json()?;
Ok(result)
} else {
Err(format!("API error: {}", response.status()).into())
}
}
pub fn health_check(&self) -> Result<bool, Box<dyn std::error::Error>> {
let url = format!("{}/api/fuse/health", self.base_url);
let response = self.client.get(&url).send()?;
Ok(response.status().is_success())
}
}
+680
View File
@@ -0,0 +1,680 @@
mod api_client;
use api_client::ApiClient;
use base64::Engine;
use fuser::{
FileAttr, FileType, Filesystem, MountOption, ReplyAttr, ReplyData, ReplyDirectory, ReplyEntry,
Request,
};
use libc::ENOENT;
use log::{debug, error, info};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const TTL: Duration = Duration::from_secs(1);
const ROOT_INO: u64 = 1;
const CACHE_TTL: Duration = Duration::from_secs(30);
const ERROR_FILE_INO: u64 = 2;
const API_ERROR_THRESHOLD: usize = 3;
/// Cached directory entry
#[derive(Clone)]
struct CachedDirEntry {
entries: Vec<String>,
timestamp: SystemTime,
}
/// Cached file attributes
#[derive(Clone)]
struct CachedAttrs {
attrs: api_client::FileAttributes,
timestamp: SystemTime,
}
/// API health tracker
struct ApiHealth {
consecutive_errors: usize,
last_error_message: String,
last_error_time: Option<SystemTime>,
}
impl ApiHealth {
fn new() -> Self {
ApiHealth {
consecutive_errors: 0,
last_error_message: String::new(),
last_error_time: None,
}
}
fn record_success(&mut self) {
self.consecutive_errors = 0;
}
fn record_error(&mut self, message: String) {
self.consecutive_errors += 1;
self.last_error_message = message;
self.last_error_time = Some(SystemTime::now());
if self.consecutive_errors >= API_ERROR_THRESHOLD {
error!("API has failed {} consecutive times. ERROR.txt will be displayed.", self.consecutive_errors);
}
}
fn is_unhealthy(&self) -> bool {
self.consecutive_errors >= API_ERROR_THRESHOLD
}
fn get_error_content(&self) -> String {
let timestamp = self.last_error_time
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
format!(
"Meta-Fuse FUSE Driver - API Connection Error\n\
=============================================\n\n\
The FUSE driver is mounted, but the meta-fuse API is not responding.\n\n\
Error Details:\n\
- Consecutive failures: {}\n\
- Last error: {}\n\
- Timestamp: {}\n\n\
Possible causes:\n\
1. meta-fuse-core service is not running\n\
2. API (port 3000) is not accessible\n\
3. Network connectivity issues\n\n\
To resolve:\n\
1. Check if meta-fuse is running: docker ps | grep meta-fuse\n\
2. Check API health: curl http://localhost:3000/api/fuse/health\n\
3. Restart the container: docker restart meta-fuse\n\n\
This ERROR.txt file will disappear once the API is responding again.\n",
self.consecutive_errors,
self.last_error_message,
timestamp
)
}
}
/// Maps virtual paths to inode numbers
struct InodeMapper {
path_to_ino: HashMap<String, u64>,
ino_to_path: HashMap<u64, String>,
next_ino: u64,
}
impl InodeMapper {
fn new() -> Self {
let mut mapper = InodeMapper {
path_to_ino: HashMap::new(),
ino_to_path: HashMap::new(),
next_ino: ROOT_INO + 1,
};
mapper.path_to_ino.insert("/".to_string(), ROOT_INO);
mapper.ino_to_path.insert(ROOT_INO, "/".to_string());
mapper
}
fn get_or_create_ino(&mut self, path: &str) -> u64 {
if let Some(&ino) = self.path_to_ino.get(path) {
return ino;
}
let ino = self.next_ino;
self.next_ino += 1;
self.path_to_ino.insert(path.to_string(), ino);
self.ino_to_path.insert(ino, path.to_string());
ino
}
fn get_path(&self, ino: u64) -> Option<&String> {
self.ino_to_path.get(&ino)
}
}
struct ApiFS {
api: ApiClient,
inode_mapper: Arc<Mutex<InodeMapper>>,
dir_cache: Arc<Mutex<HashMap<String, CachedDirEntry>>>,
attr_cache: Arc<Mutex<HashMap<String, CachedAttrs>>>,
api_health: Arc<Mutex<ApiHealth>>,
default_uid: u32,
default_gid: u32,
file_perm: u16,
dir_perm: u16,
}
impl ApiFS {
fn new(api_url: String, uid: u32, gid: u32, file_perm: u16, dir_perm: u16) -> Result<Self, Box<dyn std::error::Error>> {
let api = ApiClient::new(api_url)?;
if !api.health_check()? {
return Err("API health check failed".into());
}
Ok(ApiFS {
api,
inode_mapper: Arc::new(Mutex::new(InodeMapper::new())),
dir_cache: Arc::new(Mutex::new(HashMap::new())),
attr_cache: Arc::new(Mutex::new(HashMap::new())),
api_health: Arc::new(Mutex::new(ApiHealth::new())),
default_uid: uid,
default_gid: gid,
file_perm,
dir_perm,
})
}
fn is_cache_valid(timestamp: SystemTime) -> bool {
SystemTime::now()
.duration_since(timestamp)
.map(|d| d < CACHE_TTL)
.unwrap_or(false)
}
fn get_cached_readdir(&self, path: &str) -> Option<Vec<String>> {
let cache = self.dir_cache.lock().unwrap();
if let Some(cached) = cache.get(path) {
if Self::is_cache_valid(cached.timestamp) {
debug!("Cache hit for readdir: {}", path);
return Some(cached.entries.clone());
}
}
None
}
fn cache_readdir(&self, path: &str, entries: Vec<String>) {
let mut cache = self.dir_cache.lock().unwrap();
cache.insert(
path.to_string(),
CachedDirEntry {
entries,
timestamp: SystemTime::now(),
},
);
}
fn get_cached_attrs(&self, path: &str) -> Option<api_client::FileAttributes> {
let cache = self.attr_cache.lock().unwrap();
if let Some(cached) = cache.get(path) {
if Self::is_cache_valid(cached.timestamp) {
debug!("Cache hit for getattr: {}", path);
return Some(cached.attrs.clone());
}
}
None
}
fn cache_attrs(&self, path: &str, attrs: api_client::FileAttributes) {
let mut cache = self.attr_cache.lock().unwrap();
cache.insert(
path.to_string(),
CachedAttrs {
attrs,
timestamp: SystemTime::now(),
},
);
}
fn get_error_file_attrs(&self) -> FileAttr {
let content = self.api_health.lock().unwrap().get_error_content();
let size = content.len() as u64;
FileAttr {
ino: ERROR_FILE_INO,
size,
blocks: (size + 511) / 512,
atime: UNIX_EPOCH + Duration::from_secs(0),
mtime: UNIX_EPOCH + Duration::from_secs(0),
ctime: UNIX_EPOCH + Duration::from_secs(0),
crtime: UNIX_EPOCH,
kind: FileType::RegularFile,
perm: 0o444,
nlink: 1,
uid: self.default_uid,
gid: self.default_gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
fn convert_attrs(&self, path: &str, api_attrs: api_client::FileAttributes) -> FileAttr {
let ino = {
let mut mapper = self.inode_mapper.lock().unwrap();
mapper.get_or_create_ino(path)
};
let kind = if api_attrs.mode & 0o040000 != 0 {
FileType::Directory
} else {
FileType::RegularFile
};
let perm = if kind == FileType::Directory {
self.dir_perm
} else {
self.file_perm
};
FileAttr {
ino,
size: api_attrs.size,
blocks: (api_attrs.size + 511) / 512,
atime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.atime),
mtime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.mtime),
ctime: UNIX_EPOCH + Duration::from_secs_f64(api_attrs.ctime),
crtime: UNIX_EPOCH,
kind,
perm,
nlink: api_attrs.nlink,
uid: self.default_uid,
gid: self.default_gid,
rdev: 0,
blksize: 512,
flags: 0,
}
}
fn read_file_content(
&self,
read_result: &api_client::ReadResult,
offset: usize,
size: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
if let Some(ref content_b64) = read_result.content {
let content = base64::prelude::BASE64_STANDARD.decode(content_b64)?;
let end = std::cmp::min(offset + size, content.len());
if offset >= content.len() {
return Ok(vec![]);
}
return Ok(content[offset..end].to_vec());
}
if let Some(ref source_path) = read_result.source_path {
let mut file = File::open(source_path)?;
if offset > 0 {
use std::io::Seek;
file.seek(std::io::SeekFrom::Start(offset as u64))?;
}
let mut buffer = vec![0u8; size];
let bytes_read = file.read(&mut buffer)?;
buffer.truncate(bytes_read);
return Ok(buffer);
}
Err("No content or source path available".into())
}
}
impl Filesystem for ApiFS {
fn lookup(&mut self, _req: &Request, parent: u64, name: &OsStr, reply: ReplyEntry) {
let name_str = match name.to_str() {
Some(s) => s,
None => {
error!("Invalid UTF-8 in filename");
reply.error(ENOENT);
return;
}
};
if parent == ROOT_INO && name_str == "ERROR.txt" {
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
if is_unhealthy {
let attr = self.get_error_file_attrs();
reply.entry(&TTL, &attr, 0);
return;
}
}
let parent_path = {
let mapper = self.inode_mapper.lock().unwrap();
match mapper.get_path(parent) {
Some(p) => p.clone(),
None => {
error!("Parent inode {} not found", parent);
reply.error(ENOENT);
return;
}
}
};
let child_path = if parent_path == "/" {
format!("/{}", name_str)
} else {
format!("{}/{}", parent_path, name_str)
};
debug!("lookup: parent={} name={} -> {}", parent, name_str, child_path);
if let Some(cached_attrs) = self.get_cached_attrs(&child_path) {
let attr = self.convert_attrs(&child_path, cached_attrs);
reply.entry(&TTL, &attr, 0);
return;
}
match self.api.getattr(&child_path) {
Ok(api_attrs) => {
self.api_health.lock().unwrap().record_success();
self.cache_attrs(&child_path, api_attrs.clone());
let attr = self.convert_attrs(&child_path, api_attrs);
reply.entry(&TTL, &attr, 0);
}
Err(e) => {
self.api_health.lock().unwrap().record_error(format!("lookup failed for {}: {}", child_path, e));
debug!("lookup failed for {}: {}", child_path, e);
reply.error(ENOENT);
}
}
}
fn getattr(&mut self, _req: &Request, ino: u64, reply: ReplyAttr) {
if ino == ERROR_FILE_INO {
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
if is_unhealthy {
let attr = self.get_error_file_attrs();
reply.attr(&TTL, &attr);
return;
} else {
reply.error(ENOENT);
return;
}
}
let path = {
let mapper = self.inode_mapper.lock().unwrap();
match mapper.get_path(ino) {
Some(p) => p.clone(),
None => {
error!("Inode {} not found", ino);
reply.error(ENOENT);
return;
}
}
};
debug!("getattr: ino={} path={}", ino, path);
if let Some(cached_attrs) = self.get_cached_attrs(&path) {
let attr = self.convert_attrs(&path, cached_attrs);
reply.attr(&TTL, &attr);
return;
}
match self.api.getattr(&path) {
Ok(api_attrs) => {
self.api_health.lock().unwrap().record_success();
self.cache_attrs(&path, api_attrs.clone());
let attr = self.convert_attrs(&path, api_attrs);
reply.attr(&TTL, &attr);
}
Err(e) => {
self.api_health.lock().unwrap().record_error(format!("getattr failed for {}: {}", path, e));
error!("getattr failed for {}: {}", path, e);
reply.error(ENOENT);
}
}
}
fn read(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
size: u32,
_flags: i32,
_lock: Option<u64>,
reply: ReplyData,
) {
if ino == ERROR_FILE_INO {
let content = self.api_health.lock().unwrap().get_error_content();
let content_bytes = content.as_bytes();
let offset = offset as usize;
let size = size as usize;
if offset >= content_bytes.len() {
reply.data(&[]);
return;
}
let end = std::cmp::min(offset + size, content_bytes.len());
reply.data(&content_bytes[offset..end]);
return;
}
let path = {
let mapper = self.inode_mapper.lock().unwrap();
match mapper.get_path(ino) {
Some(p) => p.clone(),
None => {
error!("Inode {} not found", ino);
reply.error(ENOENT);
return;
}
}
};
debug!("read: ino={} path={} offset={} size={}", ino, path, offset, size);
match self.api.read(&path) {
Ok(read_result) => {
self.api_health.lock().unwrap().record_success();
match self.read_file_content(&read_result, offset as usize, size as usize) {
Ok(data) => reply.data(&data),
Err(e) => {
error!("Failed to read file content for {}: {}", path, e);
reply.error(libc::EIO);
}
}
}
Err(e) => {
self.api_health.lock().unwrap().record_error(format!("read API call failed for {}: {}", path, e));
error!("read API call failed for {}: {}", path, e);
reply.error(ENOENT);
}
}
}
fn readdir(
&mut self,
_req: &Request,
ino: u64,
_fh: u64,
offset: i64,
mut reply: ReplyDirectory,
) {
let path = {
let mapper = self.inode_mapper.lock().unwrap();
match mapper.get_path(ino) {
Some(p) => p.clone(),
None => {
error!("Inode {} not found", ino);
reply.error(ENOENT);
return;
}
}
};
debug!("readdir: ino={} path={} offset={}", ino, path, offset);
let entries = if let Some(cached_entries) = self.get_cached_readdir(&path) {
cached_entries
} else {
match self.api.readdir(&path) {
Ok(entries) => {
self.api_health.lock().unwrap().record_success();
self.cache_readdir(&path, entries.clone());
entries
}
Err(e) => {
self.api_health.lock().unwrap().record_error(format!("readdir failed for {}: {}", path, e));
error!("readdir failed for {}: {}", path, e);
reply.error(ENOENT);
return;
}
}
};
let mut full_entries = vec![
(ino, FileType::Directory, ".".to_string()),
(ino, FileType::Directory, "..".to_string()),
];
if ino == ROOT_INO {
let is_unhealthy = self.api_health.lock().unwrap().is_unhealthy();
if is_unhealthy {
full_entries.push((ERROR_FILE_INO, FileType::RegularFile, "ERROR.txt".to_string()));
}
}
for entry_name in entries {
let entry_path = if path == "/" {
format!("/{}", entry_name)
} else {
format!("{}/{}", path, entry_name)
};
let entry_ino = {
let mut mapper = self.inode_mapper.lock().unwrap();
mapper.get_or_create_ino(&entry_path)
};
let file_type = if let Some(cached_attrs) = self.get_cached_attrs(&entry_path) {
if cached_attrs.mode & 0o040000 != 0 {
FileType::Directory
} else {
FileType::RegularFile
}
} else {
match self.api.getattr(&entry_path) {
Ok(attrs) => {
self.cache_attrs(&entry_path, attrs.clone());
if attrs.mode & 0o040000 != 0 {
FileType::Directory
} else {
FileType::RegularFile
}
}
Err(_) => FileType::RegularFile,
}
};
full_entries.push((entry_ino, file_type, entry_name));
}
for (i, entry) in full_entries.into_iter().enumerate().skip(offset as usize) {
if reply.add(entry.0, (i + 1) as i64, entry.1, entry.2) {
break;
}
}
reply.ok();
}
}
fn main() {
env_logger::init();
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 || args.len() > 5 {
eprintln!("Usage: {} <mountpoint> [api-url] [uid] [gid]", args[0]);
eprintln!("\nExample: {} /mnt/virtual http://localhost:3000 1000 1000", args[0]);
eprintln!(" {} /mnt/virtual (defaults: http://localhost:3000, uid=1000, gid=1000)", args[0]);
eprintln!("\nEnvironment variables:");
eprintln!(" PUID - User ID for file ownership (default: 1000)");
eprintln!(" PGID - Group ID for file ownership (default: 1000)");
eprintln!(" FUSE_FILE_PERM - File permissions in octal (default: 755)");
eprintln!(" FUSE_DIR_PERM - Directory permissions in octal (default: 755)");
eprintln!(" FUSE_API_URL - API URL (default: http://localhost:3000)");
std::process::exit(1);
}
let mountpoint = &args[1];
let api_url = if args.len() >= 3 {
args[2].clone()
} else {
std::env::var("FUSE_API_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
};
let uid: u32 = if args.len() >= 4 {
args[3].parse().unwrap_or_else(|_| {
eprintln!("Error: Invalid UID value");
std::process::exit(1);
})
} else {
std::env::var("PUID")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
};
let gid: u32 = if args.len() >= 5 {
args[4].parse().unwrap_or_else(|_| {
eprintln!("Error: Invalid GID value");
std::process::exit(1);
})
} else {
std::env::var("PGID")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1000)
};
let file_perm: u16 = std::env::var("FUSE_FILE_PERM")
.ok()
.and_then(|v| u16::from_str_radix(&v, 8).ok())
.unwrap_or(0o755);
let dir_perm: u16 = std::env::var("FUSE_DIR_PERM")
.ok()
.and_then(|v| u16::from_str_radix(&v, 8).ok())
.unwrap_or(0o755);
info!("Connecting to API at: {}", api_url);
info!("File ownership: uid={}, gid={}", uid, gid);
info!("File permissions: {:o} (files), {:o} (directories)", file_perm, dir_perm);
let fs = match ApiFS::new(api_url.clone(), uid, gid, file_perm, dir_perm) {
Ok(fs) => {
info!("Successfully connected to meta-fuse API");
fs
}
Err(e) => {
error!("Failed to connect to API at {}: {}", api_url, e);
eprintln!("Error: Failed to connect to API at {}: {}", api_url, e);
eprintln!("\nMake sure meta-fuse-core is running.");
std::process::exit(1);
}
};
info!("Mounting filesystem at: {}", mountpoint);
let options = vec![
MountOption::RO,
MountOption::FSName("meta-fuse".to_string()),
MountOption::AutoUnmount,
MountOption::AllowOther,
];
match fuser::mount2(fs, mountpoint, &options) {
Ok(()) => {
info!("Filesystem unmounted successfully");
}
Err(e) => {
error!("Mount failed: {}", e);
eprintln!("Error: Failed to mount filesystem: {}", e);
eprintln!("\nPossible causes:");
eprintln!("1. Mount point does not exist or is not accessible");
eprintln!("2. FUSE module is not loaded (try: modprobe fuse)");
eprintln!("3. /etc/fuse.conf missing 'user_allow_other' option");
eprintln!(" To fix: echo 'user_allow_other' | sudo tee -a /etc/fuse.conf");
std::process::exit(1);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>meta-fuse Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@meta-fuse/ui",
"version": "1.0.2",
"description": "Dashboard UI for meta-fuse",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx"
},
"license": "MIT",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}
+229
View File
@@ -0,0 +1,229 @@
import { useState, useEffect, useCallback } from 'react';
import { RulesEditor } from './components/RulesEditor';
interface Stats {
fileCount: number;
directoryCount: number;
totalSize: number;
lastRefresh: string | null;
redisConnected: boolean;
}
interface HealthStatus {
status: string;
timestamp: string;
service: string;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatTime(isoString: string | null): string {
if (!isoString) return 'Never';
const date = new Date(isoString);
return date.toLocaleTimeString();
}
function App() {
const [stats, setStats] = useState<Stats | null>(null);
const [health, setHealth] = useState<HealthStatus | null>(null);
const [directories, setDirectories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showRules, setShowRules] = useState(false);
const fetchData = useCallback(async () => {
try {
const [statsRes, healthRes, dirsRes] = await Promise.all([
fetch('/api/fuse/stats'),
fetch('/api/fuse/health'),
fetch('/api/fuse/directories'),
]);
if (!statsRes.ok || !healthRes.ok) {
throw new Error('Failed to fetch data');
}
const statsData = await statsRes.json();
const healthData = await healthRes.json();
const dirsData = await dirsRes.json();
setStats(statsData);
setHealth(healthData);
setDirectories(dirsData.directories || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, []);
const handleRefresh = async () => {
setRefreshing(true);
try {
await fetch('/api/fuse/refresh', { method: 'POST' });
await fetchData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Refresh failed');
} finally {
setRefreshing(false);
}
};
useEffect(() => {
fetchData();
const dataInterval = setInterval(fetchData, 5000);
return () => {
clearInterval(dataInterval);
};
}, [fetchData]);
if (loading) {
return (
<div className="container">
<div className="loading">Loading...</div>
</div>
);
}
const isConnected = health?.status === 'ok' && stats?.redisConnected;
return (
<div className="container">
<header className="header">
<div>
<h1>meta-fuse</h1>
<p className="header-subtitle">Virtual filesystem service</p>
</div>
<div className={`status-badge ${isConnected ? 'connected' : 'disconnected'}`}>
<span className={`status-dot ${isConnected ? 'connected' : 'disconnected'}`} />
{isConnected ? 'Connected' : 'Disconnected'}
</div>
</header>
{error && <div className="error">{error}</div>}
<div className="section">
<h2 className="section-title">VFS Control</h2>
<div className="grid">
<div className="card">
<div className="card-header">
<span className="card-title">Files</span>
</div>
<div className="card-value">{stats?.fileCount ?? 0}</div>
<div className="card-subtitle">Total files in VFS</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Directories</span>
</div>
<div className="card-value">{stats?.directoryCount ?? 0}</div>
<div className="card-subtitle">Total directories</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Total Size</span>
</div>
<div className="card-value">{formatBytes(stats?.totalSize ?? 0)}</div>
<div className="card-subtitle">Combined file size</div>
</div>
<div className="card">
<div className="card-header">
<span className="card-title">Last Refresh</span>
<button
className="refresh-btn"
onClick={handleRefresh}
disabled={refreshing}
>
{refreshing ? 'Refreshing...' : 'Refresh'}
</button>
</div>
<div className="card-value" style={{ fontSize: '1.5rem' }}>
{formatTime(stats?.lastRefresh ?? null)}
</div>
<div className="card-subtitle">From Redis metadata</div>
</div>
</div>
<div className="card" style={{ marginTop: '1.5rem' }}>
<div className="card-header">
<span className="card-title">Connection Status</span>
</div>
<div className="info-grid">
<div className="info-item">
<div className="info-label">API Status</div>
<div className="info-value">{health?.status ?? 'unknown'}</div>
</div>
<div className="info-item">
<div className="info-label">Redis</div>
<div className="info-value">
{stats?.redisConnected ? 'Connected' : 'Disconnected'}
</div>
</div>
<div className="info-item">
<div className="info-label">Service</div>
<div className="info-value">{health?.service ?? 'unknown'}</div>
</div>
<div className="info-item">
<div className="info-label">Timestamp</div>
<div className="info-value">{formatTime(health?.timestamp ?? null)}</div>
</div>
</div>
</div>
<div className="subsection">
<div
className="section-header-collapsible"
onClick={() => setShowRules(!showRules)}
>
<h3 className="subsection-title">Renaming Rules</h3>
<span className="collapse-icon">{showRules ? '-' : '+'}</span>
</div>
{showRules && (
<RulesEditor onSave={fetchData} />
)}
</div>
</div>
<div className="section">
<h2 className="section-title">Virtual Directories</h2>
<div className="file-list">
{directories.length === 0 ? (
<div className="file-item">
<span className="file-name" style={{ color: '#64748b' }}>
No directories found. Waiting for metadata from meta-sort...
</span>
</div>
) : (
directories.slice(0, 20).map((dir) => (
<div key={dir} className="file-item">
<span className="file-icon">📁</span>
<span className="file-name">{dir}</span>
</div>
))
)}
{directories.length > 20 && (
<div className="file-item">
<span className="file-name" style={{ color: '#64748b' }}>
... and {directories.length - 20} more directories
</span>
</div>
)}
</div>
</div>
</div>
);
}
export default App;
@@ -0,0 +1,626 @@
import { useState, useEffect, useCallback } from 'react';
import type {
RenamingConfig,
RenamingRule,
ConditionGroup,
Condition,
PreviewItem,
TemplateVariable,
} from '../types/rules';
import { isConditionGroup, generateId } from '../types/rules';
interface RulesEditorProps {
onSave?: () => void;
}
// Available metadata fields for conditions
const AVAILABLE_FIELDS = [
{ value: 'fileType', label: 'File Type', type: 'string' },
{ value: 'titles.eng', label: 'English Title', type: 'string' },
{ value: 'originalTitle', label: 'Original Title', type: 'string' },
{ value: 'season', label: 'Season', type: 'number' },
{ value: 'episode', label: 'Episode', type: 'number' },
{ value: 'extra', label: 'Is Extra', type: 'boolean' },
{ value: 'movieYear', label: 'Movie Year', type: 'number' },
{ value: 'year', label: 'Year', type: 'number' },
{ value: 'extension', label: 'Extension', type: 'string' },
{ value: 'version', label: 'Version', type: 'string' },
{ value: 'subtitleLanguage', label: 'Subtitle Language', type: 'string' },
];
const CONDITION_TYPES = [
{ value: 'EXISTS', label: 'exists', needsValue: false },
{ value: 'NOT_EXISTS', label: 'does not exist', needsValue: false },
{ value: 'EQUALS', label: 'equals', needsValue: true },
{ value: 'NOT_EQUALS', label: 'does not equal', needsValue: true },
{ value: 'CONTAINS', label: 'contains', needsValue: true },
{ value: 'MATCHES', label: 'matches regex', needsValue: true },
];
function createEmptyRule(): RenamingRule {
return {
id: generateId(),
name: 'New Rule',
description: '',
enabled: true,
priority: 50,
conditions: { operator: 'AND', conditions: [] },
template: '',
fallbackToUnsorted: true,
};
}
function createEmptyCondition(): Condition {
return {
type: 'EXISTS',
field: 'titles.eng',
};
}
// ConditionRow component
interface ConditionRowProps {
condition: Condition;
onChange: (condition: Condition) => void;
onDelete: () => void;
}
function ConditionRow({ condition, onChange, onDelete }: ConditionRowProps) {
const conditionType = CONDITION_TYPES.find(t => t.value === condition.type);
const needsValue = conditionType?.needsValue ?? false;
return (
<div className="condition-row">
<select
value={condition.field}
onChange={e => onChange({ ...condition, field: e.target.value })}
className="condition-field"
>
{AVAILABLE_FIELDS.map(f => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
<select
value={condition.type}
onChange={e => onChange({ ...condition, type: e.target.value as Condition['type'] })}
className="condition-type"
>
{CONDITION_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
{needsValue && (
<input
type="text"
value={String(condition.value ?? '')}
onChange={e => onChange({ ...condition, value: e.target.value })}
placeholder="Value"
className="condition-value"
/>
)}
<button onClick={onDelete} className="btn-icon btn-delete" title="Remove condition">
x
</button>
</div>
);
}
// ConditionBuilder component
interface ConditionBuilderProps {
conditions: ConditionGroup;
onChange: (conditions: ConditionGroup) => void;
}
function ConditionBuilder({ conditions, onChange }: ConditionBuilderProps) {
const addCondition = () => {
onChange({
...conditions,
conditions: [...conditions.conditions, createEmptyCondition()],
});
};
const updateCondition = (index: number, updated: Condition | ConditionGroup) => {
const newConditions = [...conditions.conditions];
newConditions[index] = updated;
onChange({ ...conditions, conditions: newConditions });
};
const deleteCondition = (index: number) => {
const newConditions = conditions.conditions.filter((_, i) => i !== index);
onChange({ ...conditions, conditions: newConditions });
};
return (
<div className="condition-builder">
<div className="condition-operator">
<label>Match</label>
<select
value={conditions.operator}
onChange={e => onChange({ ...conditions, operator: e.target.value as 'AND' | 'OR' })}
>
<option value="AND">ALL conditions (AND)</option>
<option value="OR">ANY condition (OR)</option>
</select>
</div>
<div className="condition-list">
{conditions.conditions.length === 0 && (
<div className="condition-empty">No conditions - rule will match all files</div>
)}
{conditions.conditions.map((cond, index) => (
!isConditionGroup(cond) && (
<ConditionRow
key={index}
condition={cond}
onChange={updated => updateCondition(index, updated)}
onDelete={() => deleteCondition(index)}
/>
)
))}
</div>
<button onClick={addCondition} className="btn-add-condition">
+ Add Condition
</button>
</div>
);
}
// TemplateEditor component
interface TemplateEditorProps {
template: string;
onChange: (template: string) => void;
variables: TemplateVariable[];
}
function TemplateEditor({ template, onChange, variables }: TemplateEditorProps) {
const [showVars, setShowVars] = useState(false);
const insertVariable = (path: string) => {
onChange(template + `{${path}}`);
};
return (
<div className="template-editor">
<div className="template-header">
<label>Path Template</label>
<button
onClick={() => setShowVars(!showVars)}
className="btn-small"
>
{showVars ? 'Hide Variables' : 'Show Variables'}
</button>
</div>
<textarea
value={template}
onChange={e => onChange(e.target.value)}
placeholder="e.g., Movies/{titles.eng|originalTitle}{movieYear? ({movieYear})}/{fileName}.{extension}"
rows={3}
className="template-input"
/>
{showVars && (
<div className="variable-palette">
{variables.map(v => (
<button
key={v.path}
onClick={() => insertVariable(v.path)}
className="variable-chip"
title={`${v.description} (${v.type})`}
>
{v.name}
</button>
))}
</div>
)}
<div className="template-help">
<span><code>{'{var}'}</code> basic</span>
<span><code>{'{var:pad2}'}</code> zero-pad</span>
<span><code>{'{var?}'}</code> optional</span>
<span><code>{'{var?(text)}'}</code> conditional</span>
<span><code>{'{var|default}'}</code> default</span>
</div>
</div>
);
}
// RuleCard component
interface RuleCardProps {
rule: RenamingRule;
variables: TemplateVariable[];
expanded: boolean;
onToggle: () => void;
onUpdate: (rule: RenamingRule) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
}
function RuleCard({
rule,
variables,
expanded,
onToggle,
onUpdate,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: RuleCardProps) {
const conditionCount = rule.conditions.conditions.length;
return (
<div className={`rule-card ${expanded ? 'expanded' : ''} ${!rule.enabled ? 'disabled' : ''}`}>
<div className="rule-header" onClick={onToggle}>
<div className="rule-header-left">
<input
type="checkbox"
checked={rule.enabled}
onChange={e => {
e.stopPropagation();
onUpdate({ ...rule, enabled: e.target.checked });
}}
title="Enable/disable rule"
/>
<span className="rule-name">{rule.name}</span>
<span className="rule-meta">
Priority: {rule.priority} | {conditionCount} condition{conditionCount !== 1 ? 's' : ''}
</span>
</div>
<div className="rule-header-right">
<button
onClick={e => { e.stopPropagation(); onMoveUp(); }}
disabled={isFirst}
className="btn-icon"
title="Move up"
>
^
</button>
<button
onClick={e => { e.stopPropagation(); onMoveDown(); }}
disabled={isLast}
className="btn-icon"
title="Move down"
>
v
</button>
<button
onClick={e => { e.stopPropagation(); onDelete(); }}
className="btn-icon btn-delete"
title="Delete rule"
>
x
</button>
<span className="expand-icon">{expanded ? '-' : '+'}</span>
</div>
</div>
{expanded && (
<div className="rule-body">
<div className="rule-field">
<label>Name</label>
<input
type="text"
value={rule.name}
onChange={e => onUpdate({ ...rule, name: e.target.value })}
/>
</div>
<div className="rule-field">
<label>Priority (higher = evaluated first)</label>
<input
type="number"
value={rule.priority}
onChange={e => onUpdate({ ...rule, priority: parseInt(e.target.value, 10) || 0 })}
min={0}
max={1000}
/>
</div>
<div className="rule-field">
<label>Conditions</label>
<ConditionBuilder
conditions={rule.conditions}
onChange={conditions => onUpdate({ ...rule, conditions })}
/>
</div>
<div className="rule-field">
<TemplateEditor
template={rule.template}
onChange={template => onUpdate({ ...rule, template })}
variables={variables}
/>
</div>
<div className="rule-field rule-field-inline">
<label>
<input
type="checkbox"
checked={rule.fallbackToUnsorted}
onChange={e => onUpdate({ ...rule, fallbackToUnsorted: e.target.checked })}
/>
Fall back to Unsorted folder if template fails
</label>
</div>
</div>
)}
</div>
);
}
// PreviewPanel component
interface PreviewPanelProps {
previews: PreviewItem[];
loading: boolean;
total: number;
limited: boolean;
}
function PreviewPanel({ previews, loading, total, limited }: PreviewPanelProps) {
if (loading) {
return <div className="preview-loading">Loading preview...</div>;
}
return (
<div className="preview-panel">
<div className="preview-header">
<h3>Preview</h3>
<span className="preview-count">
Showing {previews.length} of {total} files
{limited && ' (limited)'}
</span>
</div>
{previews.length === 0 ? (
<div className="preview-empty">No files to preview</div>
) : (
<div className="preview-table-wrapper">
<table className="preview-table">
<thead>
<tr>
<th>Source</th>
<th>Current Path</th>
<th>New Path</th>
<th>Rule</th>
</tr>
</thead>
<tbody>
{previews.map((p, i) => {
const changed = p.newVirtualPath !== p.currentVirtualPath;
return (
<tr key={i} className={p.error ? 'error' : changed ? 'changed' : ''}>
<td title={p.sourcePath}>{p.sourcePath.split('/').pop()}</td>
<td title={p.currentVirtualPath}>{truncatePath(p.currentVirtualPath)}</td>
<td className={changed ? 'highlight' : ''} title={p.newVirtualPath || ''}>
{p.error ? (
<span className="error-text">{p.error}</span>
) : (
truncatePath(p.newVirtualPath || '')
)}
</td>
<td>{p.matchedRule || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
function truncatePath(path: string, maxLen = 40): string {
if (path.length <= maxLen) return path;
return '...' + path.slice(-maxLen + 3);
}
// Main RulesEditor component
export function RulesEditor({ onSave }: RulesEditorProps) {
const [config, setConfig] = useState<RenamingConfig | null>(null);
const [variables, setVariables] = useState<TemplateVariable[]>([]);
const [expandedRule, setExpandedRule] = useState<string | null>(null);
const [previews, setPreviews] = useState<PreviewItem[]>([]);
const [previewTotal, setPreviewTotal] = useState(0);
const [previewLimited, setPreviewLimited] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch rules and variables on mount
useEffect(() => {
Promise.all([
fetch('/api/fuse/rules').then(r => r.json()),
fetch('/api/fuse/rules/variables').then(r => r.json()),
])
.then(([rulesData, varsData]) => {
setConfig(rulesData.config);
setVariables(varsData.variables);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
// Fetch preview when config changes
const fetchPreview = useCallback(async () => {
if (!config) return;
setPreviewLoading(true);
try {
const res = await fetch('/api/fuse/rules/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: config.rules, limit: 20 }),
});
const data = await res.json();
setPreviews(data.previews || []);
setPreviewTotal(data.total || 0);
setPreviewLimited(data.limited || false);
} catch (err) {
console.error('Failed to fetch preview:', err);
} finally {
setPreviewLoading(false);
}
}, [config]);
// Debounced preview fetch
useEffect(() => {
if (!dirty || !config) return;
const timeout = setTimeout(fetchPreview, 500);
return () => clearTimeout(timeout);
}, [config, dirty, fetchPreview]);
// Initial preview
useEffect(() => {
if (config && !dirty) {
fetchPreview();
}
}, [config, dirty, fetchPreview]);
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/fuse/rules', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.errors?.join(', ') || data.error || 'Failed to save');
}
setDirty(false);
onSave?.();
// Refetch preview after save
await fetchPreview();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
};
const updateRules = (rules: RenamingRule[]) => {
if (!config) return;
setConfig({ ...config, rules });
setDirty(true);
};
const addRule = () => {
if (!config) return;
const newRule = createEmptyRule();
updateRules([...config.rules, newRule]);
setExpandedRule(newRule.id);
};
const updateRule = (id: string, updated: RenamingRule) => {
if (!config) return;
updateRules(config.rules.map(r => r.id === id ? updated : r));
};
const deleteRule = (id: string) => {
if (!config) return;
if (confirm('Are you sure you want to delete this rule?')) {
updateRules(config.rules.filter(r => r.id !== id));
if (expandedRule === id) setExpandedRule(null);
}
};
const moveRule = (index: number, direction: -1 | 1) => {
if (!config) return;
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= config.rules.length) return;
const rules = [...config.rules];
[rules[index], rules[newIndex]] = [rules[newIndex], rules[index]];
updateRules(rules);
};
if (loading) {
return <div className="rules-loading">Loading rules configuration...</div>;
}
if (!config) {
return <div className="rules-error">Failed to load rules configuration</div>;
}
return (
<div className="rules-editor">
<div className="rules-header">
<h2>Renaming Rules</h2>
<div className="rules-actions">
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
<button
onClick={handleSave}
disabled={!dirty || saving}
className={`btn-save ${dirty ? 'btn-primary' : ''}`}
>
{saving ? 'Saving...' : 'Save & Apply'}
</button>
</div>
</div>
{error && (
<div className="rules-error-message">{error}</div>
)}
<div className="rules-content">
<div className="rules-list">
{config.rules.length === 0 && (
<div className="rules-empty">
No rules configured. Click "Add Rule" to create one.
</div>
)}
{config.rules.map((rule, index) => (
<RuleCard
key={rule.id}
rule={rule}
variables={variables}
expanded={expandedRule === rule.id}
onToggle={() => setExpandedRule(expandedRule === rule.id ? null : rule.id)}
onUpdate={updated => updateRule(rule.id, updated)}
onDelete={() => deleteRule(rule.id)}
onMoveUp={() => moveRule(index, -1)}
onMoveDown={() => moveRule(index, 1)}
isFirst={index === 0}
isLast={index === config.rules.length - 1}
/>
))}
<button onClick={addRule} className="btn-add-rule">
+ Add Rule
</button>
</div>
<PreviewPanel
previews={previews}
loading={previewLoading}
total={previewTotal}
limited={previewLimited}
/>
</div>
</div>
);
}
export default RulesEditor;
+828
View File
@@ -0,0 +1,828 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--accent-secondary: #ff6b6b;
--accent-warning: #ffd93d;
--border-color: #2a2a3e;
--success: #4ecdc4;
--error: #ff6b6b;
--warning: #ffd93d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
a {
color: var(--accent-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.header h1 {
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.header-subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.connected {
background: rgba(78, 205, 196, 0.15);
color: var(--success);
}
.status-badge.disconnected {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.connected {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.status-dot.disconnected {
background: var(--error);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
}
.card:hover {
border-color: rgba(78, 205, 196, 0.3);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.card-value {
font-size: 2.25rem;
font-weight: 700;
color: var(--accent-primary);
}
.card-subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
background: var(--bg-tertiary);
padding: 1rem;
border-radius: 8px;
}
.info-label {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 1rem;
color: var(--text-primary);
font-family: monospace;
}
.section {
margin-top: 2rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
}
.subsection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.subsection-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.file-list {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-family: monospace;
font-size: 0.875rem;
}
.file-item:last-child {
border-bottom: none;
}
.file-item:hover {
background: var(--bg-tertiary);
}
.file-icon {
color: var(--text-secondary);
}
.file-name {
flex: 1;
color: var(--text-primary);
}
.file-size {
color: var(--text-secondary);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
}
.error {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid rgba(255, 107, 107, 0.3);
}
.refresh-btn {
background: var(--accent-primary);
color: var(--bg-primary);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.refresh-btn:hover {
background: #3db8b0;
transform: translateY(-1px);
}
.refresh-btn:disabled {
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: not-allowed;
transform: none;
}
/* Collapsible section header */
.section-header-collapsible {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0;
user-select: none;
}
.section-header-collapsible:hover .section-title {
color: var(--accent-primary);
}
.collapse-icon {
font-size: 1.5rem;
color: var(--text-secondary);
font-weight: bold;
}
/* Rules Editor */
.rules-editor {
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-color);
padding: 1.5rem;
margin-top: 1rem;
}
.rules-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.rules-header h2 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.rules-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.unsaved-badge {
background: rgba(255, 217, 61, 0.15);
color: var(--warning);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.btn-save {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.5rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-save.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
border-color: var(--accent-primary);
}
.btn-save:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn-save:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.rules-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rules-empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
background: var(--bg-tertiary);
border-radius: 8px;
}
.btn-add-rule {
background: transparent;
border: 2px dashed var(--border-color);
color: var(--text-secondary);
padding: 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-add-rule:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Rule Card */
.rule-card {
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
overflow: hidden;
}
.rule-card.disabled {
opacity: 0.6;
}
.rule-card.expanded {
border-color: var(--accent-primary);
}
.rule-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.rule-header:hover {
background: rgba(255, 255, 255, 0.02);
}
.rule-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.rule-header-left input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.rule-name {
font-weight: 500;
color: var(--text-primary);
}
.rule-meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.rule-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-icon {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-icon:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.btn-icon:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.btn-icon.btn-delete:hover {
border-color: var(--error);
color: var(--error);
}
.expand-icon {
font-size: 1.25rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.rule-body {
padding: 1rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 1rem;
}
.rule-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-field > label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rule-field input[type="text"],
.rule-field input[type="number"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.rule-field input:focus {
outline: none;
border-color: var(--accent-primary);
}
.rule-field-inline {
flex-direction: row;
align-items: center;
}
.rule-field-inline label {
display: flex;
align-items: center;
gap: 0.5rem;
text-transform: none;
font-size: 0.875rem;
cursor: pointer;
}
/* Condition Builder */
.condition-builder {
background: var(--bg-secondary);
border-radius: 8px;
padding: 1rem;
}
.condition-operator {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.condition-operator label {
font-size: 0.875rem;
color: var(--text-secondary);
}
.condition-operator select {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.condition-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.condition-empty {
text-align: center;
color: var(--text-secondary);
font-size: 0.875rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.condition-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.condition-field,
.condition-type {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
}
.condition-value {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
flex: 1;
min-width: 100px;
}
.condition-value:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-add-condition {
background: transparent;
border: 1px dashed var(--border-color);
color: var(--text-secondary);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-add-condition:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* Template Editor */
.template-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.template-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.template-header label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.btn-small {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.btn-small:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.template-input {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-family: monospace;
resize: vertical;
min-height: 60px;
}
.template-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.variable-palette {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
}
.variable-chip {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
transition: all 0.2s;
}
.variable-chip:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.template-help {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.template-help code {
background: var(--bg-tertiary);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-family: monospace;
color: var(--accent-primary);
}
/* Preview Panel */
.preview-panel {
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
.preview-header h3 {
font-size: 0.875rem;
font-weight: 500;
margin: 0;
}
.preview-count {
font-size: 0.75rem;
color: var(--text-secondary);
}
.preview-loading,
.preview-empty {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.preview-table-wrapper {
overflow-x: auto;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.preview-table th,
.preview-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.preview-table th {
background: var(--bg-secondary);
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
.preview-table td {
font-family: monospace;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.preview-table tr.changed td.highlight {
color: var(--accent-primary);
}
.preview-table tr.error td {
color: var(--error);
}
.error-text {
color: var(--error);
font-style: italic;
}
.rules-loading,
.rules-error {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
.rules-error-message {
background: rgba(255, 107, 107, 0.15);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+74
View File
@@ -0,0 +1,74 @@
/**
* Types for renaming rules UI
* These mirror the backend types for API communication
*/
export type ConditionOperator =
| 'EXISTS'
| 'NOT_EXISTS'
| 'EQUALS'
| 'NOT_EQUALS'
| 'CONTAINS'
| 'MATCHES';
export interface Condition {
type: ConditionOperator;
field: string;
value?: string | number | boolean;
}
export interface ConditionGroup {
operator: 'AND' | 'OR';
conditions: (Condition | ConditionGroup)[];
}
export interface RenamingRule {
id: string;
name: string;
description?: string;
enabled: boolean;
priority: number;
conditions: ConditionGroup;
template: string;
fallbackToUnsorted: boolean;
}
export interface RenamingConfig {
version: number;
rules: RenamingRule[];
defaultRule?: RenamingRule;
lastModified?: string;
isDefault?: boolean; // True if this is the default config (not user-customized)
}
export interface PreviewItem {
sourcePath: string;
currentVirtualPath: string;
newVirtualPath: string | null;
matchedRule: string | null;
error?: string;
}
export interface PreviewResponse {
previews: PreviewItem[];
total: number;
limited: boolean;
}
export interface TemplateVariable {
name: string;
path: string;
type: 'string' | 'number' | 'boolean';
description: string;
example?: string;
}
// Helper function to check if a condition is a group
export function isConditionGroup(condition: Condition | ConditionGroup): condition is ConditionGroup {
return 'operator' in condition && 'conditions' in condition;
}
// Generate a unique ID
export function generateId(): string {
return `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
+1864
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'packages/*'