6 Commits
Author SHA1 Message Date
Worph 985b000c24 align with metadata standard
Build and Push Docker Image / build (push) Failing after 14s
2026-09-13 15:56:54 +02:00
Worph 021991b00d improve stability
Build and Push Docker Image / build (push) Failing after 14s
2026-09-13 12:02:27 +02:00
Worph 862f73d26d cid rank remove
Build and Push Docker Image / build (push) Failing after 13s
2026-09-09 17:58:15 +02:00
Worph b855adb822 rework domain field
Build and Push Docker Image / build (push) Failing after 14s
2026-09-02 00:05:19 +02:00
Worph 9bf2e623f9 align to metadata keys doc 2026-08-30 19:53:03 +02:00
Worph 7bdb54f9a7 audio support widening 2026-08-21 18:23:34 +02:00
21 changed files with 730 additions and 342 deletions
@@ -11,8 +11,9 @@ import { performanceMetrics } from '../metrics/PerformanceMetrics.js';
import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js'; import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js';
import type { IKVClient } from '../kv/IKVClient.js'; import type { IKVClient } from '../kv/IKVClient.js';
import type { KVManager } from '../kv/KVManager.js'; import type { KVManager } from '../kv/KVManager.js';
import type { ExtendedProcessingSnapshot, IKVClientWithPubSub } from '../types/ExtendedInterfaces.js'; import type { ExtendedProcessingSnapshot } from '../types/ExtendedInterfaces.js';
import { getErrorMessage } from '../types/ExtendedInterfaces.js'; import { getErrorMessage, hasFileTuples } from '../types/ExtendedInterfaces.js';
import type { IKVClientWithFileTuples } from '../types/ExtendedInterfaces.js';
import { config } from '../config/EnvConfig.js'; import { config } from '../config/EnvConfig.js';
import * as webdav from '../webdav/WebdavClient.js'; import * as webdav from '../webdav/WebdavClient.js';
import type { PluginManager } from '../plugin-engine/PluginManager.js'; import type { PluginManager } from '../plugin-engine/PluginManager.js';
@@ -29,6 +30,45 @@ export interface UnifiedAPIServerConfig {
enableCors?: boolean; enableCors?: boolean;
} }
/** File totals served by /api/stats. Null when meta-core cannot supply them. */
interface FileStats {
fileCount: number | null;
totalSize: number | null;
source: 'meta-core' | 'unavailable';
}
/** Metadata reads in flight at once while a recompute builds its task list. */
const RECOMPUTE_READ_CONCURRENCY = 8;
/** Run `worker` over `items` with at most `limit` calls in flight. */
async function forEachWithConcurrency<T>(
items: readonly T[],
limit: number,
worker: (item: T) => Promise<void>
): Promise<void> {
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const item = items[next++];
await worker(item);
}
});
await Promise.all(runners);
}
/** Flatten nested metadata into the `a/b/c` keys the task scheduler expects. */
function flattenMetadata(obj: Record<string, unknown>, out: Record<string, string> = {}, prefix = ''): Record<string, string> {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}/${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
flattenMetadata(value as Record<string, unknown>, out, fullKey);
} else if (value !== undefined && value !== null) {
out[fullKey] = String(value);
}
}
return out;
}
export class UnifiedAPIServer { export class UnifiedAPIServer {
private app: FastifyInstance; private app: FastifyInstance;
private unifiedStateManager: UnifiedProcessingStateManager | null = null; private unifiedStateManager: UnifiedProcessingStateManager | null = null;
@@ -44,9 +84,15 @@ export class UnifiedAPIServer {
private containerPluginScheduler: ContainerPluginScheduler | null = null; private containerPluginScheduler: ContainerPluginScheduler | null = null;
private streamingPipeline: StreamingPipeline | null = null; private streamingPipeline: StreamingPipeline | null = null;
// Cache for total file size (avoid running du on every request) // /api/stats file totals: stale-while-revalidate, one refresh at a time.
private totalSizeCache: { value: number; timestamp: number } | null = null; // Never walk hashIds for these — on a shared meta-core that is every gateway
private static TOTAL_SIZE_CACHE_TTL_MS = 30000; // 30 seconds // record too (82k on watch.nsl.sh), and a sweep per poll stacked unbounded.
private fileStats: { value: FileStats; fetchedAt: number } | null = null;
private fileStatsInFlight: Promise<FileStats> | null = null;
private static FILE_STATS_TTL_MS = 30000; // 30 seconds
// Plugins whose recompute is currently being queued (single-flight per plugin)
private recomputeInFlight = new Set<string>();
constructor( constructor(
config: UnifiedAPIServerConfig = {}, config: UnifiedAPIServerConfig = {},
@@ -97,48 +143,46 @@ export class UnifiedAPIServer {
} }
/** /**
* Get total size of processed files from Redis sizeByte field (cached) * File count + total size of the files meta-core holds on disk, for the
* This is "eventually correct" - accumulates as files are processed by the file-info plugin * monitor's "Total Size" card: one summary call to meta-core's
* GET /api/files/tuples (batched server-side), cached for FILE_STATS_TTL_MS.
*
* Stale-while-revalidate with a single in-flight refresh — a poll never waits
* on meta-core once a value exists, and concurrent polls share one request.
*/ */
private async getTotalFileSizeFromRedis(): Promise<number> { private async getFileStats(): Promise<FileStats> {
// Check cache const cached = this.fileStats;
const now = Date.now(); if (cached && Date.now() - cached.fetchedAt < UnifiedAPIServer.FILE_STATS_TTL_MS) {
if (this.totalSizeCache && (now - this.totalSizeCache.timestamp) < UnifiedAPIServer.TOTAL_SIZE_CACHE_TTL_MS) { return cached.value;
return this.totalSizeCache.value;
} }
// Sum sizeByte from all file entries in Redis if (!this.fileStatsInFlight) {
let totalSize = 0; this.fileStatsInFlight = this.fetchFileStats()
.catch((error: unknown): FileStats => {
if (!this.kvClient) { console.warn('[API] /api/stats: meta-core file totals unavailable:', getErrorMessage(error));
return 0; return cached?.value ?? { fileCount: null, totalSize: null, source: 'unavailable' };
})
.then((value) => {
this.fileStats = { value, fetchedAt: Date.now() };
return value;
})
.finally(() => {
this.fileStatsInFlight = null;
});
} }
try { return cached ? cached.value : this.fileStatsInFlight;
// Get all file hash IDs }
const hashIds = await this.kvClient.getAllHashIds();
// Sum up sizeByte values (batch process for efficiency) private async fetchFileStats(): Promise<FileStats> {
for (const hashId of hashIds) { if (!this.kvClient || !hasFileTuples(this.kvClient)) {
try { return { fileCount: null, totalSize: null, source: 'unavailable' };
const sizeByte = await this.kvClient.getMetadata(hashId, 'sizeByte');
if (sizeByte !== null && sizeByte !== undefined) {
const size = typeof sizeByte === 'number' ? sizeByte : parseInt(String(sizeByte), 10);
if (!isNaN(size) && size > 0) {
totalSize += size;
}
}
} catch {
// Skip entries without sizeByte
}
}
} catch (error) {
console.warn('[API] Failed to calculate total size from Redis:', error);
} }
const summary = await this.kvClient.getFileTuples({ summary: true });
// Update cache if (!summary) {
this.totalSizeCache = { value: totalSize, timestamp: now }; return { fileCount: null, totalSize: null, source: 'unavailable' };
return totalSize; }
return { fileCount: summary.count, totalSize: summary.totalSize, source: 'meta-core' };
} }
/** /**
@@ -524,53 +568,17 @@ export class UnifiedAPIServer {
/** /**
* Setup Stats API routes (Redis stats for monitor UI) * Setup Stats API routes (file totals for the monitor UI)
*/ */
private setupStatsRoutes(): void { private setupStatsRoutes(): void {
if (!this.kvClient) { if (!this.kvClient) {
return; return;
} }
// Get Redis/KV stats (for monitor UI) // At most one meta-core call per FILE_STATS_TTL_MS however often the
this.app.get('/api/stats', async (request, reply) => { // monitor polls — see getFileStats.
try { this.app.get('/api/stats', async () => {
const hashIds = await this.kvClient!.getAllHashIds(); return this.getFileStats();
const redis = (this.kvClient as IKVClientWithPubSub).getRedisClient?.() as { info: (section: string) => Promise<string> } | undefined;
let memoryUsage = 'N/A';
let memoryUsageBytes = 0;
if (redis) {
try {
const info = await redis.info('memory');
const match = info.match(/used_memory_human:(\S+)/);
const bytesMatch = info.match(/used_memory:(\d+)/);
if (match) {
memoryUsage = match[1];
}
if (bytesMatch) {
memoryUsageBytes = parseInt(bytesMatch[1], 10);
}
} catch {
// Ignore memory info errors
}
}
// Get total size from Redis sizeByte (eventually correct as files are processed)
const totalSize = await this.getTotalFileSizeFromRedis();
return {
fileCount: hashIds.length,
keyCount: hashIds.length,
totalSize,
memoryUsage,
memoryUsageBytes
};
} catch (error: any) {
return reply.status(500).send({
error: 'Failed to get stats',
details: error.message
});
}
}); });
} }
@@ -886,38 +894,43 @@ export class UnifiedAPIServer {
return reply.status(400).send({ error: `Plugin '${pluginId}' is not active` }); return reply.status(400).send({ error: `Plugin '${pluginId}' is not active` });
} }
if (!hasFileTuples(this.kvClient)) {
return reply.status(501).send({ error: 'Recompute needs meta-core GET /api/files/tuples' });
}
const kvClient = this.kvClient as IKVClientWithFileTuples;
if (this.recomputeInFlight.has(pluginId)) {
return reply.status(409).send({ error: `Recompute for plugin '${pluginId}' is already being queued` });
}
this.recomputeInFlight.add(pluginId);
try { try {
// Get all files from KV // Only file-backed records can be recomputed, and meta-core lists exactly
const hashIds = await this.kvClient.getAllHashIds(); // those in one batched call. Walking getAllHashIds() instead reads every
console.log(`[Recompute] Starting recompute for plugin '${pluginId}' on ${hashIds.length} files`); // gateway record too (82k on watch.nsl.sh) to keep a few thousand.
const tuples = await kvClient.getFileTuples();
if (!tuples || !tuples.files) {
return reply.status(501).send({
error: 'Recompute needs meta-core GET /api/files/tuples',
details: 'meta-core did not return a file list'
});
}
const candidates = tuples.files;
console.log(`[Recompute] Starting recompute for plugin '${pluginId}' on ${candidates.length} files`);
// Prepare file data with metadata for TaskScheduler // Prepare file data with metadata for TaskScheduler
const files: Array<{ filePath: string; fileHash: string; kvData?: Record<string, string> }> = []; const files: Array<{ filePath: string; fileHash: string; kvData?: Record<string, string> }> = [];
await forEachWithConcurrency(candidates, RECOMPUTE_READ_CONCURRENCY, async (tuple) => {
for (const hashId of hashIds) { const metadata = await kvClient.getMetadataFlat(tuple.hashId);
const metadata = await this.kvClient.getMetadataFlat(hashId); if (!metadata) {
if (metadata && metadata.filePath) { return;
// Convert nested metadata to flat key-value pairs for KVStore
const kvData: Record<string, string> = {};
const flattenObj = (obj: any, prefix = '') => {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}/${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
flattenObj(value, fullKey);
} else if (value !== undefined && value !== null) {
kvData[fullKey] = String(value);
}
}
};
flattenObj(metadata);
files.push({
fileHash: hashId,
filePath: metadata.filePath,
kvData
});
} }
} files.push({
fileHash: tuple.hashId,
filePath: metadata.filePath || tuple.filePath,
kvData: flattenMetadata(metadata)
});
});
// Create and enqueue tasks with forceRecompute option // Create and enqueue tasks with forceRecompute option
const tasks = taskScheduler.createTasksForPluginOnFiles( const tasks = taskScheduler.createTasksForPluginOnFiles(
@@ -935,7 +948,7 @@ export class UnifiedAPIServer {
status: 'ok', status: 'ok',
message: `Recompute triggered for plugin '${pluginId}'`, message: `Recompute triggered for plugin '${pluginId}'`,
filesQueued: tasks.length, filesQueued: tasks.length,
totalFiles: hashIds.length totalFiles: candidates.length
}; };
} catch (error: any) { } catch (error: any) {
console.error(`[Recompute] Error triggering recompute for plugin '${pluginId}':`, error); console.error(`[Recompute] Error triggering recompute for plugin '${pluginId}':`, error);
@@ -943,6 +956,8 @@ export class UnifiedAPIServer {
error: 'Failed to trigger recompute', error: 'Failed to trigger recompute',
details: error.message details: error.message
}); });
} finally {
this.recomputeInFlight.delete(pluginId);
} }
}); });
} }
+176
View File
@@ -8,6 +8,7 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { UnifiedAPIServer } from './UnifiedAPIServer.js'; import { UnifiedAPIServer } from './UnifiedAPIServer.js';
import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js'; import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js';
import { performanceMetrics } from '../metrics/PerformanceMetrics.js';
import type { IKVClient } from '../kv/IKVClient.js'; import type { IKVClient } from '../kv/IKVClient.js';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
@@ -23,6 +24,25 @@ class MockKVClient implements IKVClient {
private data: Map<string, Map<string, any>> = new Map(); private data: Map<string, Map<string, any>> = new Map();
private rawStore: Map<string, string> = new Map(); private rawStore: Map<string, string> = new Map();
// Stand-in for meta-core's GET /api/files/tuples. null = a meta-core that
// predates the endpoint.
fileTuples: Array<{ hashId: string; filePath: string; sizeByte: number; mtimeNano: number }> | null = null;
fileTuplesDelayMs = 0;
calls = { getFileTuples: 0, getAllHashIds: 0, getMetadataFlat: 0 };
async getFileTuples(opts: { summary?: boolean } = {}) {
this.calls.getFileTuples++;
if (this.fileTuplesDelayMs) {
await new Promise(resolve => setTimeout(resolve, this.fileTuplesDelayMs));
}
if (!this.fileTuples) return null;
const files = this.fileTuples;
const totalSize = files.reduce((sum, f) => sum + f.sizeByte, 0);
return opts.summary
? { count: files.length, totalSize }
: { count: files.length, totalSize, files };
}
// Basic operations // Basic operations
async set(key: string, value: any): Promise<void> { async set(key: string, value: any): Promise<void> {
this.rawStore.set(key, JSON.stringify(value)); this.rawStore.set(key, JSON.stringify(value));
@@ -99,6 +119,7 @@ class MockKVClient implements IKVClient {
} }
async getMetadataFlat(hashId: string): Promise<any | null> { async getMetadataFlat(hashId: string): Promise<any | null> {
this.calls.getMetadataFlat++;
const entry = this.data.get(hashId); const entry = this.data.get(hashId);
if (!entry) return null; if (!entry) return null;
return Object.fromEntries(entry); return Object.fromEntries(entry);
@@ -125,6 +146,7 @@ class MockKVClient implements IKVClient {
} }
async getAllHashIds(): Promise<string[]> { async getAllHashIds(): Promise<string[]> {
this.calls.getAllHashIds++;
return Array.from(this.data.keys()); return Array.from(this.data.keys());
} }
@@ -736,3 +758,157 @@ describe('UnifiedAPIServer', function() {
}); });
}); });
}); });
// =============================================================================
// File totals + recompute against meta-core's GET /api/files/tuples
// =============================================================================
//
// Each test builds its own server: /api/stats caches for 30 s and recompute is
// single-flight, so shared state between tests would hide both behaviours.
describe('UnifiedAPIServer file totals and recompute', function() {
const tuple = (hashId: string, sizeByte: number) =>
({ hashId, filePath: `/files/watch/${hashId}.mkv`, sizeByte, mtimeNano: 1 });
let app: FastifyInstance;
let kv: MockKVClient;
let queued: Array<{ pluginId: string; fileHash: string }>;
beforeEach(async function() {
kv = new MockKVClient();
queued = [];
const pluginManager = { getPlugins: () => [{ id: 'ffmpeg', active: true }] };
const taskScheduler = {
createTasksForPluginOnFiles: (pluginId: string, files: Array<{ fileHash: string }>) =>
files.map(f => ({ pluginId, fileHash: f.fileHash })),
enqueueTasks: (tasks: Array<{ pluginId: string; fileHash: string }>) => { queued.push(...tasks); },
};
const server = new UnifiedAPIServer(
{ port: 0, host: 'localhost', enableCors: false },
new MockProcessingStateManager() as any,
kv as any,
4,
16,
() => ({ fast: { pending: 0, running: 0 }, background: { pending: 0, running: 0 } }),
undefined,
() => pluginManager as any,
() => taskScheduler as any
);
app = server.getApp();
await app.ready();
});
afterEach(async function() {
await app.close();
});
it('GET /api/stats sums meta-core file tuples without walking hash ids', async function() {
kv.fileTuples = [tuple('a', 100), tuple('b', 250)];
const response = await app.inject({ method: 'GET', url: '/api/stats' });
expect(response.statusCode).to.equal(200);
expect(JSON.parse(response.payload)).to.deep.equal({ fileCount: 2, totalSize: 350, source: 'meta-core' });
expect(kv.calls.getAllHashIds).to.equal(0);
expect(kv.calls.getMetadataFlat).to.equal(0);
});
it('GET /api/stats degrades to nulls on a meta-core without the endpoint', async function() {
kv.fileTuples = null;
const response = await app.inject({ method: 'GET', url: '/api/stats' });
expect(response.statusCode).to.equal(200);
expect(JSON.parse(response.payload)).to.deep.equal({ fileCount: null, totalSize: null, source: 'unavailable' });
expect(kv.calls.getAllHashIds).to.equal(0);
});
it('GET /api/stats shares one meta-core call between concurrent polls', async function() {
kv.fileTuples = [tuple('a', 100)];
kv.fileTuplesDelayMs = 50;
const responses = await Promise.all(
Array.from({ length: 10 }, () => app.inject({ method: 'GET', url: '/api/stats' }))
);
for (const response of responses) {
expect(response.statusCode).to.equal(200);
expect(JSON.parse(response.payload).totalSize).to.equal(100);
}
expect(kv.calls.getFileTuples).to.equal(1);
});
it('POST recompute returns 501 on a meta-core without the endpoint', async function() {
kv.fileTuples = null;
const response = await app.inject({ method: 'POST', url: '/api/plugins/ffmpeg/recompute' });
expect(response.statusCode).to.equal(501);
expect(kv.calls.getAllHashIds).to.equal(0);
expect(queued).to.have.length(0);
});
it('POST recompute queues only file-backed records', async function() {
kv.seedData('a', { filePath: '/files/watch/a.mkv', fileType: 'video' });
kv.seedData('b', { filePath: '/files/watch/b.mkv', fileType: 'video' });
kv.seedData('gateway-record', { title: 'Not a file' });
kv.fileTuples = [tuple('a', 100), tuple('b', 250)];
const response = await app.inject({ method: 'POST', url: '/api/plugins/ffmpeg/recompute' });
expect(response.statusCode).to.equal(200);
const body = JSON.parse(response.payload);
expect(body.filesQueued).to.equal(2);
expect(body.totalFiles).to.equal(2);
expect(queued.map(q => q.fileHash).sort()).to.deep.equal(['a', 'b']);
expect(kv.calls.getMetadataFlat).to.equal(2);
expect(kv.calls.getAllHashIds).to.equal(0);
});
it('POST recompute refuses a second run for the same plugin while one is queuing', async function() {
kv.seedData('a', { filePath: '/files/watch/a.mkv' });
kv.fileTuples = [tuple('a', 100)];
kv.fileTuplesDelayMs = 50;
const [first, second] = await Promise.all([
app.inject({ method: 'POST', url: '/api/plugins/ffmpeg/recompute' }),
app.inject({ method: 'POST', url: '/api/plugins/ffmpeg/recompute' }),
]);
expect([first.statusCode, second.statusCode].sort()).to.deep.equal([200, 409]);
expect(queued).to.have.length(1);
});
});
// =============================================================================
// File-level processing metric
// =============================================================================
describe('UnifiedProcessingStateManager file metric', function() {
// Files announced over SSE go straight to light processing; they are never
// marked discovered, which is the path that used to leave "Processed" at 0.
const runFile = (manager: UnifiedProcessingStateManager, filePath: string, error?: string) => {
manager.startLightProcessing(filePath);
manager.completeLightProcessing(filePath, 'bagacbabaeexamplehash');
manager.startHashProcessing(filePath);
manager.completeHashProcessing(filePath, 'bagacbabaeexamplehash', undefined, error);
};
it('counts a file as processed when its hash phase completes cleanly', function() {
const manager = new UnifiedProcessingStateManager();
const before = performanceMetrics.getMetrics().totalFilesProcessed;
runFile(manager, '/files/watch/metric-ok.mkv');
expect(performanceMetrics.getMetrics().totalFilesProcessed).to.equal(before + 1);
});
it('does not count a file whose hash phase failed', function() {
const manager = new UnifiedProcessingStateManager();
const before = performanceMetrics.getMetrics().totalFilesProcessed;
runFile(manager, '/files/watch/metric-failed.mkv', 'plugin failed');
expect(performanceMetrics.getMetrics().totalFilesProcessed).to.equal(before);
});
});
@@ -26,7 +26,8 @@ export function renamingRule(metamesh: Local, filepath?: string): string | null
throw new Error(`No extension found for file: ${filepath}`); throw new Error(`No extension found for file: ${filepath}`);
} }
const title = metamesh?.titles?.eng || metamesh.originalTitle; // The display name — never the `titles/*` name set, which has no preferred member (METADATA_KEYS.md §3).
const title = metamesh.title || metamesh.originalTitle;
if (!title) { if (!title) {
if (metamesh.fileType !== "torrent") { if (metamesh.fileType !== "torrent") {
//don't warn for torrent files because they don't always have a title (eg representing a folder of files) //don't warn for torrent files because they don't always have a title (eg representing a folder of files)
@@ -19,6 +19,24 @@ import {
buildFilePrefix, buildFilePrefix,
} from './MetadataUtils.js'; } from './MetadataUtils.js';
/** One file-backed root as listed by meta-core's GET /api/files/tuples. */
export interface FileTuple {
hashId: string;
filePath: string;
sizeByte: number;
mtimeNano: number;
}
/**
* GET /api/files/tuples body. `files` is present unless the summary form was
* asked for.
*/
export interface FileTuplesResult {
count: number;
totalSize: number;
files?: FileTuple[];
}
export interface MetaCoreApiWriterConfig { export interface MetaCoreApiWriterConfig {
/** Base URL for meta-core's HTTP API (e.g. http://metacore-app:9000) */ /** Base URL for meta-core's HTTP API (e.g. http://metacore-app:9000) */
apiUrl: string; apiUrl: string;
@@ -139,6 +157,27 @@ export class MetaCoreApiWriter {
return Array.isArray(ids) ? ids : []; return Array.isArray(ids) ? ids : [];
} }
/**
* GET /api/files/tuples. meta-core's FILE-BACKED roots (records carrying
* filePath, sizeByte and mtimeNano) with sizes, read server-side in batched
* MGETs — the cheap alternative to walking getAllHashIds(), which on a
* shared meta-core is every gateway record too. `summary` omits the rows.
*
* Returns null when meta-core predates the endpoint (404, or a body that is
* not the expected JSON).
*/
async getFileTuples(opts: { summary?: boolean } = {}): Promise<FileTuplesResult | null> {
const path = opts.summary ? '/api/files/tuples?summary=1' : '/api/files/tuples';
const body = await this.fetchJsonAllowing404('GET', path);
if (!body || typeof body !== 'object') return null;
const b = body as { count?: unknown; totalSize?: unknown; files?: unknown };
return {
count: Number(b.count) || 0,
totalSize: Number(b.totalSize) || 0,
files: Array.isArray(b.files) ? (b.files as FileTuple[]) : undefined,
};
}
/** /**
* GET /health on meta-core. Used as a write-path health probe. * GET /health on meta-core. Used as a write-path health probe.
*/ */
+26 -2
View File
@@ -21,7 +21,7 @@ import {
buildFilePrefix, buildFilePrefix,
buildPropertyKey, buildPropertyKey,
} from './MetadataUtils.js'; } from './MetadataUtils.js';
import { MetaCoreApiWriter } from './MetaCoreApiWriter.js'; import { MetaCoreApiWriter, type FileTuplesResult } from './MetaCoreApiWriter.js';
// ESM/CJS interop for ioredis // ESM/CJS interop for ioredis
const Redis = (IORedis as any).default ?? IORedis; const Redis = (IORedis as any).default ?? IORedis;
@@ -102,7 +102,22 @@ export class RedisKVClient implements IKVClient {
// automatic connect. Any caller that tries to use the Redis // automatic connect. Any caller that tries to use the Redis
// path (e.g. unmigrated stream code) will fail loudly rather // path (e.g. unmigrated stream code) will fail loudly rather
// than silently misbehaving. // than silently misbehaving.
this.redis = new Redis({ host: '127.0.0.1', port: 0, lazyConnect: true, enableOfflineQueue: false }); this.redis = new Redis({
host: '127.0.0.1',
port: 0,
lazyConnect: true,
enableOfflineQueue: false,
// Never reconnect. A stub that retries turns one stray call into
// an endless "Unhandled error event: ECONNREFUSED" loop.
retryStrategy: () => null,
maxRetriesPerRequest: 0,
});
let stubWarned = false;
this.redis.on('error', (err: Error) => {
if (stubWarned) return;
stubWarned = true;
console.warn(`[Redis] HTTP-only stub was used and refused (${err.message}) — a caller still reaches for direct Redis`);
});
this.isConnected = false; this.isConnected = false;
console.log('[Redis] HTTP-only mode — no direct Redis connection'); console.log('[Redis] HTTP-only mode — no direct Redis connection');
return; return;
@@ -474,6 +489,15 @@ export class RedisKVClient implements IKVClient {
return await this.redis.smembers(indexKey); return await this.redis.smembers(indexKey);
} }
/**
* File-backed roots from meta-core (GET /api/files/tuples). Only the HTTP
* writer can serve this; direct-Redis mode returns null so callers take
* their degraded path instead of sweeping every record.
*/
async getFileTuples(opts: { summary?: boolean } = {}): Promise<FileTuplesResult | null> {
return this.writer ? this.writer.getFileTuples(opts) : null;
}
// ======================================================================== // ========================================================================
// Pub/Sub Operations // Pub/Sub Operations
// ======================================================================== // ========================================================================
@@ -85,13 +85,10 @@ export class JellyfinFormatHandler implements IMetadataFormatHandler {
if (metadata.originalTitle) jellyfinFields.originalTitle = metadata.originalTitle; if (metadata.originalTitle) jellyfinFields.originalTitle = metadata.originalTitle;
if (metadata.movieYear) jellyfinFields.year = metadata.movieYear; if (metadata.movieYear) jellyfinFields.year = metadata.movieYear;
// Titles (first title or original title) // Title: the display name. `titles/*` is an unordered name set with no
if (metadata.titles) { // preferred member, so it is never the NFO title (METADATA_KEYS.md §3).
const titleEntries = Object.entries(metadata.titles); const title = metadata.title || metadata.originalTitle;
if (titleEntries.length > 0) { if (title) jellyfinFields.title = title;
jellyfinFields.title = titleEntries[0][1];
}
}
// IDs // IDs
if (metadata.imdbid) jellyfinFields.imdbid = metadata.imdbid; if (metadata.imdbid) jellyfinFields.imdbid = metadata.imdbid;
@@ -195,6 +195,16 @@ export class UnifiedProcessingStateManager {
performanceMetrics.recordHashProcessing(hashProcessingTime); performanceMetrics.recordHashProcessing(hashProcessingTime);
} }
// File-level metric, one per file that finished cleanly. Nothing recorded
// it before, so the monitor's "Processed" tile stayed at 0. Files announced
// over SSE are never marked discovered, so fall back to the light phase.
if (!error) {
const fileTime = totalProcessingTime
?? (hashState?.lightProcessingStartedAt !== undefined ? now - hashState.lightProcessingStartedAt : hashProcessingTime)
?? 0;
performanceMetrics.recordFileProcessing(filePath, fileTime);
}
const doneState: UnifiedFileState = { const doneState: UnifiedFileState = {
filePath, filePath,
state: 'done', state: 'done',
@@ -8,6 +8,7 @@
import type { HashMeta } from '@metazla/meta-interface'; import type { HashMeta } from '@metazla/meta-interface';
import type { IKVClient } from '../kv/IKVClient.js'; import type { IKVClient } from '../kv/IKVClient.js';
import type { FileTuplesResult } from '../kv/MetaCoreApiWriter.js';
import type { FileAnalyzerInterface } from '../logic/fileProcessor/FileAnalyzerInterface.js'; import type { FileAnalyzerInterface } from '../logic/fileProcessor/FileAnalyzerInterface.js';
import type { ContainerTask } from '../container-plugins/types.js'; import type { ContainerTask } from '../container-plugins/types.js';
import type { UnifiedProcessingSnapshot } from '../logic/UnifiedProcessingStateManager.js'; import type { UnifiedProcessingSnapshot } from '../logic/UnifiedProcessingStateManager.js';
@@ -41,6 +42,21 @@ export function hasPublish(client: IKVClient): client is IKVClientWithPubSub {
return typeof (client as IKVClientWithPubSub).publish === 'function'; return typeof (client as IKVClientWithPubSub).publish === 'function';
} }
/**
* IKVClient that can list meta-core's file-backed roots in one call
* (GET /api/files/tuples). Resolves null when meta-core predates the endpoint.
*/
export interface IKVClientWithFileTuples extends IKVClient {
getFileTuples(opts?: { summary?: boolean }): Promise<FileTuplesResult | null>;
}
/**
* Type guard to check if a KVClient can list file-backed roots
*/
export function hasFileTuples(client: IKVClient): client is IKVClientWithFileTuples {
return typeof (client as IKVClientWithFileTuples).getFileTuples === 'function';
}
/** /**
* IKVClient with Redis Streams capabilities * IKVClient with Redis Streams capabilities
* *
+1 -1
View File
@@ -9,7 +9,7 @@ export interface FileMetadata {
// Filename parsing // Filename parsing
title?: string; title?: string;
titles?: string[]; titles?: Record<string, Record<string, true | string>>; // key-set: lang3 → {name: "true"}
originaltitle?: string; originaltitle?: string;
showtitle?: string; showtitle?: string;
season?: number; season?: number;
+30 -24
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
ProcessingStatus, ProcessingStatus,
Metrics, Metrics,
@@ -60,29 +60,35 @@ function Monitor() {
const [failedFiles, setFailedFiles] = useState<FailedFile[]>([]); const [failedFiles, setFailedFiles] = useState<FailedFile[]>([]);
const [showPipelineHelp, setShowPipelineHelp] = useState(false); const [showPipelineHelp, setShowPipelineHelp] = useState(false);
const fetchData = useCallback(async () => { // Each endpoint renders as soon as it answers, so one slow or failing call
try { // cannot blank the page. A tick is skipped while the previous one is still
const [statusRes, metricsRes, queueRes, statsRes, failedRes] = await Promise.all([ // out, so a slow backend never collects a pile of overlapping polls.
fetch('/api/processing/status'), const pollInFlight = useRef(false);
fetch('/api/metrics'),
fetch('/api/processing/queue'),
fetch('/api/stats'),
fetch('/api/processing/failed')
]);
if (statusRes.ok) setStatus(await statusRes.json()); const fetchData = useCallback(async () => {
if (metricsRes.ok) setMetrics(await metricsRes.json()); if (pollInFlight.current) return;
if (queueRes.ok) { pollInFlight.current = true;
const queueData = await queueRes.json();
setQueue(queueData.items || []); const load = async <T,>(url: string, apply: (data: T) => void) => {
const res = await fetch(url);
if (res.ok) apply(await res.json());
};
try {
const results = await Promise.allSettled([
load<ProcessingStatus>('/api/processing/status', setStatus),
load<Metrics>('/api/metrics', setMetrics),
load<{ items?: QueueItem[] }>('/api/processing/queue', (data) => setQueue(data.items || [])),
load<RedisStats>('/api/stats', setRedisStats),
load<{ failedFiles?: FailedFile[] }>('/api/processing/failed', (data) => setFailedFiles(data.failedFiles || [])),
]);
for (const result of results) {
if (result.status === 'rejected') {
console.error('Failed to fetch data:', result.reason);
}
} }
if (statsRes.ok) setRedisStats(await statsRes.json()); } finally {
if (failedRes.ok) { pollInFlight.current = false;
const failedData = await failedRes.json();
setFailedFiles(failedData.failedFiles || []);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} }
}, []); }, []);
@@ -410,7 +416,7 @@ function Monitor() {
<h3 className="section-title">File Coverage</h3> <h3 className="section-title">File Coverage</h3>
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-box"> <div className="stat-box">
<div className="stat-value">{status?.uniqueHashes ? formatNumber(status.uniqueHashes) : (redisStats ? formatNumber(redisStats.fileCount) : '-')}</div> <div className="stat-value">{status?.uniqueHashes ? formatNumber(status.uniqueHashes) : (redisStats?.fileCount != null ? formatNumber(redisStats.fileCount) : '-')}</div>
<div className="stat-label">Unique Files</div> <div className="stat-label">Unique Files</div>
</div> </div>
<div className="stat-box"> <div className="stat-box">
@@ -432,7 +438,7 @@ function Monitor() {
<div className="card total-size-card"> <div className="card total-size-card">
<h2>Total Size</h2> <h2>Total Size</h2>
<div className="total-size-value"> <div className="total-size-value">
{redisStats ? formatBytes(redisStats.totalSize) : '-'} {redisStats?.totalSize != null ? formatBytes(redisStats.totalSize) : '-'}
</div> </div>
<div className="total-size-label">Watch Folders</div> <div className="total-size-label">Watch Folders</div>
</div> </div>
+5 -5
View File
@@ -184,12 +184,12 @@ export interface Metrics {
// NOTE: DuplicateGroup and DuplicateData types have been moved to meta-dup service // NOTE: DuplicateGroup and DuplicateData types have been moved to meta-dup service
/** GET /api/stats: totals over meta-core's file-backed roots. */
export interface RedisStats { export interface RedisStats {
fileCount: number; /** null when meta-core cannot supply file totals (source 'unavailable') */
keyCount: number; fileCount: number | null;
totalSize: number; totalSize: number | null;
memoryUsage?: string; source?: 'meta-core' | 'unavailable';
memoryUsageBytes?: number;
} }
export interface QueueItem { export interface QueueItem {
@@ -1,9 +1,23 @@
/**
* The `fileType` vocabulary — see METADATA_KEYS.md §1.
*
* ⚠ INVARIANT: a value belongs here only if an `(extension, mimeType)` lookup
* can decide it on its own. Nothing that requires knowing what the content
* *is* goes in this field — that is `contentKind`'s job — and nothing that
* decides *which app gets the record* depends on it — that is `domain`'s job
* (METADATA_KEYS.md §14.16).
*
* `card` is the one deliberate exception: it describes no bytes at all and is
* therefore never produced by the tables here, only stamped by a card feeder.
*/
export type SimpleFileType = export type SimpleFileType =
'audio' 'audio'
| 'video' | 'video'
| 'image'
| 'document' | 'document'
| 'archive' | 'archive'
| 'subtitle' | 'subtitle'
| 'torrent' | 'torrent'
| 'card'
| 'other' | 'other'
| 'undefined'; | 'undefined';
@@ -0,0 +1,125 @@
/**
* `contentKind` → `domain` / `workForm` — the routing tables from
* METADATA_KEYS.md §1.
*
* `domain` answers "which application is this record destined for?" and is the
* key every client filters its catalogue on — **one value per app**, no
* alternation. `workForm` answers the other question the old `film`/`tv` split
* was secretly encoding: is this work complete in itself, or one instalment of
* a run? It is cross-domain, so a comic issue is `serial` in `literature`
* exactly as an episode is `serial` in `screen`.
*
* Both are **stored, not derived on read**: that is what lets the `contentKind`
* vocabulary stay open, because a peer that has never heard of a kind still
* routes the record correctly without having to interpret the token.
*
* ⚠ This binding is **permanent and one-way**. Add kinds freely; never re-map
* an existing kind to a different domain. Because the value is stored, two
* peers on different versions that disagree about where a kind belongs produce
* a split-brain that redeploying cannot heal — the wrong domain is already
* written into records across the mesh. If a kind has to move, mint a new kind.
* The `film`+`tv` → `screen` merge (METADATA_KEYS.md §14.17) is the one
* deliberate exception, taken once and paid for with a corpus sweep.
*
* ⚠ Mirror of the Rust table in `meta-feeder-sdk::domain`. Edit both together.
*/
export type Domain =
'screen'
| 'music'
| 'literature'
| 'science';
/** Is the work complete in itself, or one instalment of a run? */
export type WorkForm = 'standalone' | 'serial';
/**
* The one-way binding. A kind that is absent here has no domain, which means
* the record is **not independently routable** — correct for sidecars
* (subtitles, artwork) and for un-anchored hits, a producer bug for anything
* else.
*
* ⚠ `pack` is deliberately absent: a season pack and an album release are the
* same structural thing, so the kind alone cannot tell `screen` from `music`.
* The writer stamps `pack`'s domain from its own context — see
* {@link domainForContentKind}'s contract.
*
* ⚠ `podcast` / `podcastEpisode` are absent too: the `spoken` domain was
* retired unused, and the change that first *writes* those kinds picks their
* domain.
*/
export const domainByContentKind: { [contentKind: string]: Domain } = {
// screen — films and serials alike; the split lives in workFormByContentKind
movie: 'screen',
series: 'screen',
episode: 'screen',
// music
track: 'music',
album: 'music',
artist: 'music',
musicVideo: 'music',
djMix: 'music',
liveSet: 'music',
// literature
book: 'literature',
// An audiobook's identity is the book — same author, same ISBN family,
// same series as the ebook — so meta-read can group the two editions on
// one page. The player does not decide the domain.
audiobook: 'literature',
comic: 'literature',
manga: 'literature',
magazine: 'literature',
// science
paper: 'science',
};
/**
* The `workForm` twin of {@link domainByContentKind}.
*
* ⚠ `pack` is absent for the same reason: a season pack is `serial`, an album
* release is `standalone`, and only the writer knows which.
*/
export const workFormByContentKind: { [contentKind: string]: WorkForm } = {
// screen
movie: 'standalone',
series: 'serial',
episode: 'serial',
// music — an album is a closed work, not an ongoing run, so every rung of
// the music ladder is standalone.
track: 'standalone',
album: 'standalone',
artist: 'standalone',
musicVideo: 'standalone',
djMix: 'standalone',
liveSet: 'standalone',
// literature — the split that proves workForm is not a screen-only axis.
book: 'standalone',
audiobook: 'standalone',
comic: 'serial',
manga: 'serial',
magazine: 'serial',
// science
paper: 'standalone',
};
/**
* Resolve the domain a `contentKind` belongs to.
*
* Returns `undefined` for `pack` (not derivable from the kind — the writer
* supplies it), for sidecar/format kinds that route nowhere, and for any kind
* this build has never heard of. A caller that already knows the domain from
* its own context must prefer that over this table.
*/
export function domainForContentKind(contentKind: string | undefined | null): Domain | undefined {
if (!contentKind) return undefined;
return domainByContentKind[contentKind.trim()];
}
/**
* Resolve the work form a `contentKind` sits on. Same contract as
* {@link domainForContentKind}: `undefined` means "not derivable", never a
* guess. Whoever writes `contentKind` writes both of these in the same breath.
*/
export function workFormForContentKind(contentKind: string | undefined | null): WorkForm | undefined {
if (!contentKind) return undefined;
return workFormByContentKind[contentKind.trim()];
}
@@ -1,5 +1,14 @@
import {SimpleFileType} from "../SimpleFileType.js"; import {SimpleFileType} from "../SimpleFileType.js";
/**
* Extension → `fileType`. Half of the canonical classification table
* documented in METADATA_KEYS.md §1 (the other half is
* {@link mimeTypeMappings}); this file is the source of truth and the doc
* mirrors it.
*
* An extension absent from this map resolves to `other`, which makes the file
* invisible to every typed query — so a missing entry is not a cosmetic gap.
*/
export const extensionMappings: { [key: string]: SimpleFileType } = { export const extensionMappings: { [key: string]: SimpleFileType } = {
// Audio // Audio
'mp3': 'audio', 'mp3': 'audio',
@@ -7,7 +16,19 @@ export const extensionMappings: { [key: string]: SimpleFileType } = {
'flac': 'audio', 'flac': 'audio',
'aac': 'audio', 'aac': 'audio',
'ogg': 'audio', 'ogg': 'audio',
'oga': 'audio',
'opus': 'audio',
'm4a': 'audio', 'm4a': 'audio',
// `m4b` is the audiobook container — without it `contentKind=audiobook`
// had no extension that resolved to `audio` (METADATA_KEYS.md §14.15).
'm4b': 'audio',
'wma': 'audio',
'aiff': 'audio',
'aif': 'audio',
'ape': 'audio',
'wv': 'audio',
'dsf': 'audio',
'mka': 'audio',
// Video // Video
'mp4': 'video', 'mp4': 'video',
'mkv': 'video', 'mkv': 'video',
@@ -27,6 +48,8 @@ export const extensionMappings: { [key: string]: SimpleFileType } = {
'ts': 'video', 'ts': 'video',
'vob': 'video', 'vob': 'video',
'ogm': 'video', 'ogm': 'video',
'ogv': 'video',
'asf': 'video',
'divx': 'video', 'divx': 'video',
'xvid': 'video', 'xvid': 'video',
// Document // Document
@@ -37,31 +60,64 @@ export const extensionMappings: { [key: string]: SimpleFileType } = {
'pptx': 'document', 'pptx': 'document',
'xls': 'document', 'xls': 'document',
'xlsx': 'document', 'xlsx': 'document',
'odt': 'document',
'ods': 'document',
'odp': 'document',
'txt': 'document', 'txt': 'document',
'md': 'document',
'rtf': 'document', 'rtf': 'document',
// Images (are document) // Document — literature formats. Their absence blocked meta-read outright
'jpg': 'document', // (METADATA_KEYS.md §14.15).
'jpeg': 'document', 'epub': 'document',
'png': 'document', 'mobi': 'document',
'gif': 'document', 'azw': 'document',
'bmp': 'document', 'azw3': 'document',
'tif': 'document', 'djvu': 'document',
'tiff': 'document', // Images
'svg': 'document', // ⚠ These used to map to `document` ("Images (are document)"), which meant
'webp': 'document', // the `image` bucket was unreachable from this table while Rust feeders
// wrote it anyway. A consumer asking for documents must not receive
// posters — see METADATA_KEYS.md §14.15.
'jpg': 'image',
'jpeg': 'image',
'png': 'image',
'gif': 'image',
'bmp': 'image',
'tif': 'image',
'tiff': 'image',
'svg': 'image',
'webp': 'image',
'avif': 'image',
'heic': 'image',
'heif': 'image',
'ico': 'image',
// Archive // Archive
'zip': 'archive', 'zip': 'archive',
'rar': 'archive', 'rar': 'archive',
'7z': 'archive', '7z': 'archive',
'tar': 'archive', 'tar': 'archive',
'tgz': 'archive',
'gz': 'archive', 'gz': 'archive',
'bz2': 'archive', 'bz2': 'archive',
'xz': 'archive', 'xz': 'archive',
'zst': 'archive',
// Archive — comic containers. A `.cbz` is literally a zip, so `archive` is
// the honest byte-level answer; `contentKind=comic` + `domain=literature`
// are what route it to meta-read (METADATA_KEYS.md §14.16).
'cbz': 'archive',
'cbr': 'archive',
//subtitles //subtitles
'srt': 'subtitle', 'srt': 'subtitle',
'sub': 'subtitle', 'sub': 'subtitle',
'sbv': 'subtitle', 'sbv': 'subtitle',
'vtt': 'subtitle', 'vtt': 'subtitle',
// ⚠ ASS/SSA is what meta-watch renders through libass — the client's
// primary subtitle format was the one this classifier did not know.
'ass': 'subtitle',
'ssa': 'subtitle',
'ttml': 'subtitle',
'dfxp': 'subtitle',
'smi': 'subtitle',
//torrent //torrent
'torrent': 'torrent', 'torrent': 'torrent',
}; };
@@ -1,11 +1,32 @@
import {SimpleFileType} from "../SimpleFileType.js"; import {SimpleFileType} from "../SimpleFileType.js";
/**
* MIME type → `fileType`. The other half of the canonical classification
* table (see {@link extensionMappings} and METADATA_KEYS.md §1).
*
* ⚠ Resolution is conservative on purpose: when a sniffed MIME maps to a
* definite value that *disagrees* with the extension's value,
* `FileTypeConfigurable.getFileType` returns `undefined` rather than guessing
* — a wrong `fileType` routes a file to a handler that cannot open it. Adding
* a mapping here therefore also creates a new way to disagree; only add ones
* whose extension counterpart agrees.
*/
export const mimeTypeMappings: { [key: string]: SimpleFileType } = { export const mimeTypeMappings: { [key: string]: SimpleFileType } = {
'text/plain': 'undefined',//text could be anything document or subtitle or other 'text/plain': 'undefined',//text could be anything document or subtitle or other
// Audio MIME types // Audio MIME types
'audio/mpeg': 'audio', 'audio/mpeg': 'audio',
'audio/wav': 'audio', 'audio/wav': 'audio',
'audio/x-wav': 'audio',
'audio/flac': 'audio', 'audio/flac': 'audio',
'audio/ogg': 'audio',
'audio/opus': 'audio',
'audio/aac': 'audio',
// ftyp brands `M4A `/`M4B ` sniff as audio/mp4 while a real video mp4
// sniffs as video/mp4, so this does not collide with the `mp4` extension.
'audio/mp4': 'audio',
'audio/x-ms-wma': 'audio',
'audio/x-aiff': 'audio',
'audio/x-ape': 'audio',
// Video MIME types // Video MIME types
'video/mp4': 'video', 'video/mp4': 'video',
'video/mpeg': 'video', 'video/mpeg': 'video',
@@ -13,8 +34,12 @@ export const mimeTypeMappings: { [key: string]: SimpleFileType } = {
'video/x-msvideo': 'video', 'video/x-msvideo': 'video',
'video/x-flv': 'video', 'video/x-flv': 'video',
'video/x-ms-wmv': 'video', 'video/x-ms-wmv': 'video',
'video/x-ms-asf': 'video',
'video/x-matroska': 'video', 'video/x-matroska': 'video',
'video/webm': 'video', 'video/webm': 'video',
'video/ogg': 'video',
'video/3gpp': 'video',
'video/mp2t': 'video',
// Document MIME types // Document MIME types
'application/pdf': 'document', 'application/pdf': 'document',
'application/msword': 'document', 'application/msword': 'document',
@@ -23,20 +48,47 @@ export const mimeTypeMappings: { [key: string]: SimpleFileType } = {
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document', 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document',
'application/vnd.ms-excel': 'document', 'application/vnd.ms-excel': 'document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document',
'image/jpeg': 'document', 'application/vnd.oasis.opendocument.text': 'document',
'image/png': 'document', 'application/vnd.oasis.opendocument.spreadsheet': 'document',
'image/gif': 'document', 'application/vnd.oasis.opendocument.presentation': 'document',
'application/rtf': 'document',
// Literature containers (METADATA_KEYS.md §14.15)
'application/epub+zip': 'document',
'application/x-mobipocket-ebook': 'document',
'image/vnd.djvu': 'document',
'text/markdown': 'document',
// Image MIME types — no longer folded into `document` (§14.15)
'image/jpeg': 'image',
'image/png': 'image',
'image/gif': 'image',
'image/webp': 'image',
'image/bmp': 'image',
'image/tiff': 'image',
'image/svg+xml': 'image',
'image/avif': 'image',
'image/heic': 'image',
'image/x-icon': 'image',
// Archive MIME types // Archive MIME types
'application/zip': 'archive', 'application/zip': 'archive',
'application/x-rar-compressed': 'archive', 'application/x-rar-compressed': 'archive',
'application/vnd.rar': 'archive',
'application/x-7z-compressed': 'archive', 'application/x-7z-compressed': 'archive',
'application/gzip': 'archive', 'application/gzip': 'archive',
'application/x-bzip2': 'archive',
'application/x-xz': 'archive',
'application/zstd': 'archive',
'application/x-tar': 'archive', 'application/x-tar': 'archive',
// Comic containers sniff as their underlying zip/rar, which agrees with
// the `cbz`/`cbr` extensions — both say `archive`.
'application/vnd.comicbook+zip': 'archive',
'application/vnd.comicbook-rar': 'archive',
//torrent //torrent
'application/x-bittorrent': 'torrent', 'application/x-bittorrent': 'torrent',
//subtitles //subtitles
'text/vtt': 'subtitle', 'text/vtt': 'subtitle',
'text/srt': 'subtitle', 'text/srt': 'subtitle',
'text/ssa': 'subtitle', 'text/ssa': 'subtitle',
'text/x-ssa': 'subtitle',
'text/sub': 'subtitle', 'text/sub': 'subtitle',
}; 'application/x-subrip': 'subtitle',
};
@@ -13,6 +13,7 @@ export * from './tools/StringArrayOperation.js';
export * from './tools/TwoStringsOperation.js'; export * from './tools/TwoStringsOperation.js';
export * from './config/AnimeKeywords.js'; export * from './config/AnimeKeywords.js';
export * from './config/DomainMapping.js';
export * from './config/ExtentionsMapping.js'; export * from './config/ExtentionsMapping.js';
export * from './config/KeywordsArray.js'; export * from './config/KeywordsArray.js';
export * from './config/MimeTypeMapping.js'; export * from './config/MimeTypeMapping.js';
@@ -1,10 +1,17 @@
/** /**
* Multicodec / multihash codes for every digest meta-hash can compute. * Multicodec / multihash codes for every digest meta-hash can compute.
* *
* Source of truth for these numbers, mirrored in: * Source of truth for these numbers. The same codes appear in the CID utility
* RANK, which is a separate thing this file does not implement:
* - `packages/meta-core/internal/cid/rank.go` * - `packages/meta-core/internal/cid/rank.go`
* - the `cid_rank.rs` in each of meta-search and meta-share * - the `cid_rank.rs` in each of meta-search and meta-share
* - `/cid-rank-vectors.json` (the golden fixture that pins them all together) * - `/cid-rank-vectors.json` (the golden fixture pinning those three together)
*
* ⚠ This package used to vendor that fixture at `src/tests/cid-rank-vectors.json`
* and no test ever loaded it, so every vector added cost a commit here for
* nothing. Removed 2026-09-09. If a TypeScript rank implementation is ever
* written, re-add the path to `scripts/check-cid-vectors.sh` *and* a test that
* asserts it — a vendored fixture nobody reads is not a guard.
* *
* ⚠ meta-hash encodes `codec == multihash code` (see `ComputeHash.cidFinalize`: * ⚠ meta-hash encodes `codec == multihash code` (see `ComputeHash.cidFinalize`:
* `CID.createV1(code, create(code, digest))`), whereas the fullhash plugin and * `CID.createV1(code, create(code, digest))`), whereas the fullhash plugin and
@@ -1,166 +0,0 @@
{
"$schema": "cid-rank-vectors/v1",
"$comment": [
"CANONICAL golden vectors for the CID utility rank. THE single source of truth.",
"The rank decides which of a record's several CIDs is elected canonical - the cid a",
"record is advertised and addressed by. It is reimplemented in Go, 2x Rust and 3x TS;",
"they cannot share code (independent submodules, no cargo path-dep). These vectors are",
"the only thing holding them in sync: every implementation's test suite loads this file.",
"",
"This file is VENDORED into each consuming submodule (submodule CI cannot read above its",
"own tree). scripts/check-cid-vectors.sh diffs every copy against this one.",
"",
"Ranking is by UTILITY for external retrieval, not cryptographic strength.",
"Ties break on the lexicographically smaller cid, so the election is deterministic",
"across peers and does not churn.",
"",
"See METADATA_KEYS.md section 2/14.13 and docs/cid-formats.md."
],
"ladder": {
"40": "dag-pb (IPFS block)",
"30": "sha2-256 / sha3-256 / sha3-384 - real content digests",
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
"20": "BitTorrent infohashes - swarm interop",
"10": "midhash256 - the record address",
"5": "opaque locators (nzb-release, url, card) - never outrank a real digest",
"0": "weak/unknown digests, unparseable input"
},
"tieBreak": "lexicographically smaller cid wins",
"vectors": [
{
"name": "ipfs-dagpb",
"cid": "bafybeiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 40,
"note": "dag-pb codec 0x70 - chunked IPFS file, broadest network reach"
},
{
"name": "sha2-256-raw-codec",
"cid": "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 30,
"note": "fullhash/gateway convention: raw codec 0x55, mh names the algo"
},
{
"name": "sha2-256-codec-eq-mh",
"cid": "baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua",
"rank": 30,
"note": "REAL meta-hash fixture; meta-hash sets codec == mh"
},
{
"name": "sha3-256",
"cid": "baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq",
"rank": 30,
"note": "REAL meta-hash fixture"
},
{
"name": "sha3-384",
"cid": "baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga",
"rank": 30,
"note": "REAL meta-hash fixture"
},
{
"name": "nzb-posting",
"cid": "bagbsaeraaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 25,
"note": "codec 0x1003 + REAL sha2-256 multihash (0x12) over the normalised, sorted Message-ID set - NOT a locator, so it sits ABOVE tier 5, but it addresses no fetchable block (bitswap can never satisfy a want for it - the digest is over an id set, not over any block's bytes), so it stays BELOW a real content digest (30): once meta-share materialises the media and re-seeds it, that genuine sha2-256 cid must win the canonical election, not this one. Rank MUST match on codec BEFORE falling through to the mh switch, or this scores 30 (same trap as nzb-release/url, in reverse: those need a codec check because their mh is identity 0x00; this needs one because its mh is REAL sha2-256 and would otherwise tie with tier 30)."
},
{
"name": "btih-v2-codec-eq-mh",
"cid": "bag3sdnzbeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "meta-hash encoder convention"
},
{
"name": "btih-v2-raw-codec",
"cid": "bafk3oijaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "fullhash encoder convention - SAME tier, both conventions must match"
},
{
"name": "btih-v1-file",
"cid": "bagasbajacqaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "gateway per-file BitTorrent v1"
},
{
"name": "btih-v2-file",
"cid": "bagbcbaraeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 20,
"note": "gateway per-file BitTorrent v2"
},
{
"name": "midhash256-codec-eq-mh",
"cid": "bagacbabaeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 10,
"note": "the record address; internally meaningful, externally invisible"
},
{
"name": "midhash256-raw-codec",
"cid": "bafkyaibaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 10
},
{
"name": "nzb-release-locator",
"cid": "bagcsaaazcbqxa2jonz5gez3fmvvs42lomzx55ln657fp4aab",
"rank": 5,
"note": "codec 0x1005 + IDENTITY multihash 0x00. The locator code is in the CODEC slot, not the mh slot - rank MUST match on codec. Tier 5 = below midhash: a locator never outranks a real digest, it wins only as a record's sole cid."
},
{
"name": "url-locator",
"cid": "bagdcaaaznb2hi4dthixs6zlymfwxa3dffzxxezzpmexhazdg",
"rank": 5,
"note": "codec 0x1006 + IDENTITY multihash 0x00. Same tier/reasoning as nzb-release."
},
{
"name": "card-locator",
"cid": "bagdsaaanar2g2zdcor3duojvgq3ts",
"rank": 5,
"note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent."
},
{
"name": "sha1",
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "md5",
"cid": "bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "crc32",
"cid": "bagzafmqcathsbnrk",
"rank": 0,
"note": "REAL meta-hash fixture; weak digest"
},
{
"name": "bt-pieces-root",
"cid": "bafkyf3qceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 0,
"note": "torrent-internal merkle root, NOT a file-identity cid"
},
{
"name": "cidv0-rejected",
"cid": "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG",
"rank": 0,
"note": "TRAP: Go rejected this (rank 0) while Rust's MsCid::from_str ACCEPTED it and ranked it 40 (dag-pb). Strict parse = CIDv1 base32-lower only. This vector pins the fix."
},
{
"name": "token-form-rejected",
"cid": "midhash256:bagacbabaeaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"rank": 0,
"note": "TRAP: the deprecated <algo>:<cid> token form. Rust used to strip the prefix and rank it 10. It must now be rejected."
},
{
"name": "garbage",
"cid": "not-a-cid",
"rank": 0
},
{
"name": "empty",
"cid": "",
"rank": 0
}
]
}
@@ -22,13 +22,13 @@ describe('MetaData', () => {
}); });
it('should set and get nested metadata using nested keys', () => { it('should set and get nested metadata using nested keys', () => {
root.at('titles').at('eng').set("Star Wars"); root.at('titles').at('eng').at('Star Wars').set(true);
root.at('titles').at('jpn').set("Staru Warsu"); root.at('titles').at('jpn').at('Staru Warsu').set(true);
expect(root.at('titles').at('eng').get()).to.equal("Star Wars"); expect(root.at('titles').at('eng').at('Star Wars').get()).to.equal(true);
expect(root.at('titles').at('jpn').get()).to.equal("Staru Warsu"); expect(root.at('titles').at('jpn').at('Staru Warsu').get()).to.equal(true);
expect(root.at('titles').get()).to.deep.equal({eng: "Star Wars", jpn: "Staru Warsu"}); expect(root.at('titles').get()).to.deep.equal({eng: {"Star Wars": true}, jpn: {"Staru Warsu": true}});
expect(root.get()).to.deep.equal({ expect(root.get()).to.deep.equal({
titles: {eng: "Star Wars", jpn: "Staru Warsu"} titles: {eng: {"Star Wars": true}, jpn: {"Staru Warsu": true}}
}); });
}); });
@@ -8,8 +8,9 @@ export interface VideoMeta {
increment?: string; increment?: string;
movieYear?: string; movieYear?: string;
videoType?: VideoType; videoType?: VideoType;
title?: string;//display title (localized); the one name to show
originalTitle?: string;//title in original language originalTitle?: string;//title in original language
titles?: LanguageData<string>;// <ISO 639-3 lang codes, title> titles?: LanguageData<Record<string, true>>;// key-set: <ISO 639-3 lang code, {name: true}> — every clean name incl. AKAs (METADATA_KEYS.md §3)
} }
export const VideoMetaFields = [ export const VideoMetaFields = [
@@ -1,2 +1,16 @@
export type SimpleFileType = 'audio' | 'video' | 'document' | 'archive' | 'other' | 'undefined'; /**
* The `fileType` vocabulary — METADATA_KEYS.md §1. Kept in lockstep with
* `filename-tool`'s `SimpleFileType`, which is the classifier that produces
* these values; this copy is the wire/type-level mirror.
*/
export type SimpleFileType =
'audio'
| 'video'
| 'image'
| 'document'
| 'archive'
| 'subtitle'
| 'torrent'
| 'card'
| 'other'
| 'undefined';