diff --git a/Dockerfile b/Dockerfile index 7b4e487..b5a1fea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,20 @@ FROM node:20-slim AS builder +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json tsconfig.json ./ RUN npm install +# Build the git dependency (filename-tools) +RUN cd node_modules/@metazla/filename-tools && npm install && npm run build COPY src/ ./src/ RUN npm run build FROM node:20-slim +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json ./ RUN npm install --omit=dev +# Build the git dependency in production too +RUN cd node_modules/@metazla/filename-tools && npm install && npm run build COPY --from=builder /app/dist ./dist EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ diff --git a/package.json b/package.json index 47c3697..9bf2db8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js" }, - "dependencies": { "fastify": "^5.0.0", "franc-min": "^6.2.0" }, + "dependencies": { + "fastify": "^5.0.0", + "franc-min": "^6.2.0", + "@metazla/filename-tools": "github:worph/filename-tool" + }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.3.0" } } diff --git a/src/index.ts b/src/index.ts index a1db0dc..eea84b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,26 @@ +/** + * MetaMesh Plugin: subtitle + * + * ============================================================================ + * 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 (subtitle) only requires mounts 1 and 2: + * - /files (RO) - to read subtitle files alongside media + * - /cache (RW) - to cache subtitle metadata + * + * SECURITY: Plugins must NEVER write to /files directly. + * All write operations go to /cache or /files/plugin/ only. + * + * ============================================================================ + */ + import Fastify from 'fastify'; import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js'; import { manifest, process as processFile } from './plugin.js'; diff --git a/src/meta-core-client.ts b/src/meta-core-client.ts index edbe17c..6bdbad8 100644 --- a/src/meta-core-client.ts +++ b/src/meta-core-client.ts @@ -70,4 +70,24 @@ export class MetaCoreClient { const data = await response.json() as { metadata?: Record }; return data.metadata ?? {}; } + + /** + * Compute the CID (Content Identifier) for a file + * Uses the meta-core /file/cid API endpoint + */ + async computeFileCID(filePath: string): Promise { + try { + const response = await this.safeFetch(`${this.baseUrl}/file/cid`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: filePath }), + }); + if (!response || !response.ok) return null; + const data = await response.json() as { cid?: string }; + return data.cid ?? null; + } catch (error) { + console.debug(`[MetaCoreClient] Failed to compute CID for ${filePath}:`, error); + return null; + } + } } diff --git a/src/plugin.ts b/src/plugin.ts index 6ed7210..d0d1156 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,14 +1,23 @@ /** * Subtitle Plugin - * Processes subtitle files and links them to videos + * + * For subtitle files: finds sibling video files and detects language. + * For video files: finds sibling subtitle files. + * + * Matches old SubtitleProcessor output: + * - For subtitle files: videos (add CID), subtitleLanguage + * - For video files: subtitles (add CID) */ -import { readFile, access, readdir } from 'fs/promises'; -import { dirname, basename, extname, join } from 'path'; +import { readFile, access, open } from 'fs/promises'; +import { dirname, extname } from 'path'; import { franc } from 'franc-min'; +import { FileType, getSiblingFiles } from '@metazla/filename-tools'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; +const fileType = new FileType(); + export const manifest: PluginManifest = { id: 'subtitle', name: 'Subtitle Processor', @@ -28,9 +37,6 @@ export const manifest: PluginManifest = { config: {}, }; -const VIDEO_EXTENSIONS = new Set(['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v']); -const SUBTITLE_EXTENSIONS = new Set(['srt', 'ass', 'ssa', 'vtt', 'sub']); - async function fileExists(path: string): Promise { try { await access(path); @@ -40,6 +46,20 @@ async function fileExists(path: string): Promise { } } +/** + * Read first N bytes of a file + */ +async function readFirstData(filePath: string, encoding: BufferEncoding = 'utf8', size = 1024): Promise { + const handle = await open(filePath, 'r'); + try { + const buffer = Buffer.alloc(size); + await handle.read(buffer, 0, size, 0); + return buffer.toString(encoding); + } finally { + await handle.close(); + } +} + export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise @@ -49,49 +69,12 @@ export async function process( try { const { cid, filePath, existingMeta } = request; - const fileType = existingMeta?.fileType; + const currentFileType = existingMeta?.fileType; - if (fileType === 'subtitle') { - // For subtitle files: detect language and find sibling videos - try { - const content = await readFile(filePath, { encoding: 'utf8', flag: 'r' }); - const sample = content.slice(0, 4096); - const lang = franc(sample); - if (lang && lang !== 'und') { - await metaCore.setProperty(cid, 'subtitleLanguage', lang); - } - } catch { - // Ignore read errors - } - - // Find sibling videos - const dir = dirname(filePath); - const baseName = basename(filePath, extname(filePath)); - try { - const files = await readdir(dir); - for (const file of files) { - const ext = extname(file).slice(1).toLowerCase(); - if (VIDEO_EXTENSIONS.has(ext)) { - const videoBase = basename(file, extname(file)); - // Check if video name matches subtitle base name - if (videoBase === baseName || baseName.startsWith(videoBase)) { - // We don't have CID for sibling, just note the relationship - await metaCore.setProperty(cid, 'linkedVideoName', file); - } - } - } - } catch { - // Ignore directory read errors - } - } else if (fileType === 'video') { - // For video files: find sibling subtitles - const extension = existingMeta?.extension || ''; - for (const subExt of ['srt', 'ass', 'ssa', 'vtt']) { - const subPath = filePath.replace(new RegExp(`\\.${extension}$`, 'i'), `.${subExt}`); - if (await fileExists(subPath)) { - await metaCore.setProperty(cid, `subtitleFile/${subExt}`, basename(subPath)); - } - } + if (currentFileType === 'subtitle') { + await processSubtitleFile(metaCore, cid, filePath); + } else if (currentFileType === 'video') { + await processVideoFile(metaCore, cid, filePath, existingMeta); } await sendCallback({ @@ -108,3 +91,77 @@ export async function process( }); } } + +/** + * Process subtitle file: find sibling videos and detect language + */ +async function processSubtitleFile( + metaCore: MetaCoreClient, + cid: string, + filePath: string +): Promise { + try { + // Find sibling video files + const siblings = await getSiblingFiles(filePath); + + for (const siblingPath of siblings) { + const siblingType = fileType.getFileTypeFromExtension(siblingPath); + if (siblingType === 'video') { + try { + const videoCid = await metaCore.computeFileCID(siblingPath); + if (videoCid) { + await metaCore.addToSet(cid, 'videos', videoCid); + } + } catch (e) { + console.debug(`[subtitle] Could not get CID for video: ${siblingPath}`); + } + } + } + + // Detect subtitle language by reading first 4KB + try { + const content = await readFirstData(filePath, 'utf8', 4096); + const detectedLang = franc(content); + if (detectedLang && detectedLang !== 'und') { + await metaCore.setProperty(cid, 'subtitleLanguage', detectedLang); + } + } catch (e) { + console.debug(`[subtitle] Could not detect language for: ${filePath}`); + } + + console.log(`[subtitle] Processed subtitle file: ${filePath}`); + } catch (error) { + console.error(`[subtitle] Error processing subtitle file ${filePath}:`, error); + } +} + +/** + * Process video file: find sibling subtitle files + */ +async function processVideoFile( + metaCore: MetaCoreClient, + cid: string, + filePath: string, + existingMeta?: Record +): Promise { + try { + const extension = existingMeta?.extension || ''; + const srtPath = filePath.replace(new RegExp(`\\.${extension}$`, 'i'), '.srt'); + + // Check for .srt subtitle + if (await fileExists(srtPath)) { + try { + const subtitleCid = await metaCore.computeFileCID(srtPath); + if (subtitleCid) { + await metaCore.addToSet(cid, 'subtitles', subtitleCid); + } + } catch (e) { + console.debug(`[subtitle] Could not get CID for subtitle: ${srtPath}`); + } + } + + console.log(`[subtitle] Checked subtitles for video: ${filePath}`); + } catch (error) { + console.error(`[subtitle] Error finding subtitles for ${filePath}:`, error); + } +}