bugfix
This commit is contained in:
@@ -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
@@ -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" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user