commit ff4fbf136fb9962e26fb36690f28e6007c027964 Author: Pierre Date: Sun Jan 11 17:26:07 2026 +0100 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2a1886 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1532ce0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..aae848e --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7004ff9 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..440a7d3 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..60ded9b --- /dev/null +++ b/README.md @@ -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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..461094a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..e962ec1 --- /dev/null +++ b/docker/nginx.conf @@ -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; + } + } +} diff --git a/docker/start-fuse-driver.sh b/docker/start-fuse-driver.sh new file mode 100644 index 0000000..4442445 --- /dev/null +++ b/docker/start-fuse-driver.sh @@ -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 diff --git a/docker/supervisord.conf b/docker/supervisord.conf new file mode 100644 index 0000000..82903d7 --- /dev/null +++ b/docker/supervisord.conf @@ -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 diff --git a/docker/welcome.html b/docker/welcome.html new file mode 100644 index 0000000..0174602 --- /dev/null +++ b/docker/welcome.html @@ -0,0 +1,518 @@ + + + + + + meta-fuse - Virtual Filesystem + + + + +
+ + + +
+

meta-fuse

+

Virtual Filesystem Service

+
+
+
+ Checking... +
+
+
+ +
+

What is meta-fuse?

+

+ meta-fuse 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. +

+
    +
  • Access files organized by title, year, series, etc.
  • +
  • Mount via FUSE (native) or WebDAV (network drive)
  • +
  • No file duplication - virtual paths point to real files
  • +
  • Automatically updates when new files are processed
  • +
+
+ + + +
+
+

⚙️ Setup & Configuration

+ ▼ +
+
+
+
+

🗂️ FUSE Mount (Docker)

+

Mount the virtual filesystem directly from the container. Add this volume to your docker-compose.yml to access the organized files on your host:

+
+ volumes:
  - /path/on/host:/mnt/virtual:rw,shared
+
+

The virtual filesystem is available inside the container at /mnt/virtual

+
+ +
+

🌐 WebDAV Network Drive

+

Mount the virtual filesystem as a network drive on any device:

+
+ Windows: + net use Z: http://<host>/webdav +
+
+ macOS: + Finder → Go → Connect to Server → http://<host>/webdav +
+
+ Linux: + sudo mount -t davfs http://<host>/webdav /mnt/vfs +
+
+
+
+
+ +
+

Part of the MetaMesh project

+

Discovers KV leader via shared volume and connects to Redis automatically

