diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..0415883 --- /dev/null +++ b/src/cache.ts @@ -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 { + 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(filename: string): Promise { + 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 { + 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); + } +} diff --git a/src/index.ts b/src/index.ts index dda84b2..47d3228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,24 @@ /** * 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/ (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/ only. + * + * ============================================================================ */ import Fastify from 'fastify'; diff --git a/src/plugin.ts b/src/plugin.ts index ae37aae..9366eda 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,11 +2,20 @@ * FFmpeg Plugin * * 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 type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; +import { readJson, writeJson } from './cache.js'; export const manifest: PluginManifest = { id: 'ffmpeg', @@ -72,6 +81,17 @@ interface FFprobeData { }>; } +interface FileInfoCache { + duration?: string; + formatName?: string; + streamdetails?: { + video?: Record>; + audio?: Record>; + subtitle?: Record>; + embeddedimage?: Record>; + }; +} + function runFFprobe(filePath: string): Promise { return new Promise((resolve, reject) => { ffmpeg(filePath).ffprobe((err, data) => { @@ -91,7 +111,7 @@ export async function process( try { const { cid, filePath, existingMeta } = request; - // Only process video files + // Only process video files (from file-info plugin) const fileType = existingMeta?.fileType; if (fileType !== 'video') { await sendCallback({ @@ -114,70 +134,158 @@ export async function process( return; } + // Check cache using midhash256 if available + const midhash = existingMeta?.['cid_midhash256']; + if (midhash) { + const cachedData = await readJson(`${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 const data = await runFFprobe(filePath); const metadata: Record = {}; + const fileinfo: FileInfoCache = { streamdetails: {} }; // Format metadata 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) { - metadata['fileinfo/formatName'] = data.format.format_name; + const formatName = String(data.format.format_name); + metadata['fileinfo/formatName'] = formatName; + fileinfo.formatName = formatName; } // 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 || []) { if (!stream) continue; - const codecType = stream.codec_type || ''; + const codecType = String(stream.codec_type || ''); let basePath: string; + let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage'; + let streamIdx: number; if (codecType === 'video') { - basePath = `fileinfo/streamdetails/video/${videoIdx++}`; + basePath = `fileinfo/streamdetails/video/${videoStreamIndex}`; + streamType = 'video'; + streamIdx = videoStreamIndex++; } else if (codecType === 'audio') { - basePath = `fileinfo/streamdetails/audio/${audioIdx++}`; + basePath = `fileinfo/streamdetails/audio/${audioStreamIndex}`; + streamType = 'audio'; + streamIdx = audioStreamIndex++; } 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 { 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 - if (stream.codec_name) metadata[`${basePath}/codec`] = stream.codec_name; - if (stream.index != null) metadata[`${basePath}/index`] = String(stream.index); - if (stream.duration) metadata[`${basePath}/duration`] = stream.duration; - if (stream.codec_type) metadata[`${basePath}/codecType`] = stream.codec_type; + if (stream.codec_name) { + metadata[`${basePath}/codec`] = String(stream.codec_name); + streamCache.codec = String(stream.codec_name); + } + 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) { - 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) { - 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 - if (stream.width) metadata[`${basePath}/width`] = String(stream.width); - if (stream.height) metadata[`${basePath}/height`] = String(stream.height); + // Video-specific fields + if (stream.width && stream.width !== ('N/A' as unknown)) { + 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') { - 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') { - 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 - if (stream.sample_rate) metadata[`${basePath}/sampleRate`] = stream.sample_rate; - if (stream.channel_layout) metadata[`${basePath}/channelLayout`] = stream.channel_layout; + // Audio-specific fields + if (stream.sample_rate) { + 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 await metaCore.mergeMetadata(cid, metadata); + // Cache the fileinfo object for future use + if (midhash) { + await writeJson(`${midhash}.json`, fileinfo); + } + const duration = Date.now() - startTime; console.log(`[ffmpeg] Processed in ${duration}ms`); @@ -190,6 +298,11 @@ export async function process( const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); 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({ 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 { + const metadata: Record = {}; + + 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); +}