This commit is contained in:
Pierre
2026-01-10 19:15:27 +01:00
parent 3ef412a4a3
commit a19a30088e
5 changed files with 159 additions and 49 deletions
+6
View File
@@ -1,14 +1,20 @@
FROM node:20-slim AS builder FROM node:20-slim AS builder
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package.json tsconfig.json ./ COPY package.json tsconfig.json ./
RUN npm install RUN npm install
# Build the git dependency (filename-tools)
RUN cd node_modules/@metazla/filename-tools && npm install && npm run build
COPY src/ ./src/ COPY src/ ./src/
RUN npm run build RUN npm run build
FROM node:20-slim FROM node:20-slim
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json ./
RUN npm install --omit=dev 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 COPY --from=builder /app/dist ./dist
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
+5 -1
View File
@@ -5,6 +5,10 @@
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "build": "tsc", "start": "node 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" } "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.3.0" }
} }
+23
View File
@@ -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/<id> (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/<id> only.
*
* ============================================================================
*/
import Fastify from 'fastify'; import Fastify from 'fastify';
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js'; import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
import { manifest, process as processFile } from './plugin.js'; import { manifest, process as processFile } from './plugin.js';
+20
View File
@@ -70,4 +70,24 @@ export class MetaCoreClient {
const data = await response.json() as { metadata?: Record<string, string> }; const data = await response.json() as { metadata?: Record<string, string> };
return data.metadata ?? {}; return data.metadata ?? {};
} }
/**
* Compute the CID (Content Identifier) for a file
* Uses the meta-core /file/cid API endpoint
*/
async computeFileCID(filePath: string): Promise<string | null> {
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;
}
}
} }
+105 -48
View File
@@ -1,14 +1,23 @@
/** /**
* Subtitle Plugin * 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 { readFile, access, open } from 'fs/promises';
import { dirname, basename, extname, join } from 'path'; import { dirname, extname } from 'path';
import { franc } from 'franc-min'; import { franc } from 'franc-min';
import { FileType, getSiblingFiles } from '@metazla/filename-tools';
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';
const fileType = new FileType();
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
id: 'subtitle', id: 'subtitle',
name: 'Subtitle Processor', name: 'Subtitle Processor',
@@ -28,9 +37,6 @@ export const manifest: PluginManifest = {
config: {}, 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<boolean> { async function fileExists(path: string): Promise<boolean> {
try { try {
await access(path); await access(path);
@@ -40,6 +46,20 @@ async function fileExists(path: string): Promise<boolean> {
} }
} }
/**
* Read first N bytes of a file
*/
async function readFirstData(filePath: string, encoding: BufferEncoding = 'utf8', size = 1024): Promise<string> {
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( export async function process(
request: ProcessRequest, request: ProcessRequest,
sendCallback: (payload: CallbackPayload) => Promise<void> sendCallback: (payload: CallbackPayload) => Promise<void>
@@ -49,49 +69,12 @@ export async function process(
try { try {
const { cid, filePath, existingMeta } = request; const { cid, filePath, existingMeta } = request;
const fileType = existingMeta?.fileType; const currentFileType = existingMeta?.fileType;
if (fileType === 'subtitle') { if (currentFileType === 'subtitle') {
// For subtitle files: detect language and find sibling videos await processSubtitleFile(metaCore, cid, filePath);
try { } else if (currentFileType === 'video') {
const content = await readFile(filePath, { encoding: 'utf8', flag: 'r' }); await processVideoFile(metaCore, cid, filePath, existingMeta);
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));
}
}
} }
await sendCallback({ 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<void> {
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<string, string>
): Promise<void> {
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);
}
}