bugfix
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* File-based cache utility for plugin data
|
||||||
|
* Matches the old ctx.cache API: readJson, writeJson
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure cache directory exists
|
||||||
|
*/
|
||||||
|
async function ensureCacheDir(): Promise<void> {
|
||||||
|
if (!existsSync(CACHE_DIR)) {
|
||||||
|
await mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read JSON from cache
|
||||||
|
* @param filename - Cache filename (e.g., "abc123.json")
|
||||||
|
* @returns Parsed JSON object or null if not found
|
||||||
|
*/
|
||||||
|
export async function readJson<T = unknown>(filename: string): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(CACHE_DIR, filename);
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content) as T;
|
||||||
|
} catch (error) {
|
||||||
|
console.debug(`[cache] Failed to read ${filename}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write JSON to cache
|
||||||
|
* @param filename - Cache filename (e.g., "abc123.json")
|
||||||
|
* @param data - Data to cache
|
||||||
|
*/
|
||||||
|
export async function writeJson(filename: string, data: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureCacheDir();
|
||||||
|
const filePath = path.join(CACHE_DIR, filename);
|
||||||
|
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
console.debug(`[cache] Wrote ${filename}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[cache] Failed to write ${filename}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -1,6 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* MetaMesh Plugin: ffmpeg
|
* MetaMesh Plugin: ffmpeg
|
||||||
* Entry point
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Each plugin container has exactly 3 mounts (2 for plugins without output):
|
||||||
|
*
|
||||||
|
* 1. /files (READ-ONLY) - Shared media files, read access only
|
||||||
|
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
|
||||||
|
* 3. /files/plugin/<id> (READ-WRITE) - Plugin output folder (if needed)
|
||||||
|
*
|
||||||
|
* This plugin (ffmpeg) only requires mounts 1 and 2:
|
||||||
|
* - /files (RO) - to read video files for ffprobe analysis
|
||||||
|
* - /cache (RW) - to cache ffprobe results
|
||||||
|
*
|
||||||
|
* SECURITY: Plugins must NEVER write to /files directly.
|
||||||
|
* All write operations go to /cache or /files/plugin/<id> only.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
|
|||||||
+172
-25
@@ -2,11 +2,20 @@
|
|||||||
* FFmpeg Plugin
|
* FFmpeg Plugin
|
||||||
*
|
*
|
||||||
* 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.
|
||||||
|
*
|
||||||
|
* Matches old FFMpegFileProcessor output:
|
||||||
|
* - fileinfo/duration
|
||||||
|
* - fileinfo/formatName
|
||||||
|
* - fileinfo/streamdetails/video/{n}/codec, width, height, etc.
|
||||||
|
* - fileinfo/streamdetails/audio/{n}/codec, language, etc.
|
||||||
|
* - fileinfo/streamdetails/subtitle/{n}/codec, language, etc.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
|
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
|
||||||
import { MetaCoreClient } from './meta-core-client.js';
|
import { MetaCoreClient } from './meta-core-client.js';
|
||||||
|
import { readJson, writeJson } from './cache.js';
|
||||||
|
|
||||||
export const manifest: PluginManifest = {
|
export const manifest: PluginManifest = {
|
||||||
id: 'ffmpeg',
|
id: 'ffmpeg',
|
||||||
@@ -72,6 +81,17 @@ interface FFprobeData {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileInfoCache {
|
||||||
|
duration?: string;
|
||||||
|
formatName?: string;
|
||||||
|
streamdetails?: {
|
||||||
|
video?: Record<string, Record<string, string>>;
|
||||||
|
audio?: Record<string, Record<string, string>>;
|
||||||
|
subtitle?: Record<string, Record<string, string>>;
|
||||||
|
embeddedimage?: Record<string, Record<string, string>>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function runFFprobe(filePath: string): Promise<FFprobeData> {
|
function runFFprobe(filePath: string): Promise<FFprobeData> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg(filePath).ffprobe((err, data) => {
|
ffmpeg(filePath).ffprobe((err, data) => {
|
||||||
@@ -91,7 +111,7 @@ export async function process(
|
|||||||
try {
|
try {
|
||||||
const { cid, filePath, existingMeta } = request;
|
const { cid, filePath, existingMeta } = request;
|
||||||
|
|
||||||
// Only process video files
|
// Only process video files (from file-info plugin)
|
||||||
const fileType = existingMeta?.fileType;
|
const fileType = existingMeta?.fileType;
|
||||||
if (fileType !== 'video') {
|
if (fileType !== 'video') {
|
||||||
await sendCallback({
|
await sendCallback({
|
||||||
@@ -114,70 +134,158 @@ export async function process(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check cache using midhash256 if available
|
||||||
|
const midhash = existingMeta?.['cid_midhash256'];
|
||||||
|
if (midhash) {
|
||||||
|
const cachedData = await readJson<FileInfoCache>(`${midhash}.json`);
|
||||||
|
if (cachedData) {
|
||||||
|
console.log(`[ffmpeg] Using cached FFprobe data for ${filePath}`);
|
||||||
|
await applyCachedMetadata(metaCore, cid, cachedData);
|
||||||
|
await sendCallback({
|
||||||
|
taskId: request.taskId,
|
||||||
|
status: 'completed',
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run FFprobe
|
// Run FFprobe
|
||||||
const data = await runFFprobe(filePath);
|
const data = await runFFprobe(filePath);
|
||||||
const metadata: Record<string, string> = {};
|
const metadata: Record<string, string> = {};
|
||||||
|
const fileinfo: FileInfoCache = { streamdetails: {} };
|
||||||
|
|
||||||
// Format metadata
|
// Format metadata
|
||||||
if (data.format?.duration != null) {
|
if (data.format?.duration != null) {
|
||||||
metadata['fileinfo/duration'] = String(data.format.duration);
|
const duration = String(data.format.duration);
|
||||||
|
metadata['fileinfo/duration'] = duration;
|
||||||
|
fileinfo.duration = duration;
|
||||||
}
|
}
|
||||||
if (data.format?.format_name) {
|
if (data.format?.format_name) {
|
||||||
metadata['fileinfo/formatName'] = data.format.format_name;
|
const formatName = String(data.format.format_name);
|
||||||
|
metadata['fileinfo/formatName'] = formatName;
|
||||||
|
fileinfo.formatName = formatName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream counters
|
// Stream counters
|
||||||
let videoIdx = 0, audioIdx = 0, subtitleIdx = 0;
|
let videoStreamIndex = 0;
|
||||||
|
let audioStreamIndex = 0;
|
||||||
|
let subtitleStreamIndex = 0;
|
||||||
|
let embeddedImageStreamIndex = 0;
|
||||||
|
|
||||||
|
// Process each stream
|
||||||
for (const stream of data.streams || []) {
|
for (const stream of data.streams || []) {
|
||||||
if (!stream) continue;
|
if (!stream) continue;
|
||||||
|
|
||||||
const codecType = stream.codec_type || '';
|
const codecType = String(stream.codec_type || '');
|
||||||
let basePath: string;
|
let basePath: string;
|
||||||
|
let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage';
|
||||||
|
let streamIdx: number;
|
||||||
|
|
||||||
if (codecType === 'video') {
|
if (codecType === 'video') {
|
||||||
basePath = `fileinfo/streamdetails/video/${videoIdx++}`;
|
basePath = `fileinfo/streamdetails/video/${videoStreamIndex}`;
|
||||||
|
streamType = 'video';
|
||||||
|
streamIdx = videoStreamIndex++;
|
||||||
} else if (codecType === 'audio') {
|
} else if (codecType === 'audio') {
|
||||||
basePath = `fileinfo/streamdetails/audio/${audioIdx++}`;
|
basePath = `fileinfo/streamdetails/audio/${audioStreamIndex}`;
|
||||||
|
streamType = 'audio';
|
||||||
|
streamIdx = audioStreamIndex++;
|
||||||
} else if (codecType === 'subtitle') {
|
} else if (codecType === 'subtitle') {
|
||||||
basePath = `fileinfo/streamdetails/subtitle/${subtitleIdx++}`;
|
basePath = `fileinfo/streamdetails/subtitle/${subtitleStreamIndex}`;
|
||||||
|
streamType = 'subtitle';
|
||||||
|
streamIdx = subtitleStreamIndex++;
|
||||||
|
} else if (codecType === 'embeddedimage') {
|
||||||
|
basePath = `fileinfo/streamdetails/embeddedimage/${embeddedImageStreamIndex}`;
|
||||||
|
streamType = 'embeddedimage';
|
||||||
|
streamIdx = embeddedImageStreamIndex++;
|
||||||
} else {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize stream object for cache
|
||||||
|
if (!fileinfo.streamdetails![streamType]) {
|
||||||
|
fileinfo.streamdetails![streamType] = {};
|
||||||
|
}
|
||||||
|
fileinfo.streamdetails![streamType]![streamIdx] = {};
|
||||||
|
const streamCache = fileinfo.streamdetails![streamType]![streamIdx];
|
||||||
|
|
||||||
// Common fields
|
// Common fields
|
||||||
if (stream.codec_name) metadata[`${basePath}/codec`] = stream.codec_name;
|
if (stream.codec_name) {
|
||||||
if (stream.index != null) metadata[`${basePath}/index`] = String(stream.index);
|
metadata[`${basePath}/codec`] = String(stream.codec_name);
|
||||||
if (stream.duration) metadata[`${basePath}/duration`] = stream.duration;
|
streamCache.codec = String(stream.codec_name);
|
||||||
if (stream.codec_type) metadata[`${basePath}/codecType`] = stream.codec_type;
|
}
|
||||||
|
if (stream.index != null) {
|
||||||
|
metadata[`${basePath}/index`] = String(stream.index);
|
||||||
|
streamCache.index = String(stream.index);
|
||||||
|
}
|
||||||
|
if (stream.duration) {
|
||||||
|
metadata[`${basePath}/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) {
|
||||||
metadata[`${basePath}/forced`] = stream.disposition.forced ? 'true' : 'false';
|
const forced = stream.disposition.forced ? 'true' : 'false';
|
||||||
|
metadata[`${basePath}/forced`] = forced;
|
||||||
|
streamCache.forced = forced;
|
||||||
}
|
}
|
||||||
if (stream.disposition?.default != null) {
|
if (stream.disposition?.default != null) {
|
||||||
metadata[`${basePath}/default`] = stream.disposition.default ? 'true' : 'false';
|
const defaultVal = stream.disposition.default ? 'true' : 'false';
|
||||||
|
metadata[`${basePath}/default`] = defaultVal;
|
||||||
|
streamCache.default = defaultVal;
|
||||||
|
}
|
||||||
|
if (stream.tags?.language) {
|
||||||
|
metadata[`${basePath}/language`] = String(stream.tags.language);
|
||||||
|
streamCache.language = String(stream.tags.language);
|
||||||
|
}
|
||||||
|
if (stream.tags?.title) {
|
||||||
|
metadata[`${basePath}/title`] = String(stream.tags.title);
|
||||||
|
streamCache.title = String(stream.tags.title);
|
||||||
}
|
}
|
||||||
if (stream.tags?.language) metadata[`${basePath}/language`] = stream.tags.language;
|
|
||||||
if (stream.tags?.title) metadata[`${basePath}/title`] = stream.tags.title;
|
|
||||||
|
|
||||||
// Video-specific
|
// Video-specific fields
|
||||||
if (stream.width) metadata[`${basePath}/width`] = String(stream.width);
|
if (stream.width && stream.width !== ('N/A' as unknown)) {
|
||||||
if (stream.height) metadata[`${basePath}/height`] = String(stream.height);
|
metadata[`${basePath}/width`] = String(stream.width);
|
||||||
|
streamCache.width = String(stream.width);
|
||||||
|
}
|
||||||
|
if (stream.height && stream.height !== ('N/A' as unknown)) {
|
||||||
|
metadata[`${basePath}/height`] = String(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`] = stream.bit_rate;
|
metadata[`${basePath}/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`] = stream.avg_frame_rate;
|
metadata[`${basePath}/frameRate`] = String(stream.avg_frame_rate);
|
||||||
|
streamCache.frameRate = String(stream.avg_frame_rate);
|
||||||
|
}
|
||||||
|
if (stream.tags?.rotate) {
|
||||||
|
metadata[`${basePath}/rotation`] = String(stream.tags.rotate);
|
||||||
|
streamCache.rotation = String(stream.tags.rotate);
|
||||||
}
|
}
|
||||||
if (stream.tags?.rotate) metadata[`${basePath}/rotation`] = stream.tags.rotate;
|
|
||||||
|
|
||||||
// Audio-specific
|
// Audio-specific fields
|
||||||
if (stream.sample_rate) metadata[`${basePath}/sampleRate`] = stream.sample_rate;
|
if (stream.sample_rate) {
|
||||||
if (stream.channel_layout) metadata[`${basePath}/channelLayout`] = stream.channel_layout;
|
metadata[`${basePath}/sampleRate`] = String(stream.sample_rate);
|
||||||
|
streamCache.sampleRate = String(stream.sample_rate);
|
||||||
|
}
|
||||||
|
if (stream.channel_layout) {
|
||||||
|
metadata[`${basePath}/channelLayout`] = String(stream.channel_layout);
|
||||||
|
streamCache.channelLayout = String(stream.channel_layout);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to meta-core
|
// Write to meta-core
|
||||||
await metaCore.mergeMetadata(cid, metadata);
|
await metaCore.mergeMetadata(cid, metadata);
|
||||||
|
|
||||||
|
// Cache the fileinfo object for future use
|
||||||
|
if (midhash) {
|
||||||
|
await writeJson(`${midhash}.json`, fileinfo);
|
||||||
|
}
|
||||||
|
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`[ffmpeg] Processed in ${duration}ms`);
|
console.log(`[ffmpeg] Processed in ${duration}ms`);
|
||||||
|
|
||||||
@@ -190,6 +298,11 @@ export async function process(
|
|||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[ffmpeg] Error:`, errorMessage);
|
console.error(`[ffmpeg] Error:`, errorMessage);
|
||||||
|
console.error(
|
||||||
|
'Make sure FFmpeg/FFprobe is installed. ' +
|
||||||
|
'Windows: set FFMPEG_PATH and FFPROBE_PATH environment variables. ' +
|
||||||
|
'Linux: install ffmpeg package.'
|
||||||
|
);
|
||||||
|
|
||||||
await sendCallback({
|
await sendCallback({
|
||||||
taskId: request.taskId,
|
taskId: request.taskId,
|
||||||
@@ -199,3 +312,37 @@ export async function process(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply cached metadata to meta-core
|
||||||
|
*/
|
||||||
|
async function applyCachedMetadata(
|
||||||
|
metaCore: MetaCoreClient,
|
||||||
|
cid: string,
|
||||||
|
fileinfo: FileInfoCache
|
||||||
|
): Promise<void> {
|
||||||
|
const metadata: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (fileinfo.duration != null) {
|
||||||
|
metadata['fileinfo/duration'] = String(fileinfo.duration);
|
||||||
|
}
|
||||||
|
if (fileinfo.formatName) {
|
||||||
|
metadata['fileinfo/formatName'] = String(fileinfo.formatName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply streamdetails
|
||||||
|
const streamdetails = fileinfo.streamdetails || {};
|
||||||
|
for (const streamType of ['video', 'audio', 'subtitle', 'embeddedimage'] as const) {
|
||||||
|
const streams = streamdetails[streamType] || {};
|
||||||
|
for (const [index, stream] of Object.entries(streams)) {
|
||||||
|
const basePath = `fileinfo/streamdetails/${streamType}/${index}`;
|
||||||
|
for (const [key, value] of Object.entries(stream)) {
|
||||||
|
if (value != null) {
|
||||||
|
metadata[`${basePath}/${key}`] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await metaCore.mergeMetadata(cid, metadata);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user