+
+
+ + + + diff --git a/docker/wsgidav.yaml b/docker/wsgidav.yaml new file mode 100644 index 0000000..8867c44 --- /dev/null +++ b/docker/wsgidav.yaml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..2e2d63d --- /dev/null +++ b/package.json @@ -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" +} diff --git a/packages/meta-fuse-core/package.json b/packages/meta-fuse-core/package.json new file mode 100644 index 0000000..a7e071f --- /dev/null +++ b/packages/meta-fuse-core/package.json @@ -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" + } +} diff --git a/packages/meta-fuse-core/pnpm-lock.yaml b/packages/meta-fuse-core/pnpm-lock.yaml new file mode 100644 index 0000000..4012080 --- /dev/null +++ b/packages/meta-fuse-core/pnpm-lock.yaml @@ -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/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@8.3.0': + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.1.1: + resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.6.2: + resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + hasBin: true + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@10.1.0: + resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslog@4.10.2: + resolution: {integrity: sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + +snapshots: + + '@fastify/accept-negotiator@2.0.1': {} + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/send@4.1.0': + 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/static@8.3.0': + 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/commands@1.4.0': {} + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@lukeed/ms@2.0.2': {} + + '@pinojs/redact@0.4.0': {} + + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + + abstract-logging@2.0.1: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + atomic-sleep@1.0.0: {} + + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.20.1 + + cluster-key-slot@1.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + denque@2.1.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + dotenv@16.6.1: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + escape-html@1.0.3: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.6.2: + 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 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + glob@11.1.0: + 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 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + + ioredis@5.8.2: + 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 + + ipaddr.js@2.3.0: {} + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + + lru-cache@11.2.4: {} + + mime@3.0.0: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minipass@7.1.2: {} + + ms@2.1.3: {} + + on-exit-leak-free@2.1.2: {} + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.4 + minipass: 7.1.2 + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@10.1.0: + 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 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + quick-format-unescaped@4.0.4: {} + + real-require@0.2.0: {} + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.3: {} + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + standard-as-callback@2.1.0: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + toad-cache@3.7.0: {} + + toidentifier@1.0.1: {} + + tslog@4.10.2: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 diff --git a/packages/meta-fuse-core/src/api/APIServer.ts b/packages/meta-fuse-core/src/api/APIServer.ts new file mode 100644 index 0000000..4426327 --- /dev/null +++ b/packages/meta-fuse-core/src/api/APIServer.ts @@ -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; + + 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 { + 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 { + await this.app.close(); + logger.info('API Server stopped'); + } + + // Route handlers + + private async handleHealth(_req: FastifyRequest, reply: FastifyReply): Promise { + reply.send({ + status: 'ok', + timestamp: new Date().toISOString(), + service: 'meta-fuse', + }); + } + + private async handleStats(_req: FastifyRequest, reply: FastifyReply): Promise { + const stats = this.vfs.getStats(); + reply.send(stats); + } + + private async handleReaddir( + req: FastifyRequest<{ Body: PathBody }>, + reply: FastifyReply + ): Promise { + 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 { + 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 { + 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 { + 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 = { + 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 { + 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 { + const files = this.vfs.getAllFiles(); + reply.send({ files }); + } + + private async handleDirectories(_req: FastifyRequest, reply: FastifyReply): Promise { + const directories = this.vfs.getAllDirectories(); + reply.send({ directories }); + } + + private async handleRefresh(_req: FastifyRequest, reply: FastifyReply): Promise { + await this.vfs.refresh(); + reply.send({ status: 'ok' }); + } + + private async handleServices(_req: FastifyRequest, reply: FastifyReply): Promise { + 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 { + 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 { + 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 { + 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 } }>, + reply: FastifyReply + ): Promise { + 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 { + reply.send({ variables: TEMPLATE_VARIABLES }); + } +} diff --git a/packages/meta-fuse-core/src/config/ConfigStorage.ts b/packages/meta-fuse-core/src/config/ConfigStorage.ts new file mode 100644 index 0000000..72741c0 --- /dev/null +++ b/packages/meta-fuse-core/src/config/ConfigStorage.ts @@ -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; + } +} diff --git a/packages/meta-fuse-core/src/index.ts b/packages/meta-fuse-core/src/index.ts new file mode 100644 index 0000000..0df7fef --- /dev/null +++ b/packages/meta-fuse-core/src/index.ts @@ -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 { + 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 => { + 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); +}); diff --git a/packages/meta-fuse-core/src/kv/IKVClient.ts b/packages/meta-fuse-core/src/kv/IKVClient.ts new file mode 100644 index 0000000..d4d5a7d --- /dev/null +++ b/packages/meta-fuse-core/src/kv/IKVClient.ts @@ -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; + + /** + * Count keys with a given prefix + */ + countKeysWithPrefix(prefix: string): Promise; + + /** + * Health check - verifies KV store is accessible + */ + health(): Promise; + + /** + * Get all key-value pairs with a given prefix + */ + getRange(prefix: string): Promise; + + /** + * Retrieve file metadata - reconstructs from individual property keys + */ + getMetadataFlat(hashId: string): Promise; + + /** + * Get all unique hash IDs (files) stored in KV + */ + getAllHashIds(): Promise; + + /** + * Subscribe to Redis pub/sub channel + */ + subscribe(channel: string, callback: (message: string) => void): Promise; + + /** + * Unsubscribe from Redis pub/sub channel + */ + unsubscribe(channel: string): Promise; + + /** + * Close connections and cleanup resources + */ + close(): Promise; +} + +/** + * 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; +} diff --git a/packages/meta-fuse-core/src/kv/KVManager.ts b/packages/meta-fuse-core/src/kv/KVManager.ts new file mode 100644 index 0000000..5cf69e5 --- /dev/null +++ b/packages/meta-fuse-core/src/kv/KVManager.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; + } +} diff --git a/packages/meta-fuse-core/src/kv/LeaderDiscovery.ts b/packages/meta-fuse-core/src/kv/LeaderDiscovery.ts new file mode 100644 index 0000000..f4e30c8 --- /dev/null +++ b/packages/meta-fuse-core/src/kv/LeaderDiscovery.ts @@ -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; + private lockFilePath: string; + private leaderInfo: LeaderLockInfo | null = null; + private healthCheckTimer: NodeJS.Timeout | null = null; + private fileWatcher: ReturnType | 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/meta-fuse-core/src/kv/RedisClient.ts b/packages/meta-fuse-core/src/kv/RedisClient.ts new file mode 100644 index 0000000..e696285 --- /dev/null +++ b/packages/meta-fuse-core/src/kv/RedisClient.ts @@ -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 { + private client: Redis | null = null; + private subscriber: Redis | null = null; + private config: Required; + private isConnected = false; + private reconnectTimer: NodeJS.Timeout | null = null; + private subscriptions: Map 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 { + 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((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 { + 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> { + const files = new Map(); + + 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 { + 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 { + 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 { + 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, 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 { + 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((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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/meta-fuse-core/src/kv/ServiceDiscovery.ts b/packages/meta-fuse-core/src/kv/ServiceDiscovery.ts new file mode 100644 index 0000000..211d467 --- /dev/null +++ b/packages/meta-fuse-core/src/kv/ServiceDiscovery.ts @@ -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; + + /** 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/meta-fuse-core/src/vfs/MetaDataToFolderStruct.ts b/packages/meta-fuse-core/src/vfs/MetaDataToFolderStruct.ts new file mode 100644 index 0000000..6f65ec9 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/MetaDataToFolderStruct.ts @@ -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): Map { + const virtualMap = new Map(); + + 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): Map { + 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); + } +} diff --git a/packages/meta-fuse-core/src/vfs/RenamingRule.ts b/packages/meta-fuse-core/src/vfs/RenamingRule.ts new file mode 100644 index 0000000..d77bfe7 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/RenamingRule.ts @@ -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, 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, '/'); +} diff --git a/packages/meta-fuse-core/src/vfs/VirtualFileSystem.ts b/packages/meta-fuse-core/src/vfs/VirtualFileSystem.ts new file mode 100644 index 0000000..71ea212 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/VirtualFileSystem.ts @@ -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; +} + +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 = new Map(); + private sourcePathToVirtualPath: Map = 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; + 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 { + 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 { + 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 { + 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)) { + const result = this.templateEngine.interpolate(rule.template, metadata as Record); + 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 + ); + 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 { + 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 { + 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)) { + const result = this.templateEngine.interpolate(rule.template, metadata as Record); + 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 + ); + 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)) { + return rule; + } + } + + return null; + } +} diff --git a/packages/meta-fuse-core/src/vfs/defaults/defaultRules.ts b/packages/meta-fuse-core/src/vfs/defaults/defaultRules.ts new file mode 100644 index 0000000..f8f3023 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/defaults/defaultRules.ts @@ -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; diff --git a/packages/meta-fuse-core/src/vfs/template/ConditionEvaluator.ts b/packages/meta-fuse-core/src/vfs/template/ConditionEvaluator.ts new file mode 100644 index 0000000..e79b10b --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/template/ConditionEvaluator.ts @@ -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): 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): 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): 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, + }; + } +} diff --git a/packages/meta-fuse-core/src/vfs/template/TemplateEngine.ts b/packages/meta-fuse-core/src/vfs/template/TemplateEngine.ts new file mode 100644 index 0000000..e97a862 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/template/TemplateEngine.ts @@ -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, 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)[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 | 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 = 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); + } +} diff --git a/packages/meta-fuse-core/src/vfs/template/index.ts b/packages/meta-fuse-core/src/vfs/template/index.ts new file mode 100644 index 0000000..3f08cb9 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/template/index.ts @@ -0,0 +1,6 @@ +/** + * Template module exports + */ + +export { TemplateEngine, getNestedValue } from './TemplateEngine.js'; +export { ConditionEvaluator } from './ConditionEvaluator.js'; diff --git a/packages/meta-fuse-core/src/vfs/types/RenamingRuleTypes.ts b/packages/meta-fuse-core/src/vfs/types/RenamingRuleTypes.ts new file mode 100644 index 0000000..4e28d18 --- /dev/null +++ b/packages/meta-fuse-core/src/vfs/types/RenamingRuleTypes.ts @@ -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; +} diff --git a/packages/meta-fuse-core/tsconfig.json b/packages/meta-fuse-core/tsconfig.json new file mode 100644 index 0000000..c83c533 --- /dev/null +++ b/packages/meta-fuse-core/tsconfig.json @@ -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"] +} diff --git a/packages/meta-fuse-driver/Cargo.lock b/packages/meta-fuse-driver/Cargo.lock new file mode 100644 index 0000000..2decb88 --- /dev/null +++ b/packages/meta-fuse-driver/Cargo.lock @@ -0,0 +1,1790 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fuser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e697f6f62c20b6fad1ba0f84ae909f25971cf16e735273524e3977c94604cf8" +dependencies = [ + "libc", + "log", + "memchr", + "page_size", + "pkg-config", + "smallvec", + "zerocopy", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "meta-fuse-driver" +version = "1.0.0" +dependencies = [ + "base64", + "env_logger", + "fuser", + "libc", + "log", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/packages/meta-fuse-driver/Cargo.toml b/packages/meta-fuse-driver/Cargo.toml new file mode 100644 index 0000000..9a28d23 --- /dev/null +++ b/packages/meta-fuse-driver/Cargo.toml @@ -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" diff --git a/packages/meta-fuse-driver/src/api_client.rs b/packages/meta-fuse-driver/src/api_client.rs new file mode 100644 index 0000000..d64cdaf --- /dev/null +++ b/packages/meta-fuse-driver/src/api_client.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, + pub content: Option, + #[serde(rename = "contentEncoding")] + pub content_encoding: Option, + pub size: u64, +} + +#[derive(Debug, Deserialize)] +pub struct ReaddirResponse { + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +struct PathRequest { + path: String, +} + +impl ApiClient { + pub fn new(base_url: String) -> Result> { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + Ok(ApiClient { base_url, client }) + } + + pub fn readdir(&self, path: &str) -> Result, Box> { + 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> { + 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> { + 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 = 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> { + 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> { + let url = format!("{}/api/fuse/health", self.base_url); + let response = self.client.get(&url).send()?; + Ok(response.status().is_success()) + } +} diff --git a/packages/meta-fuse-driver/src/main.rs b/packages/meta-fuse-driver/src/main.rs new file mode 100644 index 0000000..4d81f3d --- /dev/null +++ b/packages/meta-fuse-driver/src/main.rs @@ -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, + 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, +} + +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, + ino_to_path: HashMap, + 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>, + dir_cache: Arc>>, + attr_cache: Arc>>, + api_health: Arc>, + 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> { + 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> { + 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) { + 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 { + 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, Box> { + 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, + 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 = std::env::args().collect(); + + if args.len() < 2 || args.len() > 5 { + eprintln!("Usage: {} [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); + } + } +} diff --git a/packages/meta-fuse-ui/index.html b/packages/meta-fuse-ui/index.html new file mode 100644 index 0000000..39dac01 --- /dev/null +++ b/packages/meta-fuse-ui/index.html @@ -0,0 +1,12 @@ + + + + + + meta-fuse Dashboard + + +
+ + + diff --git a/packages/meta-fuse-ui/package.json b/packages/meta-fuse-ui/package.json new file mode 100644 index 0000000..9805841 --- /dev/null +++ b/packages/meta-fuse-ui/package.json @@ -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" + } +} diff --git a/packages/meta-fuse-ui/src/App.tsx b/packages/meta-fuse-ui/src/App.tsx new file mode 100644 index 0000000..1aca166 --- /dev/null +++ b/packages/meta-fuse-ui/src/App.tsx @@ -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(null); + const [health, setHealth] = useState(null); + const [directories, setDirectories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
Loading...
+
+ ); + } + + const isConnected = health?.status === 'ok' && stats?.redisConnected; + + return ( +
+
+
+

meta-fuse

+

Virtual filesystem service

+
+
+ + {isConnected ? 'Connected' : 'Disconnected'} +
+
+ + {error &&
{error}
} + +
+

VFS Control

+ +
+
+
+ Files +
+
{stats?.fileCount ?? 0}
+
Total files in VFS
+
+ +
+
+ Directories +
+
{stats?.directoryCount ?? 0}
+
Total directories
+
+ +
+
+ Total Size +
+
{formatBytes(stats?.totalSize ?? 0)}
+
Combined file size
+
+ +
+
+ Last Refresh + +
+
+ {formatTime(stats?.lastRefresh ?? null)} +
+
From Redis metadata
+
+
+ +
+
+ Connection Status +
+
+
+
API Status
+
{health?.status ?? 'unknown'}
+
+
+
Redis
+
+ {stats?.redisConnected ? 'Connected' : 'Disconnected'} +
+
+
+
Service
+
{health?.service ?? 'unknown'}
+
+
+
Timestamp
+
{formatTime(health?.timestamp ?? null)}
+
+
+
+ +
+
setShowRules(!showRules)} + > +

