From 2baa9a6925c08724c01d2b4195a93848247137ca Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 8 Feb 2026 16:29:39 +0100 Subject: [PATCH] initial commit of meta-dup --- README.md | 134 + package.json | 15 + packages/meta-dup-core/package.json | 28 + packages/meta-dup-core/src/DuplicateIndex.ts | 334 +++ packages/meta-dup-core/src/api/APIServer.ts | 228 ++ packages/meta-dup-core/src/index.ts | 268 ++ packages/meta-dup-core/src/kv/IKVClient.ts | 109 + packages/meta-dup-core/src/kv/KVManager.ts | 359 +++ packages/meta-dup-core/src/kv/LeaderClient.ts | 279 +++ packages/meta-dup-core/src/kv/RedisClient.ts | 733 ++++++ .../meta-dup-core/src/kv/ServiceDiscovery.ts | 331 +++ packages/meta-dup-core/tsconfig.json | 20 + .../meta-dup-ui/dist/assets/index-GT6exvk4.js | 660 +++++ .../dist/assets/index-GT6exvk4.js.map | 1 + packages/meta-dup-ui/dist/index.html | 13 + packages/meta-dup-ui/index.html | 13 + packages/meta-dup-ui/package.json | 24 + packages/meta-dup-ui/src/App.tsx | 653 +++++ .../meta-dup-ui/src/components/ServiceNav.tsx | 170 ++ packages/meta-dup-ui/src/main.tsx | 9 + packages/meta-dup-ui/src/types/index.ts | 48 + packages/meta-dup-ui/src/utils/format.ts | 28 + packages/meta-dup-ui/tsconfig.json | 23 + packages/meta-dup-ui/tsconfig.node.json | 10 + packages/meta-dup-ui/vite.config.ts | 23 + pnpm-lock.yaml | 2220 +++++++++++++++++ pnpm-workspace.yaml | 2 + 27 files changed, 6735 insertions(+) create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/meta-dup-core/package.json create mode 100644 packages/meta-dup-core/src/DuplicateIndex.ts create mode 100644 packages/meta-dup-core/src/api/APIServer.ts create mode 100644 packages/meta-dup-core/src/index.ts create mode 100644 packages/meta-dup-core/src/kv/IKVClient.ts create mode 100644 packages/meta-dup-core/src/kv/KVManager.ts create mode 100644 packages/meta-dup-core/src/kv/LeaderClient.ts create mode 100644 packages/meta-dup-core/src/kv/RedisClient.ts create mode 100644 packages/meta-dup-core/src/kv/ServiceDiscovery.ts create mode 100644 packages/meta-dup-core/tsconfig.json create mode 100644 packages/meta-dup-ui/dist/assets/index-GT6exvk4.js create mode 100644 packages/meta-dup-ui/dist/assets/index-GT6exvk4.js.map create mode 100644 packages/meta-dup-ui/dist/index.html create mode 100644 packages/meta-dup-ui/index.html create mode 100644 packages/meta-dup-ui/package.json create mode 100644 packages/meta-dup-ui/src/App.tsx create mode 100644 packages/meta-dup-ui/src/components/ServiceNav.tsx create mode 100644 packages/meta-dup-ui/src/main.tsx create mode 100644 packages/meta-dup-ui/src/types/index.ts create mode 100644 packages/meta-dup-ui/src/utils/format.ts create mode 100644 packages/meta-dup-ui/tsconfig.json create mode 100644 packages/meta-dup-ui/tsconfig.node.json create mode 100644 packages/meta-dup-ui/vite.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..778ccd6 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# meta-dup + +Standalone duplicate detection service for MetaMesh v2. Detects duplicate files by: +- **Hash (SHA-256)**: Files with identical content +- **Title**: Files with the same parsed title (from filename patterns) + +## Architecture + +meta-dup is a read-only service that: +1. Subscribes to Redis Streams (`meta-sort:events`) for real-time updates +2. Maintains an in-memory index of duplicates +3. Provides REST API and web dashboard + +``` +meta-sort ──► Redis Streams ──► meta-dup ──► Dashboard (port 8183) + (events) (consumer) +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Health check with stats | +| `/api/duplicates` | GET | All duplicates (hash + title) | +| `/api/duplicates/hash` | GET | Hash duplicates only | +| `/api/duplicates/title` | GET | Title duplicates only | +| `/api/duplicates/stats` | GET | Duplicate statistics | +| `/api/duplicates/rebuild` | POST | Force rebuild from Redis | + +## Response Format + +```json +{ + "hashDuplicates": [ + { + "key": "sha256-hash", + "files": [ + { "hashId": "...", "filePath": "/files/...", "sizeByte": 123456 } + ] + } + ], + "titleDuplicates": [...], + "stats": { + "hashGroupCount": 5, + "titleGroupCount": 3, + "totalFilesTracked": 100 + }, + "computedAt": 1234567890 +} +``` + +## Development + +### Build +```bash +cd packages/meta-dup +pnpm install +pnpm build +``` + +### Run (in Docker dev environment) +```bash +cd dev +./start.sh # Starts all services including meta-dup +``` + +### Rebuild +```bash +cd dev +./reload-meta-dup.sh +``` + +### Tests +```bash +docker exec meta-test-runner /app/test/test.sh dup +``` + +## Configuration + +meta-dup uses service discovery via `/meta-core/locks/kv-leader.info` to find the Redis instance. No additional configuration required. + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `BASE_URL` | `http://localhost:8183` | External URL for service | +| `SERVICE_NAME` | `meta-dup` | Service identifier | +| `API_PORT` | `3000` | Internal API port (nginx proxies to 80) | + +## Package Structure + +``` +packages/meta-dup/ +├── package.json # Workspace root +├── pnpm-workspace.yaml +└── packages/ + ├── meta-dup-core/ # Backend service + │ ├── src/ + │ │ ├── index.ts # Entry point + │ │ ├── DuplicateIndex.ts # Core duplicate detection + │ │ ├── api/ + │ │ │ └── APIServer.ts # Fastify REST API + │ │ └── kv/ # Redis client & leader discovery + │ └── package.json + └── meta-dup-ui/ # React dashboard + ├── src/ + │ ├── App.tsx + │ └── main.tsx + └── package.json +``` + +## How Duplicate Detection Works + +### Hash Duplicates +Files with the same `cid_sha2-256` (full file hash) are exact content duplicates. + +### Title Duplicates +Files parsed to the same title (e.g., "Movie Name (2020)") from their filename patterns. Useful for finding different quality versions of the same content. + +## Redis Streams Integration + +meta-dup subscribes to `meta-sort:events` stream with consumer group `meta-dup-consumer`: + +``` +Event types: +- batch: File batch processed (add/update duplicates) +- reset: Full index rebuild required +``` + +On startup, meta-dup: +1. Creates/joins consumer group +2. Processes any pending entries +3. Rebuilds full index from Redis `file:*` keys +4. Starts listening for new events diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b32cab --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "meta-dup", + "version": "1.0.0", + "private": true, + "description": "Standalone duplicate detection service for MetaMesh", + "scripts": { + "build": "pnpm -r build", + "dev": "pnpm -r --parallel dev", + "start": "pnpm --filter @meta-dup/core start", + "start:ui": "pnpm --filter @meta-dup/ui dev", + "test": "pnpm --filter @meta-dup/core test", + "lint": "pnpm -r lint" + }, + "license": "MIT" +} diff --git a/packages/meta-dup-core/package.json b/packages/meta-dup-core/package.json new file mode 100644 index 0000000..8dcfdbb --- /dev/null +++ b/packages/meta-dup-core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@meta-dup/core", + "version": "1.0.0", + "description": "Core duplicate detection service for meta-dup", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "test": "vitest run", + "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", + "vitest": "^1.0.0" + } +} diff --git a/packages/meta-dup-core/src/DuplicateIndex.ts b/packages/meta-dup-core/src/DuplicateIndex.ts new file mode 100644 index 0000000..dfc98c3 --- /dev/null +++ b/packages/meta-dup-core/src/DuplicateIndex.ts @@ -0,0 +1,334 @@ +/** + * DuplicateIndex - Core duplicate detection logic + * + * Maintains in-memory indexes for: + * - Hash duplicates: files with the same SHA-256 hash (exact copies) + * - Title duplicates: files with the same parsed title (different releases) + * + * Updates in real-time via Redis Streams events from meta-sort. + */ + +import { Logger } from 'tslog'; +import type { FileMetadata, RedisClient } from './kv/RedisClient.js'; + +const logger = new Logger({ name: 'DuplicateIndex' }); + +/** + * Information about a file in a duplicate group + */ +export interface FileInfo { + hashId: string; + filePath: string; + title?: string; + sha256?: string; + sizeByte?: number; + year?: number; + season?: number; + episode?: number; + fileType?: string; +} + +/** + * A group of duplicate files sharing the same key (hash or title) + */ +export interface DuplicateGroup { + key: string; // hash or title + files: FileInfo[]; // Array of file info +} + +/** + * Statistics about duplicate detection + */ +export interface DuplicateStats { + hashGroupCount: number; + hashFileCount: number; + titleGroupCount: number; + titleFileCount: number; + totalFilesTracked: number; + lastUpdated: string; +} + +/** + * Full duplicate data response + */ +export interface DuplicateData { + hashDuplicates: DuplicateGroup[]; + titleDuplicates: DuplicateGroup[]; + stats: DuplicateStats; + computedAt: string; +} + +export class DuplicateIndex { + // Primary indexes: key -> Set of hashIds + private byHash: Map> = new Map(); + private byTitle: Map> = new Map(); + + // File info storage: hashId -> FileInfo + private fileInfo: Map = new Map(); + + // Track when last updated + private lastUpdated: Date = new Date(); + + constructor() { + logger.info('DuplicateIndex initialized'); + } + + /** + * Add or update a file in the index + */ + addFile(hashId: string, metadata: FileMetadata): void { + // First remove any existing entry to ensure clean state + this.removeFile(hashId); + + // Extract relevant info + const info: FileInfo = { + hashId, + filePath: metadata.originalPath || metadata.sourcePath, + title: this.extractTitle(metadata), + sha256: metadata.sha256, + sizeByte: metadata.size, + year: metadata.year || metadata.movieYear, + season: metadata.season, + episode: metadata.episode, + fileType: metadata.fileType, + }; + + // Store file info + this.fileInfo.set(hashId, info); + + // Index by SHA-256 hash (if available) + if (info.sha256) { + const hashSet = this.byHash.get(info.sha256) ?? new Set(); + hashSet.add(hashId); + this.byHash.set(info.sha256, hashSet); + } + + // Index by title (if available) + const titleKey = this.getTitleKey(metadata); + if (titleKey) { + const titleSet = this.byTitle.get(titleKey) ?? new Set(); + titleSet.add(hashId); + this.byTitle.set(titleKey, titleSet); + } + + this.lastUpdated = new Date(); + } + + /** + * Remove a file from the index + */ + removeFile(hashId: string): void { + const info = this.fileInfo.get(hashId); + if (!info) return; + + // Remove from hash index + if (info.sha256) { + const hashSet = this.byHash.get(info.sha256); + if (hashSet) { + hashSet.delete(hashId); + if (hashSet.size === 0) { + this.byHash.delete(info.sha256); + } + } + } + + // Remove from title index + if (info.title) { + const titleKey = info.title.toLowerCase(); + const titleSet = this.byTitle.get(titleKey); + if (titleSet) { + titleSet.delete(hashId); + if (titleSet.size === 0) { + this.byTitle.delete(titleKey); + } + } + } + + // Remove file info + this.fileInfo.delete(hashId); + this.lastUpdated = new Date(); + } + + /** + * Extract title from metadata for duplicate detection + */ + private extractTitle(metadata: FileMetadata): string | undefined { + // Prefer parsed title over filename + if (metadata.title) { + return metadata.title; + } + if (metadata.titles?.eng) { + return metadata.titles.eng; + } + if (metadata.originalTitle) { + return metadata.originalTitle; + } + return undefined; + } + + /** + * Get normalized title key for indexing + * Includes year, season, episode for more precise matching + */ + private getTitleKey(metadata: FileMetadata): string | null { + const title = this.extractTitle(metadata); + if (!title) return null; + + // Build a normalized key + let key = title.toLowerCase().trim(); + + // Add year if available (for movies) + if (metadata.year || metadata.movieYear) { + key += `:${metadata.year || metadata.movieYear}`; + } + + // Add season/episode if available (for TV shows) + if (metadata.season !== undefined) { + key += `:s${metadata.season}`; + } + if (metadata.episode !== undefined) { + key += `:e${metadata.episode}`; + } + + return key; + } + + /** + * Get all hash-based duplicates (files with same SHA-256) + */ + getHashDuplicates(): DuplicateGroup[] { + const groups: DuplicateGroup[] = []; + + for (const [hash, hashIds] of this.byHash) { + if (hashIds.size > 1) { + const files = this.getFileInfos(hashIds); + groups.push({ + key: hash, + files: files.sort((a, b) => a.filePath.localeCompare(b.filePath)), + }); + } + } + + // Sort by number of duplicates (descending) + return groups.sort((a, b) => b.files.length - a.files.length); + } + + /** + * Get all title-based duplicates (files with same parsed title) + */ + getTitleDuplicates(): DuplicateGroup[] { + const groups: DuplicateGroup[] = []; + + for (const [title, hashIds] of this.byTitle) { + if (hashIds.size > 1) { + const files = this.getFileInfos(hashIds); + groups.push({ + key: title, + files: files.sort((a, b) => a.filePath.localeCompare(b.filePath)), + }); + } + } + + // Sort by number of duplicates (descending) + return groups.sort((a, b) => b.files.length - a.files.length); + } + + /** + * Get FileInfo for a set of hashIds + */ + private getFileInfos(hashIds: Set): FileInfo[] { + const files: FileInfo[] = []; + for (const hashId of hashIds) { + const info = this.fileInfo.get(hashId); + if (info) { + files.push(info); + } + } + return files; + } + + /** + * Get statistics about duplicates + */ + getStats(): DuplicateStats { + const hashDuplicates = this.getHashDuplicates(); + const titleDuplicates = this.getTitleDuplicates(); + + return { + hashGroupCount: hashDuplicates.length, + hashFileCount: hashDuplicates.reduce((sum, g) => sum + g.files.length, 0), + titleGroupCount: titleDuplicates.length, + titleFileCount: titleDuplicates.reduce((sum, g) => sum + g.files.length, 0), + totalFilesTracked: this.fileInfo.size, + lastUpdated: this.lastUpdated.toISOString(), + }; + } + + /** + * Get full duplicate data + */ + getDuplicateData(): DuplicateData { + return { + hashDuplicates: this.getHashDuplicates(), + titleDuplicates: this.getTitleDuplicates(), + stats: this.getStats(), + computedAt: new Date().toISOString(), + }; + } + + /** + * Clear all indexes + */ + clear(): void { + this.byHash.clear(); + this.byTitle.clear(); + this.fileInfo.clear(); + this.lastUpdated = new Date(); + logger.info('DuplicateIndex cleared'); + } + + /** + * Rebuild index from all files in Redis + */ + async rebuildFromRedis(redisClient: RedisClient): Promise { + logger.info('Rebuilding DuplicateIndex from Redis...'); + + // Clear existing data + this.clear(); + + // Load all files from Redis + const files = await redisClient.getAllFiles(); + + logger.info(`Loading ${files.size} files into DuplicateIndex...`); + + // Add each file to the index + for (const [hashId, metadata] of files) { + this.addFile(hashId, metadata); + } + + const stats = this.getStats(); + logger.info(`DuplicateIndex rebuilt: ${stats.totalFilesTracked} files, ` + + `${stats.hashGroupCount} hash duplicates, ${stats.titleGroupCount} title duplicates`); + } + + /** + * Handle file update from Redis + */ + async onFileUpdate( + hashId: string, + action: 'add' | 'update' | 'remove', + redisClient: RedisClient + ): Promise { + if (action === 'remove') { + this.removeFile(hashId); + logger.debug(`Removed file ${hashId} from index`); + } else { + // add or update - fetch latest metadata from Redis + const metadata = await redisClient.getFileByHashId(hashId); + if (metadata) { + this.addFile(hashId, metadata); + logger.debug(`${action === 'add' ? 'Added' : 'Updated'} file ${hashId} in index`); + } + } + } +} diff --git a/packages/meta-dup-core/src/api/APIServer.ts b/packages/meta-dup-core/src/api/APIServer.ts new file mode 100644 index 0000000..8672f35 --- /dev/null +++ b/packages/meta-dup-core/src/api/APIServer.ts @@ -0,0 +1,228 @@ +/** + * API Server for meta-dup + * + * REST API endpoints: + * - GET /health - Health check + * - GET /api/duplicates - Get all duplicates + * - GET /api/duplicates/hash - Hash duplicates only + * - GET /api/duplicates/title - Title duplicates only + * - GET /api/duplicates/stats - Statistics + * - POST /api/duplicates/rebuild - Force rebuild from Redis + */ + +import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import cors from '@fastify/cors'; +import fastifyStatic from '@fastify/static'; +import { Logger } from 'tslog'; +import { DuplicateIndex } from '../DuplicateIndex.js'; +import { KVManager } from '../kv/KVManager.js'; +import { RedisClient } from '../kv/RedisClient.js'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { existsSync } from 'fs'; + +const logger = new Logger({ name: 'APIServer' }); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export interface APIServerConfig { + port: number; + host: string; +} + +export class APIServer { + private app: FastifyInstance; + private config: APIServerConfig; + private duplicateIndex: DuplicateIndex; + private kvManager: KVManager; + private redisClient: RedisClient | null = null; + + constructor( + duplicateIndex: DuplicateIndex, + config: APIServerConfig, + kvManager: KVManager + ) { + this.duplicateIndex = duplicateIndex; + this.config = config; + this.kvManager = kvManager; + + this.app = Fastify({ + logger: false, + }); + + this.setupMiddleware(); + this.setupRoutes(); + } + + /** + * Set Redis client (called after connection established) + */ + setRedisClient(client: RedisClient): void { + this.redisClient = client; + } + + /** + * Setup middleware + */ + private setupMiddleware(): void { + // Enable CORS + this.app.register(cors, { + origin: true, + credentials: true, + }); + + // Serve static UI files if they exist + const uiDistPath = join(__dirname, '../../..', 'meta-dup-ui', 'dist'); + if (existsSync(uiDistPath)) { + this.app.register(fastifyStatic, { + root: uiDistPath, + prefix: '/', + }); + logger.info(`Serving UI from ${uiDistPath}`); + } else { + logger.info('UI dist not found, serving API only'); + } + } + + /** + * Setup routes + */ + private setupRoutes(): void { + // Health check + this.app.get('/health', async (request: FastifyRequest, reply: FastifyReply) => { + const isHealthy = await this.kvManager.isHealthy(); + const stats = this.duplicateIndex.getStats(); + + return { + status: isHealthy ? 'ok' : 'degraded', + service: 'meta-dup', + redis: isHealthy, + stats: { + filesTracked: stats.totalFilesTracked, + hashDuplicates: stats.hashGroupCount, + titleDuplicates: stats.titleGroupCount, + }, + timestamp: new Date().toISOString(), + }; + }); + + // Get all duplicates + this.app.get('/api/duplicates', async (request: FastifyRequest, reply: FastifyReply) => { + return this.duplicateIndex.getDuplicateData(); + }); + + // Get hash duplicates only + this.app.get('/api/duplicates/hash', async (request: FastifyRequest, reply: FastifyReply) => { + return { + duplicates: this.duplicateIndex.getHashDuplicates(), + computedAt: new Date().toISOString(), + }; + }); + + // Get title duplicates only + this.app.get('/api/duplicates/title', async (request: FastifyRequest, reply: FastifyReply) => { + return { + duplicates: this.duplicateIndex.getTitleDuplicates(), + computedAt: new Date().toISOString(), + }; + }); + + // Get statistics + this.app.get('/api/duplicates/stats', async (request: FastifyRequest, reply: FastifyReply) => { + return this.duplicateIndex.getStats(); + }); + + // Force rebuild from Redis + this.app.post('/api/duplicates/rebuild', async (request: FastifyRequest, reply: FastifyReply) => { + if (!this.redisClient) { + reply.status(503); + return { error: 'Redis not connected' }; + } + + logger.info('Manual rebuild requested'); + const startTime = Date.now(); + await this.duplicateIndex.rebuildFromRedis(this.redisClient); + const elapsed = Date.now() - startTime; + + return { + success: true, + message: 'Index rebuilt from Redis', + elapsedMs: elapsed, + stats: this.duplicateIndex.getStats(), + }; + }); + + // Service discovery (for dashboard navigation) + this.app.get('/api/services', async (request: FastifyRequest, reply: FastifyReply) => { + const services: Array<{ + name: string; + url: string; + api: string; + status: string; + role?: string; + }> = []; + + try { + const serviceDiscovery = this.kvManager.getServiceDiscovery(); + if (serviceDiscovery) { + const allServices = await serviceDiscovery.discoverAllServices(); + + for (const svc of allServices) { + services.push({ + name: svc.name || 'Unknown', + url: svc.baseUrl || '', + api: svc.baseUrl || '', + status: svc.status || 'unknown', + role: svc.role, + }); + } + } + } catch (error: any) { + logger.error('Error discovering services:', error); + } + + return { + services, + current: 'meta-dup', + }; + }); + + // Catch-all for SPA routing (serve index.html for non-API routes) + this.app.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => { + const uiDistPath = join(__dirname, '../../..', 'meta-dup-ui', 'dist'); + const indexPath = join(uiDistPath, 'index.html'); + + if (existsSync(indexPath) && !request.url.startsWith('/api/')) { + return reply.sendFile('index.html'); + } + + reply.status(404); + return { error: 'Not found' }; + }); + } + + /** + * 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 ${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'); + } +} diff --git a/packages/meta-dup-core/src/index.ts b/packages/meta-dup-core/src/index.ts new file mode 100644 index 0000000..7223e60 --- /dev/null +++ b/packages/meta-dup-core/src/index.ts @@ -0,0 +1,268 @@ +/** + * meta-dup-core entry point + * + * Standalone duplicate detection service that: + * - Consumes Redis Streams events from meta-sort + * - Detects duplicates by hash (SHA-256) and by title + * - Provides REST API and UI dashboard + * + * Uses Redis Streams for reliable event delivery from meta-sort. + */ + +import 'dotenv/config'; +import { Logger } from 'tslog'; +import { KVManager } from './kv/KVManager.js'; +import { RedisClient, StreamMessage } from './kv/RedisClient.js'; +import { DuplicateIndex } from './DuplicateIndex.js'; +import { APIServer } from './api/APIServer.js'; + +const logger = new Logger({ name: 'meta-dup' }); + +// Redis Streams for reliable event delivery from meta-core +const EVENTS_STREAM = 'file:events'; +const CONSUMER_GROUP = 'meta-dup-consumer'; + +interface BatchUpdatePayload { + timestamp: number; + changes: Array<{ + action: 'add' | 'update' | 'remove'; + hashId: string; + }>; +} + +interface ResetPayload { + timestamp: number; + action: 'reset'; +} + +/** + * Handle a stream message from Redis Streams + * Routes messages by type to appropriate handlers + * + * Supports both: + * - Direct file events from meta-core (add/change/delete/rename) + * - Legacy batch events (for backward compatibility) + */ +async function handleStreamMessage( + message: StreamMessage, + duplicateIndex: DuplicateIndex, + redisClient: RedisClient +): Promise { + try { + switch (message.type) { + // Direct file events from meta-core + case 'add': + case 'change': { + // File added or changed - look up by path and update index + // Note: We need to wait for meta-sort to process the file first + // For now, just log - the duplicate index is rebuilt on startup + logger.debug(`File ${message.type}: ${message.path}`); + break; + } + + case 'delete': { + // File deleted - would need to find by path and remove + logger.debug(`File deleted: ${message.path}`); + break; + } + + case 'rename': { + // File renamed - treated as delete + add + logger.debug(`File renamed: ${message.oldPath} -> ${message.path}`); + break; + } + + // Legacy batch events (for backward compatibility) + case 'batch': { + if (!message.payload) break; + const batch: BatchUpdatePayload = JSON.parse(message.payload); + logger.debug(`Processing batch update: ${batch.changes.length} changes`); + + for (const change of batch.changes) { + await duplicateIndex.onFileUpdate(change.hashId, change.action, redisClient); + } + break; + } + + case 'reset': { + if (!message.payload) break; + const reset: ResetPayload = JSON.parse(message.payload); + logger.info(`Processing reset event: ${reset.action}`); + await duplicateIndex.rebuildFromRedis(redisClient); + break; + } + + case 'plugin:complete': { + // Plugin completion events - update the file's metadata + // This is when we get full hash for duplicate detection + if (message.payload) { + try { + const payload = JSON.parse(message.payload); + if (payload.fileHash && payload.pluginId === 'fullhash') { + // Full hash plugin completed - this is when we can detect duplicates + await duplicateIndex.onFileUpdate(payload.fileHash, 'update', redisClient); + } + } catch { + // Ignore parse errors + } + } + break; + } + + default: + logger.warn(`Unknown stream message type: ${message.type}`); + } + } catch (error: any) { + logger.error(`Error processing stream message ${message.id}:`, error.message); + throw error; // Re-throw to prevent ACK + } +} + +async function main(): Promise { + logger.info('Starting meta-dup...'); + 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 ?? '', + apiPort: parseInt(process.env.API_PORT ?? '3000', 10), + apiHost: process.env.API_HOST ?? '0.0.0.0', + baseUrl: process.env.BASE_URL, + serviceVersion: process.env.SERVICE_VERSION ?? '1.0.0', + }; + + logger.info(`Config: META_CORE_PATH=${config.metaCorePath}`); + + // Initialize KV Manager (handles leader discovery) + const kvManager = new KVManager({ + metaCorePath: config.metaCorePath, + filesPath: config.filesPath, + serviceName: 'meta-dup', + apiPort: config.apiPort, + baseUrl: config.baseUrl, + redisPrefix: config.redisPrefix, + }); + + let redisClient: RedisClient | null = null; + let duplicateIndex: DuplicateIndex | null = null; + let apiServer: APIServer | null = null; + + // Graceful shutdown + const shutdown = async (signal: string): Promise => { + logger.info(`Received ${signal}, shutting down...`); + + try { + // Stop stream consumer first + if (redisClient) { + redisClient.stopStreamConsumer(); + } + if (apiServer) await apiServer.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 DuplicateIndex...'); + + try { + // Get Redis client from KV manager + redisClient = kvManager.getClient(); + + if (!redisClient) { + logger.error('Redis client not available'); + return; + } + + // Initialize DuplicateIndex + duplicateIndex = new DuplicateIndex(); + + // Rebuild index from all file:* keys in Redis + logger.info('Building initial duplicate index from Redis...'); + await duplicateIndex.rebuildFromRedis(redisClient); + + // Initialize Redis Streams consumer + logger.info(`Initializing stream consumer for ${EVENTS_STREAM}...`); + await redisClient.initStreamConsumer(EVENTS_STREAM, CONSUMER_GROUP); + + // Process any pending entries from crashed consumers + await redisClient.processPendingEntries( + EVENTS_STREAM, + CONSUMER_GROUP, + 30000, // 30 second idle threshold + async (message: StreamMessage) => { + await handleStreamMessage(message, duplicateIndex!, redisClient!); + } + ); + + // Start stream consumer in background + logger.info('Starting stream consumer...'); + redisClient.startStreamConsumer( + EVENTS_STREAM, + CONSUMER_GROUP, + async (message: StreamMessage) => { + await handleStreamMessage(message, duplicateIndex!, redisClient!); + }, + 5000 // 5 second block timeout + ).catch(error => { + logger.error('Stream consumer error:', error); + }); + + // Initialize API server + apiServer = new APIServer(duplicateIndex, { + port: config.apiPort, + host: config.apiHost, + }, kvManager); + + // Set Redis client for rebuild endpoint + apiServer.setRedisClient(redisClient); + + // Start API server + logger.info('Starting API server...'); + await apiServer.start(); + + logger.info('meta-dup is ready!'); + logger.info(`API: http://${config.apiHost}:${config.apiPort}`); + + // Log initial stats + const stats = duplicateIndex.getStats(); + logger.info(`Duplicates: ${stats.hashGroupCount} hash groups, ${stats.titleGroupCount} title groups`); + logger.info(`Total files tracked: ${stats.totalFilesTracked}`); + } catch (error) { + logger.error('Failed to initialize after KV ready:', error); + } + }); + + // Handle disconnect event + kvManager.onDisconnect(() => { + logger.warn('Redis disconnected, duplicate index 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-dup:', error); + process.exit(1); + } +} + +main().catch((error) => { + logger.error('Fatal error:', error); + process.exit(1); +}); diff --git a/packages/meta-dup-core/src/kv/IKVClient.ts b/packages/meta-dup-core/src/kv/IKVClient.ts new file mode 100644 index 0000000..b617735 --- /dev/null +++ b/packages/meta-dup-core/src/kv/IKVClient.ts @@ -0,0 +1,109 @@ +/** + * KV Client Interface - Read-only abstraction for meta-dup + * + * meta-dup 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 (kv-leader.info) + * Written by meta-core leader, read by other services + */ +export interface LeaderLockInfo { + /** Hostname of the leader */ + hostname: string; + + /** Base URL for the leader service (e.g., http://localhost:8180) */ + baseUrl: string; + + /** meta-core API URL (port 9000) */ + apiUrl: string; + + /** Redis connection URL (e.g., redis://10.0.1.50:6379) */ + redisUrl: string; + + /** WebDAV URL for file access */ + webdavUrl: string; + + /** Timestamp when leadership was acquired */ + timestamp: number; + + /** Process ID of the leader */ + pid: number; +} + +/** + * Service registration info (simplified) + * Full URLs are obtained via the /urls API endpoint + */ +export interface ServiceInfo { + /** Service name (e.g., 'meta-sort', 'meta-fuse') */ + name: string; + + /** Hostname */ + hostname: string; + + /** Base URL for the service */ + baseUrl: string; + + /** Current status */ + status: 'running' | 'stale' | 'stopped'; + + /** Last heartbeat timestamp (ISO string) */ + lastHeartbeat: string; + + /** Role for meta-core instances: 'leader', 'follower', or undefined for other services */ + role?: string; +} diff --git a/packages/meta-dup-core/src/kv/KVManager.ts b/packages/meta-dup-core/src/kv/KVManager.ts new file mode 100644 index 0000000..3ace81d --- /dev/null +++ b/packages/meta-dup-core/src/kv/KVManager.ts @@ -0,0 +1,359 @@ +/** + * KV Manager for meta-dup (FOLLOWER-only) + * + * Simplified KV manager that: + * 1. Discovers the leader via LeaderClient (reads kv-leader.info) + * 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. Leader election is handled by meta-core. + */ + +import { hostname } from 'os'; +import { Logger } from 'tslog'; +import { LeaderClient } from './LeaderClient.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; + + /** 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; + + /** meta-core API URL (e.g., http://localhost:9000) */ + metaCoreUrl?: string; +} + +export class KVManager { + private config: Required; + private leaderClient: LeaderClient | 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-dup', + apiPort: 3000, + redisUrl: '', + redisPrefix: '', + baseUrl: '', + metaCoreUrl: '', + ...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 and start service discovery for registration and heartbeat + const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; + this.serviceDiscovery = new ServiceDiscovery({ + metaCorePath: this.config.metaCorePath, + serviceName: this.config.serviceName, + apiUrl + }); + 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 client + this.leaderClient = new LeaderClient({ + metaCorePath: this.config.metaCorePath, + metaCoreUrl: this.config.metaCoreUrl || undefined + }); + + // Set up leader change callback + this.leaderClient.onChange(async () => { + logger.info('Leader changed, reconnecting...'); + await this.reconnect(); + }); + + // Wait for leader and connect + try { + const leaderInfo = await this.leaderClient.waitForLeader(30000); + logger.info(`Connecting to Redis at ${leaderInfo.redisUrl}...`); + await this.connectToRedis(leaderInfo.redisUrl); + } catch (error: any) { + logger.error(`Failed to connect to leader: ${error.message}`); + throw error; + } + + // Start watching for leader changes + this.leaderClient.startWatching(); + + this.isStarted = true; + } + + /** + * Reconnect to Redis after leader change + */ + private async reconnect(): Promise { + if (this.isShuttingDown || !this.leaderClient) { + return; + } + + // Disconnect existing + await this.disconnectRedis(); + this.notifyDisconnect(); + + // Wait for new leader and connect + try { + const leaderInfo = await this.leaderClient.waitForLeader(30000); + logger.info(`Reconnecting to Redis at ${leaderInfo.redisUrl}...`); + await this.connectToRedis(leaderInfo.redisUrl); + } catch (error: any) { + logger.error(`Failed to reconnect: ${error.message}`); + } + } + + /** + * Stop the KV manager + */ + async stop(): Promise { + if (!this.isStarted) return; + + logger.info('Stopping KVManager...'); + this.isShuttingDown = true; + + // Stop leader client + if (this.leaderClient) { + this.leaderClient.close(); + this.leaderClient = null; + } + + // Stop service discovery (if it was started) + if (this.serviceDiscovery) { + await this.serviceDiscovery.stop(); + this.serviceDiscovery = 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 + */ + getLeaderInfo(): LeaderLockInfo | null { + return this.leaderClient?.getCachedLeaderInfo() ?? null; + } + + /** + * Get leader client + */ + getLeaderClient(): LeaderClient | null { + return this.leaderClient; + } + + /** + * 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-dup-core/src/kv/LeaderClient.ts b/packages/meta-dup-core/src/kv/LeaderClient.ts new file mode 100644 index 0000000..50c8b1b --- /dev/null +++ b/packages/meta-dup-core/src/kv/LeaderClient.ts @@ -0,0 +1,279 @@ +/** + * LeaderClient - Client for reading leader info from meta-core + * + * This replaces the old LeaderDiscovery.ts by delegating leader election + * to meta-core (Go sidecar) and just reading the results. + * + * Features: + * - Reads kv-leader.info file for leader URLs + * - Calls meta-core /urls API for current leader URLs + * - Watches for leader changes via file system watcher + */ + +import { promises as fs } from 'fs'; +import { watch, FSWatcher } from 'fs'; +import { dirname } from 'path'; +import { Logger } from 'tslog'; +import type { LeaderLockInfo } from './IKVClient.js'; + +const logger = new Logger({ name: 'LeaderClient' }); + +export interface LeaderClientConfig { + /** Path to META_CORE_VOLUME (e.g., /meta-core) */ + metaCorePath: string; + + /** meta-core API URL (e.g., http://meta-core:9000 or http://localhost:9000) */ + metaCoreUrl?: string; +} + +export interface URLsResponse { + hostname: string; + baseUrl: string; + apiUrl: string; + redisUrl: string; + webdavUrl: string; + isLeader: boolean; +} + +export class LeaderClient { + private config: LeaderClientConfig; + private infoFilePath: string; + private leaderInfo: LeaderLockInfo | null = null; + private watcher: FSWatcher | null = null; + private onChangeCallbacks: (() => void)[] = []; + + // URL caching + private cachedUrls: URLsResponse | null = null; + private urlsCacheTime: number = 0; + private readonly urlsCacheTTL: number = 5000; // 5 seconds + + constructor(config: LeaderClientConfig) { + this.config = config; + this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`; + } + + /** + * Read API URL from file (plain text format) + */ + private async getApiUrlFromFile(): Promise { + try { + const content = await fs.readFile(this.infoFilePath, 'utf-8'); + return content.trim() || null; + } catch (error: any) { + logger.error(`Failed to read API URL from file: ${error.message}`); + return null; + } + } + + /** + * Fetch URLs from meta-core /urls API with caching + */ + private async fetchUrls(apiUrl: string): Promise { + // Check cache + const now = Date.now(); + if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) { + return this.cachedUrls; + } + + try { + const response = await fetch(`${apiUrl}/urls`, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + signal: AbortSignal.timeout(5000) + }); + + if (!response.ok) { + logger.error(`Failed to fetch URLs: ${response.status} ${response.statusText}`); + return null; + } + + this.cachedUrls = await response.json() as URLsResponse; + this.urlsCacheTime = now; + return this.cachedUrls; + } catch (error: any) { + logger.error(`Error calling /urls API: ${error.message}`); + return null; + } + } + + /** + * Read leader info from file and /urls API + */ + async getLeaderInfo(): Promise { + try { + // Read API URL from file (plain text) + const apiUrl = await this.getApiUrlFromFile(); + if (!apiUrl) { + return null; + } + + // Fetch full info from /urls API + const urls = await this.fetchUrls(apiUrl); + if (!urls) { + return null; + } + + // Convert URLsResponse to LeaderLockInfo + this.leaderInfo = { + hostname: urls.hostname, + baseUrl: urls.baseUrl, + apiUrl: urls.apiUrl, + redisUrl: urls.redisUrl, + webdavUrl: urls.webdavUrl, + timestamp: Date.now(), + pid: 0 // Unknown for remote leader + }; + return this.leaderInfo; + } catch (error: any) { + logger.error(`Failed to read leader info: ${error.message}`); + return null; + } + } + + /** + * Get Redis URL from leader info + */ + async getRedisUrl(): Promise { + const info = await this.getLeaderInfo(); + return info?.redisUrl ?? null; + } + + /** + * Get WebDAV URL from leader info + */ + async getWebdavUrl(): Promise { + const info = await this.getLeaderInfo(); + return info?.webdavUrl ?? null; + } + + /** + * Get meta-core API URL from leader info + */ + async getApiUrl(): Promise { + const info = await this.getLeaderInfo(); + return info?.apiUrl ?? null; + } + + /** + * Call meta-core /urls API to get current URLs + * Useful for getting URLs when you don't want to read the file directly + */ + async getUrls(): Promise { + // First try using configured metaCoreUrl + let apiUrl: string | null = this.config.metaCoreUrl ?? null; + + // Fall back to reading from file + if (!apiUrl) { + apiUrl = await this.getApiUrlFromFile(); + } + + if (!apiUrl) { + logger.error('No meta-core API URL available'); + return null; + } + + return this.fetchUrls(apiUrl); + } + + /** + * Wait for leader info to be available + */ + async waitForLeader(timeoutMs: number = 30000): Promise { + const startTime = Date.now(); + const pollInterval = 500; + + while (Date.now() - startTime < timeoutMs) { + const info = await this.getLeaderInfo(); + if (info) { + logger.info(`Leader found: ${info.hostname} at ${info.redisUrl}`); + return info; + } + + logger.debug('Waiting for leader...'); + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + throw new Error(`No leader found within ${timeoutMs}ms`); + } + + /** + * Start watching for leader changes + */ + startWatching(): void { + if (this.watcher) { + return; // Already watching + } + + const lockDir = dirname(this.infoFilePath); + + try { + this.watcher = watch(lockDir, (eventType, filename) => { + if (filename === 'kv-leader.info') { + logger.debug(`Lock file ${eventType}, invalidating cache...`); + // Invalidate cache to force fresh API call + this.cachedUrls = null; + this.urlsCacheTime = 0; + + this.getLeaderInfo().then(() => { + this.notifyChange(); + }).catch((err) => logger.error(`Error reloading leader info: ${err.message}`)); + } + }); + + this.watcher.on('error', (error: any) => { + logger.warn(`File watcher error: ${error.message}`); + }); + + logger.info(`Watching for leader changes in ${lockDir}`); + } catch (error: any) { + logger.error(`Failed to start watching: ${error.message}`); + } + } + + /** + * Stop watching for leader changes + */ + stopWatching(): void { + if (this.watcher) { + this.watcher.close(); + this.watcher = null; + logger.debug('Stopped watching for leader changes'); + } + } + + /** + * Register callback for leader changes + */ + onChange(callback: () => void): this { + this.onChangeCallbacks.push(callback); + return this; + } + + /** + * Notify all change callbacks + */ + private notifyChange(): void { + for (const callback of this.onChangeCallbacks) { + try { + callback(); + } catch (error: any) { + logger.error(`Error in change callback: ${error.message}`); + } + } + } + + /** + * Get cached leader info (without re-reading file) + */ + getCachedLeaderInfo(): LeaderLockInfo | null { + return this.leaderInfo; + } + + /** + * Clean up resources + */ + close(): void { + this.stopWatching(); + this.onChangeCallbacks = []; + } +} diff --git a/packages/meta-dup-core/src/kv/RedisClient.ts b/packages/meta-dup-core/src/kv/RedisClient.ts new file mode 100644 index 0000000..b10497e --- /dev/null +++ b/packages/meta-dup-core/src/kv/RedisClient.ts @@ -0,0 +1,733 @@ +/** + * Redis Client for meta-dup + * 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 Streams support for real-time updates + */ + +import { Redis } from 'ioredis'; +import { Logger } from 'tslog'; +import * as os from 'os'; +import type { IKVClient, KeyValuePair } from './IKVClient.js'; + +const logger = new Logger({ name: 'RedisClient' }); + +/** + * Stream message from Redis Streams + * Supports both: + * - Direct file events from meta-core (add/change/delete/rename) + * - Legacy batch events from meta-sort (batch/reset/plugin:complete) + */ +export interface StreamMessage { + id: string; + type: 'add' | 'change' | 'delete' | 'rename' | 'batch' | 'reset' | 'plugin:complete'; + // Direct file event fields (from meta-core) + path?: string; + size?: string; + partialHash?: string; + oldPath?: string; + // Legacy payload field (for backward compatibility) + payload?: string; + timestamp: string; +} + +/** + * Stream consumer callback + */ +export type StreamMessageHandler = (message: StreamMessage) => Promise; + +/** + * File metadata structure for duplicate detection + */ +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 + sha256?: string; // Full SHA-256 hash for duplicate detection + + // 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(); + + // Stream consumer state + private streamConsumerRunning = false; + private streamConsumerAbort: AbortController | null = null; + private consumerName: string; + + 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 ?? '', + filesVolume: config.filesVolume ?? process.env.FILES_VOLUME ?? '/files', + reconnectInterval: config.reconnectInterval ?? 5000, + }; + + // Unique consumer name for this instance + this.consumerName = `${os.hostname()}-${process.pid}`; + } + + /** + * 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 { + // Stop stream consumer first + this.stopStreamConsumer(); + + 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(hashId, 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(hashId, 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, + sha256: data['cid_sha2-256'] ?? data.sha256, + + // 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, + }; + } + + // ======================================================================== + // Redis Streams Consumer Methods + // ======================================================================== + + /** + * Initialize stream consumer group + * Creates the consumer group at position 0 to read all historical events + * + * @param stream - Stream name (e.g., 'meta-sort:events') + * @param group - Consumer group name (e.g., 'meta-dup-consumer') + */ + async initStreamConsumer(stream: string, group: string): Promise { + if (!this.client) { + throw new Error('Redis not connected'); + } + + try { + // Create consumer group at position 0 (read all historical events) + // MKSTREAM creates the stream if it doesn't exist + await this.client.xgroup('CREATE', stream, group, '0', 'MKSTREAM'); + logger.info(`Created consumer group '${group}' for stream '${stream}'`); + } catch (error: any) { + // BUSYGROUP means group already exists - that's fine + if (error.message?.includes('BUSYGROUP')) { + logger.debug(`Consumer group '${group}' already exists`); + } else { + throw error; + } + } + } + + /** + * Process pending entries from crashed consumers + * Uses XAUTOCLAIM to claim entries that have been idle too long + * + * @param stream - Stream name + * @param group - Consumer group name + * @param minIdleTime - Minimum idle time in ms before claiming (default: 30000) + * @param onMessage - Handler for each message + */ + async processPendingEntries( + stream: string, + group: string, + minIdleTime: number = 30000, + onMessage: StreamMessageHandler + ): Promise { + if (!this.client) { + return 0; + } + + let processed = 0; + let cursor = '0-0'; + + try { + while (true) { + // XAUTOCLAIM claims idle entries and returns them + const result = await this.client.xautoclaim( + stream, + group, + this.consumerName, + minIdleTime, + cursor, + 'COUNT', + 100 + ) as [string, Array<[string, string[]]>, string[]]; + + const [nextCursor, entries] = result; + cursor = nextCursor; + + if (!entries || entries.length === 0) { + break; + } + + for (const [id, fields] of entries) { + try { + const message = this.parseStreamEntry(id, fields); + if (message) { + await onMessage(message); + // ACK the message after successful processing + await this.client.xack(stream, group, id); + processed++; + } + } catch (error: any) { + logger.error(`Error processing pending entry ${id}:`, error.message); + } + } + + // If cursor is 0-0, we've processed all pending entries + if (cursor === '0-0') { + break; + } + } + + if (processed > 0) { + logger.info(`Processed ${processed} pending stream entries`); + } + } catch (error: any) { + logger.error('Error processing pending entries:', error.message); + } + + return processed; + } + + /** + * Start stream consumer loop + * Reads messages using XREADGROUP and calls the handler + * + * @param stream - Stream name + * @param group - Consumer group name + * @param onMessage - Handler for each message + * @param blockMs - Block timeout in ms (default: 5000) + */ + async startStreamConsumer( + stream: string, + group: string, + onMessage: StreamMessageHandler, + blockMs: number = 5000 + ): Promise { + if (!this.client) { + throw new Error('Redis not connected'); + } + + if (this.streamConsumerRunning) { + logger.warn('Stream consumer already running'); + return; + } + + this.streamConsumerRunning = true; + this.streamConsumerAbort = new AbortController(); + + logger.info(`Starting stream consumer: ${stream} (group: ${group}, consumer: ${this.consumerName})`); + + // Consumer loop + while (this.streamConsumerRunning && !this.streamConsumerAbort.signal.aborted) { + try { + // XREADGROUP blocks waiting for new messages + // '>' means only read new messages (not already delivered to this consumer) + const result = await (this.client.call( + 'XREADGROUP', + 'GROUP', group, + this.consumerName, + 'BLOCK', blockMs, + 'COUNT', 10, + 'STREAMS', stream, + '>' + ) as Promise]> | null>); + + if (!result || result.length === 0) { + continue; // Timeout, loop again + } + + // Process each stream's messages + for (const [streamName, entries] of result) { + for (const [id, fields] of entries) { + try { + const message = this.parseStreamEntry(id, fields); + if (message) { + await onMessage(message); + // ACK the message after successful processing + await this.client!.xack(stream, group, id); + } + } catch (error: any) { + logger.error(`Error processing stream entry ${id}:`, error.message); + // Don't ACK on error - message will be reprocessed + } + } + } + } catch (error: any) { + if (this.streamConsumerAbort?.signal.aborted) { + break; + } + logger.error('Stream consumer error:', error.message); + // Brief pause before retrying + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + logger.info('Stream consumer stopped'); + } + + /** + * Stop the stream consumer loop + */ + stopStreamConsumer(): void { + if (!this.streamConsumerRunning) { + return; + } + + logger.info('Stopping stream consumer...'); + this.streamConsumerRunning = false; + this.streamConsumerAbort?.abort(); + this.streamConsumerAbort = null; + } + + /** + * Parse a stream entry into a StreamMessage + * Handles both direct file events (add/change/delete/rename) and legacy batch events + */ + private parseStreamEntry(id: string, fields: string[]): StreamMessage | null { + // Fields come as flat array: ['type', 'add', 'path', '...', 'timestamp', '123'] + const fieldMap: Record = {}; + for (let i = 0; i < fields.length; i += 2) { + fieldMap[fields[i]] = fields[i + 1]; + } + + if (!fieldMap.type) { + logger.warn(`Invalid stream entry ${id}: missing type`); + return null; + } + + // For direct file events (add/change/delete/rename), path is required + // For legacy events (batch/reset/plugin:complete), payload is required + const directEventTypes = ['add', 'change', 'delete', 'rename']; + const isDirectEvent = directEventTypes.includes(fieldMap.type); + + if (isDirectEvent && !fieldMap.path) { + logger.warn(`Invalid stream entry ${id}: missing path for ${fieldMap.type} event`); + return null; + } + + if (!isDirectEvent && !fieldMap.payload) { + logger.warn(`Invalid stream entry ${id}: missing payload for ${fieldMap.type} event`); + return null; + } + + return { + id, + type: fieldMap.type as StreamMessage['type'], + path: fieldMap.path, + size: fieldMap.size, + partialHash: fieldMap.partialHash, + oldPath: fieldMap.oldPath, + payload: fieldMap.payload, + timestamp: fieldMap.timestamp || '0', + }; + } + + /** + * 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-dup-core/src/kv/ServiceDiscovery.ts b/packages/meta-dup-core/src/kv/ServiceDiscovery.ts new file mode 100644 index 0000000..c8f28a5 --- /dev/null +++ b/packages/meta-dup-core/src/kv/ServiceDiscovery.ts @@ -0,0 +1,331 @@ +/** + * Service Discovery using shared filesystem + * + * Each service registers itself in /meta-core/services/{service-name}-{hostname}.json + * with its base URL, status, and heartbeat timestamp. + * + * Services can discover each other by reading these JSON files. + * Stale detection: if lastHeartbeat > 60 seconds ago, service is considered stale. + * + * Each service must call start() to register itself and begin heartbeat updates. + * meta-core runs a cleanup process that removes services with stale heartbeats. + */ + +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; + + /** Base URL for the service */ + apiUrl: string; + + /** Heartbeat interval in ms (default: 30000) */ + heartbeatInterval?: number; + + /** Stale threshold in ms (default: 60000) */ + staleThreshold?: number; +} + +export class ServiceDiscovery { + private config: ServiceDiscoveryConfig; + private servicesDir: string; + private serviceFilePath: string; + private heartbeatTimer: NodeJS.Timeout | null = null; + private isShuttingDown = false; + private isStarted = false; + private currentHostname: string; + + constructor(config: ServiceDiscoveryConfig) { + this.config = { + heartbeatInterval: 30000, + staleThreshold: 60000, + ...config + }; + + this.currentHostname = hostname(); + this.servicesDir = `${this.config.metaCorePath}/services`; + // Use hostname-based file naming + this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.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, + hostname: this.currentHostname, + baseUrl: this.config.apiUrl, + status, + lastHeartbeat: new Date().toISOString() + }; + } + + /** + * Register this service by writing service info to the shared filesystem. + * meta-core's cleanup process monitors these files and removes stale entries. + */ + async register(): Promise { + await this.ensureServicesDir(); + + const info = this.buildServiceInfo('running'); + await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2)); + + logger.info(`Registered ${this.config.serviceName}-${this.currentHostname}`); + } + + /** + * 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(); + } 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 { + // Remove the service file + await fs.unlink(this.serviceFilePath); + logger.info(`Unregistered ${this.config.serviceName}-${this.currentHostname}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + logger.error('Failed to unregister:', error); + } + } + } + + /** + * Check if a service info is stale + */ + private isStale(info: ServiceInfo): boolean { + const lastHeartbeat = new Date(info.lastHeartbeat).getTime(); + return Date.now() - lastHeartbeat > this.config.staleThreshold!; + } + + // ======================================================================== + // Discovery Methods + // ======================================================================== + + /** + * Discover a service by name + * Looks for files matching pattern: {name}-*.json (hostname-based naming) + */ + async discoverService(name: string): Promise { + try { + // First try exact match for backward compatibility + const exactPath = join(this.servicesDir, `${name}.json`); + try { + const content = await fs.readFile(exactPath, 'utf-8'); + const service = JSON.parse(content) as ServiceInfo; + + if (this.isStale(service)) { + service.status = 'stale'; + } + + return service.status === 'running' ? service : null; + } catch { + // Try hostname-based files + } + + // Search for hostname-based files: name-*.json + const files = await fs.readdir(this.servicesDir); + const matchingFiles = files.filter(f => + f.startsWith(`${name}-`) && f.endsWith('.json') + ); + + // Return first valid (non-stale) service + for (const file of matchingFiles) { + const filePath = join(this.servicesDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + const service = JSON.parse(content) as ServiceInfo; + + if (this.isStale(service)) { + service.status = 'stale'; + continue; // Skip stale services + } + + if (service.status === 'running') { + return service; + } + } + + return null; + } 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 filePath = join(this.servicesDir, file); + try { + const content = await fs.readFile(filePath, 'utf-8'); + const service = JSON.parse(content) as ServiceInfo; + + if (this.isStale(service)) { + service.status = 'stale'; + } + + services.push(service); + } catch (error) { + logger.error(`Failed to read ${file}:`, error); + } + } + + 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 url = `${service.baseUrl}/health`; + + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000) + }); + return response.ok; + } catch { + return false; + } + } + + // ======================================================================== + // Lifecycle + // ======================================================================== + + /** + * Full startup sequence: register service and start heartbeat loop. + */ + async start(): Promise { + if (this.isStarted) return; + + await this.register(); + this.startHeartbeat(); + this.isStarted = true; + + logger.info(`Started ${this.config.serviceName} at ${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-dup-core/tsconfig.json b/packages/meta-dup-core/tsconfig.json new file mode 100644 index 0000000..c83c533 --- /dev/null +++ b/packages/meta-dup-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-dup-ui/dist/assets/index-GT6exvk4.js b/packages/meta-dup-ui/dist/assets/index-GT6exvk4.js new file mode 100644 index 0000000..e294f20 --- /dev/null +++ b/packages/meta-dup-ui/dist/assets/index-GT6exvk4.js @@ -0,0 +1,660 @@ +(function(){const k=document.createElement("link").relList;if(k&&k.supports&&k.supports("modulepreload"))return;for(const Ee of document.querySelectorAll('link[rel="modulepreload"]'))Qe(Ee);new MutationObserver(Ee=>{for(const Oe of Ee)if(Oe.type==="childList")for(const d of Oe.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&Qe(d)}).observe(document,{childList:!0,subtree:!0});function _e(Ee){const Oe={};return Ee.integrity&&(Oe.integrity=Ee.integrity),Ee.referrerPolicy&&(Oe.referrerPolicy=Ee.referrerPolicy),Ee.crossOrigin==="use-credentials"?Oe.credentials="include":Ee.crossOrigin==="anonymous"?Oe.credentials="omit":Oe.credentials="same-origin",Oe}function Qe(Ee){if(Ee.ep)return;Ee.ep=!0;const Oe=_e(Ee);fetch(Ee.href,Oe)}})();function M_(w){return w&&w.__esModule&&Object.prototype.hasOwnProperty.call(w,"default")?w.default:w}var hS={exports:{}},rh={},mS={exports:{}},Qc={exports:{}};Qc.exports;(function(w,k){/** + * @license React + * react.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var _e="18.3.1",Qe=Symbol.for("react.element"),Ee=Symbol.for("react.portal"),Oe=Symbol.for("react.fragment"),d=Symbol.for("react.strict_mode"),Ue=Symbol.for("react.profiler"),K=Symbol.for("react.provider"),P=Symbol.for("react.context"),He=Symbol.for("react.forward_ref"),q=Symbol.for("react.suspense"),ve=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),ge=Symbol.for("react.lazy"),rn=Symbol.for("react.offscreen"),Vn=Symbol.iterator,Lt="@@iterator";function ze(s){if(s===null||typeof s!="object")return null;var p=Vn&&s[Vn]||s[Lt];return typeof p=="function"?p:null}var Re={current:null},rt={transition:null},ae={current:null,isBatchingLegacy:!1,didScheduleLegacyUpdate:!1},Ge={current:null},he={},dt=null;function B(s){dt=s}he.setExtraStackFrame=function(s){dt=s},he.getCurrentStack=null,he.getStackAddendum=function(){var s="";dt&&(s+=dt);var p=he.getCurrentStack;return p&&(s+=p()||""),s};var Ve=!1,qe=!1,Ie=!1,se=!1,ot=!1,it={ReactCurrentDispatcher:Re,ReactCurrentBatchConfig:rt,ReactCurrentOwner:Ge};it.ReactDebugCurrentFrame=he,it.ReactCurrentActQueue=ae;function Ct(s){{for(var p=arguments.length,S=new Array(p>1?p-1:0),C=1;C1?p-1:0),C=1;C1){for(var Ke=Array(Be),Je=0;Je1){for(var be=Array(Je),lt=0;lt is not supported and will be removed in a future major release. Did you mean to render instead?")),p.Provider},set:function($){p.Provider=$}},_currentValue:{get:function(){return p._currentValue},set:function($){p._currentValue=$}},_currentValue2:{get:function(){return p._currentValue2},set:function($){p._currentValue2=$}},_threadCount:{get:function(){return p._threadCount},set:function($){p._threadCount=$}},Consumer:{get:function(){return S||(S=!0,me("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?")),p.Consumer}},displayName:{get:function(){return p.displayName},set:function($){O||(Ct("Setting `displayName` on Context.Consumer has no effect. You should set it directly on the context with Context.displayName = '%s'.",$),O=!0)}}}),p.Consumer=ee}return p._currentRenderer=null,p._currentRenderer2=null,p}var ha=-1,ta=0,Yn=1,La=2;function ti(s){if(s._status===ha){var p=s._result,S=p();if(S.then(function(ee){if(s._status===ta||s._status===ha){var $=s;$._status=Yn,$._result=ee}},function(ee){if(s._status===ta||s._status===ha){var $=s;$._status=La,$._result=ee}}),s._status===ha){var C=s;C._status=ta,C._result=S}}if(s._status===Yn){var O=s._result;return O===void 0&&me(`lazy: Expected the result of a dynamic import() call. Instead received: %s + +Your code should look like: + const MyComponent = lazy(() => import('./MyComponent')) + +Did you accidentally put curly braces around the import?`,O),"default"in O||me(`lazy: Expected the result of a dynamic import() call. Instead received: %s + +Your code should look like: + const MyComponent = lazy(() => import('./MyComponent'))`,O),O.default}else throw s._result}function v(s){var p={_status:ha,_result:s},S={$$typeof:ge,_payload:p,_init:ti};{var C,O;Object.defineProperties(S,{defaultProps:{configurable:!0,get:function(){return C},set:function(ee){me("React.lazy(...): It is not supported to assign `defaultProps` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),C=ee,Object.defineProperty(S,"defaultProps",{enumerable:!0})}},propTypes:{configurable:!0,get:function(){return O},set:function(ee){me("React.lazy(...): It is not supported to assign `propTypes` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),O=ee,Object.defineProperty(S,"propTypes",{enumerable:!0})}}})}return S}function N(s){s!=null&&s.$$typeof===G?me("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof s!="function"?me("forwardRef requires a render function but was given %s.",s===null?"null":typeof s):s.length!==0&&s.length!==2&&me("forwardRef render functions accept exactly two parameters: props and ref. %s",s.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),s!=null&&(s.defaultProps!=null||s.propTypes!=null)&&me("forwardRef render functions do not support propTypes or defaultProps. Did you accidentally pass a React component?");var p={$$typeof:He,render:s};{var S;Object.defineProperty(p,"displayName",{enumerable:!1,configurable:!0,get:function(){return S},set:function(C){S=C,!s.name&&!s.displayName&&(s.displayName=C)}})}return p}var L;L=Symbol.for("react.module.reference");function X(s){return!!(typeof s=="string"||typeof s=="function"||s===Oe||s===Ue||ot||s===d||s===q||s===ve||se||s===rn||Ve||qe||Ie||typeof s=="object"&&s!==null&&(s.$$typeof===ge||s.$$typeof===G||s.$$typeof===K||s.$$typeof===P||s.$$typeof===He||s.$$typeof===L||s.getModuleId!==void 0))}function Ce(s,p){X(s)||me("memo: The first argument must be a component. Instead received: %s",s===null?"null":typeof s);var S={$$typeof:G,type:s,compare:p===void 0?null:p};{var C;Object.defineProperty(S,"displayName",{enumerable:!1,configurable:!0,get:function(){return C},set:function(O){C=O,!s.name&&!s.displayName&&(s.displayName=O)}})}return S}function re(){var s=Re.current;return s===null&&me(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: +1. You might have mismatching versions of React and the renderer (such as React DOM) +2. You might be breaking the Rules of Hooks +3. You might have more than one copy of React in the same app +See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.`),s}function fe(s){var p=re();if(s._context!==void 0){var S=s._context;S.Consumer===s?me("Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be removed in a future major release. Did you mean to call useContext(Context) instead?"):S.Provider===s&&me("Calling useContext(Context.Provider) is not supported. Did you mean to call useContext(Context) instead?")}return p.useContext(s)}function J(s){var p=re();return p.useState(s)}function Tt(s,p,S){var C=re();return C.useReducer(s,p,S)}function tt(s){var p=re();return p.useRef(s)}function nt(s,p){var S=re();return S.useEffect(s,p)}function sn(s,p){var S=re();return S.useInsertionEffect(s,p)}function Ua(s,p){var S=re();return S.useLayoutEffect(s,p)}function ma(s,p){var S=re();return S.useCallback(s,p)}function xt(s,p){var S=re();return S.useMemo(s,p)}function ni(s,p,S){var C=re();return C.useImperativeHandle(s,p,S)}function ya(s,p){{var S=re();return S.useDebugValue(s,p)}}function ye(){var s=re();return s.useTransition()}function ai(s){var p=re();return p.useDeferredValue(s)}function zo(){var s=re();return s.useId()}function jo(s,p,S){var C=re();return C.useSyncExternalStore(s,p,S)}var xr=0,Pu,$u,Gu,qu,Qu,Ho,Vo;function Bi(){}Bi.__reactDisabledLog=!0;function Wu(){{if(xr===0){Pu=console.log,$u=console.info,Gu=console.warn,qu=console.error,Qu=console.group,Ho=console.groupCollapsed,Vo=console.groupEnd;var s={configurable:!0,enumerable:!0,value:Bi,writable:!0};Object.defineProperties(console,{info:s,log:s,warn:s,error:s,group:s,groupCollapsed:s,groupEnd:s})}xr++}}function za(){{if(xr--,xr===0){var s={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:Ut({},s,{value:Pu}),info:Ut({},s,{value:$u}),warn:Ut({},s,{value:Gu}),error:Ut({},s,{value:qu}),group:Ut({},s,{value:Qu}),groupCollapsed:Ut({},s,{value:Ho}),groupEnd:Ut({},s,{value:Vo})})}xr<0&&me("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var ri=it.ReactCurrentDispatcher,Dr;function Yi(s,p,S){{if(Dr===void 0)try{throw Error()}catch(O){var C=O.stack.trim().match(/\n( *(at )?)/);Dr=C&&C[1]||""}return` +`+Dr+s}}var ii=!1,Pi;{var Xu=typeof WeakMap=="function"?WeakMap:Map;Pi=new Xu}function Fo(s,p){if(!s||ii)return"";{var S=Pi.get(s);if(S!==void 0)return S}var C;ii=!0;var O=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var ee;ee=ri.current,ri.current=null,Wu();try{if(p){var $=function(){throw Error()};if(Object.defineProperty($.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct($,[])}catch(gt){C=gt}Reflect.construct(s,[],$)}else{try{$.call()}catch(gt){C=gt}s.call($.prototype)}}else{try{throw Error()}catch(gt){C=gt}s()}}catch(gt){if(gt&&C&&typeof gt.stack=="string"){for(var le=gt.stack.split(` +`),Te=C.stack.split(` +`),Be=le.length-1,Ke=Te.length-1;Be>=1&&Ke>=0&&le[Be]!==Te[Ke];)Ke--;for(;Be>=1&&Ke>=0;Be--,Ke--)if(le[Be]!==Te[Ke]){if(Be!==1||Ke!==1)do if(Be--,Ke--,Ke<0||le[Be]!==Te[Ke]){var Je=` +`+le[Be].replace(" at new "," at ");return s.displayName&&Je.includes("")&&(Je=Je.replace("",s.displayName)),typeof s=="function"&&Pi.set(s,Je),Je}while(Be>=1&&Ke>=0);break}}}finally{ii=!1,ri.current=ee,za(),Error.prepareStackTrace=O}var be=s?s.displayName||s.name:"",lt=be?Yi(be):"";return typeof s=="function"&&Pi.set(s,lt),lt}function Iu(s,p,S){return Fo(s,!1)}function Wc(s){var p=s.prototype;return!!(p&&p.isReactComponent)}function ui(s,p,S){if(s==null)return"";if(typeof s=="function")return Fo(s,Wc(s));if(typeof s=="string")return Yi(s);switch(s){case q:return Yi("Suspense");case ve:return Yi("SuspenseList")}if(typeof s=="object")switch(s.$$typeof){case He:return Iu(s.render);case G:return ui(s.type,p,S);case ge:{var C=s,O=C._payload,ee=C._init;try{return ui(ee(O),p,S)}catch{}}}return""}var Bo={},Ku=it.ReactDebugCurrentFrame;function Ae(s){if(s){var p=s._owner,S=ui(s.type,s._source,p?p.type:null);Ku.setExtraStackFrame(S)}else Ku.setExtraStackFrame(null)}function Xc(s,p,S,C,O){{var ee=Function.call.bind(ln);for(var $ in s)if(ee(s,$)){var le=void 0;try{if(typeof s[$]!="function"){var Te=Error((C||"React class")+": "+S+" type `"+$+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof s[$]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Te.name="Invariant Violation",Te}le=s[$](p,$,C,S,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(Be){le=Be}le&&!(le instanceof Error)&&(Ae(O),me("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",C||"React class",S,$,typeof le),Ae(null)),le instanceof Error&&!(le.message in Bo)&&(Bo[le.message]=!0,Ae(O),me("Failed %s type: %s",S,le.message),Ae(null))}}}function er(s){if(s){var p=s._owner,S=ui(s.type,s._source,p?p.type:null);B(S)}else B(null)}var ce;ce=!1;function Ju(){if(Ge.current){var s=kn(Ge.current.type);if(s)return` + +Check the render method of \``+s+"`."}return""}function En(s){if(s!==void 0){var p=s.fileName.replace(/^.*[\\\/]/,""),S=s.lineNumber;return` + +Check your code at `+p+":"+S+"."}return""}function li(s){return s!=null?En(s.__source):""}var Nr={};function Ic(s){var p=Ju();if(!p){var S=typeof s=="string"?s:s.displayName||s.name;S&&(p=` + +Check the top-level render call using <`+S+">.")}return p}function Vt(s,p){if(!(!s._store||s._store.validated||s.key!=null)){s._store.validated=!0;var S=Ic(p);if(!Nr[S]){Nr[S]=!0;var C="";s&&s._owner&&s._owner!==Ge.current&&(C=" It was passed a child from "+kn(s._owner.type)+"."),er(s),me('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',S,C),er(null)}}}function ut(s,p){if(typeof s=="object"){if(zt(s))for(var S=0;S",O=" Did you accidentally export a JSX literal instead of a component?"):$=typeof s,me("React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",$,O)}var le=pe.apply(this,arguments);if(le==null)return le;if(C)for(var Te=2;Te10&&Ct("Detected a large number of updates inside startTransition. If this is due to a subscription please re-write it to use React provided hooks. Otherwise concurrent mode guarantees are off the table."),C._updatedFibers.clear()}}}var el=!1,$i=null;function Jc(s){if($i===null)try{var p=("require"+Math.random()).slice(0,7),S=w&&w[p];$i=S.call(w,"timers").setImmediate}catch{$i=function(O){el===!1&&(el=!0,typeof MessageChannel>"u"&&me("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var ee=new MessageChannel;ee.port1.onmessage=O,ee.port2.postMessage(void 0)}}return $i(s)}var _r=0,oi=!1;function tl(s){{var p=_r;_r++,ae.current===null&&(ae.current=[]);var S=ae.isBatchingLegacy,C;try{if(ae.isBatchingLegacy=!0,C=s(),!S&&ae.didScheduleLegacyUpdate){var O=ae.current;O!==null&&(ae.didScheduleLegacyUpdate=!1,Qi(O))}}catch(be){throw tr(p),be}finally{ae.isBatchingLegacy=S}if(C!==null&&typeof C=="object"&&typeof C.then=="function"){var ee=C,$=!1,le={then:function(be,lt){$=!0,ee.then(function(gt){tr(p),_r===0?Gi(gt,be,lt):be(gt)},function(gt){tr(p),lt(gt)})}};return!oi&&typeof Promise<"u"&&Promise.resolve().then(function(){}).then(function(){$||(oi=!0,me("You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),le}else{var Te=C;if(tr(p),_r===0){var Be=ae.current;Be!==null&&(Qi(Be),ae.current=null);var Ke={then:function(be,lt){ae.current===null?(ae.current=[],Gi(Te,be,lt)):be(Te)}};return Ke}else{var Je={then:function(be,lt){be(Te)}};return Je}}}}function tr(s){s!==_r-1&&me("You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. "),_r=s}function Gi(s,p,S){{var C=ae.current;if(C!==null)try{Qi(C),Jc(function(){C.length===0?(ae.current=null,p(s)):Gi(s,p,S)})}catch(O){S(O)}else p(s)}}var qi=!1;function Qi(s){if(!qi){qi=!0;var p=0;try{for(;p1?N-1:0),X=1;X=1&&nt>=0&&J[tt]!==Tt[nt];)nt--;for(;tt>=1&&nt>=0;tt--,nt--)if(J[tt]!==Tt[nt]){if(tt!==1||nt!==1)do if(tt--,nt--,nt<0||J[tt]!==Tt[nt]){var sn=` +`+J[tt].replace(" at new "," at ");return v.displayName&&sn.includes("")&&(sn=sn.replace("",v.displayName)),typeof v=="function"&&qt.set(v,sn),sn}while(tt>=1&&nt>=0);break}}}finally{Gt=!1,Aa.current=re,Ia(),Error.prepareStackTrace=Ce}var Ua=v?v.displayName||v.name:"",ma=Ua?Bn(Ua):"";return typeof v=="function"&&qt.set(v,ma),ma}function zt(v,N,L){return wn(v,!1)}function gn(v){var N=v.prototype;return!!(N&&N.isReactComponent)}function jt(v,N,L){if(v==null)return"";if(typeof v=="function")return wn(v,gn(v));if(typeof v=="string")return Bn(v);switch(v){case P:return Bn("Suspense");case He:return Bn("SuspenseList")}if(typeof v=="object")switch(v.$$typeof){case K:return zt(v.render);case q:return jt(v.type,N,L);case ve:{var X=v,Ce=X._payload,re=X._init;try{return jt(re(Ce),N,L)}catch{}}}return""}var Ht=Object.prototype.hasOwnProperty,Zn={},Ka=Lt.ReactDebugCurrentFrame;function da(v){if(v){var N=v._owner,L=jt(v.type,v._source,N?N.type:null);Ka.setExtraStackFrame(L)}else Ka.setExtraStackFrame(null)}function kn(v,N,L,X,Ce){{var re=Function.call.bind(Ht);for(var fe in v)if(re(v,fe)){var J=void 0;try{if(typeof v[fe]!="function"){var Tt=Error((X||"React class")+": "+L+" type `"+fe+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof v[fe]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Tt.name="Invariant Violation",Tt}J=v[fe](N,fe,X,L,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(tt){J=tt}J&&!(J instanceof Error)&&(da(Ce),ze("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",X||"React class",L,fe,typeof J),da(null)),J instanceof Error&&!(J.message in Zn)&&(Zn[J.message]=!0,da(Ce),ze("Failed %s type: %s",L,J.message),da(null))}}}var ln=Array.isArray;function Wt(v){return ln(v)}function bn(v){{var N=typeof Symbol=="function"&&Symbol.toStringTag,L=N&&v[Symbol.toStringTag]||v.constructor.name||"Object";return L}}function Ma(v){try{return Rt(v),!1}catch{return!0}}function Rt(v){return""+v}function Sn(v){if(Ma(v))return ze("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.",bn(v)),Rt(v)}var An=Lt.ReactCurrentOwner,Tr={key:!0,ref:!0,__self:!0,__source:!0},Ja,M,W;W={};function pe(v){if(Ht.call(v,"ref")){var N=Object.getOwnPropertyDescriptor(v,"ref").get;if(N&&N.isReactWarning)return!1}return v.ref!==void 0}function Fe(v){if(Ht.call(v,"key")){var N=Object.getOwnPropertyDescriptor(v,"key").get;if(N&&N.isReactWarning)return!1}return v.key!==void 0}function et(v,N){if(typeof v.ref=="string"&&An.current&&N&&An.current.stateNode!==N){var L=se(An.current.type);W[L]||(ze('Component "%s" contains the string ref "%s". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://reactjs.org/link/strict-mode-string-ref',se(An.current.type),v.ref),W[L]=!0)}}function st(v,N){{var L=function(){Ja||(Ja=!0,ze("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",N))};L.isReactWarning=!0,Object.defineProperty(v,"key",{get:L,configurable:!0})}}function ct(v,N){{var L=function(){M||(M=!0,ze("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",N))};L.isReactWarning=!0,Object.defineProperty(v,"ref",{get:L,configurable:!0})}}var on=function(v,N,L,X,Ce,re,fe){var J={$$typeof:k,type:v,key:N,ref:L,props:fe,_owner:re};return J._store={},Object.defineProperty(J._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:!1}),Object.defineProperty(J,"_self",{configurable:!1,enumerable:!1,writable:!1,value:X}),Object.defineProperty(J,"_source",{configurable:!1,enumerable:!1,writable:!1,value:Ce}),Object.freeze&&(Object.freeze(J.props),Object.freeze(J)),J};function pt(v,N,L,X,Ce){{var re,fe={},J=null,Tt=null;L!==void 0&&(Sn(L),J=""+L),Fe(N)&&(Sn(N.key),J=""+N.key),pe(N)&&(Tt=N.ref,et(N,Ce));for(re in N)Ht.call(N,re)&&!Tr.hasOwnProperty(re)&&(fe[re]=N[re]);if(v&&v.defaultProps){var tt=v.defaultProps;for(re in tt)fe[re]===void 0&&(fe[re]=tt[re])}if(J||Tt){var nt=typeof v=="function"?v.displayName||v.name||"Unknown":v;J&&st(fe,nt),Tt&&ct(fe,nt)}return on(v,J,Tt,Ce,X,An.current,fe)}}var We=Lt.ReactCurrentOwner,vt=Lt.ReactDebugCurrentFrame;function pa(v){if(v){var N=v._owner,L=jt(v.type,v._source,N?N.type:null);vt.setExtraStackFrame(L)}else vt.setExtraStackFrame(null)}var va;va=!1;function ea(v){return typeof v=="object"&&v!==null&&v.$$typeof===k}function Za(){{if(We.current){var v=se(We.current.type);if(v)return` + +Check the render method of \``+v+"`."}return""}}function Yu(v){{if(v!==void 0){var N=v.fileName.replace(/^.*[\\\/]/,""),L=v.lineNumber;return` + +Check your code at `+N+":"+L+"."}return""}}var Zr={};function Vi(v){{var N=Za();if(!N){var L=typeof v=="string"?v:v.displayName||v.name;L&&(N=` + +Check the top-level render call using <`+L+">.")}return N}}function Fi(v,N){{if(!v._store||v._store.validated||v.key!=null)return;v._store.validated=!0;var L=Vi(N);if(Zr[L])return;Zr[L]=!0;var X="";v&&v._owner&&v._owner!==We.current&&(X=" It was passed a child from "+se(v._owner.type)+"."),pa(v),ze('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',L,X),pa(null)}}function ei(v,N){{if(typeof v!="object")return;if(Wt(v))for(var L=0;L",J=" Did you accidentally export a JSX literal instead of a component?"):tt=typeof v,ze("React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",tt,J)}var nt=pt(v,N,L,Ce,re);if(nt==null)return nt;if(fe){var sn=N.children;if(sn!==void 0)if(X)if(Wt(sn)){for(var Ua=0;Ua0?"{key: someKey, "+xt.join(": ..., ")+": ...}":"{key: someKey}";if(!Yn[ma+ni]){var ya=xt.length>0?"{"+xt.join(": ..., ")+": ...}":"{}";ze(`A props object containing a "key" prop is being spread into JSX: + let props = %s; + <%s {...props} /> +React keys must be passed directly to JSX without using spread: + let props = %s; + <%s key={someKey} {...props} />`,ni,ma,ya,ma),Yn[ma+ni]=!0}}return v===Qe?ta(nt):ha(nt),nt}}var ti=La;rh.Fragment=Qe,rh.jsxDEV=ti})();hS.exports=rh;var j=hS.exports,ih={},yS={exports:{}},Kn={},gS={exports:{}},bS={};(function(w){/** + * @license React + * scheduler.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var k=!1,_e=5;function Qe(M,W){var pe=M.length;M.push(W),d(M,W,pe)}function Ee(M){return M.length===0?null:M[0]}function Oe(M){if(M.length===0)return null;var W=M[0],pe=M.pop();return pe!==W&&(M[0]=pe,Ue(M,pe,0)),W}function d(M,W,pe){for(var Fe=pe;Fe>0;){var et=Fe-1>>>1,st=M[et];if(K(st,W)>0)M[et]=W,M[Fe]=st,Fe=et;else return}}function Ue(M,W,pe){for(var Fe=pe,et=M.length,st=et>>>1;Fepe&&(!M||Ka()));){var Fe=Ie.callback;if(typeof Fe=="function"){Ie.callback=null,se=Ie.priorityLevel;var et=Ie.expirationTime<=pe,st=Fe(et);pe=w.unstable_now(),typeof st=="function"?Ie.callback=st:Ie===Ee(B)&&Oe(B),Fn(pe)}else Oe(B);Ie=Ee(B)}if(Ie!==null)return!0;var ct=Ee(Ve);return ct!==null&&Sn(Jn,ct.startTime-pe),!1}function yn(M,W){switch(M){case P:case He:case q:case ve:case G:break;default:M=q}var pe=se;se=M;try{return W()}finally{se=pe}}function Ia(M){var W;switch(se){case P:case He:case q:W=q;break;default:W=se;break}var pe=se;se=W;try{return M()}finally{se=pe}}function Aa(M){var W=se;return function(){var pe=se;se=W;try{return M.apply(this,arguments)}finally{se=pe}}}function $t(M,W,pe){var Fe=w.unstable_now(),et;if(typeof pe=="object"&&pe!==null){var st=pe.delay;typeof st=="number"&&st>0?et=Fe+st:et=Fe}else et=Fe;var ct;switch(M){case P:ct=rt;break;case He:ct=ae;break;case G:ct=dt;break;case ve:ct=he;break;case q:default:ct=Ge;break}var on=et+ct,pt={id:qe++,callback:W,priorityLevel:M,startTime:et,expirationTime:on,sortIndex:-1};return et>Fe?(pt.sortIndex=et,Qe(Ve,pt),Ee(B)===null&&pt===Ee(Ve)&&(Ct?An():Ct=!0,Sn(Jn,et-Fe))):(pt.sortIndex=on,Qe(B,pt),!it&&!ot&&(it=!0,Rt(Ut))),pt}function Bn(){}function Gt(){!it&&!ot&&(it=!0,Rt(Ut))}function qt(){return Ee(B)}function Qt(M){M.callback=null}function wn(){return se}var zt=!1,gn=null,jt=-1,Ht=_e,Zn=-1;function Ka(){var M=w.unstable_now()-Zn;return!(M125){console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported");return}M>0?Ht=Math.floor(1e3/M):Ht=_e}var ln=function(){if(gn!==null){var M=w.unstable_now();Zn=M;var W=!0,pe=!0;try{pe=gn(W,M)}finally{pe?Wt():(zt=!1,gn=null)}}else zt=!1},Wt;if(typeof ca=="function")Wt=function(){ca(ln)};else if(typeof MessageChannel<"u"){var bn=new MessageChannel,Ma=bn.port2;bn.port1.onmessage=ln,Wt=function(){Ma.postMessage(null)}}else Wt=function(){me(ln,0)};function Rt(M){gn=M,zt||(zt=!0,Wt())}function Sn(M,W){jt=me(function(){M(w.unstable_now())},W)}function An(){un(jt),jt=-1}var Tr=da,Ja=null;w.unstable_IdlePriority=G,w.unstable_ImmediatePriority=P,w.unstable_LowPriority=ve,w.unstable_NormalPriority=q,w.unstable_Profiling=Ja,w.unstable_UserBlockingPriority=He,w.unstable_cancelCallback=Qt,w.unstable_continueExecution=Gt,w.unstable_forceFrameRate=kn,w.unstable_getCurrentPriorityLevel=wn,w.unstable_getFirstCallbackNode=qt,w.unstable_next=Ia,w.unstable_pauseExecution=Bn,w.unstable_requestPaint=Tr,w.unstable_runWithPriority=yn,w.unstable_scheduleCallback=$t,w.unstable_shouldYield=Ka,w.unstable_wrapCallback=Aa,typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(new Error)})()})(bS);gS.exports=bS;var z_=gS.exports;/** + * @license React + * react-dom.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var w=mn,k=z_,_e=w.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,Qe=!1;function Ee(e){Qe=e}function Oe(e){if(!Qe){for(var t=arguments.length,n=new Array(t>1?t-1:0),a=1;a1?t-1:0),a=1;a2&&(e[0]==="o"||e[0]==="O")&&(e[1]==="n"||e[1]==="N")}function ct(e,t,n,a){if(n!==null&&n.type===Wt)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":{if(a)return!1;if(n!==null)return!n.acceptsBooleans;var r=e.toLowerCase().slice(0,5);return r!=="data-"&&r!=="aria-"}default:return!1}}function on(e,t,n,a){if(t===null||typeof t>"u"||ct(e,t,n,a))return!0;if(a)return!1;if(n!==null)switch(n.type){case Rt:return!t;case Sn:return t===!1;case An:return isNaN(t);case Tr:return isNaN(t)||t<1}return!1}function pt(e){return vt.hasOwnProperty(e)?vt[e]:null}function We(e,t,n,a,r,i,u){this.acceptsBooleans=t===Ma||t===Rt||t===Sn,this.attributeName=a,this.attributeNamespace=r,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=u}var vt={},pa=["children","dangerouslySetInnerHTML","defaultValue","defaultChecked","innerHTML","suppressContentEditableWarning","suppressHydrationWarning","style"];pa.forEach(function(e){vt[e]=new We(e,Wt,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0],n=e[1];vt[t]=new We(t,bn,!1,n,null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){vt[e]=new We(e,Ma,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){vt[e]=new We(e,Ma,!1,e,null,!1,!1)}),["allowFullScreen","async","autoFocus","autoPlay","controls","default","defer","disabled","disablePictureInPicture","disableRemotePlayback","formNoValidate","hidden","loop","noModule","noValidate","open","playsInline","readOnly","required","reversed","scoped","seamless","itemScope"].forEach(function(e){vt[e]=new We(e,Rt,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){vt[e]=new We(e,Rt,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){vt[e]=new We(e,Sn,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){vt[e]=new We(e,Tr,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){vt[e]=new We(e,An,!1,e.toLowerCase(),null,!1,!1)});var va=/[\-\:]([a-z])/g,ea=function(e){return e[1].toUpperCase()};["accent-height","alignment-baseline","arabic-form","baseline-shift","cap-height","clip-path","clip-rule","color-interpolation","color-interpolation-filters","color-profile","color-rendering","dominant-baseline","enable-background","fill-opacity","fill-rule","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","glyph-name","glyph-orientation-horizontal","glyph-orientation-vertical","horiz-adv-x","horiz-origin-x","image-rendering","letter-spacing","lighting-color","marker-end","marker-mid","marker-start","overline-position","overline-thickness","paint-order","panose-1","pointer-events","rendering-intent","shape-rendering","stop-color","stop-opacity","strikethrough-position","strikethrough-thickness","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","text-anchor","text-decoration","text-rendering","underline-position","underline-thickness","unicode-bidi","unicode-range","units-per-em","v-alphabetic","v-hanging","v-ideographic","v-mathematical","vector-effect","vert-adv-y","vert-origin-x","vert-origin-y","word-spacing","writing-mode","xmlns:xlink","x-height"].forEach(function(e){var t=e.replace(va,ea);vt[t]=new We(t,bn,!1,e,null,!1,!1)}),["xlink:actuate","xlink:arcrole","xlink:role","xlink:show","xlink:title","xlink:type"].forEach(function(e){var t=e.replace(va,ea);vt[t]=new We(t,bn,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(va,ea);vt[t]=new We(t,bn,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){vt[e]=new We(e,bn,!1,e.toLowerCase(),null,!1,!1)});var Za="xlinkHref";vt[Za]=new We("xlinkHref",bn,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){vt[e]=new We(e,bn,!1,e.toLowerCase(),null,!0,!0)});var Yu=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i,Zr=!1;function Vi(e){!Zr&&Yu.test(e)&&(Zr=!0,d("A future version of React will block javascript: URLs as a security precaution. Use event handlers instead if you can. If you need to generate unsafe HTML try using dangerouslySetInnerHTML instead. React was passed %s.",JSON.stringify(e)))}function Fi(e,t,n,a){if(a.mustUseProperty){var r=a.propertyName;return e[r]}else{Ht(n,t),a.sanitizeURL&&Vi(""+n);var i=a.attributeName,u=null;if(a.type===Sn){if(e.hasAttribute(i)){var l=e.getAttribute(i);return l===""?!0:on(t,n,a,!1)?l:l===""+n?n:l}}else if(e.hasAttribute(i)){if(on(t,n,a,!1))return e.getAttribute(i);if(a.type===Rt)return n;u=e.getAttribute(i)}return on(t,n,a,!1)?u===null?n:u:u===""+n?n:u}}function ei(e,t,n,a){{if(!et(t))return;if(!e.hasAttribute(t))return n===void 0?void 0:null;var r=e.getAttribute(t);return Ht(n,t),r===""+n?n:r}}function ha(e,t,n,a){var r=pt(t);if(!st(t,r,a)){if(on(t,n,r,a)&&(n=null),a||r===null){if(et(t)){var i=t;n===null?e.removeAttribute(i):(Ht(n,t),e.setAttribute(i,""+n))}return}var u=r.mustUseProperty;if(u){var l=r.propertyName;if(n===null){var o=r.type;e[l]=o===Rt?!1:""}else e[l]=n;return}var c=r.attributeName,f=r.attributeNamespace;if(n===null)e.removeAttribute(c);else{var m=r.type,h;m===Rt||m===Sn&&n===!0?h="":(Ht(n,c),h=""+n,r.sanitizeURL&&Vi(h.toString())),f?e.setAttributeNS(f,c,h):e.setAttribute(c,h)}}}var ta=Symbol.for("react.element"),Yn=Symbol.for("react.portal"),La=Symbol.for("react.fragment"),ti=Symbol.for("react.strict_mode"),v=Symbol.for("react.profiler"),N=Symbol.for("react.provider"),L=Symbol.for("react.context"),X=Symbol.for("react.forward_ref"),Ce=Symbol.for("react.suspense"),re=Symbol.for("react.suspense_list"),fe=Symbol.for("react.memo"),J=Symbol.for("react.lazy"),Tt=Symbol.for("react.scope"),tt=Symbol.for("react.debug_trace_mode"),nt=Symbol.for("react.offscreen"),sn=Symbol.for("react.legacy_hidden"),Ua=Symbol.for("react.cache"),ma=Symbol.for("react.tracing_marker"),xt=Symbol.iterator,ni="@@iterator";function ya(e){if(e===null||typeof e!="object")return null;var t=xt&&e[xt]||e[ni];return typeof t=="function"?t:null}var ye=Object.assign,ai=0,zo,jo,xr,Pu,$u,Gu,qu;function Qu(){}Qu.__reactDisabledLog=!0;function Ho(){{if(ai===0){zo=console.log,jo=console.info,xr=console.warn,Pu=console.error,$u=console.group,Gu=console.groupCollapsed,qu=console.groupEnd;var e={configurable:!0,enumerable:!0,value:Qu,writable:!0};Object.defineProperties(console,{info:e,log:e,warn:e,error:e,group:e,groupCollapsed:e,groupEnd:e})}ai++}}function Vo(){{if(ai--,ai===0){var e={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:ye({},e,{value:zo}),info:ye({},e,{value:jo}),warn:ye({},e,{value:xr}),error:ye({},e,{value:Pu}),group:ye({},e,{value:$u}),groupCollapsed:ye({},e,{value:Gu}),groupEnd:ye({},e,{value:qu})})}ai<0&&d("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var Bi=_e.ReactCurrentDispatcher,Wu;function za(e,t,n){{if(Wu===void 0)try{throw Error()}catch(r){var a=r.stack.trim().match(/\n( *(at )?)/);Wu=a&&a[1]||""}return` +`+Wu+e}}var ri=!1,Dr;{var Yi=typeof WeakMap=="function"?WeakMap:Map;Dr=new Yi}function ii(e,t){if(!e||ri)return"";{var n=Dr.get(e);if(n!==void 0)return n}var a;ri=!0;var r=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var i;i=Bi.current,Bi.current=null,Ho();try{if(t){var u=function(){throw Error()};if(Object.defineProperty(u.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(u,[])}catch(E){a=E}Reflect.construct(e,[],u)}else{try{u.call()}catch(E){a=E}e.call(u.prototype)}}else{try{throw Error()}catch(E){a=E}e()}}catch(E){if(E&&a&&typeof E.stack=="string"){for(var l=E.stack.split(` +`),o=a.stack.split(` +`),c=l.length-1,f=o.length-1;c>=1&&f>=0&&l[c]!==o[f];)f--;for(;c>=1&&f>=0;c--,f--)if(l[c]!==o[f]){if(c!==1||f!==1)do if(c--,f--,f<0||l[c]!==o[f]){var m=` +`+l[c].replace(" at new "," at ");return e.displayName&&m.includes("")&&(m=m.replace("",e.displayName)),typeof e=="function"&&Dr.set(e,m),m}while(c>=1&&f>=0);break}}}finally{ri=!1,Bi.current=i,Vo(),Error.prepareStackTrace=r}var h=e?e.displayName||e.name:"",b=h?za(h):"";return typeof e=="function"&&Dr.set(e,b),b}function Pi(e,t,n){return ii(e,!0)}function Xu(e,t,n){return ii(e,!1)}function Fo(e){var t=e.prototype;return!!(t&&t.isReactComponent)}function Iu(e,t,n){if(e==null)return"";if(typeof e=="function")return ii(e,Fo(e));if(typeof e=="string")return za(e);switch(e){case Ce:return za("Suspense");case re:return za("SuspenseList")}if(typeof e=="object")switch(e.$$typeof){case X:return Xu(e.render);case fe:return Iu(e.type,t,n);case J:{var a=e,r=a._payload,i=a._init;try{return Iu(i(r),t,n)}catch{}}}return""}function Wc(e){switch(e._debugOwner&&e._debugOwner.type,e._debugSource,e.tag){case G:return za(e.type);case dt:return za("Lazy");case ae:return za("Suspense");case qe:return za("SuspenseList");case K:case He:case he:return Xu(e.type);case Re:return Xu(e.type.render);case P:return Pi(e.type);default:return""}}function ui(e){try{var t="",n=e;do t+=Wc(n),n=n.return;while(n);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}function Bo(e,t,n){var a=e.displayName;if(a)return a;var r=t.displayName||t.name||"";return r!==""?n+"("+r+")":n}function Ku(e){return e.displayName||"Context"}function Ae(e){if(e==null)return null;if(typeof e.tag=="number"&&d("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case La:return"Fragment";case Yn:return"Portal";case v:return"Profiler";case ti:return"StrictMode";case Ce:return"Suspense";case re:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case L:var t=e;return Ku(t)+".Consumer";case N:var n=e;return Ku(n._context)+".Provider";case X:return Bo(e,e.render,"ForwardRef");case fe:var a=e.displayName||null;return a!==null?a:Ae(e.type)||"Memo";case J:{var r=e,i=r._payload,u=r._init;try{return Ae(u(i))}catch{return null}}}return null}function Xc(e,t,n){var a=t.displayName||t.name||"";return e.displayName||(a!==""?n+"("+a+")":n)}function er(e){return e.displayName||"Context"}function ce(e){var t=e.tag,n=e.type;switch(t){case it:return"Cache";case Lt:var a=n;return er(a)+".Consumer";case ze:var r=n;return er(r._context)+".Provider";case Ve:return"DehydratedFragment";case Re:return Xc(n,n.render,"ForwardRef");case rn:return"Fragment";case G:return n;case ve:return"Portal";case q:return"Root";case ge:return"Text";case dt:return Ae(n);case Vn:return n===ti?"StrictMode":"Mode";case se:return"Offscreen";case rt:return"Profiler";case Ie:return"Scope";case ae:return"Suspense";case qe:return"SuspenseList";case Ct:return"TracingMarker";case P:case K:case B:case He:case Ge:case he:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n;break}return null}var Ju=_e.ReactDebugCurrentFrame,En=null,li=!1;function Nr(){{if(En===null)return null;var e=En._debugOwner;if(e!==null&&typeof e<"u")return ce(e)}return null}function Ic(){return En===null?"":ui(En)}function Vt(){Ju.getCurrentStack=null,En=null,li=!1}function ut(e){Ju.getCurrentStack=e===null?null:Ic,En=e,li=!1}function Yo(){return En}function na(e){li=e}function Cn(e){return""+e}function ga(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return ln(e),e;default:return""}}var Kc={button:!0,checkbox:!0,image:!0,hidden:!0,radio:!0,reset:!0,submit:!0};function Zu(e,t){Kc[t.type]||t.onChange||t.onInput||t.readOnly||t.disabled||t.value==null||d("You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."),t.onChange||t.readOnly||t.disabled||t.checked==null||d("You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`.")}function Po(e){var t=e.type,n=e.nodeName;return n&&n.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function el(e){return e._valueTracker}function $i(e){e._valueTracker=null}function Jc(e){var t="";return e&&(Po(e)?t=e.checked?"true":"false":t=e.value),t}function _r(e){var t=Po(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);ln(e[t]);var a=""+e[t];if(!(e.hasOwnProperty(t)||typeof n>"u"||typeof n.get!="function"||typeof n.set!="function")){var r=n.get,i=n.set;Object.defineProperty(e,t,{configurable:!0,get:function(){return r.call(this)},set:function(l){ln(l),a=""+l,i.call(this,l)}}),Object.defineProperty(e,t,{enumerable:n.enumerable});var u={getValue:function(){return a},setValue:function(l){ln(l),a=""+l},stopTracking:function(){$i(e),delete e[t]}};return u}}function oi(e){el(e)||(e._valueTracker=_r(e))}function tl(e){if(!e)return!1;var t=el(e);if(!t)return!0;var n=t.getValue(),a=Jc(e);return a!==n?(t.setValue(a),!0):!1}function tr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Gi=!1,qi=!1,Qi=!1,$o=!1;function Go(e){var t=e.type==="checkbox"||e.type==="radio";return t?e.checked!=null:e.value!=null}function nl(e,t){var n=e,a=t.checked,r=ye({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??n._wrapperState.initialChecked});return r}function qo(e,t){Zu("input",t),t.checked!==void 0&&t.defaultChecked!==void 0&&!qi&&(d("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Nr()||"A component",t.type),qi=!0),t.value!==void 0&&t.defaultValue!==void 0&&!Gi&&(d("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Nr()||"A component",t.type),Gi=!0);var n=e,a=t.defaultValue==null?"":t.defaultValue;n._wrapperState={initialChecked:t.checked!=null?t.checked:t.defaultChecked,initialValue:ga(t.value!=null?t.value:a),controlled:Go(t)}}function s(e,t){var n=e,a=t.checked;a!=null&&ha(n,"checked",a,!1)}function p(e,t){var n=e;{var a=Go(t);!n._wrapperState.controlled&&a&&!$o&&(d("A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),$o=!0),n._wrapperState.controlled&&!a&&!Qi&&(d("A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),Qi=!0)}s(e,t);var r=ga(t.value),i=t.type;if(r!=null)i==="number"?(r===0&&n.value===""||n.value!=r)&&(n.value=Cn(r)):n.value!==Cn(r)&&(n.value=Cn(r));else if(i==="submit"||i==="reset"){n.removeAttribute("value");return}t.hasOwnProperty("value")?ee(n,t.type,r):t.hasOwnProperty("defaultValue")&&ee(n,t.type,ga(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(n.defaultChecked=!!t.defaultChecked)}function S(e,t,n){var a=e;if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type,i=r==="submit"||r==="reset";if(i&&(t.value===void 0||t.value===null))return;var u=Cn(a._wrapperState.initialValue);n||u!==a.value&&(a.value=u),a.defaultValue=u}var l=a.name;l!==""&&(a.name=""),a.defaultChecked=!a.defaultChecked,a.defaultChecked=!!a._wrapperState.initialChecked,l!==""&&(a.name=l)}function C(e,t){var n=e;p(n,t),O(n,t)}function O(e,t){var n=t.name;if(t.type==="radio"&&n!=null){for(var a=e;a.parentNode;)a=a.parentNode;Ht(n,"name");for(var r=a.querySelectorAll("input[name="+JSON.stringify(""+n)+'][type="radio"]'),i=0;i.")))}):t.dangerouslySetInnerHTML!=null&&(Te||(Te=!0,d("Pass a `value` prop if you set dangerouslyInnerHTML so React knows which value should be selected.")))),t.selected!=null&&!$&&(d("Use the `defaultValue` or `value` props on must be a scalar value if `multiple` is false.%s",n,gt())}}}}function nr(e,t,n,a){var r=e.options;if(t){for(var i=n,u={},l=0;l.");var a=ye({},t,{value:void 0,defaultValue:void 0,children:Cn(n._wrapperState.initialValue)});return a}function lh(e,t){var n=e;Zu("textarea",t),t.value!==void 0&&t.defaultValue!==void 0&&!uh&&(d("%s contains a textarea with both value and defaultValue props. Textarea elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled textarea and remove one of these props. More info: https://reactjs.org/link/controlled-components",Nr()||"A component"),uh=!0);var a=t.value;if(a==null){var r=t.children,i=t.defaultValue;if(r!=null){d("Use the `defaultValue` or `value` props instead of setting children on