bug fix and improvement
This commit is contained in:
@@ -64,29 +64,37 @@ The **FILES_VOLUME** (canonical name: `/files`) is the shared drive for all medi
|
|||||||
|
|
||||||
```
|
```
|
||||||
FILES_VOLUME (/files)
|
FILES_VOLUME (/files)
|
||||||
├── media1/ # Local folder mounted from host
|
├── watch/ # Host media folder (read-only bind mount)
|
||||||
│ ├── Movies/
|
│ ├── Movies/
|
||||||
│ │ └── Inception (2010)/
|
│ │ └── Inception (2010)/
|
||||||
│ │ └── Inception.mkv
|
│ │ └── Inception.mkv
|
||||||
│ └── TV Shows/
|
│ └── TV Shows/
|
||||||
│ └── Breaking Bad/
|
│ └── Breaking Bad/
|
||||||
│ └── Season 01/
|
|
||||||
│
|
│
|
||||||
├── media2/ # Another local media folder
|
├── test/ # Test media folder (development)
|
||||||
│ └── Anime/
|
|
||||||
│ └── Steins Gate/
|
|
||||||
│
|
│
|
||||||
├── smb/ # SMB/CIFS remote mount
|
├── plugin/ # Plugin output files (read-write)
|
||||||
│ └── NAS-Media/
|
│ └── {plugin-id}/ # Per-plugin output directory
|
||||||
│ └── Movies/
|
|
||||||
│
|
│
|
||||||
├── nfs/ # NFS remote mount
|
└── corn/ # SMB/rclone mounts (mounted inside container)
|
||||||
│ └── server-media/
|
├── nas-share/ # SMB mount via rclone
|
||||||
│
|
└── gdrive/ # Google Drive mount via rclone
|
||||||
└── gdrive/ # rclone remote mount (Google Drive)
|
|
||||||
└── Cloud-Media/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### WebDAV File Access
|
||||||
|
|
||||||
|
meta-sort exposes `/files` via **WebDAV** at the `/webdav/` endpoint. This allows:
|
||||||
|
- **Container plugins** to access files without direct volume mounts
|
||||||
|
- **meta-fuse** and **meta-stremio** to read files from meta-sort
|
||||||
|
- Access to dynamic mounts (SMB, rclone) that can't be pre-mounted in other containers
|
||||||
|
|
||||||
|
```
|
||||||
|
Container Plugin ──► WebDAV (http://meta-sort/webdav/) ──► /files/
|
||||||
|
GET, PUT, DELETE, MKCOL supported
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variable: `PLUGIN_WEBDAV_URL=http://meta-sort-dev/webdav`
|
||||||
|
|
||||||
### File Path References
|
### File Path References
|
||||||
|
|
||||||
All file paths stored in the KV database are **relative to FILES_VOLUME**:
|
All file paths stored in the KV database are **relative to FILES_VOLUME**:
|
||||||
@@ -501,8 +509,20 @@ services:
|
|||||||
| `META_CORE_PATH` | `/meta-core` | Path to META_CORE_VOLUME (infrastructure) |
|
| `META_CORE_PATH` | `/meta-core` | Path to META_CORE_VOLUME (infrastructure) |
|
||||||
| `FILES_PATH` | `/files` | Path to FILES_VOLUME (shared media) |
|
| `FILES_PATH` | `/files` | Path to FILES_VOLUME (shared media) |
|
||||||
| `REDIS_URL` | Auto-discover | Redis connection (from leader lock file) |
|
| `REDIS_URL` | Auto-discover | Redis connection (from leader lock file) |
|
||||||
| `WATCH_PATHS` | `$FILES_PATH` | Folders to watch for media |
|
| `WATCH_FOLDER_LIST` | `$FILES_PATH` | Comma-separated folders to watch for media |
|
||||||
| `SERVICE_NAME` | Container hostname | Unique service identifier |
|
| `SERVICE_NAME` | Container hostname | Unique service identifier |
|
||||||
|
| `BASE_URL` | `http://localhost:8180` | External URL for this service |
|
||||||
|
|
||||||
|
**Container Plugins:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CONTAINER_PLUGINS_CONFIG` | `/app/plugins.yml` | Path to plugins configuration |
|
||||||
|
| `DOCKER_SOCKET_PATH` | `/var/run/docker.sock` | Docker socket for container management |
|
||||||
|
| `CONTAINER_CALLBACK_URL` | `http://meta-sort:80` | URL plugins use to call back |
|
||||||
|
| `CONTAINER_NETWORK` | `meta-network` | Docker network for plugin containers |
|
||||||
|
| `PLUGIN_WEBDAV_URL` | - | WebDAV URL for plugin file access (e.g., `http://meta-sort-dev/webdav`) |
|
||||||
|
| `PLUGIN_STACK_NAME` | - | Docker Compose project name for grouping containers |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -538,20 +558,30 @@ Plugins are the core processing units that extract and enrich metadata. Each plu
|
|||||||
- Runs in either **fast** (high concurrency) or **background** (low concurrency) queue
|
- Runs in either **fast** (high concurrency) or **background** (low concurrency) queue
|
||||||
- Can depend on other plugins to ensure correct execution order
|
- Can depend on other plugins to ensure correct execution order
|
||||||
|
|
||||||
### Built-in Plugins
|
### Container Plugins
|
||||||
|
|
||||||
| Plugin | Queue | Priority | Description |
|
All plugins run as **Docker containers** for isolation and flexibility. Plugins are configured in `plugins.yml` and communicate via HTTP API.
|
||||||
|--------|-------|----------|-------------|
|
|
||||||
| **file-info** | fast | 10 | Basic file info (type, MIME, size, midhash256) |
|
| Plugin | Language | Queue | Priority | Description |
|
||||||
| **ffmpeg** | fast | 15 | Video/audio/subtitle stream metadata via FFprobe |
|
|--------|----------|-------|----------|-------------|
|
||||||
| **filename-parser** | fast | 20 | Parse title, season, episode, year, quality from filename |
|
| **file-info** | TypeScript | fast | 10 | Basic file info (type, MIME, size, midhash256) |
|
||||||
| **jellyfin-nfo** | fast | 25 | Parse Jellyfin/Kodi NFO metadata files |
|
| **ffmpeg** | TypeScript | fast | 15 | Video/audio/subtitle stream metadata via FFprobe |
|
||||||
| **tmdb** | background | 30 | Fetch metadata from The Movie Database API |
|
| **filename-parser** | TypeScript | fast | 20 | Parse title, season, episode, year, quality from filename |
|
||||||
| **anime-detector** | fast | 35 | Detect anime content from keywords and audio tracks |
|
| **jellyfin-nfo** | TypeScript | fast | 25 | Parse Jellyfin/Kodi NFO metadata files |
|
||||||
| **language** | fast | 40 | Aggregate languages from all streams (ISO 639-3) |
|
| **tmdb** | TypeScript | background | 30 | Fetch metadata from The Movie Database API |
|
||||||
| **subtitle** | fast | 45 | Find sibling subtitle files and detect language |
|
| **anime-detector** | TypeScript | fast | 35 | Detect anime content from keywords and audio tracks |
|
||||||
| **torrent** | fast | 50 | Parse .torrent file metadata |
|
| **language** | TypeScript | fast | 40 | Aggregate languages from all streams (ISO 639-3) |
|
||||||
| **full-hash** | background | 100 | Compute SHA-256 hash of entire file |
|
| **subtitle** | TypeScript | fast | 45 | Find sibling subtitle files and detect language |
|
||||||
|
| **torrent** | TypeScript | fast | 50 | Parse .torrent file metadata |
|
||||||
|
| **full-hash** | TypeScript | background | 100 | Compute SHA-256/SHA-1/MD5 hashes (JS implementation) |
|
||||||
|
| **fast-full-hash** | **Rust** | background | 100 | High-performance multi-algorithm hashing (Rust/Axum) |
|
||||||
|
|
||||||
|
### Plugin File Access
|
||||||
|
|
||||||
|
Plugins access files via **WebDAV** served by meta-sort nginx. This allows plugins to:
|
||||||
|
- Access files without requiring direct volume mounts
|
||||||
|
- See SMB/rclone mounts that are mounted dynamically inside meta-sort
|
||||||
|
- Read and write to the plugin output directory at `/files/plugin/`
|
||||||
|
|
||||||
### Plugin Manifest Example
|
### Plugin Manifest Example
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type { KVManager } from '../kv/KVManager.js';
|
|||||||
import { config } from '../config/EnvConfig.js';
|
import { config } from '../config/EnvConfig.js';
|
||||||
import type { PluginManager } from '../plugin-engine/PluginManager.js';
|
import type { PluginManager } from '../plugin-engine/PluginManager.js';
|
||||||
import type { TaskScheduler } from '../plugin-engine/TaskScheduler.js';
|
import type { TaskScheduler } from '../plugin-engine/TaskScheduler.js';
|
||||||
import type { ContainerManager, ContainerPluginScheduler, PluginCallbackPayload } from '../container-plugins/index.js';
|
import type { ContainerManager, ContainerPluginScheduler, PluginCallbackPayload, GateStatus } from '../container-plugins/index.js';
|
||||||
|
import type { StreamingPipeline } from '../logic/pipeline/StreamingPipeline.js';
|
||||||
|
|
||||||
export interface UnifiedAPIServerConfig {
|
export interface UnifiedAPIServerConfig {
|
||||||
/** Port to listen on (default: 3000) */
|
/** Port to listen on (default: 3000) */
|
||||||
@@ -44,6 +45,7 @@ export class UnifiedAPIServer {
|
|||||||
private getTaskScheduler: (() => TaskScheduler | null) | null = null;
|
private getTaskScheduler: (() => TaskScheduler | null) | null = null;
|
||||||
private containerManager: ContainerManager | null = null;
|
private containerManager: ContainerManager | null = null;
|
||||||
private containerPluginScheduler: ContainerPluginScheduler | null = null;
|
private containerPluginScheduler: ContainerPluginScheduler | null = null;
|
||||||
|
private streamingPipeline: StreamingPipeline | null = null;
|
||||||
|
|
||||||
// Cache for total file size (avoid running du on every request)
|
// Cache for total file size (avoid running du on every request)
|
||||||
private totalSizeCache: { value: number; timestamp: number } | null = null;
|
private totalSizeCache: { value: number; timestamp: number } | null = null;
|
||||||
@@ -104,34 +106,43 @@ export class UnifiedAPIServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get total size of watch folders using du command (cached)
|
* Get total size of processed files from Redis sizeByte field (cached)
|
||||||
|
* This is "eventually correct" - accumulates as files are processed by the file-info plugin
|
||||||
*/
|
*/
|
||||||
private getTotalWatchFolderSize(): number {
|
private async getTotalFileSizeFromRedis(): Promise<number> {
|
||||||
// Check cache
|
// Check cache
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.totalSizeCache && (now - this.totalSizeCache.timestamp) < UnifiedAPIServer.TOTAL_SIZE_CACHE_TTL_MS) {
|
if (this.totalSizeCache && (now - this.totalSizeCache.timestamp) < UnifiedAPIServer.TOTAL_SIZE_CACHE_TTL_MS) {
|
||||||
return this.totalSizeCache.value;
|
return this.totalSizeCache.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total size using du -sb on each watch folder
|
// Sum sizeByte from all file entries in Redis
|
||||||
let totalSize = 0;
|
let totalSize = 0;
|
||||||
const watchFolders = config.WATCH_FOLDER_LIST?.split(',').map(f => f.trim()).filter(f => f) || [];
|
|
||||||
|
|
||||||
for (const folder of watchFolders) {
|
if (!this.kvClient) {
|
||||||
try {
|
return 0;
|
||||||
// du -sb gives total bytes for directory
|
}
|
||||||
const output = execSync(`du -sb "${folder}" 2>/dev/null || echo "0"`, {
|
|
||||||
encoding: 'utf8',
|
try {
|
||||||
timeout: 10000 // 10s timeout per folder
|
// Get all file hash IDs
|
||||||
});
|
const hashIds = await this.kvClient.getAllHashIds();
|
||||||
const bytes = parseInt(output.split('\t')[0], 10);
|
|
||||||
if (!isNaN(bytes)) {
|
// Sum up sizeByte values (batch process for efficiency)
|
||||||
totalSize += bytes;
|
for (const hashId of hashIds) {
|
||||||
|
try {
|
||||||
|
const sizeByte = await this.kvClient.getMetadata(hashId, 'sizeByte');
|
||||||
|
if (sizeByte !== null && sizeByte !== undefined) {
|
||||||
|
const size = typeof sizeByte === 'number' ? sizeByte : parseInt(String(sizeByte), 10);
|
||||||
|
if (!isNaN(size) && size > 0) {
|
||||||
|
totalSize += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip entries without sizeByte
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// Skip folders that fail (permission issues, doesn't exist, etc.)
|
|
||||||
console.warn(`Failed to get size for folder ${folder}:`, error);
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[API] Failed to calculate total size from Redis:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
@@ -566,6 +577,89 @@ export class UnifiedAPIServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/mounts/:id/safe-unmount - Safe unmount with queue drain
|
||||||
|
// Closes gate, waits for queues to empty, then unmounts
|
||||||
|
this.app.post<{
|
||||||
|
Params: { id: string };
|
||||||
|
Querystring: { timeout?: string };
|
||||||
|
}>('/api/mounts/:id/safe-unmount', async (request, reply) => {
|
||||||
|
const timeoutMs = parseInt(request.query.timeout || '60000', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mountsConfig = await readMountsConfig();
|
||||||
|
const mount = mountsConfig.mounts.find((m: any) => m.id === request.params.id);
|
||||||
|
|
||||||
|
if (!mount) {
|
||||||
|
return reply.status(404).send({ error: 'Mount not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Close the gate to stop new jobs
|
||||||
|
if (this.containerPluginScheduler) {
|
||||||
|
console.log(`[Mounts] Safe unmount: closing gate for ${mount.name}`);
|
||||||
|
this.containerPluginScheduler.setGate(false);
|
||||||
|
|
||||||
|
// Step 2: Wait for queues to drain
|
||||||
|
console.log(`[Mounts] Safe unmount: waiting for queues to drain (timeout: ${timeoutMs}ms)`);
|
||||||
|
const drained = await this.containerPluginScheduler.waitForEmpty(timeoutMs);
|
||||||
|
|
||||||
|
if (!drained) {
|
||||||
|
// Re-open gate and abort
|
||||||
|
this.containerPluginScheduler.setGate(true);
|
||||||
|
const gateStatus = this.containerPluginScheduler.getGateStatus();
|
||||||
|
return reply.status(408).send({
|
||||||
|
status: 'timeout',
|
||||||
|
message: `Queue did not drain within ${timeoutMs}ms. Gate re-opened.`,
|
||||||
|
gateStatus
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Request unmount
|
||||||
|
mount.desiredMounted = false;
|
||||||
|
await writeMountsConfig(mountsConfig);
|
||||||
|
|
||||||
|
// Step 4: Wait for unmount to complete
|
||||||
|
console.log(`[Mounts] Safe unmount: waiting for mount to detach: ${mount.name}`);
|
||||||
|
let unmountSuccess = false;
|
||||||
|
for (let i = 0; i < 30; i++) { // 15 seconds max wait
|
||||||
|
if (!isMounted(mount.mountPath)) {
|
||||||
|
unmountSuccess = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Re-open the gate
|
||||||
|
if (this.containerPluginScheduler) {
|
||||||
|
console.log(`[Mounts] Safe unmount: re-opening gate`);
|
||||||
|
this.containerPluginScheduler.setGate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unmountSuccess) {
|
||||||
|
console.warn(`[Mounts] Safe unmount: mount still attached after timeout: ${mount.name}`);
|
||||||
|
return {
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Unmount requested but mount may still be attached',
|
||||||
|
gateStatus: this.containerPluginScheduler?.getGateStatus() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Mounts] Safe unmount completed: ${mount.name}`);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Safe unmount completed',
|
||||||
|
gateStatus: this.containerPluginScheduler?.getGateStatus() ?? null
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// Re-open gate on error
|
||||||
|
if (this.containerPluginScheduler) {
|
||||||
|
this.containerPluginScheduler.setGate(true);
|
||||||
|
}
|
||||||
|
console.error(`[Mounts] Safe unmount error: ${error.message}`);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/mounts/:id - Remove mount config
|
// DELETE /api/mounts/:id - Remove mount config
|
||||||
this.app.delete<{ Params: { id: string } }>('/api/mounts/:id', async (request, reply) => {
|
this.app.delete<{ Params: { id: string } }>('/api/mounts/:id', async (request, reply) => {
|
||||||
const fs = await import('fs/promises');
|
const fs = await import('fs/promises');
|
||||||
@@ -1341,8 +1435,8 @@ export class UnifiedAPIServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total size of watch folders (cached, uses du command)
|
// Get total size from Redis sizeByte (eventually correct as files are processed)
|
||||||
const totalSize = this.getTotalWatchFolderSize();
|
const totalSize = await this.getTotalFileSizeFromRedis();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prefix: 'file:',
|
prefix: 'file:',
|
||||||
@@ -1519,6 +1613,9 @@ export class UnifiedAPIServer {
|
|||||||
// hashProcessing.size includes both waiting AND running files
|
// hashProcessing.size includes both waiting AND running files
|
||||||
snapshot.awaitingBackground = Math.max(0, snapshot.awaitingBackground - backgroundQueueRunning);
|
snapshot.awaitingBackground = Math.max(0, snapshot.awaitingBackground - backgroundQueueRunning);
|
||||||
|
|
||||||
|
// Get gate status if container scheduler is available
|
||||||
|
const gateStatus = this.containerPluginScheduler?.getGateStatus();
|
||||||
|
|
||||||
(snapshot as any).computed = {
|
(snapshot as any).computed = {
|
||||||
// Pre-process queue (validation - quick extension checks)
|
// Pre-process queue (validation - quick extension checks)
|
||||||
preProcessRunning,
|
preProcessRunning,
|
||||||
@@ -1537,6 +1634,11 @@ export class UnifiedAPIServer {
|
|||||||
backgroundQueueTotal: backgroundQueueRunning + backgroundQueuePending + (queueStatus.backgroundQueue?.size || 0),
|
backgroundQueueTotal: backgroundQueueRunning + backgroundQueuePending + (queueStatus.backgroundQueue?.size || 0),
|
||||||
backgroundQueuePaused: queueStatus.backgroundQueue?.isPaused || false,
|
backgroundQueuePaused: queueStatus.backgroundQueue?.isPaused || false,
|
||||||
|
|
||||||
|
// Gate control status (for safe mount/unmount operations)
|
||||||
|
gateOpen: gateStatus?.isOpen ?? true,
|
||||||
|
gateStatus: gateStatus ?? null,
|
||||||
|
pipelinePaused: this.streamingPipeline?.isPaused() ?? false,
|
||||||
|
|
||||||
// Legacy compatibility fields (UI may still reference these)
|
// Legacy compatibility fields (UI may still reference these)
|
||||||
actualRunningHashWorkers: backgroundQueueRunning,
|
actualRunningHashWorkers: backgroundQueueRunning,
|
||||||
actualRunningLightWorkers: preProcessRunning + fastQueueRunning,
|
actualRunningLightWorkers: preProcessRunning + fastQueueRunning,
|
||||||
@@ -1604,6 +1706,80 @@ export class UnifiedAPIServer {
|
|||||||
|
|
||||||
return { status: 'ok', message: `${queued} files queued for retry` };
|
return { status: 'ok', message: `${queued} files queued for retry` };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/processing/stop - Stop all processing
|
||||||
|
// Pauses pipeline queues and closes container plugin gate
|
||||||
|
// Running tasks will complete, but no new tasks will start
|
||||||
|
this.app.post('/api/processing/stop', async (request, reply) => {
|
||||||
|
// Pause the streaming pipeline (stops new files from being processed)
|
||||||
|
if (this.streamingPipeline) {
|
||||||
|
this.streamingPipeline.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the container plugin gate (stops new plugin tasks)
|
||||||
|
if (this.containerPluginScheduler) {
|
||||||
|
this.containerPluginScheduler.setGate(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateStatus = this.containerPluginScheduler?.getGateStatus() ?? null;
|
||||||
|
const pipelinePaused = this.streamingPipeline?.isPaused() ?? false;
|
||||||
|
|
||||||
|
console.log('[API] Processing stopped - pipeline paused, gate closed');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Processing stopped - pipeline paused, gate closed',
|
||||||
|
gateStatus,
|
||||||
|
pipelinePaused
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/processing/start - Resume all processing
|
||||||
|
// Resumes pipeline queues and opens container plugin gate
|
||||||
|
this.app.post('/api/processing/start', async (request, reply) => {
|
||||||
|
// Resume the streaming pipeline
|
||||||
|
if (this.streamingPipeline) {
|
||||||
|
this.streamingPipeline.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the container plugin gate
|
||||||
|
if (this.containerPluginScheduler) {
|
||||||
|
this.containerPluginScheduler.setGate(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateStatus = this.containerPluginScheduler?.getGateStatus() ?? null;
|
||||||
|
const pipelinePaused = this.streamingPipeline?.isPaused() ?? false;
|
||||||
|
|
||||||
|
console.log('[API] Processing started - pipeline resumed, gate opened');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Processing started - pipeline resumed, gate opened',
|
||||||
|
gateStatus,
|
||||||
|
pipelinePaused
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/processing/wait-empty - Wait for queues to drain
|
||||||
|
// Optional query param: timeout (ms, default 60000)
|
||||||
|
this.app.post<{ Querystring: { timeout?: string } }>('/api/processing/wait-empty', async (request, reply) => {
|
||||||
|
if (!this.containerPluginScheduler) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Container plugin scheduler not available'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutMs = parseInt(request.query.timeout || '60000', 10);
|
||||||
|
const success = await this.containerPluginScheduler.waitForEmpty(timeoutMs);
|
||||||
|
const gateStatus = this.containerPluginScheduler.getGateStatus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: success ? 'ok' : 'timeout',
|
||||||
|
message: success ? 'Queues are empty' : `Timeout after ${timeoutMs}ms`,
|
||||||
|
gateStatus
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1639,8 +1815,8 @@ export class UnifiedAPIServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total size of watch folders (cached, uses du command)
|
// Get total size from Redis sizeByte (eventually correct as files are processed)
|
||||||
const totalSize = this.getTotalWatchFolderSize();
|
const totalSize = await this.getTotalFileSizeFromRedis();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileCount: hashIds.length,
|
fileCount: hashIds.length,
|
||||||
@@ -2483,6 +2659,14 @@ export class UnifiedAPIServer {
|
|||||||
console.log('[UnifiedAPIServer] Container plugin manager and scheduler set');
|
console.log('[UnifiedAPIServer] Container plugin manager and scheduler set');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set streaming pipeline (for pause/resume control)
|
||||||
|
*/
|
||||||
|
public setStreamingPipeline(pipeline: StreamingPipeline): void {
|
||||||
|
this.streamingPipeline = pipeline;
|
||||||
|
console.log('[UnifiedAPIServer] Streaming pipeline set');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the HTTP server
|
* Start the HTTP server
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -113,21 +113,11 @@ interface EnvConfig {
|
|||||||
CONTAINER_NETWORK: string;
|
CONTAINER_NETWORK: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin file mounts - maps subpaths to host paths for /files in plugin containers.
|
* WebDAV URL for plugin file access.
|
||||||
* Format: "subpath:host_path,subpath2:host_path2"
|
* Plugins access /files via this WebDAV endpoint served by meta-sort nginx.
|
||||||
* Example: "watch:/data/media,test:/app/test/media"
|
* Example: "http://meta-sort-dev/webdav"
|
||||||
* Results in /files/watch and /files/test mounted in plugin containers (read-only).
|
|
||||||
*/
|
*/
|
||||||
PLUGIN_FILE_MOUNTS?: string;
|
PLUGIN_WEBDAV_URL?: string;
|
||||||
|
|
||||||
/** Host path for plugin cache folder - each plugin gets its own subdirectory at /cache (optional, dev only) */
|
|
||||||
PLUGIN_CACHE_FOLDER?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Host path for plugin output folders - each plugin gets /files/plugin/<id> mount (READ-WRITE)
|
|
||||||
* Plugins decide whether to use this folder - we always mount it if this env is set
|
|
||||||
*/
|
|
||||||
PLUGIN_OUTPUT_FOLDER?: string;
|
|
||||||
|
|
||||||
/** Docker Compose project name for grouping containers in Docker Desktop (optional). When set, plugin containers appear grouped as a stack. */
|
/** Docker Compose project name for grouping containers in Docker Desktop (optional). When set, plugin containers appear grouped as a stack. */
|
||||||
PLUGIN_STACK_NAME?: string;
|
PLUGIN_STACK_NAME?: string;
|
||||||
@@ -202,9 +192,7 @@ export const config: EnvConfig = {
|
|||||||
CONTAINER_CALLBACK_URL: process.env.CONTAINER_CALLBACK_URL || 'http://meta-sort:8180',
|
CONTAINER_CALLBACK_URL: process.env.CONTAINER_CALLBACK_URL || 'http://meta-sort:8180',
|
||||||
CONTAINER_META_CORE_URL: process.env.CONTAINER_META_CORE_URL || 'http://meta-sort:9000',
|
CONTAINER_META_CORE_URL: process.env.CONTAINER_META_CORE_URL || 'http://meta-sort:9000',
|
||||||
CONTAINER_NETWORK: process.env.CONTAINER_NETWORK || 'meta-network',
|
CONTAINER_NETWORK: process.env.CONTAINER_NETWORK || 'meta-network',
|
||||||
PLUGIN_FILE_MOUNTS: process.env.PLUGIN_FILE_MOUNTS,
|
PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL,
|
||||||
PLUGIN_CACHE_FOLDER: process.env.PLUGIN_CACHE_FOLDER,
|
|
||||||
PLUGIN_OUTPUT_FOLDER: process.env.PLUGIN_OUTPUT_FOLDER,
|
|
||||||
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,62 +49,29 @@ function getContainerName(pluginId: string, instanceIndex: number): string {
|
|||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
* PLUGIN MOUNT ARCHITECTURE - HARDCODED PATHS
|
* PLUGIN FILE ACCESS ARCHITECTURE - WebDAV
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*
|
*
|
||||||
* Each plugin container has exactly 3 mount points (hardcoded):
|
* Plugins access the /files directory via WebDAV served by meta-sort nginx:
|
||||||
*
|
*
|
||||||
* /files (READ-ONLY) - Files to process, same structure as meta-sort
|
* /webdav (WebDAV endpoint) - Exposes /files with caching
|
||||||
* /output (READ-WRITE) - Plugin-generated files
|
* /files/watch - Watch folder (media files)
|
||||||
* /cache (READ-WRITE) - Plugin-specific cache
|
* /files/test - Test media folder
|
||||||
|
* /files/plugin/ - Plugin output (each plugin writes to /files/plugin/<name>/)
|
||||||
|
* /files/corn - SMB mounts (mounted by rclone inside meta-sort)
|
||||||
*
|
*
|
||||||
* Host paths are configured via environment variables:
|
* WebDAV configuration:
|
||||||
* - PLUGIN_FILE_MOUNTS: Maps subpaths under /files to host paths
|
* - PLUGIN_WEBDAV_URL: WebDAV endpoint URL (e.g., "http://meta-sort-dev/webdav")
|
||||||
* Format: "subpath:host_path,subpath2:host_path2"
|
|
||||||
* Example: "watch:/data/media,test:/app/test/media"
|
|
||||||
* Results in: /files/watch, /files/test mounted in container
|
|
||||||
*
|
*
|
||||||
* - PLUGIN_CACHE_FOLDER: Base path for /cache mounts
|
* Benefits:
|
||||||
* - PLUGIN_OUTPUT_FOLDER: Base path for /output mounts
|
* - Works with Docker's overlay filesystem (no NFS file handle issues)
|
||||||
*
|
* - nginx caching for multiple plugins accessing same file
|
||||||
* SECURITY: Plugins have read-only access to /files.
|
* - Range requests for streaming large files
|
||||||
* Only /cache and /output are writable.
|
* - Bidirectional (read/write) via standard HTTP methods
|
||||||
*
|
*
|
||||||
* ============================================================================
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* File mount configuration - maps container path to host path
|
|
||||||
*/
|
|
||||||
interface FileMountConfig {
|
|
||||||
containerPath: string;
|
|
||||||
hostPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse PLUGIN_FILE_MOUNTS env var into mount configs
|
|
||||||
* Format: "container_path:host_path,container_path2:host_path2"
|
|
||||||
*/
|
|
||||||
function parseFileMounts(mountsStr?: string): FileMountConfig[] {
|
|
||||||
if (!mountsStr) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const mounts: FileMountConfig[] = [];
|
|
||||||
const pairs = mountsStr.split(',').map(s => s.trim()).filter(s => s);
|
|
||||||
|
|
||||||
for (const pair of pairs) {
|
|
||||||
const [containerPath, hostPath] = pair.split(':').map(s => s.trim());
|
|
||||||
if (containerPath && hostPath) {
|
|
||||||
mounts.push({ containerPath, hostPath });
|
|
||||||
} else {
|
|
||||||
console.warn(`[ContainerManager] Invalid mount format: "${pair}" - expected "container_path:host_path"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mounts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ContainerManager extends EventEmitter {
|
export class ContainerManager extends EventEmitter {
|
||||||
private dockerClient: DockerClient;
|
private dockerClient: DockerClient;
|
||||||
private pluginsConfig: ContainerPluginsConfig | null = null;
|
private pluginsConfig: ContainerPluginsConfig | null = null;
|
||||||
@@ -113,9 +80,7 @@ export class ContainerManager extends EventEmitter {
|
|||||||
private roundRobinCounters: Map<string, number> = new Map();
|
private roundRobinCounters: Map<string, number> = new Map();
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
private shutdownInProgress: boolean = false;
|
private shutdownInProgress: boolean = false;
|
||||||
private fileMounts: FileMountConfig[] = [];
|
private webdavUrl?: string;
|
||||||
private pluginCacheFolder?: string;
|
|
||||||
private pluginOutputFolder?: string;
|
|
||||||
private stackName?: string;
|
private stackName?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -129,14 +94,8 @@ export class ContainerManager extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
this.dockerClient = dockerClient || new DockerClient(config.DOCKER_SOCKET_PATH);
|
this.dockerClient = dockerClient || new DockerClient(config.DOCKER_SOCKET_PATH);
|
||||||
|
|
||||||
// Parse file mounts from PLUGIN_FILE_MOUNTS env var
|
// WebDAV configuration for plugin file access
|
||||||
this.fileMounts = parseFileMounts(config.PLUGIN_FILE_MOUNTS);
|
this.webdavUrl = config.PLUGIN_WEBDAV_URL;
|
||||||
|
|
||||||
// Configure plugin cache folder (READ-WRITE)
|
|
||||||
this.pluginCacheFolder = config.PLUGIN_CACHE_FOLDER;
|
|
||||||
|
|
||||||
// Configure plugin output folder (READ-WRITE for /plugin-files)
|
|
||||||
this.pluginOutputFolder = config.PLUGIN_OUTPUT_FOLDER;
|
|
||||||
|
|
||||||
// Configure stack name for Docker Desktop grouping
|
// Configure stack name for Docker Desktop grouping
|
||||||
this.stackName = config.PLUGIN_STACK_NAME;
|
this.stackName = config.PLUGIN_STACK_NAME;
|
||||||
@@ -152,21 +111,18 @@ export class ContainerManager extends EventEmitter {
|
|||||||
|
|
||||||
console.log('[ContainerManager] Initializing...');
|
console.log('[ContainerManager] Initializing...');
|
||||||
|
|
||||||
// Log configured host mounts
|
// Log WebDAV configuration
|
||||||
console.log('[ContainerManager] Plugin mount configuration (hardcoded paths):');
|
console.log('[ContainerManager] Plugin file access configuration (WebDAV):');
|
||||||
console.log(' /files (RO) - Files to process:');
|
if (this.webdavUrl) {
|
||||||
if (this.fileMounts.length > 0) {
|
console.log(` WebDAV URL: ${this.webdavUrl}`);
|
||||||
for (const mount of this.fileMounts) {
|
console.log(' /files/watch - Watch folder (media files)');
|
||||||
const targetPath = mount.containerPath.startsWith('/files')
|
console.log(' /files/test - Test media folder');
|
||||||
? mount.containerPath
|
console.log(' /files/plugin/ - Plugin output (per-plugin subdirs)');
|
||||||
: `/files/${mount.containerPath}`;
|
console.log(' /files/corn - SMB mounts (visible via WebDAV)');
|
||||||
console.log(` ${targetPath} -> ${mount.hostPath}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.warn(' (not configured) - Set PLUGIN_FILE_MOUNTS');
|
console.warn(' WebDAV not configured. Set PLUGIN_WEBDAV_URL.');
|
||||||
|
console.warn(' Plugins will not have access to files until WebDAV is configured.');
|
||||||
}
|
}
|
||||||
console.log(` /cache (RW) - Plugin cache: ${this.pluginCacheFolder || '(not configured)'}`);
|
|
||||||
console.log(` /output (RW) - Plugin output: ${this.pluginOutputFolder || '(not configured)'}`)
|
|
||||||
|
|
||||||
// Initialize Docker client
|
// Initialize Docker client
|
||||||
await this.dockerClient.initialize();
|
await this.dockerClient.initialize();
|
||||||
@@ -336,47 +292,18 @@ export class ContainerManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PLUGIN MOUNT ARCHITECTURE - HARDCODED PATHS:
|
* PLUGIN FILE ACCESS ARCHITECTURE - WebDAV:
|
||||||
* /files (READ-ONLY) - Files to process
|
* Plugins access files via WebDAV URL (no container mounts needed)
|
||||||
* /output (READ-WRITE) - Plugin-generated files
|
* Plugin output goes to /files/plugin/<plugin-id>/ via WebDAV PUT
|
||||||
* /cache (READ-WRITE) - Plugin-specific cache
|
* Sees all mounts including SMB via rclone
|
||||||
|
*
|
||||||
|
* Note: No volume mounts for /files - plugins use HTTP/WebDAV client
|
||||||
*/
|
*/
|
||||||
const mounts: Array<{ source: string; target: string; readonly: boolean }> = [];
|
const mounts: Array<{ source: string; target: string; readonly: boolean }> = [];
|
||||||
|
|
||||||
// /files/* (READ-ONLY) - Mount each configured subpath under /files
|
// No file mounts needed - plugins access files via WebDAV
|
||||||
for (const fileMount of this.fileMounts) {
|
if (!this.webdavUrl) {
|
||||||
// fileMount.containerPath is the subpath (e.g., "watch", "test")
|
console.warn(`[ContainerManager] WARNING: WebDAV not configured. Plugin '${pluginId}' won't have access to files. Set PLUGIN_WEBDAV_URL.`);
|
||||||
// Mount at /files/{subpath}
|
|
||||||
const targetPath = fileMount.containerPath.startsWith('/files')
|
|
||||||
? fileMount.containerPath
|
|
||||||
: `/files/${fileMount.containerPath}`;
|
|
||||||
mounts.push({
|
|
||||||
source: fileMount.hostPath,
|
|
||||||
target: targetPath,
|
|
||||||
readonly: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.fileMounts.length === 0) {
|
|
||||||
console.warn(`[ContainerManager] WARNING: No file mounts configured. Plugin '${pluginId}' won't have access to files.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// /cache (READ-WRITE) - Plugin-specific cache
|
|
||||||
if (this.pluginCacheFolder) {
|
|
||||||
mounts.push({
|
|
||||||
source: `${this.pluginCacheFolder}/${pluginId}`,
|
|
||||||
target: '/cache',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// /output (READ-WRITE) - Plugin-generated files
|
|
||||||
if (this.pluginOutputFolder) {
|
|
||||||
mounts.push({
|
|
||||||
source: `${this.pluginOutputFolder}/${pluginId}`,
|
|
||||||
target: '/output',
|
|
||||||
readonly: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create container
|
// Create container
|
||||||
@@ -395,6 +322,8 @@ export class ContainerManager extends EventEmitter {
|
|||||||
PLUGIN_ID: pluginId,
|
PLUGIN_ID: pluginId,
|
||||||
META_CORE_URL: this.metaCoreUrl,
|
META_CORE_URL: this.metaCoreUrl,
|
||||||
CALLBACK_URL: this.callbackUrl,
|
CALLBACK_URL: this.callbackUrl,
|
||||||
|
WEBDAV_URL: this.webdavUrl || '',
|
||||||
|
FILES_PATH: '/files', // Virtual path - files accessed via WebDAV
|
||||||
},
|
},
|
||||||
// Docker Desktop grouping
|
// Docker Desktop grouping
|
||||||
stackName: this.stackName,
|
stackName: this.stackName,
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ interface PendingCallback {
|
|||||||
timeoutHandle: NodeJS.Timeout;
|
timeoutHandle: NodeJS.Timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gate status for queue control
|
||||||
|
*/
|
||||||
|
export interface GateStatus {
|
||||||
|
isOpen: boolean;
|
||||||
|
fastPending: number;
|
||||||
|
fastRunning: number;
|
||||||
|
backgroundPending: number;
|
||||||
|
backgroundRunning: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Container Plugin Scheduler
|
* Container Plugin Scheduler
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +70,7 @@ export class ContainerPluginScheduler extends EventEmitter {
|
|||||||
private metaCoreUrl: string;
|
private metaCoreUrl: string;
|
||||||
private defaultTimeout: number = 60000; // 60 seconds
|
private defaultTimeout: number = 60000; // 60 seconds
|
||||||
private kvClient: IKVClient | null = null;
|
private kvClient: IKVClient | null = null;
|
||||||
|
private isAcceptingNewTasks: boolean = true; // Gate control for safe mount/unmount
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
containerManager: ContainerManager,
|
containerManager: ContainerManager,
|
||||||
@@ -231,13 +243,29 @@ export class ContainerPluginScheduler extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue a task for execution
|
* Enqueue a task for execution
|
||||||
|
* Returns false if the gate is closed and the task was rejected
|
||||||
*/
|
*/
|
||||||
enqueueTask(task: ContainerTask): void {
|
enqueueTask(task: ContainerTask): boolean {
|
||||||
|
if (!this.isAcceptingNewTasks) {
|
||||||
|
console.log(`[Gate] Task ${task.id} rejected - gate closed (plugin: ${task.pluginId}, file: ${task.filePath})`);
|
||||||
|
// Remove from tracking since it won't be executed
|
||||||
|
this.pendingTasks.delete(task.id);
|
||||||
|
const fileTasks = this.fileTasks.get(task.fileHash);
|
||||||
|
if (fileTasks) {
|
||||||
|
fileTasks.delete(task.id);
|
||||||
|
if (fileTasks.size === 0) {
|
||||||
|
this.fileTasks.delete(task.fileHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
|
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
|
||||||
|
|
||||||
queue.add(async () => {
|
queue.add(async () => {
|
||||||
await this.executeTask(task);
|
await this.executeTask(task);
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -523,6 +551,66 @@ export class ContainerPluginScheduler extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the gate state (open/closed)
|
||||||
|
* When closed, new tasks will be rejected but existing tasks will complete
|
||||||
|
*/
|
||||||
|
setGate(accepting: boolean): void {
|
||||||
|
const wasAccepting = this.isAcceptingNewTasks;
|
||||||
|
this.isAcceptingNewTasks = accepting;
|
||||||
|
if (wasAccepting !== accepting) {
|
||||||
|
console.log(`[Gate] Gate ${accepting ? 'opened' : 'closed'} - ${accepting ? 'accepting' : 'rejecting'} new tasks`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the gate is open (accepting new tasks)
|
||||||
|
*/
|
||||||
|
isGateOpen(): boolean {
|
||||||
|
return this.isAcceptingNewTasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed gate status including queue counts
|
||||||
|
*/
|
||||||
|
getGateStatus(): GateStatus {
|
||||||
|
const queueStatus = this.getQueueStatus();
|
||||||
|
return {
|
||||||
|
isOpen: this.isAcceptingNewTasks,
|
||||||
|
fastPending: queueStatus.fast.pending,
|
||||||
|
fastRunning: queueStatus.fast.running,
|
||||||
|
backgroundPending: queueStatus.background.pending,
|
||||||
|
backgroundRunning: queueStatus.background.running,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for both queues to drain (all tasks completed)
|
||||||
|
* Useful for safe unmount operations
|
||||||
|
* @param timeoutMs Maximum time to wait (default: 60000ms)
|
||||||
|
* @returns true if queues are empty, false if timeout
|
||||||
|
*/
|
||||||
|
async waitForEmpty(timeoutMs: number = 60000): Promise<boolean> {
|
||||||
|
const start = Date.now();
|
||||||
|
const pollInterval = 500;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
const status = this.getGateStatus();
|
||||||
|
const totalActive = status.fastPending + status.fastRunning +
|
||||||
|
status.backgroundPending + status.backgroundRunning;
|
||||||
|
|
||||||
|
if (totalActive === 0) {
|
||||||
|
console.log(`[Gate] Queues drained after ${Date.now() - start}ms`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, pollInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Gate] Timeout waiting for queues to drain (${timeoutMs}ms)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get pending tasks for a file
|
* Get pending tasks for a file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export class DockerClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build Binds array for bind mounts
|
||||||
|
const binds = options.mounts?.map(m => {
|
||||||
|
const opts = [m.readonly ? 'ro' : 'rw'];
|
||||||
|
return `${m.source}:${m.target}:${opts.join(',')}`;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
const createOptions: ContainerCreateOptions = {
|
const createOptions: ContainerCreateOptions = {
|
||||||
Image: options.image,
|
Image: options.image,
|
||||||
name: options.name,
|
name: options.name,
|
||||||
@@ -111,9 +117,7 @@ export class DockerClient {
|
|||||||
: undefined,
|
: undefined,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
HostConfig: {
|
HostConfig: {
|
||||||
Binds: options.mounts?.map(m =>
|
Binds: binds.length > 0 ? binds : undefined,
|
||||||
`${m.source}:${m.target}:${m.readonly ? 'ro' : 'rw'}`
|
|
||||||
),
|
|
||||||
NetworkMode: options.network,
|
NetworkMode: options.network,
|
||||||
Memory: options.resources?.memory
|
Memory: options.resources?.memory
|
||||||
? parseMemoryLimit(options.resources.memory)
|
? parseMemoryLimit(options.resources.memory)
|
||||||
|
|||||||
@@ -31,4 +31,5 @@ export {
|
|||||||
ContainerPluginScheduler,
|
ContainerPluginScheduler,
|
||||||
createContainerPluginScheduler,
|
createContainerPluginScheduler,
|
||||||
type ContainerSchedulerEvents,
|
type ContainerSchedulerEvents,
|
||||||
|
type GateStatus,
|
||||||
} from './ContainerPluginScheduler.js';
|
} from './ContainerPluginScheduler.js';
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export interface ContainerSpawnOptions {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
/** Resource limits */
|
/** Resource limits */
|
||||||
resources?: ContainerResourceLimits;
|
resources?: ContainerResourceLimits;
|
||||||
/** Volume mounts */
|
/** Volume mounts (bind mounts only - files accessed via WebDAV) */
|
||||||
mounts?: Array<{
|
mounts?: Array<{
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
|||||||
@@ -258,6 +258,10 @@ process.on('SIGINT', async () => {
|
|||||||
console.log('[Startup] ContainerPluginScheduler connected to StreamingPipeline');
|
console.log('[Startup] ContainerPluginScheduler connected to StreamingPipeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Connect StreamingPipeline to API server (for pause/resume control)
|
||||||
|
apiServer.setStreamingPipeline(pipeline);
|
||||||
|
console.log('[Startup] StreamingPipeline connected to API server');
|
||||||
|
|
||||||
// First, rebuild VFS from KV to restore state from previous runs
|
// First, rebuild VFS from KV to restore state from previous runs
|
||||||
console.log('[Startup] Rebuilding VirtualFileSystem from KV...');
|
console.log('[Startup] Rebuilding VirtualFileSystem from KV...');
|
||||||
await fileProcessor.rebuildVFSFromKV();
|
await fileProcessor.rebuildVFSFromKV();
|
||||||
|
|||||||
@@ -432,6 +432,36 @@ export class StreamingPipeline {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause all pipeline queues
|
||||||
|
* Running tasks will complete, but no new tasks will start
|
||||||
|
*/
|
||||||
|
pause(): void {
|
||||||
|
this.validationQueue.pause();
|
||||||
|
this.fastQueue.pause();
|
||||||
|
this.backgroundQueue.pause();
|
||||||
|
console.log('[Pipeline] All queues paused');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume all pipeline queues
|
||||||
|
*/
|
||||||
|
resume(): void {
|
||||||
|
this.validationQueue.start();
|
||||||
|
this.fastQueue.start();
|
||||||
|
this.backgroundQueue.start();
|
||||||
|
console.log('[Pipeline] All queues resumed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pipeline is paused
|
||||||
|
*/
|
||||||
|
isPaused(): boolean {
|
||||||
|
return this.validationQueue.isPaused ||
|
||||||
|
this.fastQueue.isPaused ||
|
||||||
|
this.backgroundQueue.isPaused;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log current pipeline progress
|
* Log current pipeline progress
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ function Monitor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopProcessing = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/processing/stop', { method: 'POST' });
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop processing:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startProcessing = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/processing/start', { method: 'POST' });
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start processing:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const retryFile = async (filePath: string) => {
|
const retryFile = async (filePath: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/processing/retry', {
|
await fetch('/api/processing/retry', {
|
||||||
@@ -156,9 +174,20 @@ function Monitor() {
|
|||||||
<div className="monitor-page">
|
<div className="monitor-page">
|
||||||
<div className="monitor-header">
|
<div className="monitor-header">
|
||||||
<h1>Processing Monitor</h1>
|
<h1>Processing Monitor</h1>
|
||||||
<button className="btn btn-primary" onClick={triggerScan}>
|
<div className="header-actions">
|
||||||
Trigger Scan
|
{status?.computed?.gateOpen === false ? (
|
||||||
</button>
|
<button className="btn btn-success" onClick={startProcessing}>
|
||||||
|
Start Processing
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-danger" onClick={stopProcessing}>
|
||||||
|
Stop Processing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary" onClick={triggerScan}>
|
||||||
|
Trigger Scan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pipeline Progress Bar - 6 Stage (including failed) */}
|
{/* Pipeline Progress Bar - 6 Stage (including failed) */}
|
||||||
@@ -523,6 +552,29 @@ function Monitor() {
|
|||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: linear-gradient(135deg, #059669, #047857);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Pipeline Progress Bar */
|
/* Pipeline Progress Bar */
|
||||||
.pipeline-section {
|
.pipeline-section {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export interface ComputedQueueStats {
|
|||||||
backgroundQueueTotal: number;
|
backgroundQueueTotal: number;
|
||||||
backgroundQueuePaused: boolean;
|
backgroundQueuePaused: boolean;
|
||||||
|
|
||||||
|
// Gate control status
|
||||||
|
gateOpen: boolean;
|
||||||
|
|
||||||
// Legacy fields for backwards compatibility
|
// Legacy fields for backwards compatibility
|
||||||
actualRunningLightWorkers: number;
|
actualRunningLightWorkers: number;
|
||||||
actualRunningHashWorkers: number;
|
actualRunningHashWorkers: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user