fix KV API to unified format, and test

This commit is contained in:
Pierre
2026-01-24 18:15:21 +01:00
parent 24b1c2fcea
commit 1c51d09719
13 changed files with 4064 additions and 83 deletions
+36
View File
@@ -0,0 +1,36 @@
# MetaMesh Plugin: ffmpeg - Test Image
# Builds the plugin and runs tests with FFprobe available
FROM node:20-slim
# Install ffmpeg for integration tests
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy package files and install ALL dependencies (including devDependencies)
COPY package.json tsconfig.json ./
RUN npm install
# Copy source and test files
COPY src/ ./src/
COPY test/ ./test/
# Build the plugin
RUN npm run build
# Generate test fixtures
RUN chmod +x ./test/fixtures/generate-test-video.sh && \
./test/fixtures/generate-test-video.sh
# Create cache directory for tests
RUN mkdir -p /cache && chmod 777 /cache
# Set environment for tests
ENV NODE_ENV=test
ENV CACHE_PATH=/cache
# Default command: run tests
CMD ["npm", "test"]
+3399
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -7,7 +7,10 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsx watch src/index.ts" "dev": "tsx watch src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"fastify": "^5.0.0", "fastify": "^5.0.0",
@@ -16,8 +19,10 @@
"devDependencies": { "devDependencies": {
"@types/fluent-ffmpeg": "^2.1.24", "@types/fluent-ffmpeg": "^2.1.24",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@vitest/coverage-v8": "^1.0.0",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.3.0" "typescript": "^5.3.0",
"vitest": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
+72 -81
View File
@@ -4,12 +4,13 @@
* Extracts video/audio/subtitle stream metadata using FFprobe. * Extracts video/audio/subtitle stream metadata using FFprobe.
* Depends on file-info plugin to determine file type. * Depends on file-info plugin to determine file type.
* *
* Matches old FFMpegFileProcessor output: * Storage format:
* - fileinfo/duration * - fileinfo/duration = "123.45"
* - fileinfo/formatName * - fileinfo/formatName = "matroska,webm"
* - fileinfo/streamdetails/video/{n}/codec, width, height, etc. * - stream/0 = '{"type":"video","codec":"h264","width":1920,...}' (JSON string)
* - fileinfo/streamdetails/audio/{n}/codec, language, etc. * - stream/1 = '{"type":"audio","codec":"aac","language":"eng",...}' (JSON string)
* - fileinfo/streamdetails/subtitle/{n}/codec, language, etc. *
* Each stream is stored as a JSON string at the stream level.
*/ */
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
@@ -40,23 +41,12 @@ export const manifest: PluginManifest = {
schema: { schema: {
'fileinfo/duration': { label: 'Duration', type: 'string', readonly: true }, 'fileinfo/duration': { label: 'Duration', type: 'string', readonly: true },
'fileinfo/formatName': { label: 'Format Name', type: 'string', readonly: true }, 'fileinfo/formatName': { label: 'Format Name', type: 'string', readonly: true },
'fileinfo/streamdetails/video': { // Streams are stored as stream/0, stream/1, etc. - each is a JSON string
label: 'Video Streams', 'stream/*': {
label: 'Stream',
type: 'json', type: 'json',
readonly: true, readonly: true,
hint: 'Video codec, resolution, bitrate, frame rate', hint: 'Stream metadata (video/audio/subtitle) as JSON string',
},
'fileinfo/streamdetails/audio': {
label: 'Audio Streams',
type: 'json',
readonly: true,
hint: 'Audio codec, language, sample rate, channels',
},
'fileinfo/streamdetails/subtitle': {
label: 'Subtitle Streams',
type: 'json',
readonly: true,
hint: 'Subtitle codec, language, title',
}, },
}, },
config: {}, config: {},
@@ -192,115 +182,99 @@ export async function process(
fileinfo.formatName = formatName; fileinfo.formatName = formatName;
} }
// Stream counters // Global stream counter (all streams share one index)
let videoStreamIndex = 0; let globalStreamIndex = 0;
let audioStreamIndex = 0;
let subtitleStreamIndex = 0;
let embeddedImageStreamIndex = 0;
// Process each stream // Process each stream - store as JSON string at stream level
for (const stream of data.streams || []) { for (const stream of data.streams || []) {
if (!stream) continue; if (!stream) continue;
const codecType = String(stream.codec_type || ''); const codecType = String(stream.codec_type || '');
let basePath: string;
let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage'; let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage';
let streamIdx: number;
if (codecType === 'video') { if (codecType === 'video') {
basePath = `fileinfo/streamdetails/video/${videoStreamIndex}`;
streamType = 'video'; streamType = 'video';
streamIdx = videoStreamIndex++;
} else if (codecType === 'audio') { } else if (codecType === 'audio') {
basePath = `fileinfo/streamdetails/audio/${audioStreamIndex}`;
streamType = 'audio'; streamType = 'audio';
streamIdx = audioStreamIndex++;
} else if (codecType === 'subtitle') { } else if (codecType === 'subtitle') {
basePath = `fileinfo/streamdetails/subtitle/${subtitleStreamIndex}`;
streamType = 'subtitle'; streamType = 'subtitle';
streamIdx = subtitleStreamIndex++;
} else if (codecType === 'embeddedimage') { } else if (codecType === 'embeddedimage') {
basePath = `fileinfo/streamdetails/embeddedimage/${embeddedImageStreamIndex}`;
streamType = 'embeddedimage'; streamType = 'embeddedimage';
streamIdx = embeddedImageStreamIndex++;
} else { } else {
continue; continue;
} }
// Initialize stream object for cache // Build stream object with all relevant fields
if (!fileinfo.streamdetails![streamType]) { const streamData: Record<string, string | number | boolean> = {
fileinfo.streamdetails![streamType] = {}; type: streamType,
} };
fileinfo.streamdetails![streamType]![streamIdx] = {};
const streamCache = fileinfo.streamdetails![streamType]![streamIdx];
// Common fields // Common fields
if (stream.codec_name) { if (stream.codec_name) {
metadata[`${basePath}/codec`] = String(stream.codec_name); streamData.codec = String(stream.codec_name);
streamCache.codec = String(stream.codec_name);
} }
if (stream.index != null) { if (stream.index != null) {
metadata[`${basePath}/index`] = String(stream.index); streamData.index = stream.index;
streamCache.index = String(stream.index);
} }
if (stream.duration) { if (stream.duration) {
metadata[`${basePath}/duration`] = String(stream.duration); streamData.duration = String(stream.duration);
streamCache.duration = String(stream.duration);
}
if (stream.codec_type) {
metadata[`${basePath}/codecType`] = String(stream.codec_type);
streamCache.codecType = String(stream.codec_type);
} }
if (stream.disposition?.forced != null) { if (stream.disposition?.forced != null) {
const forced = stream.disposition.forced ? 'true' : 'false'; streamData.forced = stream.disposition.forced === 1;
metadata[`${basePath}/forced`] = forced;
streamCache.forced = forced;
} }
if (stream.disposition?.default != null) { if (stream.disposition?.default != null) {
const defaultVal = stream.disposition.default ? 'true' : 'false'; streamData.default = stream.disposition.default === 1;
metadata[`${basePath}/default`] = defaultVal;
streamCache.default = defaultVal;
} }
if (stream.tags?.language) { if (stream.tags?.language) {
metadata[`${basePath}/language`] = String(stream.tags.language); streamData.language = String(stream.tags.language);
streamCache.language = String(stream.tags.language);
} }
if (stream.tags?.title) { if (stream.tags?.title) {
metadata[`${basePath}/title`] = String(stream.tags.title); streamData.title = String(stream.tags.title);
streamCache.title = String(stream.tags.title);
} }
// Video-specific fields // Video-specific fields
if (stream.width && stream.width !== ('N/A' as unknown)) { if (stream.width && stream.width !== ('N/A' as unknown)) {
metadata[`${basePath}/width`] = String(stream.width); streamData.width = stream.width;
streamCache.width = String(stream.width);
} }
if (stream.height && stream.height !== ('N/A' as unknown)) { if (stream.height && stream.height !== ('N/A' as unknown)) {
metadata[`${basePath}/height`] = String(stream.height); streamData.height = stream.height;
streamCache.height = String(stream.height);
} }
if (stream.bit_rate && stream.bit_rate !== 'N/A') { if (stream.bit_rate && stream.bit_rate !== 'N/A') {
metadata[`${basePath}/bitrate`] = String(stream.bit_rate); streamData.bitrate = String(stream.bit_rate);
streamCache.bitrate = String(stream.bit_rate);
} }
if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') { if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') {
metadata[`${basePath}/frameRate`] = String(stream.avg_frame_rate); streamData.frameRate = String(stream.avg_frame_rate);
streamCache.frameRate = String(stream.avg_frame_rate);
} }
if (stream.tags?.rotate) { if (stream.tags?.rotate) {
metadata[`${basePath}/rotation`] = String(stream.tags.rotate); streamData.rotation = String(stream.tags.rotate);
streamCache.rotation = String(stream.tags.rotate);
} }
// Audio-specific fields // Audio-specific fields
if (stream.sample_rate) { if (stream.sample_rate) {
metadata[`${basePath}/sampleRate`] = String(stream.sample_rate); streamData.sampleRate = String(stream.sample_rate);
streamCache.sampleRate = String(stream.sample_rate);
} }
if (stream.channel_layout) { if (stream.channel_layout) {
metadata[`${basePath}/channelLayout`] = String(stream.channel_layout); streamData.channelLayout = String(stream.channel_layout);
streamCache.channelLayout = String(stream.channel_layout);
} }
// Store stream as JSON string at stream/{n}
metadata[`stream/${globalStreamIndex}`] = JSON.stringify(streamData);
// Initialize stream object for cache (keep old format for backwards compat)
if (!fileinfo.streamdetails![streamType]) {
fileinfo.streamdetails![streamType] = {};
}
// Store stringified fields in cache for reconstruction
const typeStreamIndex = Object.keys(fileinfo.streamdetails![streamType]!).length;
fileinfo.streamdetails![streamType]![typeStreamIndex] = {};
const streamCache = fileinfo.streamdetails![streamType]![typeStreamIndex];
for (const [key, value] of Object.entries(streamData)) {
if (key !== 'type') {
streamCache[key] = String(value);
}
}
globalStreamIndex++;
} }
// Write to meta-core // Write to meta-core
@@ -356,17 +330,34 @@ async function applyCachedMetadata(
metadata['fileinfo/formatName'] = String(fileinfo.formatName); metadata['fileinfo/formatName'] = String(fileinfo.formatName);
} }
// Apply streamdetails // Apply streamdetails - store each stream as JSON string at stream/{n}
const streamdetails = fileinfo.streamdetails || {}; const streamdetails = fileinfo.streamdetails || {};
let globalStreamIndex = 0;
for (const streamType of ['video', 'audio', 'subtitle', 'embeddedimage'] as const) { for (const streamType of ['video', 'audio', 'subtitle', 'embeddedimage'] as const) {
const streams = streamdetails[streamType] || {}; const streams = streamdetails[streamType] || {};
for (const [index, stream] of Object.entries(streams)) { for (const [, stream] of Object.entries(streams)) {
const basePath = `fileinfo/streamdetails/${streamType}/${index}`; // Build stream object with type
const streamData: Record<string, string | number | boolean> = {
type: streamType,
};
for (const [key, value] of Object.entries(stream)) { for (const [key, value] of Object.entries(stream)) {
if (value != null) { if (value != null) {
metadata[`${basePath}/${key}`] = String(value); // Try to parse numbers and booleans back to their original types
if (value === 'true') {
streamData[key] = true;
} else if (value === 'false') {
streamData[key] = false;
} else if (!isNaN(Number(value)) && value !== '') {
streamData[key] = Number(value);
} else {
streamData[key] = value;
}
} }
} }
// Store as JSON string
metadata[`stream/${globalStreamIndex}`] = JSON.stringify(streamData);
globalStreamIndex++;
} }
} }
+93
View File
@@ -0,0 +1,93 @@
#!/bin/bash
# =============================================================================
# FFmpeg Plugin Test Runner
#
# Runs integration tests inside a Docker container with FFprobe available.
# Usage:
# ./test.sh # Build and run tests
# ./test.sh --no-cache # Rebuild without cache
# ./test.sh --shell # Start shell in test container for debugging
# =============================================================================
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
IMAGE_NAME="metamesh-plugin-ffmpeg-test"
DOCKERFILE="Dockerfile.test"
# Parse arguments
NO_CACHE=""
SHELL_MODE=""
for arg in "$@"; do
case $arg in
--no-cache)
NO_CACHE="--no-cache"
;;
--shell)
SHELL_MODE=1
;;
--help|-h)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --no-cache Rebuild Docker image without cache"
echo " --shell Start interactive shell in test container"
echo " --help Show this help message"
exit 0
;;
esac
done
echo "=============================================="
echo " FFmpeg Plugin Test Runner"
echo "=============================================="
echo ""
# Check if Docker is available
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed or not in PATH"
exit 1
fi
# Build the test image
echo "[1/2] Building test image..."
docker build $NO_CACHE -f "$DOCKERFILE" -t "$IMAGE_NAME" .
if [ -n "$SHELL_MODE" ]; then
echo ""
echo "[2/2] Starting interactive shell..."
echo " Run 'npm test' to execute tests"
echo ""
docker run --rm -it \
-v "$SCRIPT_DIR/test:/app/test:ro" \
-v "$SCRIPT_DIR/src:/app/src:ro" \
"$IMAGE_NAME" \
/bin/bash
else
echo ""
echo "[2/2] Running tests..."
echo ""
# Run tests and capture exit code
docker run --rm \
-e CI=true \
"$IMAGE_NAME"
EXIT_CODE=$?
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo "=============================================="
echo " All tests passed!"
echo "=============================================="
else
echo "=============================================="
echo " Tests failed (exit code: $EXIT_CODE)"
echo "=============================================="
fi
exit $EXIT_CODE
fi
+93
View File
@@ -0,0 +1,93 @@
# FFmpeg Plugin Tests
Integration tests for the FFmpeg metadata extraction plugin.
## Requirements
Tests require FFprobe to be installed. Since FFprobe is typically only available in the Docker container, tests are designed to run inside Docker.
## Running Tests
### Quick Start (Recommended)
```bash
# From the plugin directory
./test.sh
```
This builds a test Docker image and runs all tests inside it.
### Options
```bash
./test.sh --no-cache # Rebuild without Docker cache
./test.sh --shell # Start interactive shell for debugging
```
### Running Locally (if FFprobe is installed)
```bash
npm install
npm test
```
## Test Structure
```
test/
├── fixtures/
│ ├── generate-test-video.sh # Generates test video files
│ ├── simple.mp4 # Basic video + audio (generated)
│ ├── multi-audio.mkv # Multiple audio tracks (generated)
│ ├── with-subs.mkv # Video with subtitles (generated)
│ └── audio-only.mp3 # Audio file for skip tests (generated)
├── integration.test.ts # Integration tests
└── README.md # This file
```
## Test Categories
### Integration Tests (`integration.test.ts`)
1. **Prerequisites** - Verify FFprobe and fixtures are available
2. **Manifest** - Validate plugin manifest structure
3. **Skip Logic** - Test that non-video files are skipped
4. **FFprobe Validation** - Direct FFprobe output verification
5. **Output Format** - Validate FFprobe JSON matches plugin expectations
## Test Fixtures
Test fixtures are generated automatically during Docker build using `generate-test-video.sh`. The script creates:
- `simple.mp4` - 2-second 320x240 H.264 video with AAC audio
- `multi-audio.mkv` - Video with two audio tracks (English + Japanese)
- `with-subs.mkv` - Video with embedded SRT subtitles
- `audio-only.mp3` - Audio file (for testing skip logic)
## Writing New Tests
```typescript
import { describe, it, expect } from 'vitest';
import path from 'path';
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
describe('My New Test', () => {
it('does something', async () => {
// Your test here
});
});
```
## Debugging
Start an interactive shell in the test container:
```bash
./test.sh --shell
# Inside container:
npm test # Run all tests
npx vitest run -t "skip" # Run tests matching "skip"
ffprobe test/fixtures/simple.mp4 # Manually inspect video
```
BIN
View File
Binary file not shown.
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# Generate minimal test videos for FFprobe integration tests
# Run inside Docker container with FFmpeg installed
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "Generating test video files..."
# 1. Simple video with video + audio streams (2 seconds, ~100KB)
if [ ! -f "simple.mp4" ]; then
echo "Creating simple.mp4..."
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=24 \
-f lavfi -i sine=frequency=440:duration=2 \
-c:v libx264 -preset ultrafast -crf 28 \
-c:a aac -b:a 64k \
-shortest \
simple.mp4
fi
# 2. Video with multiple audio tracks (for multi-stream testing)
if [ ! -f "multi-audio.mkv" ]; then
echo "Creating multi-audio.mkv..."
ffmpeg -y -f lavfi -i testsrc=duration=2:size=640x480:rate=30 \
-f lavfi -i sine=frequency=440:duration=2 \
-f lavfi -i sine=frequency=880:duration=2 \
-c:v libx264 -preset ultrafast -crf 28 \
-filter_complex "[1:a]aformat=channel_layouts=stereo[a1];[2:a]aformat=channel_layouts=stereo[a2]" \
-map 0:v -map "[a1]" -map "[a2]" \
-c:a aac -b:a 64k \
-metadata:s:a:0 language=eng -metadata:s:a:0 title="English" \
-metadata:s:a:1 language=jpn -metadata:s:a:1 title="Japanese" \
multi-audio.mkv
fi
# 3. Video with subtitles (if possible, create with embedded subs)
if [ ! -f "with-subs.mkv" ]; then
echo "Creating with-subs.mkv..."
# Create a simple SRT file
cat > temp_subs.srt << 'EOF'
1
00:00:00,000 --> 00:00:01,000
Test subtitle line 1
2
00:00:01,000 --> 00:00:02,000
Test subtitle line 2
EOF
ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=24 \
-f lavfi -i sine=frequency=440:duration=2 \
-i temp_subs.srt \
-c:v libx264 -preset ultrafast -crf 28 \
-c:a aac -b:a 64k \
-c:s srt \
-metadata:s:s:0 language=eng \
with-subs.mkv
rm -f temp_subs.srt
fi
# 4. Audio-only file (should be skipped by FFmpeg plugin)
if [ ! -f "audio-only.mp3" ]; then
echo "Creating audio-only.mp3..."
ffmpeg -y -f lavfi -i sine=frequency=440:duration=2 \
-c:a libmp3lame -b:a 128k \
audio-only.mp3
fi
echo "Test fixtures generated:"
ls -la *.mp4 *.mkv *.mp3 2>/dev/null || true
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+274
View File
@@ -0,0 +1,274 @@
/**
* FFmpeg Plugin Integration Tests
*
* These tests require FFprobe to be installed (run in Docker container).
* Tests the full processing pipeline with real video files.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = path.join(__dirname, 'fixtures');
// Dynamic import of plugin module (ES modules)
let processFile: typeof import('../src/plugin.js').process;
let manifest: typeof import('../src/plugin.js').manifest;
// Mock callback collector
interface CallbackResult {
taskId: string;
status: 'completed' | 'failed' | 'skipped';
duration: number;
error?: string;
reason?: string;
}
let lastCallback: CallbackResult | null = null;
const mockSendCallback = async (payload: CallbackResult): Promise<void> => {
lastCallback = payload;
};
// Check if FFprobe is available
function isFFprobeAvailable(): boolean {
try {
execSync('ffprobe -version', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// Check if test fixtures exist
function fixturesExist(): boolean {
return fs.existsSync(path.join(FIXTURES_DIR, 'simple.mp4'));
}
describe('FFmpeg Plugin Integration Tests', () => {
beforeAll(async () => {
// Skip all tests if FFprobe is not available
if (!isFFprobeAvailable()) {
console.warn('FFprobe not available - skipping integration tests');
return;
}
// Check fixtures
if (!fixturesExist()) {
console.warn('Test fixtures not found - run generate-test-video.sh first');
return;
}
// Import the plugin module
const plugin = await import('../src/plugin.js');
processFile = plugin.process;
manifest = plugin.manifest;
});
afterAll(() => {
lastCallback = null;
});
describe('Prerequisites', () => {
it('FFprobe is available', () => {
expect(isFFprobeAvailable()).toBe(true);
});
it('test fixtures exist', () => {
expect(fixturesExist()).toBe(true);
});
});
describe('Manifest', () => {
it('has required fields', async () => {
if (!manifest) return;
expect(manifest.id).toBe('ffmpeg');
expect(manifest.name).toBeDefined();
expect(manifest.version).toBeDefined();
expect(manifest.dependencies).toContain('file-info');
expect(manifest.priority).toBe(15);
expect(manifest.timeout).toBe(60000);
});
it('declares correct schema', async () => {
if (!manifest) return;
expect(manifest.schema).toHaveProperty('fileinfo/duration');
expect(manifest.schema).toHaveProperty('fileinfo/formatName');
expect(manifest.schema).toHaveProperty('stream/*');
});
});
describe('Process - Skip Logic', () => {
it('skips non-video files', async () => {
if (!processFile) return;
await processFile({
taskId: 'test-skip-1',
cid: 'test-cid-1',
filePath: path.join(FIXTURES_DIR, 'audio-only.mp3'),
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: { fileType: 'audio' },
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('skipped');
expect(lastCallback?.reason).toContain('Not a video');
});
it('skips already processed files', async () => {
if (!processFile) return;
await processFile({
taskId: 'test-skip-2',
cid: 'test-cid-2',
filePath: path.join(FIXTURES_DIR, 'simple.mp4'),
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000',
existingMeta: {
fileType: 'video',
'fileinfo/duration': '2.0', // Already has duration
},
}, mockSendCallback);
expect(lastCallback).toBeDefined();
expect(lastCallback?.status).toBe('skipped');
expect(lastCallback?.reason).toContain('Already processed');
});
});
describe('Process - Simple Video (MP4)', () => {
it('extracts metadata from simple.mp4', async () => {
if (!processFile) return;
// Create a mock MetaCoreClient that captures the metadata
let capturedMetadata: Record<string, string> = {};
// We need to mock the MetaCoreClient - for now, we'll just verify the callback
await processFile({
taskId: 'test-simple-1',
cid: 'test-cid-simple',
filePath: path.join(FIXTURES_DIR, 'simple.mp4'),
callbackUrl: 'http://localhost/callback',
metaCoreUrl: 'http://localhost:9000', // Won't actually connect
existingMeta: { fileType: 'video' },
}, mockSendCallback);
// The test will fail when trying to connect to metaCoreUrl
// but we can still verify the callback behavior
expect(lastCallback).toBeDefined();
// If meta-core is not available, it will fail
// In a real test we'd mock the MetaCoreClient
});
});
describe('Process - Direct FFprobe Validation', () => {
it('ffprobe can read simple.mp4', async () => {
const result = execSync(
`ffprobe -v quiet -print_format json -show_format -show_streams "${path.join(FIXTURES_DIR, 'simple.mp4')}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
expect(data.format).toBeDefined();
expect(data.format.duration).toBeDefined();
expect(parseFloat(data.format.duration)).toBeGreaterThan(0);
expect(data.streams).toBeDefined();
expect(data.streams.length).toBeGreaterThanOrEqual(1);
});
it('ffprobe extracts video stream from simple.mp4', async () => {
const result = execSync(
`ffprobe -v quiet -print_format json -show_streams "${path.join(FIXTURES_DIR, 'simple.mp4')}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
expect(videoStream).toBeDefined();
expect(videoStream.codec_name).toBe('h264');
expect(videoStream.width).toBe(320);
expect(videoStream.height).toBe(240);
});
it('ffprobe extracts audio stream from simple.mp4', async () => {
const result = execSync(
`ffprobe -v quiet -print_format json -show_streams "${path.join(FIXTURES_DIR, 'simple.mp4')}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');
expect(audioStream).toBeDefined();
expect(audioStream.codec_name).toBe('aac');
});
it('ffprobe extracts multiple audio tracks from multi-audio.mkv', async () => {
const mkvPath = path.join(FIXTURES_DIR, 'multi-audio.mkv');
if (!fs.existsSync(mkvPath)) {
console.warn('multi-audio.mkv not found, skipping');
return;
}
const result = execSync(
`ffprobe -v quiet -print_format json -show_streams "${mkvPath}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
const audioStreams = data.streams.filter((s: any) => s.codec_type === 'audio');
expect(audioStreams.length).toBeGreaterThanOrEqual(2);
});
it('ffprobe extracts subtitles from with-subs.mkv', async () => {
const mkvPath = path.join(FIXTURES_DIR, 'with-subs.mkv');
if (!fs.existsSync(mkvPath)) {
console.warn('with-subs.mkv not found, skipping');
return;
}
const result = execSync(
`ffprobe -v quiet -print_format json -show_streams "${mkvPath}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
const subStream = data.streams.find((s: any) => s.codec_type === 'subtitle');
expect(subStream).toBeDefined();
});
});
});
describe('FFprobe Output Format', () => {
it('validates stream JSON structure matches plugin expectations', async () => {
if (!fixturesExist()) return;
const result = execSync(
`ffprobe -v quiet -print_format json -show_format -show_streams "${path.join(FIXTURES_DIR, 'simple.mp4')}"`,
{ encoding: 'utf-8' }
);
const data = JSON.parse(result);
// Validate format fields the plugin expects
expect(data.format).toHaveProperty('duration');
expect(data.format).toHaveProperty('format_name');
// Validate stream fields the plugin expects
const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
expect(videoStream).toHaveProperty('codec_type');
expect(videoStream).toHaveProperty('codec_name');
expect(videoStream).toHaveProperty('index');
expect(videoStream).toHaveProperty('width');
expect(videoStream).toHaveProperty('height');
const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');
expect(audioStream).toHaveProperty('codec_type');
expect(audioStream).toHaveProperty('codec_name');
expect(audioStream).toHaveProperty('sample_rate');
});
});
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
testTimeout: 30000, // 30s timeout for FFprobe tests
hookTimeout: 60000, // 60s for setup/teardown
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/types.ts'],
},
},
});