initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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> - /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://<host>/webdav</code>
|
||||
</div>
|
||||
<div class="command">
|
||||
<strong>macOS:</strong>
|
||||
<code>Finder → Go → Connect to Server → http://<host>/webdav</code>
|
||||
</div>
|
||||
<div class="command">
|
||||
<strong>Linux:</strong>
|
||||
<code>sudo mount -t davfs http://<host>/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>
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+856
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+1790
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+1864
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
Reference in New Issue
Block a user