Renaming Rules

+ {showRules ? '-' : '+'} +
+ {showRules && ( + + )} +
+
+ +
+

Virtual Directories

+
+ {directories.length === 0 ? ( +
+ + No directories found. Waiting for metadata from meta-sort... + +
+ ) : ( + directories.slice(0, 20).map((dir) => ( +
+ 📁 + {dir} +
+ )) + )} + {directories.length > 20 && ( +
+ + ... and {directories.length - 20} more directories + +
+ )} +
+
+
+ ); +} + +export default App; diff --git a/packages/meta-fuse-ui/src/components/RulesEditor.tsx b/packages/meta-fuse-ui/src/components/RulesEditor.tsx new file mode 100644 index 0000000..580f664 --- /dev/null +++ b/packages/meta-fuse-ui/src/components/RulesEditor.tsx @@ -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 ( +
+ + + + + {needsValue && ( + onChange({ ...condition, value: e.target.value })} + placeholder="Value" + className="condition-value" + /> + )} + + +
+ ); +} + +// 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 ( +
+
+ + +
+ +
+ {conditions.conditions.length === 0 && ( +
No conditions - rule will match all files
+ )} + {conditions.conditions.map((cond, index) => ( + !isConditionGroup(cond) && ( + updateCondition(index, updated)} + onDelete={() => deleteCondition(index)} + /> + ) + ))} +
+ + +
+ ); +} + +// 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 ( +
+
+ + +
+ +