initial commit

This commit is contained in:
Pierre
2026-06-06 15:20:36 +02:00
commit 62a2b796e2
16 changed files with 2001 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
/**
* MetaMesh Plugin: OpenSubtitles
*
* ============================================================================
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
* ============================================================================
*
* Each plugin container has exactly 3 mounts:
*
* 1. /files (READ-ONLY) - Shared media files, read access only
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
* 3. /files/plugin/<name> (READ-WRITE for this plugin, READ-ONLY for others)
* - Plugin output folder for generated files
*
* SECURITY: Plugins must NEVER write to /files directly.
* - Use /cache for temporary/cache data
* - Use /files/plugin/<name> for output files (e.g., posters, generated assets)
*
* This architecture ensures:
* - Plugins cannot corrupt user's media files
* - Plugins can share generated assets with other plugins (read-only)
* - Each plugin has isolated cache storage
*
* ============================================================================
*/
import Fastify from 'fastify';
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
import { manifest, process as processFile, configure } from './plugin.js';
const app = Fastify({ logger: true });
let ready = false;
app.get('/health', async (): Promise<HealthResponse> => ({ status: 'healthy', ready, version: manifest.version }));
app.get('/manifest', async () => manifest);
app.post<{ Body: ConfigureRequest }>('/configure', async (request): Promise<ConfigureResponse> => {
try {
configure(request.body.config || {});
console.log(`[${manifest.id}] Configuration updated`);
return { status: 'ok' };
} catch (error) {
return { status: 'error', error: error instanceof Error ? error.message : String(error) };
}
});
app.post<{ Body: ProcessRequest }>('/process', async (request, reply) => {
const { taskId, cid, filePath, callbackUrl, metaCoreUrl } = request.body;
if (!taskId || !cid || !filePath || !callbackUrl || !metaCoreUrl) {
return reply.send({ status: 'rejected', error: 'Missing required fields' } as ProcessResponse);
}
processFile(request.body, async (payload: CallbackPayload) => {
try { await fetch(callbackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
catch (error) { console.error(`[${manifest.id}] Callback error:`, error); }
}).catch(console.error);
return reply.send({ status: 'accepted' } as ProcessResponse);
});
const port = parseInt(process.env.PORT || '8080', 10);
app.listen({ port, host: '0.0.0.0' }).then(() => { ready = true; console.log(`[${manifest.id}] Listening on port ${port}`); });
process.on('SIGTERM', async () => { ready = false; await app.close(); process.exit(0); });
+72
View File
@@ -0,0 +1,72 @@
/**
* Language code normalization.
*
* OpenSubtitles speaks ISO 639-1 (2-letter, e.g. "en", "pt-br") while the
* MetaMesh metadata store uses ISO 639-3 (`lang3`, e.g. "eng") everywhere
* (see METADATA_KEYS.md §"Value formats" and §9). This maps the common
* subtitle languages OpenSubtitles serves to their 639-3 equivalents.
*
* Special case mirrored from the rest of the store: `jpl` is reserved for
* romaji transliterations of Japanese — OpenSubtitles has no such code, so
* it never appears here.
*/
// ISO 639-1 → ISO 639-3 for the languages OpenSubtitles commonly serves.
const ISO1_TO_ISO3: Record<string, string> = {
ab: 'abk', af: 'afr', am: 'amh', ar: 'ara', as: 'asm', az: 'aze',
be: 'bel', bg: 'bul', bn: 'ben', br: 'bre', bs: 'bos',
ca: 'cat', cs: 'ces', cy: 'cym', da: 'dan', de: 'deu',
el: 'ell', en: 'eng', eo: 'epo', es: 'spa', et: 'est', eu: 'eus',
fa: 'fas', fi: 'fin', fr: 'fra', ga: 'gle', gl: 'glg',
he: 'heb', hi: 'hin', hr: 'hrv', hu: 'hun', hy: 'hye',
id: 'ind', is: 'isl', it: 'ita', ja: 'jpn', ka: 'kat', kk: 'kaz',
km: 'khm', kn: 'kan', ko: 'kor', ku: 'kur', lb: 'ltz', lt: 'lit',
lv: 'lav', mk: 'mkd', ml: 'mal', mn: 'mon', mr: 'mar', ms: 'msa',
my: 'mya', nb: 'nob', ne: 'nep', nl: 'nld', nn: 'nno', no: 'nor',
oc: 'oci', pa: 'pan', pl: 'pol', pt: 'por', ro: 'ron', ru: 'rus',
si: 'sin', sk: 'slk', sl: 'slv', sq: 'sqi', sr: 'srp', sv: 'swe',
sw: 'swa', ta: 'tam', te: 'tel', th: 'tha', tl: 'tgl', tr: 'tur',
uk: 'ukr', ur: 'urd', uz: 'uzb', vi: 'vie', zh: 'zho',
};
// ISO 639-2/B (bibliographic) → ISO 639-3 (terminological) aliases. These show
// up in OpenSubtitles responses and release filenames; canonicalize them so the
// store never holds two codes for one language (e.g. `fre` and `fra`). Ported
// from the gateway's torznab subtitles.rs::iso3_canonical.
const ISO3_ALIASES: Record<string, string> = {
fre: 'fra', ger: 'deu', dut: 'nld', chi: 'zho', cze: 'ces',
gre: 'ell', rum: 'ron', baq: 'eus', wel: 'cym', arm: 'hye',
geo: 'kat', ice: 'isl', mac: 'mkd', may: 'msa', bur: 'mya',
per: 'fas', tib: 'bod', slo: 'slk', alb: 'sqi',
};
/**
* Normalize an OpenSubtitles language code to ISO 639-3 (`lang3`).
*
* Handles region suffixes ("pt-BR" → "por", "zh-CN" → "zho") by taking the
* primary subtag, and canonicalizes 639-2/B bibliographic codes ("fre" → "fra").
* Already-canonical 3-letter codes pass through lowercased. Returns "und"
* (undetermined) for anything unrecognized so the value still satisfies the
* `lang3` contract.
*/
export function toLang3(code: string | undefined | null): string {
if (!code) return 'und';
const primary = code.toLowerCase().split(/[-_]/)[0];
if (primary.length === 3) return ISO3_ALIASES[primary] ?? primary;
return ISO1_TO_ISO3[primary] ?? 'und';
}
/**
* Parse a user-supplied CSV of language codes (e.g. "en,fr,pt-BR") into the
* deduped lowercase 2-letter (or region-tagged) codes OpenSubtitles expects
* in its `languages` query parameter.
*/
export function parseWantedLanguages(csv: string | undefined): string[] {
if (!csv) return ['en'];
const seen = new Set<string>();
for (const raw of csv.split(',')) {
const code = raw.trim().toLowerCase();
if (code) seen.add(code);
}
return seen.size > 0 ? [...seen] : ['en'];
}
+89
View File
@@ -0,0 +1,89 @@
/**
* meta-core API Client for writing metadata
* Fails gracefully if meta-core is unavailable (logs warning, returns without throwing)
*/
export class MetaCoreClient {
constructor(private baseUrl: string) {}
private async safeFetch(url: string, options: RequestInit): Promise<Response | null> {
try {
return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
} catch (error) {
console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}`);
return null;
}
}
async setProperty(hashId: string, key: string, value: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to set property: ${response.status}`);
}
}
async getProperty(hashId: string, key: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' });
if (!response || response.status === 404) return null;
if (!response.ok) return null;
const data = await response.json() as { value?: string };
return data.value ?? null;
}
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to merge metadata: ${response.status}`);
}
}
async deleteProperty(hashId: string, key: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' });
if (response && !response.ok && response.status !== 404) {
console.warn(`[MetaCoreClient] Failed to delete property: ${response.status}`);
}
}
async addToSet(hashId: string, key: string, value: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to add to set: ${response.status}`);
}
}
async getMetadata(hashId: string): Promise<Record<string, string>> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' });
if (!response || response.status === 404) return {};
if (!response.ok) return {};
const data = await response.json() as { metadata?: Record<string, string> };
return data.metadata ?? {};
}
/**
* Compute CID for a file
* @param path - Path relative to FILES_PATH (e.g., "poster/Movie (2024)[tmdb12345]_poster.jpg")
* @returns CID string or null if failed
*/
async computeFileCID(path: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/file/cid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!response || !response.ok) return null;
const data = await response.json() as { cid?: string };
return data.cid ?? null;
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* OpenSubtitles REST API client (api.opensubtitles.com / API v1).
*
* Auth is two-tier:
* - The `Api-Key` header + a registered `User-Agent` are enough to call
* `/subtitles` (search).
* - The `/download` endpoint additionally needs a JWT obtained from
* `POST /login` (username + password). The token is cached and reused
* until the API rejects it, then re-fetched once.
*
* Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api
*/
import axios, { AxiosInstance, isAxiosError } from 'axios';
const DEFAULT_BASE_URL = 'https://api.opensubtitles.com/api/v1';
export interface OpenSubtitlesConfig {
apiKey: string;
username?: string;
password?: string;
userAgent: string;
}
export interface SubtitleFile {
fileId: number;
fileName?: string;
}
export interface SubtitleResult {
/** ISO 639-1 (or region-tagged) language code as OpenSubtitles reports it. */
language: string;
moviehashMatch: boolean;
/** Transient popularity signal — used only to rank candidates, never stored. */
downloadCount: number;
files: SubtitleFile[];
release?: string;
}
export interface SearchParams {
imdbId?: string; // numeric, no "tt" prefix
tmdbId?: string;
query?: string;
year?: string;
seasonNumber?: string;
episodeNumber?: string;
moviehash?: string;
/** CSV of 2-letter codes, e.g. "en,fr". */
languages: string;
}
export interface DownloadResult {
link: string;
fileName?: string;
/** Downloads remaining in the current quota window (per OpenSubtitles). */
remaining: number;
}
/**
* Compute the OpenSubtitles "moviehash" — a 64-bit hash of the file size plus
* the 64-bit little-endian words across the first and last 64 KiB. Returned
* as a zero-padded 16-char lowercase hex string. Caller supplies the size and
* the two 64 KiB chunks (read over WebDAV Range requests).
*
* Returns null if the file is smaller than 128 KiB (the algorithm is undefined
* there and OpenSubtitles ignores undersized hashes anyway).
*/
export function computeMovieHash(size: number, head: Buffer, tail: Buffer): string | null {
const CHUNK = 65536;
if (size < CHUNK * 2 || head.length < CHUNK || tail.length < CHUNK) return null;
const mask = (1n << 64n) - 1n;
let hash = BigInt(size) & mask;
const addWords = (buf: Buffer) => {
for (let i = 0; i + 8 <= buf.length; i += 8) {
hash = (hash + buf.readBigUInt64LE(i)) & mask;
}
};
addWords(head.subarray(0, CHUNK));
addWords(tail.subarray(tail.length - CHUNK));
return hash.toString(16).padStart(16, '0');
}
export class OpenSubtitlesClient {
private http: AxiosInstance;
private token: string | null = null;
constructor(private cfg: OpenSubtitlesConfig) {
this.http = axios.create({
baseURL: DEFAULT_BASE_URL,
timeout: 30000,
headers: {
'Api-Key': cfg.apiKey,
'User-Agent': cfg.userAgent,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
}
/** Lazily log in and cache the JWT. No-op if already authenticated. */
private async ensureToken(): Promise<string> {
if (this.token) return this.token;
if (!this.cfg.username || !this.cfg.password) {
throw new Error('OpenSubtitles username/password required for downloads');
}
const resp = await this.http.post('/login', {
username: this.cfg.username,
password: this.cfg.password,
});
const token = resp.data?.token as string | undefined;
if (!token) throw new Error('OpenSubtitles login returned no token');
this.token = token;
return token;
}
/**
* Search for subtitles. Best-effort: returns [] on any API error so a
* single bad lookup never fails the whole pipeline task.
*/
async search(params: SearchParams): Promise<SubtitleResult[]> {
const query: Record<string, string> = { languages: params.languages };
if (params.imdbId) query.imdb_id = params.imdbId;
if (params.tmdbId) query.tmdb_id = params.tmdbId;
if (params.moviehash) query.moviehash = params.moviehash;
if (params.query) query.query = params.query;
if (params.year) query.year = params.year;
if (params.seasonNumber) query.season_number = params.seasonNumber;
if (params.episodeNumber) query.episode_number = params.episodeNumber;
try {
const resp = await this.http.get('/subtitles', { params: query });
const data = (resp.data?.data ?? []) as any[];
return data.map((item) => {
const attr = item.attributes ?? {};
return {
language: attr.language ?? '',
moviehashMatch: attr.moviehash_match === true,
downloadCount: Number(attr.download_count ?? 0),
release: attr.release,
files: (attr.files ?? [])
.map((f: any) => ({ fileId: Number(f.file_id), fileName: f.file_name }))
.filter((f: SubtitleFile) => Number.isFinite(f.fileId) && f.fileId > 0),
} as SubtitleResult;
}).filter((r) => r.files.length > 0);
} catch (err) {
console.warn(`[opensubtitles] search failed: ${describeError(err)}`);
return [];
}
}
/**
* Request a download link for a subtitle file. Retries once on 401/403 by
* dropping the cached token and re-authenticating. Throws on quota
* exhaustion (406) so the caller can stop early.
*/
async requestDownload(fileId: number): Promise<DownloadResult> {
const doRequest = async (token: string) => {
const resp = await this.http.post(
'/download',
{ file_id: fileId },
{ headers: { Authorization: `Bearer ${token}` } },
);
return resp.data;
};
let token = await this.ensureToken();
let data: any;
try {
data = await doRequest(token);
} catch (err) {
if (isAxiosError(err) && (err.response?.status === 401 || err.response?.status === 403)) {
// Stale token — re-auth once.
this.token = null;
token = await this.ensureToken();
data = await doRequest(token);
} else if (isAxiosError(err) && err.response?.status === 406) {
throw new QuotaExceededError(describeError(err));
} else {
throw err;
}
}
if (!data?.link) throw new Error('OpenSubtitles download returned no link');
return {
link: data.link as string,
fileName: data.file_name as string | undefined,
remaining: Number(data.remaining ?? -1),
};
}
/** Fetch the actual subtitle bytes from a (short-lived) download link. */
async fetchSubtitleBytes(link: string): Promise<Buffer> {
const resp = await axios.get(link, {
responseType: 'arraybuffer',
timeout: 30000,
headers: { 'User-Agent': this.cfg.userAgent },
});
return Buffer.from(resp.data);
}
}
export class QuotaExceededError extends Error {
constructor(message: string) {
super(message);
this.name = 'QuotaExceededError';
}
}
function describeError(err: unknown): string {
if (isAxiosError(err)) {
const status = err.response?.status;
const body = err.response?.data;
const detail = typeof body === 'object' && body
? (body as any).message ?? JSON.stringify(body)
: String(body ?? '');
return `HTTP ${status ?? '?'}${detail ? ` — ${detail}` : ''}`;
}
return err instanceof Error ? err.message : String(err);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Unit tests for the pure helpers, ported from the gateway's torznab
* subtitles.rs tests (lang mapping, region-stripping) plus the moviehash.
* Run with `pnpm test` (uses Node's built-in test runner — no extra deps).
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toLang3, parseWantedLanguages } from './langs.js';
import { computeMovieHash } from './opensubtitles.js';
test('toLang3 maps ISO 639-1 to 639-3', () => {
assert.equal(toLang3('en'), 'eng');
assert.equal(toLang3('fr'), 'fra');
assert.equal(toLang3('pt'), 'por');
});
test('toLang3 strips region suffixes', () => {
assert.equal(toLang3('pt-BR'), 'por');
assert.equal(toLang3('zh-CN'), 'zho');
assert.equal(toLang3('en_US'), 'eng');
});
test('toLang3 canonicalizes 639-2/B bibliographic codes', () => {
assert.equal(toLang3('fre'), 'fra');
assert.equal(toLang3('ger'), 'deu');
assert.equal(toLang3('chi'), 'zho');
assert.equal(toLang3('fra'), 'fra'); // already canonical
});
test('toLang3 falls back to und', () => {
assert.equal(toLang3('xx'), 'und');
assert.equal(toLang3(undefined), 'und');
assert.equal(toLang3(''), 'und');
});
test('parseWantedLanguages dedupes and lowercases, defaults to en', () => {
assert.deepEqual(parseWantedLanguages('en,fr'), ['en', 'fr']);
assert.deepEqual(parseWantedLanguages(' EN , Fr , en '), ['en', 'fr']);
assert.deepEqual(parseWantedLanguages(undefined), ['en']);
assert.deepEqual(parseWantedLanguages(''), ['en']);
});
test('computeMovieHash returns null for files smaller than 128 KiB', () => {
const small = Buffer.alloc(1000);
assert.equal(computeMovieHash(1000, small, small), null);
});
test('computeMovieHash sums size + 64KiB head/tail words (zero chunks => size)', () => {
// All-zero head/tail → hash == size, as 16-char zero-padded lowercase hex.
const zeros = Buffer.alloc(65536);
const size = 0x20000; // 131072 = 2 * 64 KiB
assert.equal(computeMovieHash(size, zeros, zeros), '0000000000020000');
});
test('computeMovieHash folds in non-zero words', () => {
const head = Buffer.alloc(65536);
head.writeBigUInt64LE(1n, 0); // one word = 1
const tail = Buffer.alloc(65536);
const size = 0x20000;
// hash = size + 1
assert.equal(computeMovieHash(size, head, tail), '0000000000020001');
});
+470
View File
@@ -0,0 +1,470 @@
/**
* OpenSubtitles Plugin
*
* Fetches subtitle sidecars from OpenSubtitles (api.opensubtitles.com) for a
* video and registers them as first-class subtitle records, the same way
* `subtitle-extractor` registers subs pulled out of embedded streams — except
* the bytes come from the network instead of ffmpeg.
*
* ============================================================================
* PLUGIN FILE ACCESS ARCHITECTURE - WebDAV
* ============================================================================
*
* File access via WebDAV (WEBDAV_URL environment variable):
* - Read media files (for moviehash): GET (Range) /webdav/watch/...
* - Write output: PUT /webdav/plugin/opensubtitles/...
* - Cache: Local /cache mount (unused for now)
*
* Output subtitle files become their own records under their midhash256 CID;
* we only stamp the CID onto the video's `subtitles` set — no path is stored
* (the watcher indexes /files/plugin/opensubtitles/ as a first-class root).
*
* ============================================================================
* METADATA (see METADATA_KEYS.md §8/§9)
* ============================================================================
* Fetched subs are external/sidecar provenance, which §8 maps to `subtitles`
* (NOT `extractedSubtitles`, which is embedded-stream-only). We write the
* language-nested **key-set** target shape (matching the gateway's torznab
* enricher, which this plugin supersedes):
* - companion record on the subtitle CID: fileType=subtitle, subtitleLanguage,
* videos/<videoCid>=true (written explicitly — no watcher dependency)
* - on the video: subtitles/<lang3>/<cid>=true, subtitleLanguages/<lang3>=true,
* languages/<lang3>=true (all via PATCH/merge, path-safe like tmdb plot/eng)
*/
import { createHash } from 'crypto';
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
import { MetaCoreClient } from './meta-core-client.js';
import { WebDAVClient } from './webdav-client.js';
import {
OpenSubtitlesClient,
QuotaExceededError,
computeMovieHash,
type SearchParams,
type SubtitleResult,
} from './opensubtitles.js';
import { toLang3, parseWantedLanguages } from './langs.js';
const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/opensubtitles';
const HASH_CHUNK = 65536; // 64 KiB head/tail for the OpenSubtitles moviehash
/**
* Resolve the WebDAV target from the meta-core URL handed to us in the request,
* rather than a static WEBDAV_URL env. This keeps the subtitle BYTES in the same
* meta-core whose METADATA we update — critical when the gateway drives this
* plugin against its own dedicated meta-core (otherwise we'd split-brain: bytes
* to meta-sort's volume, metadata to the gateway's). Falls back to the
* WEBDAV_URL env when the urls endpoint is unreachable. Cached per meta-core.
*/
const webdavCache = new Map<string, WebDAVClient | null>();
async function resolveWebdav(metaCoreUrl: string): Promise<WebDAVClient | null> {
const cached = webdavCache.get(metaCoreUrl);
if (cached !== undefined) return cached;
let client: WebDAVClient | null = null;
const base = metaCoreUrl.replace(/\/$/, '');
for (const path of ['/urls', '/api/urls']) {
try {
const resp = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) continue;
const data = (await resp.json()) as { webdavUrlInternal?: string; webdavUrl?: string };
const url = data.webdavUrlInternal || data.webdavUrl;
if (url) {
client = new WebDAVClient(url);
console.log(`[opensubtitles] WebDAV resolved from ${base}${path}: ${url}`);
break;
}
} catch {
/* try next path / fall back to env */
}
}
if (!client) {
// NB: this module exports a function named `process`, which shadows the
// Node global at module scope — reach the real one via globalThis.
const env = globalThis.process.env.WEBDAV_URL;
if (env) {
client = new WebDAVClient(env);
console.log(`[opensubtitles] WebDAV from WEBDAV_URL env: ${env}`);
} else {
console.warn(`[opensubtitles] WARNING: could not resolve WebDAV from ${base} and WEBDAV_URL unset`);
}
}
webdavCache.set(metaCoreUrl, client);
return client;
}
export const manifest: PluginManifest = {
id: 'opensubtitles',
name: 'OpenSubtitles Fetcher',
version: '1.0.0',
description: 'Fetches subtitle sidecars from OpenSubtitles and links them to videos',
author: 'MetaMesh',
dependencies: ['file-info', 'filename-parser', 'tmdb'],
priority: 55, // after tmdb (30) so imdbid/tmdbid are populated
color: '#1B9335',
defaultQueue: 'background', // network-bound, quota-limited
timeout: 120000,
schema: {
subtitles: { label: 'Subtitles', type: 'array', readonly: true },
subtitleLanguages: { label: 'Subtitle Languages', type: 'array', readonly: true },
},
config: {
apiKey: {
type: 'string',
label: 'OpenSubtitles API Key',
required: true,
secret: true,
},
username: {
type: 'string',
label: 'OpenSubtitles Username (required for downloads)',
},
password: {
type: 'string',
label: 'OpenSubtitles Password',
secret: true,
},
languages: {
type: 'string',
label: 'Wanted Languages (CSV of ISO 639-1 codes, e.g. "en,fr")',
default: 'en',
},
userAgent: {
type: 'string',
label: 'User-Agent (registered with OpenSubtitles)',
default: 'MetaMesh-OpenSubtitles-Plugin/1.0',
},
forceRecompute: {
type: 'boolean',
label: 'Force Recompute',
default: false,
},
},
};
let apiKey: string | null = null;
let username: string | undefined;
let password: string | undefined;
let wantedLanguages: string[] = ['en'];
let userAgent = 'MetaMesh-OpenSubtitles-Plugin/1.0';
let forceRecompute = false;
export function configure(config: Record<string, unknown>): void {
console.log(`[opensubtitles] Received config keys: ${Object.keys(config).join(', ')}`);
apiKey = (config.apiKey as string) || null;
username = (config.username as string) || undefined;
password = (config.password as string) || undefined;
wantedLanguages = parseWantedLanguages(config.languages as string | undefined);
userAgent = (config.userAgent as string) || 'MetaMesh-OpenSubtitles-Plugin/1.0';
forceRecompute = config.forceRecompute === true;
console.log(
`[opensubtitles] Configured: apiKey=${apiKey ? 'set' : 'missing'}, ` +
`login=${username ? 'set' : 'missing'}, languages=${wantedLanguages.join(',')}`,
);
}
/**
* Compute the midhash256 CID of a buffer (matches the meta-hash algorithm;
* identical to subtitle-extractor's helper).
*/
function computeMidHash256FromBuffer(data: Buffer): string {
const SAMPLE_SIZE = 1024 * 1024;
const MIDHASH_VARINT = Buffer.from([0x80, 0x20]);
const fileSize = data.length;
const sizeBuffer = Buffer.allocUnsafe(8);
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
let sampleData: Buffer;
if (fileSize <= SAMPLE_SIZE) {
sampleData = data;
} else {
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
sampleData = data.subarray(middleOffset, middleOffset + SAMPLE_SIZE);
}
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
const hashBuffer = createHash('sha256').update(hashInput).digest();
const cidBytes = Buffer.concat([
Buffer.from([0x01]),
MIDHASH_VARINT,
MIDHASH_VARINT,
Buffer.from([0x20]),
hashBuffer,
]);
const base32Chars = 'abcdefghijklmnopqrstuvwxyz234567';
let cid = 'b';
let bits = 0;
let value = 0;
for (const byte of cidBytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
cid += base32Chars[(value >> bits) & 0x1f];
}
}
if (bits > 0) {
cid += base32Chars[(value << (5 - bits)) & 0x1f];
}
return cid;
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim();
}
/** Derive a subtitle file extension from a remote filename, defaulting to srt. */
function subtitleExt(fileName: string | undefined): string {
if (!fileName) return 'srt';
const m = fileName.toLowerCase().match(/\.(srt|ass|ssa|vtt|sub|smi)$/);
return m ? m[1] : 'srt';
}
/** Best-effort OpenSubtitles moviehash for a video, read over WebDAV. */
async function computeVideoMovieHash(client: WebDAVClient, filePath: string): Promise<string | null> {
try {
const stats = await client.stat(filePath);
const size = stats.size;
if (!size || size < HASH_CHUNK * 2) return null;
const head = await client.readBytes(filePath, 0, HASH_CHUNK - 1);
const tail = await client.readBytes(filePath, size - HASH_CHUNK, size - 1);
return computeMovieHash(size, head, tail);
} catch (e) {
console.warn(`[opensubtitles] moviehash unavailable: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
/**
* Negative cache of search misses, keyed by the search signature. Ported from
* the gateway's redb `(tmdb_id, lang)` negative cache (opensubtitles.rs §quota):
* a title OpenSubtitles has no subs for must never re-burn the (rate-limited)
* search budget on a rescan. In-memory per process — good enough for the
* plugin's lifetime; cleared on restart.
*/
const searchMissCache = new Set<string>();
/** True once OpenSubtitles reports its download quota spent — short-circuits the
* rest of this process's downloads until restart (mirrors the gateway's
* "skip OS for the rest of this run" hard stop). */
let downloadQuotaSpent = false;
/** Rank: prefer an exact moviehash match, then higher (transient) popularity. */
function rankResults(a: SubtitleResult, b: SubtitleResult): number {
if (a.moviehashMatch !== b.moviehashMatch) return a.moviehashMatch ? -1 : 1;
return b.downloadCount - a.downloadCount;
}
export async function process(
request: ProcessRequest,
sendCallback: (payload: CallbackPayload) => Promise<void>,
): Promise<void> {
const startTime = Date.now();
const metaCore = new MetaCoreClient(request.metaCoreUrl);
const done = (status: CallbackPayload['status'], extra: Partial<CallbackPayload> = {}) =>
sendCallback({ taskId: request.taskId, status, duration: Date.now() - startTime, ...extra });
try {
const { cid, filePath, existingMeta } = request;
if (!apiKey) return void (await done('skipped', { reason: 'No API key configured' }));
if (existingMeta?.fileType !== 'video') return void (await done('skipped', { reason: 'Not a video file' }));
if (downloadQuotaSpent && !forceRecompute) {
return void (await done('skipped', { reason: 'OpenSubtitles download quota spent for this run' }));
}
// Resolve WebDAV from the meta-core we were handed (not a static env) so
// bytes and metadata land in the same store. Required for gateway use.
const webdavClient = await resolveWebdav(request.metaCoreUrl);
if (!webdavClient) return void (await done('failed', { error: 'Could not resolve a WebDAV target' }));
// Which lang3 codes do we still need? Read both the legacy csv-set
// (`subtitleLanguages`) and the key-set (`subtitleLanguages/<lang3>`).
const wantedLang3 = new Map<string, string>(); // lang3 -> original 2-letter code
for (const code of wantedLanguages) wantedLang3.set(toLang3(code), code);
const haveLang3 = new Set<string>();
for (const l of (existingMeta?.subtitleLanguages ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
haveLang3.add(l);
}
for (const key of Object.keys(existingMeta ?? {})) {
if (key.startsWith('subtitleLanguages/')) {
const l = key.slice('subtitleLanguages/'.length);
if (l) haveLang3.add(l);
}
}
if (!forceRecompute) {
for (const lang3 of [...wantedLang3.keys()]) {
if (haveLang3.has(lang3)) wantedLang3.delete(lang3);
}
}
if (wantedLang3.size === 0) {
return void (await done('skipped', { reason: 'Subtitles already present for all wanted languages' }));
}
// Build the search. Prefer structured IDs + moviehash for accuracy.
const moviehash = await computeVideoMovieHash(webdavClient, filePath);
const imdbRaw = existingMeta?.imdbid;
const imdbId = imdbRaw ? imdbRaw.replace(/^tt/i, '').replace(/^0+/, '') : undefined;
const tmdbId = existingMeta?.tmdbid;
const isEpisode = !!(existingMeta?.season || existingMeta?.episode)
|| existingMeta?.videoType === 'tvshow' || existingMeta?.videoType === 'tv';
let title = existingMeta?.originalTitle || existingMeta?.title || existingMeta?.fileName;
const year = existingMeta?.movieYear;
if (title && year) {
title = title.replace(new RegExp(`\\s*[\\(\\[]?${year}[\\)\\]]?\\s*$`), '').trim();
}
const search: SearchParams = { languages: [...wantedLang3.values()].join(',') };
if (moviehash) search.moviehash = moviehash;
if (imdbId) search.imdbId = imdbId;
if (tmdbId) search.tmdbId = tmdbId;
if (isEpisode && existingMeta?.season) search.seasonNumber = existingMeta.season;
if (isEpisode && existingMeta?.episode) search.episodeNumber = existingMeta.episode;
// Title query only as a fallback when we have no structured identifier.
if (!imdbId && !tmdbId && !moviehash && title) {
search.query = title;
if (year) search.year = year;
}
// Negative cache: skip a search we already know returns nothing (no
// moviehash, since that's video-specific). Saves the search budget.
const missKey = [
moviehash ? '' : (tmdbId ?? ''),
moviehash ? '' : (imdbId ?? ''),
moviehash ? '' : (search.query ?? ''),
search.seasonNumber ?? '',
search.episodeNumber ?? '',
[...wantedLang3.keys()].sort().join('+'),
].join('|');
if (!moviehash && !forceRecompute && searchMissCache.has(missKey)) {
return void (await done('skipped', { reason: 'Known OpenSubtitles miss (negative cache)' }));
}
const client = new OpenSubtitlesClient({ apiKey, username, password, userAgent });
const results = await client.search(search);
if (results.length === 0) {
if (!moviehash) searchMissCache.add(missKey);
return void (await done('completed', { reason: 'No OpenSubtitles match' }));
}
// Pick the single best subtitle per still-wanted language.
const bestByLang3 = new Map<string, SubtitleResult>();
for (const r of results) {
const lang3 = toLang3(r.language);
if (!wantedLang3.has(lang3)) continue;
const existing = bestByLang3.get(lang3);
if (!existing || rankResults(r, existing) < 0) bestByLang3.set(lang3, r);
}
if (bestByLang3.size === 0) {
if (!moviehash) searchMissCache.add(missKey);
return void (await done('completed', { reason: 'No results in wanted languages' }));
}
const displayTitle = existingMeta?.title || existingMeta?.originalTitle || title || undefined;
const safeTitle = sanitizeFilename(title || existingMeta?.fileName || 'video');
const yearStr = year ? ` (${year})` : '';
let fetched = 0;
let quotaHit = false;
for (const [lang3, result] of bestByLang3) {
const file = result.files[0];
if (!file) continue;
try {
const download = await client.requestDownload(file.fileId);
const ext = subtitleExt(download.fileName || file.fileName);
const outputFilename = `${safeTitle}${yearStr}[${cid}]_subtitle.${lang3}.${ext}`;
const webdavOutputPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${outputFilename}`;
const opts = { extension: ext, fileName: download.fileName || file.fileName, parentTitle: displayTitle };
// Reuse an already-downloaded sidecar unless forcing.
if (!forceRecompute && (await webdavClient.exists(webdavOutputPath))) {
const existingData = await webdavClient.readFile(webdavOutputPath);
await registerSubtitle(metaCore, cid, computeMidHash256FromBuffer(existingData), lang3, opts);
fetched++;
continue;
}
const bytes = await client.fetchSubtitleBytes(download.link);
if (bytes.length < 10) {
console.warn(`[opensubtitles] empty subtitle for ${lang3}, skipping`);
continue;
}
const subtitleCid = computeMidHash256FromBuffer(bytes);
await webdavClient.writeFile(webdavOutputPath, bytes);
await registerSubtitle(metaCore, cid, subtitleCid, lang3, opts);
fetched++;
console.log(`[opensubtitles] fetched ${lang3} subtitle -> ${subtitleCid} (${webdavOutputPath})`);
if (download.remaining === 0) {
console.warn('[opensubtitles] download quota exhausted, stopping');
downloadQuotaSpent = true;
quotaHit = true;
break;
}
} catch (e) {
if (e instanceof QuotaExceededError) {
console.warn(`[opensubtitles] quota exceeded: ${e.message}`);
downloadQuotaSpent = true;
quotaHit = true;
break;
}
console.error(`[opensubtitles] failed to fetch ${lang3}: ${e instanceof Error ? e.message : String(e)}`);
}
}
await done('completed', {
reason: quotaHit
? `Fetched ${fetched} subtitle(s); stopped on quota`
: `Fetched ${fetched} subtitle(s)`,
});
} catch (error) {
await done('failed', { error: error instanceof Error ? error.message : String(error) });
}
}
/** Companion metadata for a fetched subtitle's own record. */
interface SubtitleOpts {
extension: string;
fileName?: string;
parentTitle?: string;
}
/**
* Register a fetched subtitle. Mirrors the gateway's torznab subtitle enricher
* (subtitles.rs): writes the subtitle's own companion record (keyed by its CID)
* AND stamps the language-nested key-set onto the video. See METADATA_KEYS.md
* §8/§9. We write the companion record explicitly (not via the watcher) so this
* works when the gateway drives the plugin against a meta-core with no watcher.
*/
async function registerSubtitle(
metaCore: MetaCoreClient,
videoCid: string,
subtitleCid: string,
lang3: string,
opts: SubtitleOpts,
): Promise<void> {
// 1. Companion subtitle-file record (fileType=subtitle, lang, video backref).
const companion: Record<string, string> = {
fileType: 'subtitle',
extension: opts.extension,
subtitleLanguage: lang3,
[`videos/${videoCid}`]: 'true',
};
if (opts.fileName) companion.fileName = opts.fileName;
if (opts.parentTitle) companion.title = `${opts.parentTitle} (${lang3} subtitles)`;
await metaCore.mergeMetadata(subtitleCid, companion);
// 2. Language-nested key-set on the video (target shape, METADATA_KEYS.md §8).
await metaCore.mergeMetadata(videoCid, {
[`subtitles/${lang3}/${subtitleCid}`]: 'true',
[`subtitleLanguages/${lang3}`]: 'true',
[`languages/${lang3}`]: 'true',
});
}
+70
View File
@@ -0,0 +1,70 @@
/**
* MetaMesh Plugin Types
*/
export interface PluginManifest {
id: string;
name: string;
version: string;
description?: string;
author?: string;
dependencies?: string[];
schema?: Record<string, SchemaField>;
config?: Record<string, ConfigField>;
priority?: number;
color?: string;
defaultQueue?: 'fast' | 'background';
timeout?: number;
}
export interface SchemaField {
label: string;
type?: 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid';
readonly?: boolean;
hint?: string;
}
export interface ConfigField {
type: 'string' | 'number' | 'boolean' | 'select';
label?: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}
export interface HealthResponse {
status: 'healthy' | 'unhealthy';
ready: boolean;
version: string;
}
export interface ProcessRequest {
taskId: string;
cid: string;
filePath: string;
callbackUrl: string;
metaCoreUrl: string;
existingMeta?: Record<string, string>;
}
export interface ProcessResponse {
status: 'accepted' | 'rejected';
error?: string;
}
export interface CallbackPayload {
taskId: string;
status: 'completed' | 'failed' | 'skipped';
duration: number;
error?: string;
reason?: string;
}
export interface ConfigureRequest {
config: Record<string, unknown>;
}
export interface ConfigureResponse {
status: 'ok' | 'error';
error?: string;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* WebDAV Client for Plugin File Access
*
* Provides HTTP-based file access to meta-sort's WebDAV endpoint.
* Replaces direct filesystem access for containerized plugins.
*/
export interface FileStats {
size: number;
mtime?: Date;
}
export class WebDAVClient {
private baseUrl: string;
constructor(baseUrl: string) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Convert absolute file path to WebDAV URL
* Strips /files prefix and appends to base URL
*/
toWebDAVUrl(filePath: string): string {
// filePath is like "/files/watch/movie.mkv"
// We need to convert to "http://meta-sort-dev/webdav/watch/movie.mkv"
let relativePath = filePath;
// Strip /files prefix if present
if (relativePath.startsWith('/files/')) {
relativePath = relativePath.substring(6); // Remove "/files"
} else if (relativePath.startsWith('/files')) {
relativePath = relativePath.substring(6);
}
// Ensure path starts with /
if (!relativePath.startsWith('/')) {
relativePath = '/' + relativePath;
}
return this.baseUrl + relativePath;
}
/**
* Get file stats (size, mtime) via HTTP HEAD request
*/
async stat(filePath: string): Promise<FileStats> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`WebDAV HEAD failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const contentLength = response.headers.get('content-length');
const lastModified = response.headers.get('last-modified');
return {
size: contentLength ? parseInt(contentLength, 10) : 0,
mtime: lastModified ? new Date(lastModified) : undefined,
};
}
/**
* Read first N bytes of a file (for magic byte detection)
* Uses HTTP Range request
*/
async readBytes(filePath: string, start: number, end: number): Promise<Buffer> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, {
method: 'GET',
headers: {
'Range': `bytes=${start}-${end}`,
},
});
if (!response.ok && response.status !== 206) {
throw new Error(`WebDAV Range GET failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Read entire file as buffer (for small files only)
*/
async readFile(filePath: string): Promise<Buffer> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
throw new Error(`WebDAV GET failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Write file via HTTP PUT
*/
async writeFile(filePath: string, data: Buffer | string): Promise<void> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, {
method: 'PUT',
body: data,
headers: {
'Content-Type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error(`WebDAV PUT failed for ${filePath}: ${response.status} ${response.statusText}`);
}
}
/**
* Check if file exists via HTTP HEAD
*/
async exists(filePath: string): Promise<boolean> {
const url = this.toWebDAVUrl(filePath);
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
}
/**
* Create a WebDAV client from environment variables
*/
export function createWebDAVClient(): WebDAVClient | null {
const webdavUrl = process.env.WEBDAV_URL;
if (!webdavUrl) {
console.warn('[webdav-client] WEBDAV_URL not set, WebDAV client unavailable');
return null;
}
return new WebDAVClient(webdavUrl);
}