13 Commits
Author SHA1 Message Date
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
Worph e192632328 card locator vector
Build and Push Docker Image / build (push) Failing after 14s
2026-07-26 18:51:04 +02:00
Worph ba44daf7eb cid rank vector update 2026-07-25 21:30:14 +02:00
Worph 13fa36ffc7 fix cids 2026-07-14 16:08:20 +02:00
Pierre 44b103c97e change how cid are handled 2026-05-31 11:40:37 +02:00
Pierre 31bb1245da improve doc and bug fix 2026-05-25 23:48:42 +02:00
Pierre f77bb71b44 bugfix 2026-05-24 17:43:45 +02:00
Pierre b1d8a56c53 adaptation related to meta-core api changes 2026-05-24 17:26:41 +02:00
Pierre f7315cbc3f fix metasort pipeline 2026-04-28 12:21:23 +02:00
61 changed files with 3291 additions and 3382 deletions
+6
View File
@@ -0,0 +1,6 @@
# FIX CRLF always when developer has not set
# Linux/Mac: git config --global core.autocrlf input
# Windows: git config --global core.autocrlf true
# Auto detect text files and perform LF normalization
* text=auto
* eol=lf
+342 -807
View File
File diff suppressed because it is too large Load Diff
-386
View File
@@ -1,386 +0,0 @@
#!/bin/bash
# =============================================================================
# Mount Watcher Script
#
# Monitors /meta-core/mounts/mounts.json and manages NFS, SMB, and rclone mounts.
# Runs as a supervisord program, polling every 5 seconds.
#
# Uses findmnt to check actual mount status (source of truth).
# Writes errors to /meta-core/mounts/errors/{id}.error
# =============================================================================
set -u
CONFIG_DIR="/meta-core/mounts"
CONFIG_FILE="$CONFIG_DIR/mounts.json"
ERROR_DIR="$CONFIG_DIR/errors"
FILES_PATH="${FILES_PATH:-/files}"
POLL_INTERVAL="${MOUNT_POLL_INTERVAL:-5}"
# ANSI colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
log() {
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] $1"
}
log_success() {
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${GREEN}$1${NC}"
}
log_warn() {
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${YELLOW}$1${NC}"
}
log_error() {
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${RED}$1${NC}"
}
# Initialize directories
init_dirs() {
mkdir -p "$CONFIG_DIR" "$ERROR_DIR"
# Initialize empty config if not exists
if [ ! -f "$CONFIG_FILE" ]; then
echo '{"version":1,"mounts":[]}' > "$CONFIG_FILE"
log "Initialized empty mounts config"
fi
}
# Check if path is mounted
is_mounted() {
local mount_path="$1"
findmnt -n "$mount_path" > /dev/null 2>&1
}
# Clear error file for mount
clear_error() {
local id="$1"
rm -f "$ERROR_DIR/$id.error"
}
# Write error to file
write_error() {
local id="$1"
local error="$2"
echo "$(date -Iseconds)" > "$ERROR_DIR/$id.error"
echo "$error" >> "$ERROR_DIR/$id.error"
}
# Mount NFS share (always read-only)
do_mount_nfs() {
local id="$1"
local server="$2"
local path="$3"
local mount_path="$4"
local options="${5:-}"
mkdir -p "$mount_path"
# Always mount read-only for safety
local mount_opts="ro"
[ -n "$options" ] && mount_opts="${mount_opts},${options}"
log "Mounting NFS (read-only): ${server}:${path} -> ${mount_path}"
output=$(mount -t nfs -o "${mount_opts}" "${server}:${path}" "${mount_path}" 2>&1)
local rc=$?
if [ $rc -eq 0 ]; then
clear_error "$id"
log_success "NFS mount successful: $mount_path"
else
write_error "$id" "$output"
log_error "NFS mount failed: $output"
fi
return $rc
}
# Mount SMB/CIFS share (always read-only)
do_mount_smb() {
local id="$1"
local server="$2"
local share="$3"
local mount_path="$4"
local username="${5:-}"
local password_obscured="${6:-}"
local domain="${7:-}"
local options="${8:-}"
mkdir -p "$mount_path"
# Always mount read-only for safety
local mount_opts="ro"
# If we have credentials, reveal password and build options
if [ -n "$username" ]; then
mount_opts="${mount_opts},username=${username}"
if [ -n "$password_obscured" ]; then
# Reveal password using rclone (only exists in memory briefly)
local password
password=$(rclone reveal "$password_obscured" 2>/dev/null)
if [ -n "$password" ]; then
mount_opts="${mount_opts},password=${password}"
fi
fi
if [ -n "$domain" ]; then
mount_opts="${mount_opts},domain=${domain}"
fi
fi
# Add any additional options
if [ -n "$options" ]; then
if [ -n "$mount_opts" ]; then
mount_opts="${mount_opts},${options}"
else
mount_opts="$options"
fi
fi
log "Mounting SMB (read-only): //${server}/${share} -> ${mount_path}"
# Execute mount directly (not via eval) to handle special chars in password
local output
local rc
if [ -n "$mount_opts" ]; then
output=$(mount -t cifs "//${server}/${share}" "${mount_path}" -o "${mount_opts}" 2>&1)
rc=$?
else
output=$(mount -t cifs "//${server}/${share}" "${mount_path}" 2>&1)
rc=$?
fi
# Clear password from any variables (best effort)
password=""
mount_opts=""
if [ $rc -eq 0 ]; then
clear_error "$id"
log_success "SMB mount successful: $mount_path"
else
write_error "$id" "$output"
log_error "SMB mount failed: $output"
fi
return $rc
}
# Mount rclone remote using RC API (always read-only)
do_mount_rclone() {
local id="$1"
local remote="$2"
local remote_path="$3"
local mount_path="$4"
local options="${5:-}"
mkdir -p "$mount_path"
# Build the remote source string
local fs="${remote}:${remote_path}"
# Use rclone RC API to mount (always read-only for safety)
log "Mounting rclone (read-only): ${fs} -> ${mount_path}"
# Build JSON body for rclone mount with read-only option
local json_body
json_body=$(cat <<EOF
{
"fs": "${fs}",
"mountPoint": "${mount_path}",
"mountOpt": {
"AllowOther": true,
"ReadOnly": true
},
"vfsOpt": {
"CacheMode": 2,
"ReadOnly": true
}
}
EOF
)
output=$(curl -s -X POST \
-H "Content-Type: application/json" \
-u admin:admin \
-d "$json_body" \
"http://127.0.0.1:5572/mount/mount" 2>&1)
# Wait a moment for mount to establish
sleep 2
if is_mounted "$mount_path"; then
clear_error "$id"
log_success "rclone mount successful: $mount_path"
return 0
else
local error_msg="rclone mount failed"
# Try to extract error from response
if echo "$output" | grep -q "error"; then
error_msg="$output"
fi
write_error "$id" "$error_msg"
log_error "rclone mount failed: $error_msg"
return 1
fi
}
# Unmount a path
do_unmount() {
local id="$1"
local mount_path="$2"
local type="$3"
if ! is_mounted "$mount_path"; then
log "Already unmounted: $mount_path"
return 0
fi
if [ "$type" = "rclone" ]; then
# Use rclone RC API to unmount
log "Unmounting rclone: $mount_path"
curl -s -X POST \
-H "Content-Type: application/json" \
-u admin:admin \
-d "{\"mountPoint\":\"${mount_path}\"}" \
"http://127.0.0.1:5572/mount/unmount" > /dev/null 2>&1
else
log "Unmounting: $mount_path"
umount "$mount_path" 2>&1
fi
# Wait a moment
sleep 1
# Check if still mounted
if is_mounted "$mount_path"; then
# Force unmount with lazy option
log_warn "Force unmounting (lazy): $mount_path"
umount -l "$mount_path" 2>&1
sleep 1
fi
if is_mounted "$mount_path"; then
log_error "Failed to unmount: $mount_path"
return 1
fi
log_success "Unmounted: $mount_path"
return 0
}
# Process all mounts from config
process_mounts() {
# Check if config file exists
if [ ! -f "$CONFIG_FILE" ]; then
return
fi
# Parse JSON using python3
local mounts
mounts=$(python3 -c '
import sys, json
try:
with open("'"$CONFIG_FILE"'", "r") as f:
data = json.load(f)
except:
sys.exit(0)
for m in data.get("mounts", []):
# Output pipe-separated values
fields = [
m.get("id", ""),
m.get("type", ""),
str(m.get("enabled", True)),
str(m.get("desiredMounted", False)),
m.get("mountPath", ""),
m.get("nfsServer", ""),
m.get("nfsPath", ""),
m.get("smbServer", ""),
m.get("smbShare", ""),
m.get("smbUsername", ""),
m.get("smbPasswordObscured", ""),
m.get("smbDomain", ""),
m.get("rcloneRemote", ""),
m.get("rclonePath", ""),
m.get("options", "")
]
print("|".join(fields))
' 2>/dev/null)
if [ -z "$mounts" ]; then
return
fi
while IFS='|' read -r id type enabled desired mount_path nfs_server nfs_path smb_server smb_share smb_username smb_password_obscured smb_domain rclone_remote rclone_path options; do
# Skip empty lines
[ -z "$id" ] && continue
# Check actual mount status
local is_mounted_now=false
is_mounted "$mount_path" && is_mounted_now=true
# Handle disabled mounts - unmount if currently mounted
if [ "$enabled" != "True" ] && [ "$enabled" != "true" ]; then
if [ "$is_mounted_now" = true ]; then
log "Mount $id ($mount_path) disabled, unmounting..."
do_unmount "$id" "$mount_path" "$type"
fi
continue
fi
# Handle desired state
if [ "$desired" = "True" ] || [ "$desired" = "true" ]; then
# Should be mounted
if [ "$is_mounted_now" = false ]; then
log "Mount $id ($mount_path) desired but not mounted, mounting..."
case "$type" in
nfs)
do_mount_nfs "$id" "$nfs_server" "$nfs_path" "$mount_path" "$options"
;;
smb)
do_mount_smb "$id" "$smb_server" "$smb_share" "$mount_path" "$smb_username" "$smb_password_obscured" "$smb_domain" "$options"
;;
rclone)
do_mount_rclone "$id" "$rclone_remote" "$rclone_path" "$mount_path" "$options"
;;
*)
log_error "Unknown mount type: $type"
;;
esac
fi
else
# Should be unmounted
if [ "$is_mounted_now" = true ]; then
log "Mount $id ($mount_path) not desired, unmounting..."
do_unmount "$id" "$mount_path" "$type"
fi
fi
done <<< "$mounts"
}
# Main loop
main() {
log "Starting mount watcher (poll interval: ${POLL_INTERVAL}s)"
init_dirs
while true; do
process_mounts
sleep "$POLL_INTERVAL"
done
}
# Handle signals for graceful shutdown
cleanup() {
log "Shutting down mount watcher..."
exit 0
}
trap cleanup SIGTERM SIGINT
main "$@"
+5 -5
View File
@@ -587,7 +587,7 @@ Deduplication logic remains identical. Fast-hash serves the same purpose as full
The Stremio addon retrieves files by metadata queries (title, season, episode), not by hash. The hash is used internally for metadata storage. Changing to fast-hash is transparent to the addon. The Stremio addon retrieves files by metadata queries (title, season, episode), not by hash. The hash is used internally for metadata storage. Changing to fast-hash is transparent to the addon.
### Meta-Orbit (Distributed Synchronization) ### meta-share (Distributed Synchronization)
**Impact:** Hash serves as content identifier for P2P synchronization. **Impact:** Hash serves as content identifier for P2P synchronization.
@@ -716,7 +716,7 @@ MetaMesh implements a two-phase processing pipeline:
**Phase 2: Hash Processing (Full Hashes - Background)** **Phase 2: Hash Processing (Full Hashes - Background)**
- Compute full-file hashes: SHA-256, SHA-1, MD5, CRC32 (slow, minutes) - Compute full-file hashes: SHA-256, SHA-1, MD5, CRC32 (slow, minutes)
- Update etcd with additional hash metadata - Update etcd with additional hash metadata
- Required for Meta-Orbit distributed synchronization - Required for meta-share distributed synchronization
### Why Both Are Needed ### Why Both Are Needed
@@ -726,7 +726,7 @@ MetaMesh implements a two-phase processing pipeline:
- **Fast deduplication**: Quickly identify potential duplicates - **Fast deduplication**: Quickly identify potential duplicates
**Full Hash Purpose:** **Full Hash Purpose:**
- **Meta-Orbit integration**: Distributed P2P synchronization requires cryptographically secure hashes - **meta-share integration**: Distributed P2P synchronization requires cryptographically secure hashes
- **External system compatibility**: Torrent verification, IPFS CIDs - **External system compatibility**: Torrent verification, IPFS CIDs
- **Collision resolution**: If midhash256 collision detected, full SHA-256 provides definitive answer - **Collision resolution**: If midhash256 collision detected, full SHA-256 provides definitive answer
@@ -744,7 +744,7 @@ Light Processing (32 workers, parallel)
Hash Processing (8 workers, background) Hash Processing (8 workers, background)
- Compute SHA-256, SHA-1, MD5, CRC32 (15+ minutes for 50GB) - Compute SHA-256, SHA-1, MD5, CRC32 (15+ minutes for 50GB)
- Update etcd with full hashes - Update etcd with full hashes
- Enable Meta-Orbit synchronization ✓ - Enable meta-share synchronization ✓
``` ```
### Collision Detection and Handling ### Collision Detection and Handling
@@ -778,4 +778,4 @@ The trade-offs—weaker cryptographic guarantees, potential collisions for adver
This architecture aligns with Meta-Mesh's design philosophy: **optimize for the common case** (large media files, organic content, personal libraries) while maintaining **practicality and performance** over theoretical perfection. This architecture aligns with Meta-Mesh's design philosophy: **optimize for the common case** (large media files, organic content, personal libraries) while maintaining **practicality and performance** over theoretical perfection.
Meta-Mesh leverages midhash256 for **immediate file availability** while computing full SHA-256 hashes **in the background** for Meta-Orbit distributed synchronization and external system compatibility. This dual-hash approach provides both speed and security. Meta-Mesh leverages midhash256 for **immediate file availability** while computing full SHA-256 hashes **in the background** for meta-share distributed synchronization and external system compatibility. This dual-hash approach provides both speed and security.
+27 -754
View File
@@ -1,762 +1,35 @@
# Meta-Sort Plugin System Architecture # Plugin System (Deprecated — See Container Plugins)
This document describes the plugin system architecture for meta-sort, enabling extensible metadata extraction through dynamically loaded plugins. > **This document is a historical pointer.** The in-process plugin system it
> originally described — `plugins/<id>/{manifest.yml,index.ts}` directories
> loaded via dynamic `import()` inside meta-sort-core — has been removed.
> All plugins now run as Docker containers and communicate over HTTP.
## Overview For the current design, read:
The plugin system provides a modular architecture for metadata extraction. Plugins are self-contained units that: - **[containerized-plugin-architecture.md](./containerized-plugin-architecture.md)** — full container plugin design (HTTP contract, lifecycle, callbacks, KV access).
- **[plugin-task-queue-architecture.md](./plugin-task-queue-architecture.md)** — how plugin work is dispatched across the fast / background queues.
- Root **`CLAUDE.md`** (`Plugin Development` and `Runtime Plugin Configuration` sections) — operational quickstart.
- Plugin sources at **`packages/plugins/metamesh-plugin-*`** (separate submodules) — `file-info`, `ffmpeg`, `filename-parser`, `jellyfin-nfo`, `tmdb`, `anime-detector`, `language`, `subtitle`, `subtitle-extractor`, `torrent`, `fullhash`.
- Are discovered and loaded from a plugins directory at startup ## What survived from the old design
- Can be activated/deactivated at runtime without restart
- Process files sequentially in dependency order
- Store metadata in a flat key-value format
- Have access to their own persistent cache folder
## Directory Structure The `Plugin` / `PluginContext` / `PluginLifecycleContext` / `PluginKVStore`
interfaces in `src/plugin-engine/types.ts` are still exported, but the
container-only `PluginManager` (`src/plugin-engine/PluginManager.ts`) never
constructs an in-process `Plugin` instance — manifests, lifecycle, and
execution are owned by `ContainerManager` and `ContainerPluginScheduler`
under `src/container-plugins/`. Treat the `Plugin`-interface types as
vestigial and do not write new code against them.
``` ## Why the change
packages/meta-sort/packages/meta-sort-core/
├── plugins/ # All plugins (shipped + custom)
│ ├── file-info/
│ │ ├── manifest.yml # Plugin metadata & config schema
│ │ └── index.ts # Plugin implementation
│ ├── ffmpeg/
│ ├── filename-parser/
│ ├── anime-detector/
│ ├── jellyfin-nfo/
│ ├── subtitle/
│ ├── torrent/
│ ├── language/
│ ├── tmdb/
│ └── debug/
├── plugins.json # Runtime state (active/inactive + configs)
└── src/
└── plugin/
├── types.ts # Type definitions
├── PluginLoader.ts # Manifest parsing, dynamic import
├── PluginContext.ts # Context factory
├── PluginManager.ts # Lifecycle management
└── PluginExecutor.ts # Dependency-ordered execution
```
Cache folders are stored separately under `CACHE_FOLDER_PATH` (default `/data/cache/plugins/`): | Limitation of in-process plugins | Container model |
|----------------------------------|-----------------|
| Heavy native deps (FFmpeg, Whisper, ML libs) inflated the meta-sort image | Each plugin ships its own image |
| All plugins had to be TypeScript / Node | Any language with an HTTP server |
| A crashing plugin took down meta-sort | Plugin crashes are isolated; meta-sort restarts the container |
| Plugin updates required redeploying meta-sort | Plugins are versioned and pulled independently |
``` See `containerized-plugin-architecture.md` for the migration rationale and
/data/cache/plugins/ the HTTP contract container plugins must implement.
├── ffmpeg/
│ ├── {cid1}.json
│ └── {cid2}.json
├── tmdb/
│ ├── {cid1}.json
│ └── {cid2}.json
└── ...
```
## Plugin Manifest
Each plugin must have a `manifest.yml` file defining its metadata, dependencies, and configuration schema.
```yaml
# manifest.yml
id: tmdb # Unique identifier (matches folder name)
name: TMDB Enricher # Human-readable name
version: 1.0.0 # Semantic version
description: Enriches metadata from The Movie Database API
# Plugins that must execute BEFORE this one
dependencies:
- filename-parser # Needs parsed title/year
# Configuration schema (rendered in dashboard)
config:
apiKey:
type: string
label: API Key
required: true
secret: true # Masked in UI
language:
type: string
label: Language
default: en-US
includeAdult:
type: boolean
label: Include Adult Content
default: false
# Metadata schema for editor UI (optional)
# Describes keys this plugin writes
schema:
tmdb/id:
label: TMDB ID
type: string
readonly: true
tmdb/title:
label: Title
type: string
tmdb/overview:
label: Overview
type: text
tmdb/poster:
label: Poster URL
type: string
format: url
tmdb/genre/{n}:
label: Genre
type: string
indexed: true # Indicates indexed keys (0, 1, 2...)
```
### Manifest Fields
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique plugin identifier, must match folder name |
| `name` | Yes | Display name for dashboard |
| `version` | Yes | Semantic version string |
| `description` | No | Brief description |
| `dependencies` | No | Array of plugin IDs that must run before this one |
| `config` | No | Configuration schema for dashboard UI |
| `schema` | No | Metadata keys written by this plugin (for editor) |
### Config Field Types
```yaml
config:
fieldName:
type: string | number | boolean | select
label: Display Label
required: true | false # Default: false
default: <value> # Default value
secret: true | false # Mask in UI (default: false)
options: # For type: select
- value: opt1
label: Option 1
- value: opt2
label: Option 2
```
## Plugin Interface
Plugins export a default object implementing the `Plugin` interface.
```typescript
// types.ts
export interface Plugin {
/**
* Called once when the plugin is loaded at startup.
* Use for one-time initialization.
*/
onLoad?(ctx: PluginLifecycleContext): Promise<void>;
/**
* Called when the plugin is activated.
* Triggered at startup (if previously active) or from dashboard.
*/
onActivate?(ctx: PluginLifecycleContext): Promise<void>;
/**
* Called when the plugin is deactivated from the dashboard.
* Use for cleanup, releasing resources.
*/
onDeactivate?(ctx: PluginLifecycleContext): Promise<void>;
/**
* Called when the plugin is unloaded at shutdown.
*/
onUnload?(ctx: PluginLifecycleContext): Promise<void>;
/**
* Process a single file. Called for each file in the library.
* Must be implemented by all plugins.
*/
process(ctx: PluginContext): Promise<void>;
}
export interface PluginLifecycleContext {
/** Plugin configuration from plugins.json */
config: Record<string, unknown>;
/** Absolute path to plugin's cache directory */
cacheDir: string;
/** Scoped logger */
log: PluginLogger;
}
```
## Plugin Context
The `PluginContext` is injected into the `process()` function, providing access to file metadata and utilities.
```typescript
export interface PluginContext {
/**
* Key-value storage for the current file's metadata.
* Keys use path notation: "video/codec", "audio/0/language"
*/
kv: PluginKVStore;
/** Absolute path to the file being processed */
filePath: string;
/** Plugin configuration values */
config: Record<string, unknown>;
/** Absolute path to plugin's cache directory */
cacheDir: string;
/** Cache utilities for persistent storage */
cache: PluginCache;
/** Scoped logger (prefixed with plugin name) */
log: PluginLogger;
}
```
### KV Store Interface
```typescript
export interface PluginKVStore {
/**
* Get a value by key.
* @param key Path-style key, e.g., "video/codec" or "audio/0/language"
*/
get(key: string): string | undefined;
/**
* Set a value. Only string values are supported.
* @param key Path-style key
* @param value String value
*/
set(key: string, value: string): void;
/**
* Delete a key.
*/
delete(key: string): void;
/**
* Get all keys, optionally filtered by prefix.
* @param prefix Optional prefix filter, e.g., "audio/" returns ["audio/0/codec", "audio/0/language", ...]
*/
keys(prefix?: string): string[];
/**
* Get all key-value pairs, optionally filtered by prefix.
*/
entries(prefix?: string): Array<[string, string]>;
}
```
### Cache Interface
```typescript
export interface PluginCache {
/**
* Get full path for a cache file.
* @param filename Relative filename within cache directory
*/
getPath(filename: string): string;
/**
* Read and parse a JSON cache file.
* @returns Parsed data or null if not found
*/
readJson<T>(filename: string): Promise<T | null>;
/**
* Write data as JSON to cache file.
*/
writeJson(filename: string, data: unknown): Promise<void>;
/**
* Check if a cache file exists.
*/
exists(filename: string): Promise<boolean>;
/**
* Delete a cache file.
*/
delete(filename: string): Promise<void>;
/**
* Clear all files in the plugin's cache directory.
*/
clear(): Promise<void>;
}
```
### Logger Interface
```typescript
export interface PluginLogger {
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
```
## Plugin Implementation Example
```typescript
// plugins/tmdb/index.ts
import type { Plugin, PluginContext, PluginLifecycleContext } from '../../src/plugin/types';
interface TmdbConfig {
apiKey: string;
language: string;
includeAdult: boolean;
}
const plugin: Plugin = {
async onLoad(ctx: PluginLifecycleContext) {
ctx.log.info('TMDB plugin loaded');
},
async onActivate(ctx: PluginLifecycleContext) {
const config = ctx.config as TmdbConfig;
if (!config.apiKey) {
ctx.log.warn('No API key configured, plugin will skip processing');
}
},
async onDeactivate(ctx: PluginLifecycleContext) {
ctx.log.info('TMDB plugin deactivated');
},
async process(ctx: PluginContext) {
const config = ctx.config as TmdbConfig;
// Skip if not configured
if (!config.apiKey) return;
// Get data from previous plugins
const title = ctx.kv.get('filename/title');
const year = ctx.kv.get('filename/year');
const videoType = ctx.kv.get('video/type');
if (!title) return;
// Check cache first (keyed by title+year)
const cacheKey = `${sanitize(title)}-${year || 'unknown'}.json`;
const cached = await ctx.cache.readJson<TmdbResult>(cacheKey);
if (cached) {
applyResult(ctx.kv, cached);
return;
}
// Fetch from TMDB API
try {
const result = await fetchTmdb(title, year, videoType, config);
if (result) {
await ctx.cache.writeJson(cacheKey, result);
applyResult(ctx.kv, result);
}
} catch (error) {
ctx.log.error('TMDB fetch failed', error);
}
}
};
function applyResult(kv: PluginContext['kv'], result: TmdbResult) {
kv.set('tmdb/id', String(result.id));
kv.set('tmdb/title', result.title);
kv.set('tmdb/overview', result.overview);
kv.set('tmdb/poster', result.poster_path);
kv.set('tmdb/backdrop', result.backdrop_path);
kv.set('tmdb/rating', String(result.vote_average));
kv.set('tmdb/year', result.release_date?.slice(0, 4) || '');
// Genres as indexed keys
result.genres?.forEach((genre, i) => {
kv.set(`tmdb/genre/${i}`, genre.name);
});
}
export default plugin;
```
## Metadata Key Format
All metadata is stored as flat key-value pairs with string values only. Keys use path notation with `/` as separator.
### Key Naming Convention
```
{plugin-id}/{field} # Simple field
{plugin-id}/{group}/{field} # Grouped field
{plugin-id}/{group}/{index}/{field} # Indexed field (for lists)
```
### Examples
```
# File info plugin
file/name = "Movie.2024.1080p.mkv"
file/size = "4294967296"
file/type = "video"
file/mime = "video/x-matroska"
# FFmpeg plugin
video/codec = "h264"
video/width = "1920"
video/height = "1080"
video/duration = "7200.5"
video/bitrate = "8000000"
audio/0/codec = "aac"
audio/0/language = "eng"
audio/0/channels = "6"
audio/1/codec = "dts"
audio/1/language = "fra"
audio/1/channels = "6"
subtitle/0/language = "eng"
subtitle/0/format = "subrip"
subtitle/1/language = "fra"
subtitle/1/format = "subrip"
# Filename parser plugin
filename/title = "Movie Title"
filename/year = "2024"
filename/quality = "1080p"
filename/source = "bluray"
# TMDB plugin
tmdb/id = "12345"
tmdb/title = "Movie Title"
tmdb/overview = "A movie about..."
tmdb/poster = "/path/to/poster.jpg"
tmdb/genre/0 = "Action"
tmdb/genre/1 = "Adventure"
```
### Key Constraints
- Keys must be non-empty strings
- Keys use `/` as path separator
- Values must be strings (numbers converted via `String()`)
- No nested objects or arrays (use indexed keys instead)
- Keys are case-sensitive
- Recommended: use lowercase with hyphens for multi-word keys
## Plugin Manager
The `PluginManager` handles plugin lifecycle and orchestration.
```typescript
export class PluginManager extends EventEmitter {
/**
* Scan plugins directory and load manifests.
* Called once at startup.
*/
async scanPlugins(): Promise<PluginInfo[]>;
/**
* Load all plugins and activate those marked active in state.
* Called once at startup after scanning.
*/
async loadAll(): Promise<void>;
/**
* Activate a plugin (hot, no restart required).
* Calls plugin.onActivate(), rebuilds execution order, saves state.
* Emits 'plugin:activated' event.
*/
async activate(pluginId: string): Promise<void>;
/**
* Deactivate a plugin (hot, no restart required).
* Calls plugin.onDeactivate(), rebuilds execution order, saves state.
* Emits 'plugin:deactivated' event.
*/
async deactivate(pluginId: string): Promise<void>;
/**
* Update plugin configuration.
* Saves to plugins.json.
* Emits 'plugin:config-changed' event.
*/
async updateConfig(pluginId: string, config: Record<string, unknown>): Promise<void>;
/**
* Clear a plugin's cache directory.
*/
async clearCache(pluginId: string): Promise<void>;
/**
* Get all discovered plugins.
*/
getPlugins(): PluginInfo[];
/**
* Get only active plugins.
*/
getActivePlugins(): PluginInfo[];
/**
* Get execution order (dependency-sorted active plugin IDs).
*/
getExecutionOrder(): string[];
/**
* Process a file through all active plugins.
*/
async processFile(filePath: string, kv: KVStore): Promise<ProcessingResult>;
/**
* Shutdown: deactivate and unload all plugins.
*/
async shutdown(): Promise<void>;
}
```
### Events
| Event | Payload | Description |
|-------|---------|-------------|
| `plugin:activated` | `{ pluginId: string }` | Plugin was activated |
| `plugin:deactivated` | `{ pluginId: string }` | Plugin was deactivated |
| `plugin:config-changed` | `{ pluginId: string, config: object }` | Plugin config updated |
| `plugin:error` | `{ pluginId: string, error: Error }` | Plugin error occurred |
### Plugin Info
```typescript
export interface PluginInfo {
id: string;
name: string;
version: string;
description?: string;
dependencies: string[];
active: boolean;
config: Record<string, unknown>;
configSchema?: ConfigSchema;
metadataSchema?: MetadataSchema;
status: 'loaded' | 'error' | 'unloaded';
error?: string;
}
```
## State Management
Plugin state is persisted in `plugins.json` located in the meta-sort-core package root.
```json
{
"plugins": {
"file-info": {
"active": true,
"config": {}
},
"ffmpeg": {
"active": true,
"config": {}
},
"tmdb": {
"active": true,
"config": {
"apiKey": "your-api-key",
"language": "en-US",
"includeAdult": false
}
},
"custom-plugin": {
"active": false,
"config": {}
}
}
}
```
### State Behavior
- **New plugins**: Discovered plugins not in state file are added with `active: false`
- **Removed plugins**: Plugins in state but not on disk are kept (preserves config for reinstall)
- **Config merge**: Default values from manifest are merged with saved config
- **Persistence**: State is saved immediately on any change
## Execution Pipeline
### Startup Sequence
```
1. PluginManager.scanPlugins()
└── Scan plugins/ directory
└── Parse each manifest.yml
└── Validate manifest structure
2. PluginManager.loadAll()
└── Load plugins.json state
└── For each discovered plugin:
├── Dynamic import(plugin/index.js)
├── Validate Plugin interface
├── Call plugin.onLoad()
└── If active in state:
└── Call plugin.onActivate()
└── Resolve execution order (topological sort)
```
### File Processing Sequence
```
1. Create KVStore for file
2. For each active plugin (in dependency order):
├── Create PluginContext
├── Start timing
├── try:
│ └── await plugin.process(ctx)
└── catch:
└── Log error, continue to next plugin
3. Persist KV to Redis
```
### Activation Sequence (Hot)
```
1. PluginManager.activate(pluginId)
├── Validate plugin exists and is loaded
├── Check dependencies are active
├── Call plugin.onActivate()
├── Mark as active in memory
├── Rebuild execution order
├── Save plugins.json
└── Emit 'plugin:activated' event
2. Caller receives event
└── Trigger library rescan
```
### Deactivation Sequence (Hot)
```
1. PluginManager.deactivate(pluginId)
├── Check no active plugins depend on this one
├── Call plugin.onDeactivate()
├── Mark as inactive in memory
├── Rebuild execution order
├── Save plugins.json
└── Emit 'plugin:deactivated' event
2. Caller receives event
└── Trigger library rescan
Note: Metadata written by the plugin remains in Redis.
A separate "clean metadata" feature can remove orphaned keys.
```
## Dependency Resolution
Plugins declare dependencies in their manifest. The system performs topological sort to determine execution order.
### Rules
1. A plugin cannot be activated if any of its dependencies are inactive
2. A plugin cannot be deactivated if any active plugin depends on it
3. Circular dependencies are detected and rejected at load time
4. Missing dependencies are logged as warnings; plugin can still load but may fail
### Example
```yaml
# filename-parser depends on file-info
# tmdb depends on filename-parser
# anime-detector depends on filename-parser
Resolved order: file-info → filename-parser → [tmdb, anime-detector]
```
Plugins at the same dependency level may execute in any order (or parallel in future).
## REST API
The plugin system exposes REST endpoints for dashboard integration.
### Endpoints
```
GET /api/plugins # List all plugins
GET /api/plugins/:id # Get plugin details
POST /api/plugins/:id/activate # Activate plugin
POST /api/plugins/:id/deactivate # Deactivate plugin
PUT /api/plugins/:id/config # Update plugin config
DELETE /api/plugins/:id/cache # Clear plugin cache
GET /api/plugins/:id/schema # Get metadata schema (for editor)
```
### Response Format
```typescript
// GET /api/plugins
interface PluginsResponse {
plugins: PluginInfo[];
executionOrder: string[];
}
// POST /api/plugins/:id/activate
interface ActivateResponse {
success: boolean;
error?: string; # e.g., "Dependency 'x' is not active"
}
// PUT /api/plugins/:id/config
interface ConfigUpdateRequest {
config: Record<string, unknown>;
}
```
## Error Handling
### Plugin Load Errors
- Invalid manifest: Plugin marked as `status: 'error'`, logged, skipped
- Import failure: Plugin marked as `status: 'error'`, logged, skipped
- Missing dependencies: Warning logged, plugin can load but activation may fail
### Plugin Execution Errors
- Errors in `process()` are caught, logged, and execution continues to next plugin
- The file's metadata from previous plugins is preserved
- Error count is tracked per plugin for monitoring
### Lifecycle Hook Errors
- Errors in `onActivate()` prevent activation, error returned to caller
- Errors in `onDeactivate()` are logged but deactivation proceeds
- Errors in `onLoad()`/`onUnload()` are logged, plugin marked as error state
## Future Considerations
### Metadata Cleanup
A future feature will allow cleaning metadata keys written by a specific plugin:
```typescript
// Future API
await pluginManager.cleanMetadata(pluginId);
// Removes all keys matching "{pluginId}/*" from Redis
```
### Parallel Execution
Plugins at the same dependency level could execute in parallel:
```typescript
// Future: parallel execution of independent plugins
const level1 = ['file-info'];
const level2 = ['ffmpeg', 'filename-parser']; // parallel
const level3 = ['tmdb', 'anime-detector']; // parallel
```
### Plugin Hot Reload
Reloading a plugin's code without full restart (for development):
```typescript
// Future API
await pluginManager.reload(pluginId);
// Re-imports plugin code, preserves state
```
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -35,7 +35,7 @@ lazyValue().then(value => console.log(value));
### Listener Cleaner ### Listener Cleaner
```typescript ```typescript
import { ListenerCleaner } from "worph/async-utils"; import { ListenerCleaner } from "@worph/async-utils";
const cleaner = new ListenerCleaner(); const cleaner = new ListenerCleaner();
cleaner.add(() => console.log("Cleanup action")); cleaner.add(() => console.log("Cleanup action"));
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"spec": "dist/api/api.test.js", "spec": ["dist/api/api.test.js", "dist/kv/*.test.js"],
"timeout": 30000, "timeout": 30000,
"exit": true "exit": true
} }
@@ -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)
for (const hashId of hashIds) {
try {
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);
} }
// Update cache private async fetchFileStats(): Promise<FileStats> {
this.totalSizeCache = { value: totalSize, timestamp: now }; if (!this.kvClient || !hasFileTuples(this.kvClient)) {
return totalSize; return { fileCount: null, totalSize: null, source: 'unavailable' };
}
const summary = await this.kvClient.getFileTuples({ summary: true });
if (!summary) {
return { fileCount: null, totalSize: null, source: 'unavailable' };
}
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({ files.push({
fileHash: hashId, fileHash: tuple.hashId,
filePath: metadata.filePath, filePath: metadata.filePath || tuple.filePath,
kvData 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);
});
});
@@ -69,6 +69,18 @@ interface EnvConfig {
/** Concurrency for fast plugin queue. Default: 32 */ /** Concurrency for fast plugin queue. Default: 32 */
FAST_QUEUE_CONCURRENCY: number; FAST_QUEUE_CONCURRENCY: number;
/**
* Maximum number of files concurrently in the light (fast-queue) phase.
* Caps fan-out so the underlying file cache (WebDAV/nginx) only has to
* hold N files at a time. Default: 16.
*/
FILE_LIGHT_SLOTS: number;
/**
* Maximum number of files concurrently in the background phase. Default: 16.
*/
FILE_BG_SLOTS: number;
// ======================================================================== // ========================================================================
// Container Plugins Configuration // Container Plugins Configuration
// All plugins run as Docker containers for isolation and flexibility // All plugins run as Docker containers for isolation and flexibility
@@ -139,6 +151,8 @@ export const config: EnvConfig = {
// TaskScheduler Configuration // TaskScheduler Configuration
FAST_QUEUE_CONCURRENCY: parseInt(process.env.FAST_QUEUE_CONCURRENCY || "32", 10), FAST_QUEUE_CONCURRENCY: parseInt(process.env.FAST_QUEUE_CONCURRENCY || "32", 10),
FILE_LIGHT_SLOTS: parseInt(process.env.FILE_LIGHT_SLOTS || "16", 10),
FILE_BG_SLOTS: parseInt(process.env.FILE_BG_SLOTS || "16", 10),
// Container Plugins Configuration // Container Plugins Configuration
CONTAINER_PLUGINS_CONFIG: process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml', CONTAINER_PLUGINS_CONFIG: process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml',
@@ -1,21 +1,27 @@
import {CID_ALGORITHM_NAMES} from "@metazla/meta-hash"; import {CidAlgorithm} from "@metazla/meta-hash";
// Target hashes for Piscina worker (full-file hash computation) // Target hashes for Piscina worker (full-file hash computation)
// Note: midhash256 is NOT included here because it's computed in the main thread // Note: midhash256 is NOT included here because it's computed in the main thread
// during light processing, not by the Piscina worker // during light processing, not by the Piscina worker
export const targetHash = [ //
CID_ALGORITHM_NAMES.sha1, // For torrent verification // These are algorithm *selectors*, not metadata field names. The old
CID_ALGORITHM_NAMES.sha256, // For IPFS compatibility // `CID_ALGORITHM_NAMES` enum ('cid_sha2-256', …) served as both, which is how the
CID_ALGORITHM_NAMES.md5, // For compatibility // removed `cid_<algo>` record fields kept getting minted in the first place.
CID_ALGORITHM_NAMES.sha3_256, // Additional cryptographic hash // Digests are now persisted only as bare-CID key-set members (`cids/<cid>`).
CID_ALGORITHM_NAMES.sha3_384, // Additional cryptographic hash // See METADATA_KEYS.md §2/§14.13.
CID_ALGORITHM_NAMES.crc32, // Fast checksum export const targetHash: CidAlgorithm[] = [
CID_ALGORITHM_NAMES.btih_v2 // BitTorrent v2 info hash (BEP 52) 'sha1', // For torrent verification
'sha256', // For IPFS compatibility
'md5', // For compatibility
'sha3_256', // Additional cryptographic hash
'sha3_384', // Additional cryptographic hash
'crc32', // Fast checksum
'btih_v2', // BitTorrent v2 info hash (BEP 52)
] ]
// Target hashes for index-cid cache (includes all hashes we want to cache) // Target hashes for index-cid cache (includes all hashes we want to cache)
// This includes midhash256 which is computed separately from the worker hashes // This includes midhash256 which is computed separately from the worker hashes
export const targetHashForIndex = [ export const targetHashForIndex: CidAlgorithm[] = [
...targetHash, ...targetHash,
CID_ALGORITHM_NAMES.midhash256 // Fast hash for file identification (computed in light processing) 'midhash256', // Fast hash for file identification (computed in light processing)
] ]
@@ -21,6 +21,7 @@ import type { TaskQueueType } from '../plugin-engine/types.js';
import type { IKVClient } from '../kv/IKVClient.js'; import type { IKVClient } from '../kv/IKVClient.js';
import { type ExtendedContainerTask } from '../types/ExtendedInterfaces.js'; import { type ExtendedContainerTask } from '../types/ExtendedInterfaces.js';
import { config } from '../config/EnvConfig.js'; import { config } from '../config/EnvConfig.js';
import { FileSlotGate } from './FileSlotGate.js';
/** /**
* Scheduler events * Scheduler events
@@ -55,6 +56,18 @@ export interface GateStatus {
backgroundRunning: number; backgroundRunning: number;
} }
/**
* Per-file holder snapshot exposed via getQueueStatus for the dashboard.
* Lets operators see which 16 files are tying up cache slots and what plugin
* each file is currently waiting on (the long-pole per file).
*/
export interface CacheSlotHolder {
fileHash: string;
filePath?: string;
currentPlugin?: string;
tasksRemaining: number;
}
/** /**
* Container Plugin Scheduler * Container Plugin Scheduler
*/ */
@@ -73,11 +86,30 @@ export class ContainerPluginScheduler extends EventEmitter {
private kvClient: IKVClient | null = null; private kvClient: IKVClient | null = null;
private isAcceptingNewTasks: boolean = true; // Gate control for safe mount/unmount private isAcceptingNewTasks: boolean = true; // Gate control for safe mount/unmount
// File-level cache slots — see FileSlotGate.ts. Caps the number of files
// whose bytes are concurrently being touched in each phase, so the WebDAV
// cache only has to hold N files at a time regardless of plugin fan-out.
private lightFileGate: FileSlotGate;
private bgFileGate: FileSlotGate;
// Per-(file, phase) outstanding task counter — drives slot release.
private lightTasksPerFile: Map<string, number> = new Map();
private bgTasksPerFile: Map<string, number> = new Map();
// Tasks staged while waiting for a slot. Once the gate grants the file a
// slot we flush the buffered tasks into the underlying PQueue.
private lightBufferedTasks: Map<string, ContainerTask[]> = new Map();
private bgBufferedTasks: Map<string, ContainerTask[]> = new Map();
// Currently-dispatched plugin per file in each phase — used by the
// dashboard to show "this file is currently waiting on plugin X".
private lightCurrentPlugin: Map<string, string> = new Map();
private bgCurrentPlugin: Map<string, string> = new Map();
constructor( constructor(
containerManager: ContainerManager, containerManager: ContainerManager,
options?: { options?: {
fastConcurrency?: number; fastConcurrency?: number;
backgroundConcurrency?: number; backgroundConcurrency?: number;
lightFileSlots?: number;
bgFileSlots?: number;
callbackUrl?: string; callbackUrl?: string;
metaCoreUrl?: string; metaCoreUrl?: string;
defaultTimeout?: number; defaultTimeout?: number;
@@ -98,6 +130,9 @@ export class ContainerPluginScheduler extends EventEmitter {
autoStart: false, autoStart: false,
}); });
this.lightFileGate = new FileSlotGate(options?.lightFileSlots ?? config.FILE_LIGHT_SLOTS);
this.bgFileGate = new FileSlotGate(options?.bgFileSlots ?? config.FILE_BG_SLOTS);
// When fast queue becomes idle, start background queue // When fast queue becomes idle, start background queue
this.fastQueue.on('idle', () => { this.fastQueue.on('idle', () => {
this.onFastQueueIdle(); this.onFastQueueIdle();
@@ -246,6 +281,17 @@ export class ContainerPluginScheduler extends EventEmitter {
/** /**
* Enqueue a task for execution * Enqueue a task for execution
* Returns false if the gate is closed and the task was rejected * Returns false if the gate is closed and the task was rejected
*
* Tasks pass through the per-phase FileSlotGate before reaching PQueue:
* - If the file already holds a slot in this phase, the task goes
* straight to PQueue.
* - Otherwise the task is buffered, and the file's first task triggers
* a slot acquire. When the slot is granted, all buffered tasks for
* that file are flushed into PQueue.
*
* This caps concurrent files per phase (and therefore concurrent file
* fetches against CORN/SMB) at the gate's capacity, regardless of how
* many plugin tasks each file fans out to.
*/ */
enqueueTask(task: ContainerTask): boolean { enqueueTask(task: ContainerTask): boolean {
if (!this.isAcceptingNewTasks) { if (!this.isAcceptingNewTasks) {
@@ -262,20 +308,90 @@ export class ContainerPluginScheduler extends EventEmitter {
return false; return false;
} }
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
// If adding to fast queue, pause background queue (fast takes priority) // If adding to fast queue, pause background queue (fast takes priority)
if (task.queue === 'fast' && !this.backgroundQueue.isPaused) { if (task.queue === 'fast' && !this.backgroundQueue.isPaused) {
console.log('[ContainerScheduler] Fast task added, pausing background queue'); console.log('[ContainerScheduler] Fast task added, pausing background queue');
this.backgroundQueue.pause(); this.backgroundQueue.pause();
} }
queue.add(async () => { const counterMap = task.queue === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
await this.executeTask(task); const bufferMap = task.queue === 'fast' ? this.lightBufferedTasks : this.bgBufferedTasks;
const gate = task.queue === 'fast' ? this.lightFileGate : this.bgFileGate;
// Increment the per-file counter for this phase eagerly. Decrement
// happens in the executeTask finally clause.
counterMap.set(task.fileHash, (counterMap.get(task.fileHash) ?? 0) + 1);
if (gate.holds(task.fileHash)) {
// File already has a slot — dispatch immediately.
this.dispatchToPQueue(task);
return true;
}
// File doesn't hold a slot. Buffer the task. If this is the first
// buffered task for the file, trigger gate.acquire and flush on grant.
const existingBuffer = bufferMap.get(task.fileHash);
if (existingBuffer) {
existingBuffer.push(task);
return true;
}
bufferMap.set(task.fileHash, [task]);
gate.acquire(task.fileHash).then(() => {
const buffered = bufferMap.get(task.fileHash) ?? [];
bufferMap.delete(task.fileHash);
for (const t of buffered) {
this.dispatchToPQueue(t);
}
// Edge case: if every buffered task got cancelled by clear() before
// the slot was granted, release immediately so the slot doesn't
// leak.
if (buffered.length === 0 && (counterMap.get(task.fileHash) ?? 0) === 0) {
gate.release(task.fileHash);
}
}); });
return true; return true;
} }
/**
* Dispatch a task into the underlying PQueue and release its file's gate
* slot when the file's last task in that phase finishes.
*/
private dispatchToPQueue(task: ContainerTask): void {
const queue = task.queue === 'background' ? this.backgroundQueue : this.fastQueue;
queue.add(async () => {
try {
await this.executeTask(task);
} finally {
this.onTaskFinished(task);
}
});
}
/**
* Bookkeeping after a task leaves PQueue (success, failure, or thrown).
* Decrements the per-(file, phase) task counter and releases the gate
* slot when the counter hits zero.
*/
private onTaskFinished(task: ContainerTask): void {
const counterMap = task.queue === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
const currentMap = task.queue === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
const gate = task.queue === 'fast' ? this.lightFileGate : this.bgFileGate;
const remaining = (counterMap.get(task.fileHash) ?? 1) - 1;
if (remaining <= 0) {
counterMap.delete(task.fileHash);
currentMap.delete(task.fileHash);
gate.release(task.fileHash);
} else {
counterMap.set(task.fileHash, remaining);
// Clear current-plugin only if it points at this completed task.
if (currentMap.get(task.fileHash) === task.pluginId) {
currentMap.delete(task.fileHash);
}
}
}
/** /**
* Called when fast queue becomes idle - start background queue if it has work * Called when fast queue becomes idle - start background queue if it has work
*/ */
@@ -314,6 +430,11 @@ export class ContainerPluginScheduler extends EventEmitter {
task.dispatchedAt = Date.now(); task.dispatchedAt = Date.now();
task.instanceName = instance.containerName; task.instanceName = instance.containerName;
// Record which plugin is currently running for this file (per phase)
// so the dashboard can show the long-pole plugin per held cache slot.
const currentMap = task.queue === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
currentMap.set(task.fileHash, task.pluginId);
this.emit('task:dispatched', { task }); this.emit('task:dispatched', { task });
try { try {
@@ -581,6 +702,16 @@ export class ContainerPluginScheduler extends EventEmitter {
backgroundRunning: number; // unique files with background tasks running backgroundRunning: number; // unique files with background tasks running
backgroundWaiting: number; // unique files with background tasks waiting backgroundWaiting: number; // unique files with background tasks waiting
}; };
// Per-phase cache-slot view — bounded by FileSlotGate. This is the
// metric operators actually care about: how many distinct files are
// tying up the WebDAV cache right now, and which plugin is the
// long-pole on each. `paused` reflects the underlying PQueue state so
// the UI can distinguish "16 files reserved but bg PQueue paused"
// from "16 files actively dispatching".
cacheSlots: {
fast: { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] };
background: { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] };
};
} { } {
// Count pending callbacks by queue type // Count pending callbacks by queue type
// These are tasks dispatched to containers awaiting completion - the real "running" count // These are tasks dispatched to containers awaiting completion - the real "running" count
@@ -639,6 +770,56 @@ export class ContainerPluginScheduler extends EventEmitter {
backgroundRunning: backgroundRunningFiles.size, backgroundRunning: backgroundRunningFiles.size,
backgroundWaiting: backgroundWaitingFiles.size, backgroundWaiting: backgroundWaitingFiles.size,
}, },
cacheSlots: {
fast: this.snapshotCacheSlot('fast'),
background: this.snapshotCacheSlot('background'),
},
};
}
/**
* Build the per-phase cache-slot snapshot for the queue status payload.
* `currentPlugin` is the plugin currently dispatched against the file
* (the long-pole) — falls back to undefined if no task is mid-dispatch.
* `tasksRemaining` is the count of light/bg tasks still outstanding for
* the file in this phase, which is what holds the slot open.
*/
private snapshotCacheSlot(
phase: TaskQueueType
): { capacity: number; inUse: number; waiting: number; paused: boolean; holders: CacheSlotHolder[] } {
const gate = phase === 'fast' ? this.lightFileGate : this.bgFileGate;
const counterMap = phase === 'fast' ? this.lightTasksPerFile : this.bgTasksPerFile;
const currentMap = phase === 'fast' ? this.lightCurrentPlugin : this.bgCurrentPlugin;
const queue = phase === 'fast' ? this.fastQueue : this.backgroundQueue;
const holders: CacheSlotHolder[] = gate.getHolders().map((fileHash) => {
// Find any task for this file in this phase to recover filePath
// (one filePath is enough for the UI; duplicates collapse).
let filePath: string | undefined;
const taskIds = this.fileTasks.get(fileHash);
if (taskIds) {
for (const tid of taskIds) {
const t = this.pendingTasks.get(tid);
if (t && t.queue === phase) {
filePath = t.filePath;
break;
}
}
}
return {
fileHash,
filePath,
currentPlugin: currentMap.get(fileHash),
tasksRemaining: counterMap.get(fileHash) ?? 0,
};
});
return {
capacity: gate.capacity,
inUse: gate.inUse(),
waiting: gate.waiting(),
paused: queue.isPaused,
holders,
}; };
} }
@@ -751,6 +932,22 @@ export class ContainerPluginScheduler extends EventEmitter {
this.fileTasks.clear(); this.fileTasks.clear();
this.fileCompletedPlugins.clear(); this.fileCompletedPlugins.clear();
this.dependencyWaiters.clear(); this.dependencyWaiters.clear();
// Drop file-slot-gate state. Tasks that were buffered or holding
// slots are no longer interesting once we've cleared the queues; a
// fresh scheduler state implies fresh gate state.
this.lightTasksPerFile.clear();
this.bgTasksPerFile.clear();
this.lightBufferedTasks.clear();
this.bgBufferedTasks.clear();
this.lightCurrentPlugin.clear();
this.bgCurrentPlugin.clear();
for (const fileHash of this.lightFileGate.getHolders()) {
this.lightFileGate.release(fileHash);
}
for (const fileHash of this.bgFileGate.getHolders()) {
this.bgFileGate.release(fileHash);
}
} }
/** /**
@@ -0,0 +1,81 @@
/**
* FileSlotGate — bounds the number of files concurrently in a processing phase.
*
* Motivation: every plugin that runs against a file pulls bytes from CORN/SMB
* via WebDAV. Without a per-file gate, the task queue happily fans 8 plugins
* across hundreds of files at once, multiplying the WebDAV fetch cost by the
* plugin count and overwhelming whatever local cache exists. The gate caps the
* number of files that can be "in flight" in a given phase so the cache only
* has to hold N files at a time.
*
* Semantics:
* - Keyed by fileHash. A file holds at most one slot in a given phase.
* - acquire(fileHash) resolves immediately if the file already holds a slot
* or there is free capacity; otherwise it queues FIFO.
* - release(fileHash) is idempotent for non-holders. When called on a real
* holder it frees the slot and wakes the next waiter.
*
* The gate intentionally knows nothing about tasks or plugins — it is a thin
* file-level semaphore. Counting tasks per (file, phase) and deciding when to
* release is the caller's responsibility.
*/
export class FileSlotGate {
private readonly holders: Set<string> = new Set();
private readonly waiters: Array<{ fileHash: string; resolve: () => void }> = [];
constructor(public readonly capacity: number) {
if (!Number.isFinite(capacity) || capacity < 1) {
throw new Error(`FileSlotGate capacity must be a positive integer, got ${capacity}`);
}
}
/**
* Acquire a slot for fileHash. If the file already holds a slot, resolves
* immediately (re-entrant for the same file). Otherwise queues until a
* slot is released.
*/
acquire(fileHash: string): Promise<void> {
if (this.holders.has(fileHash)) {
return Promise.resolve();
}
if (this.holders.size < this.capacity) {
this.holders.add(fileHash);
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.waiters.push({ fileHash, resolve });
});
}
/**
* Release the slot held by fileHash. No-op if the file doesn't hold one.
* Wakes the next FIFO waiter, if any, and grants it a slot.
*/
release(fileHash: string): void {
if (!this.holders.delete(fileHash)) {
return;
}
const next = this.waiters.shift();
if (!next) {
return;
}
this.holders.add(next.fileHash);
next.resolve();
}
holds(fileHash: string): boolean {
return this.holders.has(fileHash);
}
inUse(): number {
return this.holders.size;
}
waiting(): number {
return this.waiters.length;
}
getHolders(): string[] {
return Array.from(this.holders);
}
}
@@ -1,14 +1,20 @@
/** /**
* FileEventConsumer * FileEventConsumer
* *
* Consumes file events from meta-core via Redis Stream. * Consumes file events from meta-core via SSE (Server-Sent Events).
* When events are received, they are forwarded to the StreamingPipeline. * When events are received, they are forwarded to the StreamingPipeline.
* *
* This enables meta-sort to run without direct filesystem access - it receives * This enables meta-sort to run without direct filesystem access — it receives
* file discovery events from meta-core and processes files via WebDAV. * file discovery events from meta-core and processes files via WebDAV.
*
* Previously consumed `file:events` via Redis Streams XREADGROUP. The
* api-mediated-access migration (PR C) routes this through meta-core's HTTP
* /api/events/files endpoint instead, so consumers no longer need direct
* Redis access. Cursor durability is now the consumer's responsibility —
* see SSEEventClient.
*/ */
import type { RedisKVClient, StreamMessage } from '../kv/RedisClient.js'; import { SSEEventClient, type SSEEvent } from './SSEEventClient.js';
// Pipeline interface (minimal, matching StreamingPipeline) // Pipeline interface (minimal, matching StreamingPipeline)
interface Pipeline { interface Pipeline {
@@ -18,13 +24,9 @@ interface Pipeline {
reset(): Promise<void>; reset(): Promise<void>;
} }
// Redis Stream configuration
const EVENTS_STREAM = 'file:events';
const CONSUMER_GROUP = 'meta-sort-processor';
export interface FileEventConsumerOptions { export interface FileEventConsumerOptions {
/** Redis KV client */ /** meta-core API base URL (e.g. http://metacore-app:9000). */
kvClient: RedisKVClient; metaCoreApiUrl: string;
/** Pipeline to receive events */ /** Pipeline to receive events */
pipeline: Pipeline; pipeline: Pipeline;
@@ -32,115 +34,104 @@ export interface FileEventConsumerOptions {
/** Base path for files (e.g., /files). Paths from meta-core are relative and need this prefix. */ /** Base path for files (e.g., /files). Paths from meta-core are relative and need this prefix. */
filesPath?: string; filesPath?: string;
/** Block timeout in milliseconds for stream reads */ /** Where to persist the SSE cursor. Defaults to /meta-core/cursors/meta-sort-files.cursor. */
blockMs?: number; cursorPath?: string;
} }
export class FileEventConsumer { export class FileEventConsumer {
private kvClient: RedisKVClient;
private pipeline: Pipeline; private pipeline: Pipeline;
private filesPath: string; private filesPath: string;
private blockMs: number; private sse: SSEEventClient;
private isRunning = false; private isRunning = false;
constructor(options: FileEventConsumerOptions) { constructor(options: FileEventConsumerOptions) {
this.kvClient = options.kvClient;
this.pipeline = options.pipeline; this.pipeline = options.pipeline;
this.filesPath = options.filesPath ?? '/files'; this.filesPath = options.filesPath ?? '/files';
this.blockMs = options.blockMs ?? 5000;
const apiUrl = options.metaCoreApiUrl.replace(/\/+$/, '');
const cursorPath = options.cursorPath ?? '/meta-core/cursors/meta-sort-files.cursor';
this.sse = new SSEEventClient({
url: `${apiUrl}/api/events/files`,
cursorPath,
logTag: '[FileEventConsumer]',
onEvent: (e) => this.handleSSEEvent(e),
// gap on file:events means we missed retention. Easiest correct
// response: ask meta-core for a watcher rescan so the StreamingPipeline
// can rebuild from a known-good baseline.
onGap: () => {
console.warn('[FileEventConsumer] Stream cursor trimmed — pipeline will rebuild from next reset event');
},
});
} }
/** /**
* Start consuming events from Redis Stream * Start consuming events from the meta-core SSE endpoint.
*/ */
async start(): Promise<void> { async start(): Promise<void> {
if (this.isRunning) { if (this.isRunning) {
console.log('[FileEventConsumer] Already running'); console.log('[FileEventConsumer] Already running');
return; return;
} }
this.isRunning = true; this.isRunning = true;
console.log(`[FileEventConsumer] Starting stream consumer for ${EVENTS_STREAM}...`); console.log('[FileEventConsumer] Starting SSE stream consumer for /api/events/files...');
// Fire-and-forget. SSEEventClient.start() returns only when stopped.
// Initialize consumer group this.sse.start().catch(error => {
await this.kvClient.initStreamConsumer(EVENTS_STREAM, CONSUMER_GROUP); console.error('[FileEventConsumer] Stream consumer terminated:', error);
// Process any pending entries from crashed consumers
await this.kvClient.processPendingEntries(
EVENTS_STREAM,
CONSUMER_GROUP,
30000, // 30 second idle threshold
async (message) => this.handleMessage(message)
);
// Start consumer loop in background (don't await - runs indefinitely)
this.kvClient.startStreamConsumer(
EVENTS_STREAM,
CONSUMER_GROUP,
async (message) => this.handleMessage(message),
this.blockMs
).catch(error => {
console.error('[FileEventConsumer] Stream consumer error:', error);
}); });
console.log('[FileEventConsumer] SSE stream consumer started');
console.log('[FileEventConsumer] Stream consumer started');
} }
/** /**
* Stop consuming events * Stop consuming events
*/ */
stop(): void { async stop(): Promise<void> {
if (!this.isRunning) { if (!this.isRunning) {
return; return;
} }
this.isRunning = false; this.isRunning = false;
this.kvClient.stopStreamConsumer(); await this.sse.stop();
console.log('[FileEventConsumer] Stopped'); console.log('[FileEventConsumer] Stopped');
} }
/** private async handleSSEEvent(e: SSEEvent): Promise<void> {
* Handle a stream message
*/
private async handleMessage(message: StreamMessage): Promise<void> {
// Convert relative path to absolute path // Convert relative path to absolute path
const toAbsolutePath = (relativePath: string): string => { const toAbsolutePath = (relativePath: string): string => {
// If path is already absolute, return as-is if (!relativePath) return relativePath;
if (relativePath.startsWith('/')) { if (relativePath.startsWith('/')) return relativePath;
return relativePath;
}
// Otherwise, prepend filesPath
return `${this.filesPath}/${relativePath}`; return `${this.filesPath}/${relativePath}`;
}; };
switch (message.type) { const path: string = e.data?.path ?? '';
const midhash256: string | undefined = e.data?.midhash256 ?? undefined;
const oldPath: string | undefined = e.data?.oldPath ?? undefined;
const watcherId: string | undefined = e.data?.watcherId ?? undefined;
switch (e.event) {
case 'reset': case 'reset':
console.log(`[FileEventConsumer] Received reset event from watcher: ${message.watcherId || 'all'}`); console.log(`[FileEventConsumer] Received reset event from watcher: ${watcherId || 'all'}`);
await this.pipeline.reset(); await this.pipeline.reset();
break; break;
case 'add': case 'add':
this.pipeline.handleFileAdded(toAbsolutePath(message.path), message.midhash256); if (path) this.pipeline.handleFileAdded(toAbsolutePath(path), midhash256);
break; break;
case 'change': case 'change':
this.pipeline.handleFileChanged(toAbsolutePath(message.path), message.midhash256); if (path) this.pipeline.handleFileChanged(toAbsolutePath(path), midhash256);
break; break;
case 'delete': case 'delete':
this.pipeline.handleFileDeleted(toAbsolutePath(message.path)); if (path) this.pipeline.handleFileDeleted(toAbsolutePath(path));
break; break;
case 'rename': case 'rename':
// Handle rename as delete + add if (oldPath) this.pipeline.handleFileDeleted(toAbsolutePath(oldPath));
if (message.oldPath) { if (path) this.pipeline.handleFileAdded(toAbsolutePath(path), midhash256);
this.pipeline.handleFileDeleted(toAbsolutePath(message.oldPath));
}
this.pipeline.handleFileAdded(toAbsolutePath(message.path), message.midhash256);
break; break;
default: default:
console.warn(`[FileEventConsumer] Unknown event type: ${message.type}`); console.warn(`[FileEventConsumer] Unknown event type: ${e.event}`);
} }
} }
} }
@@ -149,12 +140,12 @@ export class FileEventConsumer {
* Create a FileEventConsumer with default configuration * Create a FileEventConsumer with default configuration
*/ */
export function createFileEventConsumer( export function createFileEventConsumer(
kvClient: RedisKVClient, metaCoreApiUrl: string,
pipeline: Pipeline, pipeline: Pipeline,
options?: Partial<FileEventConsumerOptions> options?: Partial<FileEventConsumerOptions>
): FileEventConsumer { ): FileEventConsumer {
return new FileEventConsumer({ return new FileEventConsumer({
kvClient, metaCoreApiUrl,
pipeline, pipeline,
...options ...options
}); });
@@ -0,0 +1,295 @@
/**
* SSEEventClient — HTTP Server-Sent Events client for meta-core event streams.
*
* Replaces the Redis Streams XREADGROUP loop. Each consumer service holds
* one long-lived HTTP connection to meta-core's /api/events/{files,meta}
* endpoint and receives JSON events as the underlying Redis Stream is
* appended to.
*
* Cursor semantics (see meta-core docs/api-mediated-access.md):
* - The SSE wire id is the opaque Redis Stream entry ID (`<ms>-<seq>`).
* We treat it as a black-box string.
* - On reconnect we send Last-Event-ID so meta-core resumes from the
* entry AFTER our cursor.
* - We persist the cursor to disk so the loop survives a process
* restart. The cursor is written every flushIntervalMs and on graceful
* stop.
* - On a `gap` event the server is telling us our cursor was trimmed
* out of retention. Caller-supplied onGap handles it (typically a
* re-bootstrap from a full HTTP fetch). If the caller doesn't handle
* gaps, we just continue from the new cursor.
*/
import { promises as fs } from 'fs';
import { dirname } from 'path';
/** One SSE event parsed off the wire. */
export interface SSEEvent {
/** Opaque cursor (Redis Stream entry ID under the hood). */
id: string;
/** Event type — `add` / `change` / `delete` / `rename` / `reset` for
* file:events; `set` / `del` / `expire` for meta:events; `gap` for
* trim notifications; arbitrary otherwise. */
event: string;
/** Parsed JSON payload from the `data:` line. */
data: any;
}
export interface SSEClientOptions {
/** Full URL to subscribe to (e.g. http://metacore-app:9000/api/events/files). */
url: string;
/** Path where the cursor is persisted between runs.
* Set to null to keep the cursor in-memory only (at-most-once on crash). */
cursorPath: string | null;
/** Handler for every non-gap event. */
onEvent: (e: SSEEvent) => Promise<void> | void;
/** Optional handler for the synthetic `gap` event. If omitted we just
* log and continue from the resumeFrom cursor inside the payload. */
onGap?: (payload: { requested: string; resumeFrom: string; reason: string }) => Promise<void> | void;
/** Logger prefix used for console.log lines. */
logTag?: string;
/** Initial reconnect backoff in ms (doubles per failure, capped). Default 500. */
reconnectBaseMs?: number;
/** Max reconnect backoff in ms. Default 30_000. */
reconnectMaxMs?: number;
/** How often to flush the in-memory cursor to disk. Default 2000. */
flushIntervalMs?: number;
}
export class SSEEventClient {
private readonly opts: Required<Omit<SSEClientOptions, 'onGap'>> & Pick<SSEClientOptions, 'onGap'>;
private cursor: string = '';
private cursorDirty = false;
private abort: AbortController | null = null;
private flushTimer: NodeJS.Timeout | null = null;
private stopped = false;
private backoff = 0;
constructor(options: SSEClientOptions) {
this.opts = {
url: options.url,
cursorPath: options.cursorPath,
onEvent: options.onEvent,
onGap: options.onGap,
logTag: options.logTag ?? '[SSE]',
reconnectBaseMs: options.reconnectBaseMs ?? 500,
reconnectMaxMs: options.reconnectMaxMs ?? 30_000,
flushIntervalMs: options.flushIntervalMs ?? 2000,
};
this.backoff = this.opts.reconnectBaseMs;
}
/**
* Read the persisted cursor (if any) and begin streaming. The returned
* promise resolves on stop()/permanent error; callers should not await
* it directly — fire-and-forget and call stop() to shut down.
*/
async start(): Promise<void> {
await this.loadCursor();
this.startFlushTimer();
await this.runLoop();
}
/** Graceful shutdown — closes the open connection and flushes the cursor. */
async stop(): Promise<void> {
this.stopped = true;
if (this.abort) {
this.abort.abort();
this.abort = null;
}
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
await this.flushCursor();
}
/** Currently-known cursor — useful for tests. */
getCursor(): string {
return this.cursor;
}
private async loadCursor(): Promise<void> {
if (!this.opts.cursorPath) return;
try {
const data = await fs.readFile(this.opts.cursorPath, 'utf-8');
const trimmed = data.trim();
if (trimmed) {
this.cursor = trimmed;
console.log(`${this.opts.logTag} Resuming from cursor ${this.cursor}`);
}
} catch (err: any) {
if (err.code !== 'ENOENT') {
console.warn(`${this.opts.logTag} Could not read cursor file ${this.opts.cursorPath}: ${err.message}`);
}
// ENOENT is expected first time; start at "$" (server-default).
}
}
private startFlushTimer(): void {
this.flushTimer = setInterval(() => {
if (this.cursorDirty) {
this.flushCursor().catch(err =>
console.warn(`${this.opts.logTag} Cursor flush failed: ${err.message}`)
);
}
}, this.opts.flushIntervalMs);
}
private async flushCursor(): Promise<void> {
if (!this.opts.cursorPath || !this.cursorDirty) return;
this.cursorDirty = false;
try {
await fs.mkdir(dirname(this.opts.cursorPath), { recursive: true });
await fs.writeFile(this.opts.cursorPath, this.cursor, 'utf-8');
} catch (err: any) {
// Re-mark dirty so the next tick retries.
this.cursorDirty = true;
throw err;
}
}
private async runLoop(): Promise<void> {
while (!this.stopped) {
try {
await this.connect();
// connect() only returns on graceful end-of-stream — reset
// backoff so a long-running connection that finally
// disconnects reconnects quickly.
this.backoff = this.opts.reconnectBaseMs;
} catch (err: any) {
if (this.stopped) return;
console.warn(`${this.opts.logTag} Stream error, reconnecting in ${this.backoff}ms: ${err?.message ?? err}`);
await this.sleep(this.backoff);
this.backoff = Math.min(this.backoff * 2, this.opts.reconnectMaxMs);
}
}
}
private async connect(): Promise<void> {
this.abort = new AbortController();
const headers: Record<string, string> = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
};
if (this.cursor) {
headers['Last-Event-ID'] = this.cursor;
}
const response = await fetch(this.opts.url, {
method: 'GET',
headers,
signal: this.abort.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response has no body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (!this.stopped) {
const { value, done } = await reader.read();
if (done) {
return;
}
buffer += decoder.decode(value, { stream: true });
// Each SSE event ends with a blank line ("\n\n"). Split on
// that boundary and process each chunk.
let idx: number;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const rawChunk = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
await this.handleChunk(rawChunk);
}
}
} finally {
try { reader.releaseLock(); } catch { /* nothing useful */ }
}
}
private async handleChunk(chunk: string): Promise<void> {
let id = '';
let event = '';
const dataLines: string[] = [];
for (const rawLine of chunk.split('\n')) {
if (!rawLine || rawLine.startsWith(':')) {
continue; // comment / heartbeat — ignore
}
const colon = rawLine.indexOf(':');
if (colon === -1) continue;
const field = rawLine.slice(0, colon);
// SSE spec: ": " (single space after the colon) is stripped; if no space, keep as-is.
let value = rawLine.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
switch (field) {
case 'id':
id = value;
break;
case 'event':
event = value;
break;
case 'data':
dataLines.push(value);
break;
// ignore other fields (retry, etc.)
}
}
if (dataLines.length === 0) {
return; // bare heartbeat / no payload
}
let data: any;
try {
data = JSON.parse(dataLines.join('\n'));
} catch (err: any) {
console.warn(`${this.opts.logTag} Skipping unparseable event ${id || '(no id)'}: ${err.message}`);
return;
}
const ev: SSEEvent = { id, event: event || 'message', data };
if (ev.event === 'gap') {
if (this.opts.onGap) {
await this.opts.onGap(ev.data);
} else {
console.warn(`${this.opts.logTag} Gap event (cursor ${ev.data?.requested} trimmed): resumed from ${ev.data?.resumeFrom}`);
}
if (typeof ev.data?.resumeFrom === 'string' && ev.data.resumeFrom) {
this.cursor = ev.data.resumeFrom;
this.cursorDirty = true;
}
return;
}
try {
await this.opts.onEvent(ev);
} catch (err: any) {
// Handler error doesn't tear down the connection — log and move on.
console.error(`${this.opts.logTag} onEvent handler failed for ${ev.id}: ${err?.stack ?? err}`);
}
if (ev.id) {
this.cursor = ev.id;
this.cursorDirty = true;
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
+9 -9
View File
@@ -104,7 +104,7 @@ process.on('SIGINT', async () => {
// Stop File Event Consumer if running // Stop File Event Consumer if running
if (fileEventConsumer) { if (fileEventConsumer) {
console.log('[Shutdown] Stopping File Event Consumer...'); console.log('[Shutdown] Stopping File Event Consumer...');
fileEventConsumer.stop(); await fileEventConsumer.stop();
} }
// Stop Container Manager if running // Stop Container Manager if running
@@ -256,21 +256,21 @@ process.on('SIGINT', async () => {
console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`); console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`);
} }
// Connect to meta-core for file events via Redis Stream // Connect to meta-core for file events via SSE
// KV client must be available const leaderInfoForSSE = kvManager?.getLeaderInfo();
if (!kvClient) { const metaCoreApiUrl = leaderInfoForSSE?.apiUrl;
console.error('[Startup] ERROR: KV client not available. Cannot consume file events.'); if (!metaCoreApiUrl) {
console.error('[Startup] Make sure meta-core is running and Redis is accessible.'); console.error('[Startup] ERROR: meta-core apiUrl not available from leader info. Cannot consume file events.');
process.exit(1); process.exit(1);
} }
console.log('[Startup] Starting File Event Consumer (Redis Stream)...'); console.log(`[Startup] Starting File Event Consumer (SSE → ${metaCoreApiUrl}/api/events/files)...`);
fileEventConsumer = new FileEventConsumer({ fileEventConsumer = new FileEventConsumer({
kvClient: kvClient as RedisKVClient, metaCoreApiUrl,
pipeline: pipeline, pipeline: pipeline,
filesPath: config.FILES_PATH, filesPath: config.FILES_PATH,
}); });
await fileEventConsumer.start(); await fileEventConsumer.start();
console.log('[Startup] FileEventConsumer started, consuming from file:events stream'); console.log('[Startup] FileEventConsumer started, consuming via SSE');
} catch (error) { } catch (error) {
console.error('[Startup] Error during startup:', error); console.error('[Startup] Error during startup:', error);
+17 -5
View File
@@ -73,13 +73,17 @@ export class KVManager {
} }
/** /**
* Create Redis client from URL * Create Redis client from URL. When apiUrl is supplied, the resulting
* client routes writes through meta-core's HTTP API (PR B of the
* api-mediated-access lockdown) while keeping reads + stream consumption
* on the direct Redis connection.
*/ */
private async createRedisClient(redisUrl: string): Promise<RedisKVClient> { private async createRedisClient(redisUrl: string, apiUrl?: string): Promise<RedisKVClient> {
const client = new RedisKVClient({ const client = new RedisKVClient({
url: redisUrl, url: redisUrl,
timeout: 30000, timeout: 30000,
prefix: '' // No prefix for shared access prefix: '', // No prefix for shared access
metaCoreApiUrl: apiUrl,
}); });
await client.connect(); await client.connect();
@@ -121,8 +125,16 @@ export class KVManager {
// Wait for leader and connect // Wait for leader and connect
try { try {
const leaderInfo = await this.leaderClient.waitForLeader(30000); const leaderInfo = await this.leaderClient.waitForLeader(30000);
if (leaderInfo.redisUrl) {
// api-mediated-access PR D removes redisUrl from leader info.
// Until that lands we still log it but services boot fine
// when it's absent — all writes/reads route through HTTP.
console.warn('[KVManager] Leader info still publishes redisUrl; direct Redis access is being deprecated, prefer meta-core HTTP');
console.log(`[KVManager] Connecting to Redis at ${leaderInfo.redisUrl}...`); console.log(`[KVManager] Connecting to Redis at ${leaderInfo.redisUrl}...`);
this.kvClient = await this.createRedisClient(leaderInfo.redisUrl); } else {
console.log('[KVManager] redisUrl absent in leader info — running in HTTP-only mode');
}
this.kvClient = await this.createRedisClient(leaderInfo.redisUrl, leaderInfo.apiUrl);
// Configure WebDAV client with leader's WebDAV URL (prefer internal for container-to-container) // Configure WebDAV client with leader's WebDAV URL (prefer internal for container-to-container)
const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl; const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl;
@@ -169,7 +181,7 @@ export class KVManager {
try { try {
const leaderInfo = await this.leaderClient.waitForLeader(30000); const leaderInfo = await this.leaderClient.waitForLeader(30000);
console.log(`[KVManager] Reconnecting to Redis at ${leaderInfo.redisUrl}...`); console.log(`[KVManager] Reconnecting to Redis at ${leaderInfo.redisUrl}...`);
this.kvClient = await this.createRedisClient(leaderInfo.redisUrl); this.kvClient = await this.createRedisClient(leaderInfo.redisUrl, leaderInfo.apiUrl);
// Reconfigure WebDAV client with new leader's WebDAV URL (prefer internal for container-to-container) // Reconfigure WebDAV client with new leader's WebDAV URL (prefer internal for container-to-container)
const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl; const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl;
+18 -1
View File
@@ -85,7 +85,24 @@ export class LeaderClient {
return null; return null;
} }
this.cachedUrls = await response.json() as URLsResponse; const parsed = await response.json() as URLsResponse;
// api-mediated-access PR D: redisUrl is no longer published by
// meta-core. If it shows up, an older build snuck back in —
// fail loudly so the rollback can't go unnoticed. Set
// ALLOW_LEGACY_REDIS_URL=1 to downgrade to a warning during a
// deliberate temporary rollback.
if (parsed.redisUrl) {
const msg =
'[LeaderClient] meta-core still publishes redisUrl; ' +
'direct Redis access was retired by the api-mediated-access ' +
'lockdown. Verify meta-core version.';
if (process.env.ALLOW_LEGACY_REDIS_URL === '1') {
console.warn('WARNING: ' + msg);
} else {
throw new Error(msg);
}
}
this.cachedUrls = parsed;
this.urlsCacheTime = now; this.urlsCacheTime = now;
return this.cachedUrls; return this.cachedUrls;
} catch (error) { } catch (error) {
@@ -0,0 +1,262 @@
/**
* MetaCoreApiWriter — HTTP writer for metadata, replacing direct Redis writes.
*
* meta-core owns the Redis schema (UUID-rooted, reverse-indexed by CID). Any
* service writing directly to Redis risks drifting the schema; the lockdown
* plan (docs/api-mediated-access.md in meta-core) routes every write through
* meta-core's HTTP /meta/{hash} family.
*
* This writer covers the three operations meta-sort actually performs in
* production:
* - setMetadataFlat → PATCH /meta/{hash} (merge semantics)
* - setMetadataProperty → PUT /meta/{hash}/{key}
* - deleteMetadataFlat → DELETE /meta/{hash}
*
* Reads and stream consumption stay on Redis in PR B; PR C migrates those.
*/
import {
buildRecordFields,
buildFilePrefix,
} 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 {
/** Base URL for meta-core's HTTP API (e.g. http://metacore-app:9000) */
apiUrl: string;
/** Optional per-request timeout in ms (default: 30000) */
timeoutMs?: number;
}
export class MetaCoreApiWriter {
private apiUrl: string;
private timeoutMs: number;
constructor(config: MetaCoreApiWriterConfig) {
// Trim trailing slash so we can join paths predictably.
this.apiUrl = config.apiUrl.replace(/\/+$/, '');
this.timeoutMs = config.timeoutMs ?? 30000;
}
/**
* PATCH /meta/{hash}.
*
* meta-core's PATCH calls MergeMetadataFlat — merge semantics, matches
* what setMetadataFlat used to do via Redis pipeline of SETs.
*/
async setMetadataFlat(
hashId: string,
metadata: any,
excludeFields: string[] = []
): Promise<void> {
// `buildRecordFields` is the single write-shape boundary, shared with the
// direct-Redis path in RedisClient. It emits the `cids/<cid>` key-set and
// never a per-algorithm `cid_*` field — which meta-core would now reject
// with a 400 anyway.
const flat = buildRecordFields(metadata, excludeFields);
if (Object.keys(flat).length === 0) return;
await this.fetchJson('PATCH', `/meta/${encodeURIComponent(hashId)}`, flat);
}
/**
* PUT /meta/{hash}/{key}.
*
* meta-core accepts either JSON {"value":"..."} or plain text; we use
* the JSON form to avoid ambiguity with values that happen to look like
* JSON themselves.
*/
async setMetadataProperty(hashId: string, property: string, value: string): Promise<void> {
await this.fetchJson(
'PUT',
`/meta/${encodeURIComponent(hashId)}/${this.encodePropertyPath(property)}`,
{ value }
);
}
/**
* DELETE /meta/{hash}.
*
* Removes every property and unregisters the hashId from file:__index__.
*/
async deleteMetadataFlat(hashId: string): Promise<number> {
const body = await this.fetchJson(
'DELETE',
`/meta/${encodeURIComponent(hashId)}`,
null
);
// meta-core's response is {success, hashId, deleted: N}.
if (body && typeof body === 'object' && typeof (body as any).deleted === 'number') {
return (body as any).deleted;
}
return 0;
}
/**
* GET /meta/{hash}. Returns the flat metadata map (or null if absent).
*/
async getMetadataFlat(hashId: string): Promise<any | null> {
const path = `/meta/${encodeURIComponent(hashId)}`;
const body = await this.fetchJsonAllowing404('GET', path);
if (body === null) return null;
// meta-core wraps the flat map under `metadata`.
const flat = (body as any)?.metadata;
if (!flat || typeof flat !== 'object') return null;
return flat;
}
/**
* GET /meta/{hash}/{propertyPath}. Returns the raw string value (or null
* if absent). meta-core sends back text/plain.
*/
async getMetadata(hashId: string, propertyPath: string): Promise<any | null> {
const path = `/meta/${encodeURIComponent(hashId)}/${this.encodePropertyPath(propertyPath)}`;
const url = `${this.apiUrl}${path}`;
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(this.timeoutMs),
headers: { Accept: 'text/plain' },
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`[MetaCoreApiWriter] GET ${path} → ${response.status} ${response.statusText}`);
}
const text = await response.text();
if (!text) return null;
// Mirror the legacy RedisClient: try JSON-parse first, fall back to raw string.
try {
return JSON.parse(text);
} catch {
return text;
}
}
/**
* GET /meta. Returns the list of all hashIds known to meta-core.
*/
async getAllHashIds(): Promise<string[]> {
const body = await this.fetchJson('GET', '/meta', null);
const ids = (body as any)?.hashIds;
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.
*/
async health(): Promise<boolean> {
try {
const response = await fetch(`${this.apiUrl}/health`, {
method: 'GET',
signal: AbortSignal.timeout(Math.min(this.timeoutMs, 5000)),
headers: { Accept: 'application/json' },
});
return response.ok;
} catch {
return false;
}
}
private async fetchJsonAllowing404(method: string, path: string): Promise<unknown> {
const url = `${this.apiUrl}${path}`;
const response = await fetch(url, {
method,
signal: AbortSignal.timeout(this.timeoutMs),
headers: { Accept: 'application/json' },
});
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`[MetaCoreApiWriter] ${method} ${path} → ${response.status} ${response.statusText}`);
}
const text = await response.text();
if (!text) return null;
try { return JSON.parse(text); } catch { return text; }
}
/**
* Encode a slash-containing property path safely for the URL while still
* letting meta-core's `{key:.*}` route match the multi-segment key.
*/
private encodePropertyPath(property: string): string {
return property
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
}
private async fetchJson(
method: string,
path: string,
body: unknown
): Promise<unknown> {
const url = `${this.apiUrl}${path}`;
const init: RequestInit = {
method,
signal: AbortSignal.timeout(this.timeoutMs),
headers: { Accept: 'application/json' },
};
if (body !== null && body !== undefined) {
init.headers = { ...init.headers, 'Content-Type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await fetch(url, init);
if (!response.ok) {
// Surface meta-core's error envelope when it's structured.
let detail = response.statusText;
try {
const errBody = await response.text();
if (errBody) detail = `${detail}: ${errBody}`;
} catch {
/* ignore body-read errors; statusText still useful */
}
throw new Error(`[MetaCoreApiWriter] ${method} ${path} → ${response.status} ${detail}`);
}
// 204 / empty body — return null so callers don't choke on JSON parse.
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
}
@@ -0,0 +1,86 @@
import { strict as assert } from 'assert';
import { buildRecordFields } from './MetadataUtils.js';
/**
* `buildRecordFields` is the single boundary where in-memory metadata becomes a
* record. It is worth testing precisely because the bug it fixes had no coverage:
* meta-sort had *two* write paths (the meta-core HTTP writer and a direct-Redis
* fallback), only one of which turned sibling CIDs into the `cids/<cid>` key-set.
* The other wrote a literal `cid_midhash256` field, which meta-core stores but
* never reverse-indexes — so those records were unresolvable by their own CID,
* silently. Both paths now go through this function.
*/
describe('buildRecordFields', () => {
const MIDHASH = 'bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4';
const SHA256 = 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua';
it('turns cids[] into the bare-CID key-set, not array indices', () => {
const flat = buildRecordFields({
hashId: MIDHASH,
cids: [MIDHASH, SHA256],
title: 'Inception',
});
assert.equal(flat[`cids/${MIDHASH}`], 'true');
assert.equal(flat[`cids/${SHA256}`], 'true');
// The trap: the generic flattener would emit `cids/0`, `cids/1` for an
// array. meta-core's reverse-index hook only matches `cids/<cid>`, so
// those would be stored and never indexed.
assert.equal(flat['cids/0'], undefined);
assert.equal(flat['cids/1'], undefined);
});
it('never emits a deprecated cid_* or midhash256 field', () => {
const flat = buildRecordFields({
hashId: MIDHASH,
cids: [MIDHASH],
title: 'Inception',
});
// meta-core returns 400 for any of these now — a record that carried one
// was stored but unindexed.
for (const field of Object.keys(flat)) {
assert.ok(
!field.startsWith('cid_'),
`deprecated per-algorithm field reached the record: ${field}`
);
assert.notEqual(field, 'midhash256');
assert.notEqual(field, 'canonical_cid');
}
});
it('does not store hashId as a property of its own record', () => {
const flat = buildRecordFields({hashId: MIDHASH, cids: [MIDHASH], title: 'x'});
// hashId IS the record key (`/file/{hashId}`); repeating it inside is noise.
assert.equal(flat['hashId'], undefined);
});
it('still flattens ordinary nested metadata', () => {
const flat = buildRecordFields({
hashId: MIDHASH,
cids: [MIDHASH],
title: 'Inception',
fileinfo: {duration: 8878.5, streamdetails: {video: {codec: 'h264'}}},
});
assert.equal(flat['title'], 'Inception');
assert.equal(flat['fileinfo/duration'], '8878.5');
assert.equal(flat['fileinfo/streamdetails/video/codec'], 'h264');
});
it('honours excludeFields', () => {
const flat = buildRecordFields(
{hashId: MIDHASH, cids: [MIDHASH], title: 'x', processingStatus: 'processing'},
['processingStatus']
);
assert.equal(flat['processingStatus'], undefined);
assert.equal(flat['title'], 'x');
});
it('tolerates a record with no cids at all', () => {
const flat = buildRecordFields({title: 'x'});
assert.equal(flat['title'], 'x');
assert.ok(!Object.keys(flat).some(k => k.startsWith('cids/')));
});
});
@@ -22,6 +22,63 @@ import type { KeyValuePair } from './IKVClient.js';
* // { key: "/file/abc123/video/codec", value: "h265" } * // { key: "/file/abc123/video/codec", value: "h265" }
* // ] * // ]
*/ */
/**
* Fields that never go onto a record under their own name.
*
* `cids` is an **array of bare CIDs** and must become the key-set
* `cids/<cid> = "true"`. If it fell through the generic flattener below it would
* emit `cids/0`, `cids/1`, … — array indices, not CIDs — which meta-core's
* reverse-index hook would not recognise, silently leaving the record
* unresolvable by any of its CIDs. `buildRecordFields` handles it explicitly.
*
* `hashId` is the record's own address (it *is* the `/file/{hashId}` key), so
* storing it as a property inside itself is redundant.
*/
const STRUCTURAL_FIELDS = ['cids', 'hashId'];
/**
* Turn an in-memory `FileMetadata` into the flat `field → value` map that a
* record is made of.
*
* **This is the single write-shape boundary.** Both writers go through it — the
* meta-core HTTP writer and the direct-Redis fallback — because they used not
* to: only the HTTP path emitted the `cids/` key-set, so a meta-sort configured
* with `redisUrl` and no `metaCoreApiUrl` wrote raw `cid_midhash256` fields that
* meta-core never indexed. Records looked fine and could not be found by their
* own midhash. One function, one shape, no second path to forget.
*
* Returns field names relative to the record root (no `/file/{hashId}` prefix).
*/
export function buildRecordFields(
metadata: any,
excludeFields: string[] = []
): Record<string, string> {
const prefix = 'r'; // throwaway root; stripped below
const pairs = flattenMetadata(metadata, prefix, [...excludeFields, ...STRUCTURAL_FIELDS]);
const flat: Record<string, string> = {};
for (const pair of pairs) {
const field = pair.key.slice(prefix.length + 1); // +1 for the trailing '/'
if (field) {
flat[field] = pair.value;
}
}
// Sibling CIDs are a bare-CID key-set, never per-algorithm named fields.
// The midhash is just the member whose multicodec is 0x1000 — there is no
// `cid_midhash256`. See METADATA_KEYS.md §2/§14.13.
const cids: unknown = metadata?.cids;
if (Array.isArray(cids)) {
for (const cid of cids) {
if (typeof cid === 'string' && cid) {
flat[`cids/${cid}`] = 'true';
}
}
}
return flat;
}
export function flattenMetadata( export function flattenMetadata(
metadata: any, metadata: any,
prefix: string, prefix: string,
+130 -14
View File
@@ -16,11 +16,12 @@ import * as IORedis from 'ioredis';
import * as os from 'os'; import * as os from 'os';
import type { IKVClient, KeyValuePair } from './IKVClient.js'; import type { IKVClient, KeyValuePair } from './IKVClient.js';
import { import {
flattenMetadata, buildRecordFields,
reconstructMetadata, reconstructMetadata,
buildFilePrefix, buildFilePrefix,
buildPropertyKey, buildPropertyKey,
} from './MetadataUtils.js'; } from './MetadataUtils.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;
@@ -50,6 +51,18 @@ interface RedisClientConfig {
url: string; url: string;
timeout?: number; timeout?: number;
prefix?: string; prefix?: string;
/**
* meta-core HTTP API base URL. When set, write methods
* (setMetadataFlat / setMetadataProperty / deleteMetadataFlat) route
* through the /meta/{hash} HTTP family instead of writing to Redis
* directly. Reads and stream consumption still use the Redis connection.
*
* Wiring this is the meta-sort half of the api-mediated-access lockdown
* (see meta-core docs/api-mediated-access.md, PR B). Leaving it unset
* preserves the legacy direct-write path for tests / one-off scripts.
*/
metaCoreApiUrl?: string;
} }
export class RedisKVClient implements IKVClient { export class RedisKVClient implements IKVClient {
@@ -58,6 +71,7 @@ export class RedisKVClient implements IKVClient {
private timeout: number; private timeout: number;
private prefix: string; private prefix: string;
private isConnected: boolean = false; private isConnected: boolean = false;
private writer: MetaCoreApiWriter | null = null;
// Stream consumer state // Stream consumer state
private streamConsumerRunning = false; private streamConsumerRunning = false;
@@ -68,9 +82,47 @@ export class RedisKVClient implements IKVClient {
this.timeout = config.timeout || 30000; this.timeout = config.timeout || 30000;
this.prefix = config.prefix || ''; this.prefix = config.prefix || '';
if (config.metaCoreApiUrl) {
this.writer = new MetaCoreApiWriter({
apiUrl: config.metaCoreApiUrl,
timeoutMs: this.timeout,
});
console.log(`[Redis] Writes routed through meta-core API at ${config.metaCoreApiUrl}`);
}
// Unique consumer name for this instance // Unique consumer name for this instance
this.consumerName = `${os.hostname()}-${process.pid}`; this.consumerName = `${os.hostname()}-${process.pid}`;
// HTTP-only mode: no Redis URL AND a writer is configured. Create a
// disconnected stub Redis so accidental fallthrough into a Redis
// method throws immediately instead of silently connecting to
// localhost.
if (!config.url && this.writer) {
// ioredis requires a config object; lazyConnect prevents the
// automatic connect. Any caller that tries to use the Redis
// path (e.g. unmigrated stream code) will fail loudly rather
// than silently misbehaving.
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;
console.log('[Redis] HTTP-only mode — no direct Redis connection');
return;
}
// Parse Redis URL and create client // Parse Redis URL and create client
this.redis = new Redis(config.url, { this.redis = new Redis(config.url, {
commandTimeout: this.timeout, commandTimeout: this.timeout,
@@ -102,10 +154,22 @@ export class RedisKVClient implements IKVClient {
} }
/** /**
* Connect to Redis (call before using other methods) * Connect to Redis (call before using other methods).
*
* In HTTP-only mode (no Redis URL + writer configured), connect is a
* no-op — there's no underlying socket to open.
*/ */
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.isConnected) return; if (this.isConnected) return;
if (this.writer && !this.redis.options.host) {
// HTTP-only mode stub — nothing to connect.
return;
}
// Skip explicit connect when host is 127.0.0.1 with port 0 (HTTP-only stub).
const opts = this.redis.options as { host?: string; port?: number };
if (opts && opts.port === 0) {
return;
}
await this.redis.connect(); await this.redis.connect();
} }
@@ -163,6 +227,11 @@ export class RedisKVClient implements IKVClient {
} }
async health(): Promise<boolean> { async health(): Promise<boolean> {
if (this.writer) {
// Probe meta-core /health rather than Redis PING; that's the
// surface the application actually depends on after the lockdown.
return this.writer.health();
}
try { try {
const result = await this.redis.ping(); const result = await this.redis.ping();
return result === 'PONG'; return result === 'PONG';
@@ -271,24 +340,30 @@ export class RedisKVClient implements IKVClient {
metadata: any, metadata: any,
excludeFields: string[] = [] excludeFields: string[] = []
): Promise<void> { ): Promise<void> {
const prefix = buildFilePrefix(hashId); if (this.writer) {
const pairs = flattenMetadata(metadata, prefix, excludeFields); // PATCH /meta/{hash} — meta-core handles the flat-key fanout and
// the file:__index__ membership atomically server-side.
return this.writer.setMetadataFlat(hashId, metadata, excludeFields);
}
if (pairs.length === 0) return; // Direct-Redis fallback (no metaCoreApiUrl configured — see KVManager).
//
// This path used to flatten the metadata itself, which meant it skipped
// the `cid_* → cids/` transform that only the HTTP writer applied: it
// wrote a literal `file:<hash>/cid_midhash256` key. meta-core's
// reverse-index hook only registers aliases for fields named `cids/<cid>`,
// so those records were stored but never indexed — unresolvable by their
// own midhash, with nothing logged. Both paths now build the record
// through the same `buildRecordFields`, so the key-set cannot be skipped.
const flat = buildRecordFields(metadata, excludeFields);
if (Object.keys(flat).length === 0) return;
// Use pipeline for efficient batch SET operations // Use pipeline for efficient batch SET operations
const pipeline = this.redis.pipeline(); const pipeline = this.redis.pipeline();
for (const [field, value] of Object.entries(flat)) {
for (const pair of pairs) {
// Extract field name by removing the prefix
const field = pair.key.slice(prefix.length + 1); // +1 for trailing /
if (field) {
// Key format: file:{hashId}/{property} // Key format: file:{hashId}/{property}
const redisKey = this.buildKey(`file:${hashId}/${field}`); pipeline.set(this.buildKey(`file:${hashId}/${field}`), value);
pipeline.set(redisKey, pair.value);
} }
}
await pipeline.exec(); await pipeline.exec();
// Maintain index set for getAllHashIds // Maintain index set for getAllHashIds
@@ -296,6 +371,20 @@ export class RedisKVClient implements IKVClient {
} }
async getMetadataFlat(hashId: string): Promise<any | null> { async getMetadataFlat(hashId: string): Promise<any | null> {
if (this.writer) {
// GET /meta/{hash} via HTTP — returns the flat map directly,
// so we don't need a Redis SCAN/MGET round trip. Reconstruct
// the nested structure consumers expect.
const flat = await this.writer.getMetadataFlat(hashId);
if (!flat) return null;
const prefix = buildFilePrefix(hashId);
const pairs: KeyValuePair[] = Object.entries(flat).map(([field, value]) => ({
key: `${prefix}/${field}`,
value: value as string,
}));
return reconstructMetadata(pairs, prefix);
}
const keyPrefix = this.buildKey(`file:${hashId}/`); const keyPrefix = this.buildKey(`file:${hashId}/`);
// Scan for all keys with this prefix // Scan for all keys with this prefix
@@ -329,6 +418,13 @@ export class RedisKVClient implements IKVClient {
} }
async getMetadata(hashId: string, propertyPath: string): Promise<any | null> { async getMetadata(hashId: string, propertyPath: string): Promise<any | null> {
if (this.writer) {
// GET /meta/{hash}/{prop} via HTTP; meta-core returns text/plain
// and the writer mirrors the JSON-or-raw fallback the Redis path
// uses below.
return this.writer.getMetadata(hashId, propertyPath);
}
const key = this.buildKey(`file:${hashId}/${propertyPath}`); const key = this.buildKey(`file:${hashId}/${propertyPath}`);
const value = await this.redis.get(key); const value = await this.redis.get(key);
@@ -346,6 +442,10 @@ export class RedisKVClient implements IKVClient {
* Writes to flat key: file:{hashId}/{property} * Writes to flat key: file:{hashId}/{property}
*/ */
async setMetadataProperty(hashId: string, property: string, value: string): Promise<void> { async setMetadataProperty(hashId: string, property: string, value: string): Promise<void> {
if (this.writer) {
return this.writer.setMetadataProperty(hashId, property, value);
}
const key = this.buildKey(`file:${hashId}/${property}`); const key = this.buildKey(`file:${hashId}/${property}`);
await this.redis.set(key, value); await this.redis.set(key, value);
@@ -354,6 +454,10 @@ export class RedisKVClient implements IKVClient {
} }
async deleteMetadataFlat(hashId: string): Promise<number> { async deleteMetadataFlat(hashId: string): Promise<number> {
if (this.writer) {
return this.writer.deleteMetadataFlat(hashId);
}
const keyPrefix = this.buildKey(`file:${hashId}/`); const keyPrefix = this.buildKey(`file:${hashId}/`);
// Scan and delete all keys with this prefix // Scan and delete all keys with this prefix
@@ -378,10 +482,22 @@ export class RedisKVClient implements IKVClient {
} }
async getAllHashIds(): Promise<string[]> { async getAllHashIds(): Promise<string[]> {
if (this.writer) {
return this.writer.getAllHashIds();
}
const indexKey = this.buildKey('file:__index__'); const indexKey = this.buildKey('file:__index__');
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
// ======================================================================== // ========================================================================
@@ -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',
@@ -106,7 +106,7 @@ export class WatchedFileProcessor implements FileProcessorInterface {
// Map StreamingPipeline queues to the 3-queue architecture UI expects // Map StreamingPipeline queues to the 3-queue architecture UI expects
// PQueue: .pending = currently running, .size = waiting in queue // PQueue: .pending = currently running, .size = waiting in queue
// API expects: .pending = running, .size = waiting // API expects: .pending = running, .size = waiting
const result = { const result: any = {
preProcessQueue: { preProcessQueue: {
// validation = pre-process (quick extension checks) // validation = pre-process (quick extension checks)
size: queues.validation.size, // waiting in queue size: queues.validation.size, // waiting in queue
@@ -130,6 +130,15 @@ export class WatchedFileProcessor implements FileProcessorInterface {
// File-level counts (what users care about) // File-level counts (what users care about)
files: queues.files files: queues.files
}; };
// Layer in the per-file cache-slot view from the ContainerPluginScheduler
// when available — this is the metric that reflects WebDAV/cache load
// (number of distinct files concurrently being touched), not just task
// throughput. Older deployments without container plugins simply omit it.
const containerStatus = this.taskScheduler?.getContainerQueueStatus?.() ?? null;
if (containerStatus?.cacheSlots) {
result.cacheSlots = containerStatus.cacheSlots;
}
return result; return result;
} }
@@ -273,8 +282,8 @@ export class WatchedFileProcessor implements FileProcessorInterface {
); );
let midHash256: string; let midHash256: string;
if (indexLine?.cid_midhash256) { if (indexLine?.midhash256) {
midHash256 = indexLine.cid_midhash256; midHash256 = indexLine.midhash256;
performanceMetrics.recordCacheHit('midhash256'); performanceMetrics.recordCacheHit('midhash256');
} else { } else {
const computeStart = performance.now(); const computeStart = performance.now();
@@ -288,7 +297,7 @@ export class WatchedFileProcessor implements FileProcessorInterface {
return; return;
} }
midHash256 = hash; midHash256 = hash;
performanceMetrics.recordHashComputation('cid_midhash256', Math.ceil(performance.now() - computeStart)); performanceMetrics.recordHashComputation('midhash256', Math.ceil(performance.now() - computeStart));
performanceMetrics.recordCacheMiss('midhash256'); performanceMetrics.recordCacheMiss('midhash256');
// Store in cache for future lookups // Store in cache for future lookups
@@ -296,13 +305,20 @@ export class WatchedFileProcessor implements FileProcessorInterface {
filePath, filePath,
fileStats.size, fileStats.size,
fileStats.mtime.toISOString(), fileStats.mtime.toISOString(),
{ cid_midhash256: midHash256 } { midhash256: midHash256 }
); );
} }
// STEP 2: Initialize metadata with midhash256 // STEP 2: Initialize metadata with midhash256.
//
// The midhash plays two roles and now says so: it is the record
// address (`hashId`, the `/file/{hashId}` key) *and* one member of
// the record's bare-CID key-set (`cids`). It used to be a single
// `cid_midhash256` property, which conflated the two and leaked a
// deprecated field name onto the record. See METADATA_KEYS.md §14.13.
const metadata: any = { const metadata: any = {
cid_midhash256: midHash256, hashId: midHash256,
cids: [midHash256],
filePath, filePath,
processingStatus: 'processing' processingStatus: 'processing'
}; };
@@ -432,8 +448,8 @@ export class WatchedFileProcessor implements FileProcessorInterface {
const metadata = db.get(filePath); const metadata = db.get(filePath);
// Collect hashId before deletion (for Stremio notification) // Collect hashId before deletion (for Stremio notification)
if (metadata && metadata.cid_midhash256) { if (metadata && metadata.hashId) {
removedHashIds.push(metadata.cid_midhash256); removedHashIds.push(metadata.hashId);
} }
db.delete(filePath); db.delete(filePath);
@@ -191,9 +191,9 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
let midHash256: string | undefined; let midHash256: string | undefined;
const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString()); const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
if (indexLine && indexLine.cid_midhash256) { if (indexLine && indexLine.midhash256) {
// Cache hit! Use cached midhash256 (no network I/O needed) // Cache hit! Use cached midhash256 (no network I/O needed)
midHash256 = indexLine.cid_midhash256; midHash256 = indexLine.midhash256;
performanceMetrics.recordCacheHit('midhash256'); performanceMetrics.recordCacheHit('midhash256');
} else { } else {
// Cache miss - compute midhash256 (< 1 second, but requires network I/O on network drives) // Cache miss - compute midhash256 (< 1 second, but requires network I/O on network drives)
@@ -203,16 +203,22 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
const computeTime = Math.ceil(performance.now() - computeStart); const computeTime = Math.ceil(performance.now() - computeStart);
// Record midhash256 computation time (excludes cache hits) // Record midhash256 computation time (excludes cache hits)
performanceMetrics.recordHashComputation('cid_midhash256', computeTime); performanceMetrics.recordHashComputation('midhash256', computeTime);
performanceMetrics.recordCacheMiss('midhash256'); performanceMetrics.recordCacheMiss('midhash256');
// Store in cache for future lookups // Store in cache for future lookups
this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { cid_midhash256: midHash256 }); this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { midhash256: midHash256 });
} }
// Initialize metadata with midhash256 // Initialize metadata with midhash256.
//
// Two roles, now named separately: the record address (`hashId`, the
// `/file/{hashId}` key) and a member of the record's bare-CID key-set
// (`cids`). The old single `cid_midhash256` property conflated them and
// leaked a deprecated field name onto the record. See METADATA_KEYS.md §14.13.
const metadata: FileMetadata = { const metadata: FileMetadata = {
cid_midhash256: midHash256, hashId: midHash256,
cids: [midHash256],
processingStatus: 'processing' processingStatus: 'processing'
}; };
@@ -296,19 +302,16 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
metadata.processingStatus = 'complete'; metadata.processingStatus = 'complete';
this.database.set(filePath, metadata); this.database.set(filePath, metadata);
// STEP 5: Update metadata in KV with full hashes // STEP 5: Update metadata in KV.
// Full hashes (sha2-256, sha1, …) are computed by the full-hash
// container plugin and written to meta-core directly as cids/<cid>
// members — they are not held in this in-memory metadata. The
// midhash256 is the primary id; on a (rare) midhash collision the
// file simply shares the record address until the plugin's
// distinct cids/* members disambiguate it.
if (this.kvClient) { if (this.kvClient) {
try { try {
// Choose hash ID: Use SHA-256 if collision detected, otherwise midhash256 const hashId = midHash256;
let hashId: string;
if (collisionDetected && metadata['cid_sha2-256']) {
// Collision: Use full SHA-256 as primary ID
hashId = metadata['cid_sha2-256'];
console.log(` Using SHA-256 as primary ID for collision: ${hashId}`);
} else {
// Normal: Use midhash256 as primary ID
hashId = midHash256;
}
if (hashId) { if (hashId) {
// Prepare metadata with filePath added // Prepare metadata with filePath added
@@ -412,9 +415,9 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
// Check cache or compute locally // Check cache or compute locally
const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString()); const indexLine = this.indexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
if (indexLine && indexLine.cid_midhash256) { if (indexLine && indexLine.midhash256) {
// Cache hit! Use cached midhash256 (no network I/O needed) // Cache hit! Use cached midhash256 (no network I/O needed)
midHash256 = indexLine.cid_midhash256; midHash256 = indexLine.midhash256;
performanceMetrics.recordCacheHit('midhash256'); performanceMetrics.recordCacheHit('midhash256');
} else { } else {
// Cache miss - compute midhash256 locally (requires file access) // Cache miss - compute midhash256 locally (requires file access)
@@ -426,17 +429,23 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
const computeTime = Math.ceil(performance.now() - computeStart); const computeTime = Math.ceil(performance.now() - computeStart);
// Record midhash256 computation time (excludes cache hits) // Record midhash256 computation time (excludes cache hits)
performanceMetrics.recordHashComputation('cid_midhash256', computeTime); performanceMetrics.recordHashComputation('midhash256', computeTime);
performanceMetrics.recordCacheMiss('midhash256'); performanceMetrics.recordCacheMiss('midhash256');
// Store in cache for future lookups // Store in cache for future lookups
this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { cid_midhash256: midHash256 }); this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { midhash256: midHash256 });
} }
} }
// Initialize metadata with midhash256 // Initialize metadata with midhash256.
//
// Two roles, now named separately: the record address (`hashId`, the
// `/file/{hashId}` key) and a member of the record's bare-CID key-set
// (`cids`). The old single `cid_midhash256` property conflated them and
// leaked a deprecated field name onto the record. See METADATA_KEYS.md §14.13.
const metadata: FileMetadata = { const metadata: FileMetadata = {
cid_midhash256: midHash256, hashId: midHash256,
cids: [midHash256],
processingStatus: 'processing' processingStatus: 'processing'
}; };
@@ -527,7 +536,7 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
throw new Error('Metadata not found - light processing may have failed'); throw new Error('Metadata not found - light processing may have failed');
} }
const midHash256 = metadata.cid_midhash256; const midHash256 = metadata.hashId;
if (!midHash256) { if (!midHash256) {
throw new Error('midhash256 not found in metadata'); throw new Error('midhash256 not found in metadata');
} }
@@ -625,7 +634,7 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
// Delete from KV if configured (using nested key architecture) // Delete from KV if configured (using nested key architecture)
if (this.kvClient && metadata) { if (this.kvClient && metadata) {
try { try {
const hashId = metadata.cid_midhash256 || metadata['cid_sha2-256'] || metadata.cid_sha1 || metadata.cid_md5; const hashId = metadata.hashId;
// Delete all properties for this file using nested key architecture // Delete all properties for this file using nested key architecture
if (hashId) { if (hashId) {
@@ -1,4 +1,4 @@
import {CID_ALGORITHM_NAMES, HashComputerFile} from "@metazla/meta-hash"; import {CidAlgorithm, HashComputerFile, MultiHashData, pickCidByAlgorithm} from "@metazla/meta-hash";
import {HashIndexManager} from "@metazla/meta-hash"; import {HashIndexManager} from "@metazla/meta-hash";
import {config} from "../../config/EnvConfig.js"; import {config} from "../../config/EnvConfig.js";
import {targetHash, targetHashForIndex} from "../../config/TargetHash.js"; import {targetHash, targetHashForIndex} from "../../config/TargetHash.js";
@@ -16,15 +16,30 @@ class SimpleFileToCid{
if (!stats || !stats.exists) { if (!stats || !stats.exists) {
return null; return null;
} }
let indexLine = globalHashIndex.getCidForFile(filePath, stats.size, stats.mtime.toISOString()); const indexLine = globalHashIndex.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
if(indexLine) { if (indexLine) {
return indexLine[CID_ALGORITHM_NAMES.sha256]; // The on-disk index keeps per-algorithm columns, keyed by bare
}else { // algorithm name (not the removed `cid_<algo>` record fields).
let hashs = {}; return indexLine['sha256'] ?? null;
await globalHashComputer.computeMissingHash(filePath, hashs);
globalHashIndex.addFileCid(filePath, stats.size, stats.mtime.toISOString(), hashs);
return hashs[CID_ALGORITHM_NAMES.sha256];
} }
// The computer returns bare CIDs; the algorithm is recovered from the
// multicodec, never from a field name.
const hashes: MultiHashData = {};
await globalHashComputer.computeMissingHash(filePath, hashes);
const columns: Partial<Record<CidAlgorithm, string>> = {};
for (const algo of targetHash) {
const cid = pickCidByAlgorithm(hashes.cids, algo);
if (cid) {
columns[algo] = cid;
}
}
if (Object.keys(columns).length > 0) {
globalHashIndex.addFileCid(filePath, stats.size, stats.mtime.toISOString(), columns);
}
return pickCidByAlgorithm(hashes.cids, 'sha256') ?? null;
} catch (e) { } catch (e) {
return null; return null;
} }
@@ -166,14 +166,14 @@ export class StreamingPipeline {
// After light processing, dispatch to plugins and queue background tasks // After light processing, dispatch to plugins and queue background tasks
const metadata = this.config.fileProcessor.getDatabase().get(filePath); const metadata = this.config.fileProcessor.getDatabase().get(filePath);
if (metadata && metadata.cid_midhash256) { if (metadata && metadata.hashId) {
// Notify Stremio addon to add video // Notify Stremio addon to add video
this.notifyStremioAdd(metadata.cid_midhash256).catch(err => { this.notifyStremioAdd(metadata.hashId).catch(err => {
// Silent fail - Stremio addon may not be running // Silent fail - Stremio addon may not be running
}); });
// Dispatch tasks to container plugins (fire and forget) // Dispatch tasks to container plugins (fire and forget)
this.dispatchContainerPluginTasks(filePath, metadata.cid_midhash256, metadata); this.dispatchContainerPluginTasks(filePath, metadata.hashId, metadata);
// Stage 3: Queue for background processing (SHA-256 computation) // Stage 3: Queue for background processing (SHA-256 computation)
this.backgroundQueue.add(() => this.processHashPhase(filePath)) this.backgroundQueue.add(() => this.processHashPhase(filePath))
@@ -246,7 +246,7 @@ export class StreamingPipeline {
// Otherwise, wait for 'file:complete' event from ContainerPluginScheduler. // Otherwise, wait for 'file:complete' event from ContainerPluginScheduler.
if (!this.config.containerPluginScheduler) { if (!this.config.containerPluginScheduler) {
const metadata = this.config.fileProcessor.getDatabase().get(filePath); const metadata = this.config.fileProcessor.getDatabase().get(filePath);
const hashId = metadata?.cid_midhash256; const hashId = metadata?.hashId;
let virtualPath: string | undefined; let virtualPath: string | undefined;
try { try {
virtualPath = metadata ? this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath) : undefined; virtualPath = metadata ? this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath) : undefined;
@@ -584,6 +584,16 @@ export class TaskScheduler extends EventEmitter {
return []; return [];
} }
/**
* Pass-through to ContainerPluginScheduler.getQueueStatus so callers like
* WatchedFileProcessor can surface the file-cache-slot view (capacity,
* holders, current plugin per file) without reaching past the scheduler
* boundary themselves.
*/
getContainerQueueStatus(): ReturnType<ContainerPluginScheduler['getQueueStatus']> | null {
return this.containerPluginScheduler?.getQueueStatus() ?? null;
}
// ========================================================================= // =========================================================================
// Shutdown // Shutdown
// ========================================================================= // =========================================================================
@@ -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
* *
@@ -102,13 +118,18 @@ export interface ExtendedHashMeta extends HashMeta {
*/ */
export interface IndexManager { export interface IndexManager {
/** /**
* Get cached CID for a file based on path, size, and mtime * Get cached CID for a file based on path, size, and mtime.
*
* Structural mirror of meta-hash's `HashIndexManager`. Its columns are keyed
* by bare **algorithm** (`midhash256`, `sha256`), not by the removed
* `cid_<algo>` record-field names — the local index and the record shape are
* deliberately separate concerns now. See METADATA_KEYS.md §14.13.
*/ */
getCidForFile( getCidForFile(
path: string, path: string,
size: number, size: number,
mtime: string mtime: string
): { cid_midhash256?: string } | undefined; ): { midhash256?: string } | undefined;
/** /**
* Add a file CID to the cache * Add a file CID to the cache
@@ -117,7 +138,7 @@ export interface IndexManager {
path: string, path: string,
size: number, size: number,
mtime: string, mtime: string,
cid: { cid_midhash256: string } cid: { midhash256: string }
): void; ): void;
} }
@@ -18,4 +18,28 @@ export type ProcessingStatus = 'pending' | 'processing' | 'complete' | 'error';
export interface FileMetadata extends HashMeta { export interface FileMetadata extends HashMeta {
/** Current status of file processing */ /** Current status of file processing */
processingStatus?: ProcessingStatus; processingStatus?: ProcessingStatus;
/**
* The record address: the file's midhash256 CID, computed in the light phase
* and used as the `hashId` throughout meta-sort and as the key of the
* `/file/{hashId}` record.
*
* This replaces the old `cid_midhash256` field. The rename is the point: that
* name was a *metadata property* name, and it leaked — a `cid_*` field written
* to a record is stored but never reverse-indexed by meta-core, leaving the
* record unresolvable by its own CID. meta-core now rejects such fields with a
* 400. See METADATA_KEYS.md §1/§14.13.
*/
hashId?: string;
/**
* Every digest known for this file, as **bare CIDv1 strings** — the midhash
* included. Persisted as the key-set `cids/<cid> = "true"`, which is the only
* shape meta-core indexes.
*
* A CIDv1 is self-describing, so a specific digest is recovered by decoding
* the multicodec (`pickCidByAlgorithm` from `@metazla/meta-hash`), never by a
* field name.
*/
cids?: string[];
} }
@@ -365,27 +365,115 @@ function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): b
return false; return false;
} }
// Human label per multihash code / CID codec. Source of truth for codes:
// meta-hash MultiHashData + METADATA_KEYS.md §2.
const CID_LABELS: Record<number, string> = {
0x12: 'SHA-256',
0x16: 'SHA3-256',
0x15: 'SHA3-384',
0x11: 'SHA-1',
0xd5: 'MD5',
0x132: 'CRC32',
0x1000: 'MidHash256',
0xb702: 'BT Pieces Root',
0x70: 'IPFS (dag-pb)',
0x10b7: 'BitTorrent v2',
0x1001: 'BitTorrent v1 (file)',
0x1002: 'BitTorrent v2 (file)',
0x1005: 'Usenet release',
0x1006: 'URL',
};
/** /**
* Extract all hash values (cid_*) from metadata * Display order for a record's CIDs. This mirrors the canonical rank ladder
* (`packages/meta-core/internal/cid/rank.go`, the two `cid_rank.rs`, and the
* `/cid-rank-vectors.json` golden fixture) — same ordering, scaled down.
*
* It used to be its own invented ladder (5/4/3/2) with no dag-pb entry at all,
* which meant an IPFS root fell through to 0 and sorted *below* MD5. Display-only,
* so it never broke anything — but it was a seventh, silently divergent copy of a
* rule that already has too many. Keep it in step: a code with no entry sorts last.
*/
const RANK_BY_LABEL: Record<string, number> = {
'IPFS (dag-pb)': 6,
'SHA-256': 5, 'SHA3-256': 5, 'SHA3-384': 5,
'BitTorrent v2': 4, 'BitTorrent v1 (file)': 4, 'BitTorrent v2 (file)': 4,
MidHash256: 3,
'Usenet release': 2, URL: 2,
'SHA-1': 1, MD5: 1, CRC32: 1,
};
const B32 = 'abcdefghijklmnopqrstuvwxyz234567';
function decodeBase32(s: string): Uint8Array | null {
let bits = 0, value = 0;
const out: number[] = [];
for (const ch of s) {
const idx = B32.indexOf(ch);
if (idx < 0) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
function readUvarint(buf: Uint8Array, pos: number): [number, number] {
let result = 0, shift = 0, p = pos;
while (p < buf.length) {
const b = buf[p++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) return [result >>> 0, p];
shift += 7;
}
return [0, pos];
}
/**
* Decode a bare multibase-base32 CIDv1 into [codec, multihashCode], or null.
* The algorithm is recovered from these — sibling CIDs are stored as a
* key-set (cids/<cid>) with no per-algorithm field name.
*/
function decodeCid(cid: string): { codec: number; mh: number } | null {
if (!cid.startsWith('b')) return null;
const bytes = decodeBase32(cid.slice(1));
if (!bytes || bytes.length < 3) return null;
let v: number, pos = 0;
[v, pos] = readUvarint(bytes, pos);
if (v !== 1) return null;
let codec: number;
[codec, pos] = readUvarint(bytes, pos);
let mh: number;
[mh, pos] = readUvarint(bytes, pos);
return { codec, mh };
}
function cidLabel(cid: string): string {
const d = decodeCid(cid);
if (!d) return 'CID';
// Per-file BitTorrent codecs are carried on the CID codec, not the mh code.
return CID_LABELS[d.codec] ?? CID_LABELS[d.mh] ?? 'CID';
}
/**
* Extract all sibling CIDs from the record's bare-CID key-set (cids/<cid>),
* labelling each by its multicodec. Replaces the old per-algorithm cid_*
* fields. See METADATA_KEYS.md §2/§14.13.
*/ */
function getHashValues(metadata: FileMetadata): { key: string; label: string; value: string }[] { function getHashValues(metadata: FileMetadata): { key: string; label: string; value: string }[] {
const hashOrder = [ const out: { key: string; label: string; value: string }[] = [];
{ key: 'cid_sha2-256', label: 'SHA-256' }, for (const key of Object.keys(metadata as any)) {
{ key: 'cid_sha3-256', label: 'SHA3-256' }, if (!key.startsWith('cids/')) continue;
{ key: 'cid_sha3-384', label: 'SHA3-384' }, const cid = key.slice('cids/'.length);
{ key: 'cid_sha1', label: 'SHA-1' }, if (!cid) continue;
{ key: 'cid_md5', label: 'MD5' }, out.push({ key, label: cidLabel(cid), value: cid });
{ key: 'cid_crc32', label: 'CRC32' }, }
{ key: 'cid_btih_v2', label: 'BitTorrent v2' }, // Most useful first (sha/ipfs > btih > midhash > weak digests), then by label.
]; out.sort((a, b) => (RANK_BY_LABEL[b.label] ?? 0) - (RANK_BY_LABEL[a.label] ?? 0) || a.label.localeCompare(b.label));
return out;
return hashOrder
.filter(({ key }) => (metadata as any)[key])
.map(({ key, label }) => ({
key,
label,
value: (metadata as any)[key],
}));
} }
/** /**
+4 -7
View File
@@ -41,13 +41,10 @@ export interface FileMetadata {
collectionnumber?: number; collectionnumber?: number;
criticrating?: number; criticrating?: number;
// Hashes // Sibling CIDs are the bare-CID key-set `cids/<cid> = "true"` (dynamic
cid_crc32?: string; // keys), not per-algorithm fields. Accessed via prefix-scan; the algorithm
cid_md5?: string; // is recovered from each CID's multicodec. See METADATA_KEYS.md §2/§14.13.
cid_sha1?: string; [cidsKey: `cids/${string}`]: string | undefined;
'cid_sha2-256'?: string;
'cid_sha3-256'?: string;
cid_sha3_384?: string;
// Video info // Video info
video?: { video?: {
+109 -34
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { import {
ProcessingStatus, ProcessingStatus,
Metrics, Metrics,
RedisStats, RedisStats,
QueueItem, QueueItem,
HashTimingStats, HashTimingStats,
FailedFile FailedFile,
CacheSlotPhase
} from '../types'; } from '../types';
import { import {
formatMs, formatMs,
@@ -14,6 +15,43 @@ import {
getFilename getFilename
} from '../utils/format'; } from '../utils/format';
/**
* Renders a single phase of the file-cache-slot view: capacity bar, count of
* waiting files, and a list of currently-held files showing which plugin
* each is currently waiting on. The plugin column is the long-pole — it tells
* operators which plugin is the per-file bottleneck.
*/
function CacheSlotPanel({ label, phase }: { label: string; phase: CacheSlotPhase }) {
const fillPct = Math.min(100, (phase.inUse / Math.max(1, phase.capacity)) * 100);
return (
<div style={{ marginTop: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong>{label}</strong>
<span style={{ color: '#9ca3af', fontSize: '0.85rem' }}>
{phase.inUse}/{phase.capacity} files
{phase.waiting > 0 ? ` • ${phase.waiting} waiting for slot` : ''}
</span>
</div>
<div style={{ height: '6px', background: '#0f3460', borderRadius: '3px', overflow: 'hidden', margin: '0.25rem 0 0.5rem' }}>
<div style={{ width: `${fillPct}%`, height: '100%', background: '#4ade80', transition: 'width 0.5s linear' }} />
</div>
{phase.holders.length > 0 && (
<div style={{ fontSize: '0.78rem', color: '#cbd5e1', maxHeight: '160px', overflow: 'auto' }}>
{phase.holders.map((h) => (
<div key={h.fileHash} style={{ display: 'flex', gap: '0.5rem', padding: '2px 0' }}>
<span style={{ color: '#facc15', minWidth: '8rem' }}>{h.currentPlugin ?? '—'}</span>
<span style={{ color: '#94a3b8', minWidth: '4rem' }}>×{h.tasksRemaining}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={h.filePath ?? h.fileHash}>
{h.filePath ? getFilename(h.filePath) : h.fileHash}
</span>
</div>
))}
</div>
)}
</div>
);
}
function Monitor() { function Monitor() {
const [status, setStatus] = useState<ProcessingStatus | null>(null); const [status, setStatus] = useState<ProcessingStatus | null>(null);
const [metrics, setMetrics] = useState<Metrics | null>(null); const [metrics, setMetrics] = useState<Metrics | null>(null);
@@ -22,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());
if (failedRes.ok) {
const failedData = await failedRes.json();
setFailedFiles(failedData.failedFiles || []);
} }
} catch (err) { } finally {
console.error('Failed to fetch data:', err); pollInFlight.current = false;
} }
}, []); }, []);
@@ -107,31 +151,50 @@ function Monitor() {
// Get queue statistics for display // Get queue statistics for display
// StreamingPipeline stages: validation → lightProcessing → hashProcessing // StreamingPipeline stages: validation → lightProcessing → hashProcessing
// UI mapping: Validation → Fast Queue → Background Queue // UI mapping: Validation → Fast Queue → Background Queue
//
// Fast/Background "Running/Max" represents file-cache slot occupancy
// (cacheSlots.inUse / capacity), not raw task throughput. This matches the
// operator mental model: "how many files are currently tying up the WebDAV
// cache?" The task-callback count was misleading because dependency-wait
// and per-plugin HTTP queueing made it under-report by ~5×.
const getQueueStats = () => { const getQueueStats = () => {
const computed = status?.computed; const computed = status?.computed;
const cacheSlots = status?.queueStatus?.cacheSlots;
const fastQueueConcurrency = status?.fastQueueConcurrency || 16; const fastQueueConcurrency = status?.fastQueueConcurrency || 16;
const backgroundQueueConcurrency = status?.backgroundQueueConcurrency || 8; const backgroundQueueConcurrency = status?.backgroundQueueConcurrency || 8;
return { return {
// Stage 1: Validation (quick extension checks) // Stage 1: Validation (quick extension checks). Still task-level
// Concurrency: 2x fast queue workers // — pre-process is a lightweight midhash256 scan, no cache impact.
preProcess: { preProcess: {
running: computed?.preProcessRunning || 0, running: computed?.preProcessRunning || 0,
pending: computed?.preProcessPending || 0, pending: computed?.preProcessPending || 0,
max: fastQueueConcurrency * 2, max: fastQueueConcurrency * 2,
paused: computed?.preProcessPaused || false paused: computed?.preProcessPaused || false
}, },
// Stage 2: Fast Queue (midhash256 + FFmpeg + plugins) // Stage 2: Fast Queue — files holding a light cache slot.
// Concurrency: fastQueueConcurrency workers // running = files in cache, pending = files waiting for a slot.
fast: { // paused comes from the cacheSlots payload (true ContainerPluginScheduler
// PQueue state) — the legacy `computed.fastQueuePaused` field is sourced
// from a different StreamingPipeline queue and is unreliable here.
fast: cacheSlots ? {
running: cacheSlots.fast.inUse,
pending: cacheSlots.fast.waiting,
max: cacheSlots.fast.capacity,
paused: cacheSlots.fast.paused
} : {
running: computed?.fastQueueRunning || 0, running: computed?.fastQueueRunning || 0,
pending: computed?.fastQueuePending || 0, pending: computed?.fastQueuePending || 0,
max: fastQueueConcurrency, max: fastQueueConcurrency,
paused: computed?.fastQueuePaused || false paused: computed?.fastQueuePaused || false
}, },
// Stage 3: Background Queue (SHA-256 full file hash) // Stage 3: Background Queue — files holding a bg cache slot.
// Concurrency: backgroundQueueConcurrency workers background: cacheSlots ? {
background: { running: cacheSlots.background.inUse,
pending: cacheSlots.background.waiting,
max: cacheSlots.background.capacity,
paused: cacheSlots.background.paused
} : {
running: computed?.backgroundQueueRunning || 0, running: computed?.backgroundQueueRunning || 0,
pending: computed?.backgroundQueuePending || 0, pending: computed?.backgroundQueuePending || 0,
max: backgroundQueueConcurrency, max: backgroundQueueConcurrency,
@@ -165,9 +228,9 @@ function Monitor() {
{showPipelineHelp && ( {showPipelineHelp && (
<div className="pipeline-help"> <div className="pipeline-help">
<div className="help-item"><span className="help-dot discovered"></span><strong>Discovered:</strong> Files found on disk, waiting to be processed.</div> <div className="help-item"><span className="help-dot discovered"></span><strong>Discovered:</strong> Files found on disk, waiting to be processed.</div>
<div className="help-item"><span className="help-dot fast"></span><strong>Fast Queue:</strong> Files being processed for quick metadata extraction (hash, ffmpeg, etc.).</div> <div className="help-item"><span className="help-dot fast"></span><strong>Fast Queue:</strong> Files holding a light cache slot. The badge shows slots-in-use over the gate capacity (e.g. 16/16). Pending = files waiting for a slot to free.</div>
<div className="help-item"><span className="help-dot awaiting-background"></span><strong>Awaiting BG:</strong> Files done with fast processing, queued for slower tasks.</div> <div className="help-item"><span className="help-dot awaiting-background"></span><strong>Awaiting BG:</strong> Files done with fast processing, queued for slower tasks.</div>
<div className="help-item"><span className="help-dot background"></span><strong>Background:</strong> Files running slow tasks like full file hashing or TMDB lookups.</div> <div className="help-item"><span className="help-dot background"></span><strong>Background:</strong> Files holding a background cache slot (full-hash, TMDB, etc.). Same slot-occupancy semantics as Fast Queue.</div>
<div className="help-item"><span className="help-dot done"></span><strong>Complete:</strong> Files fully processed and available in the virtual filesystem.</div> <div className="help-item"><span className="help-dot done"></span><strong>Complete:</strong> Files fully processed and available in the virtual filesystem.</div>
<div className="help-item"><span className="help-dot failed"></span><strong>Failed:</strong> Files that encountered errors during processing.</div> <div className="help-item"><span className="help-dot failed"></span><strong>Failed:</strong> Files that encountered errors during processing.</div>
</div> </div>
@@ -301,6 +364,18 @@ function Monitor() {
</div> </div>
)} )}
{status?.queueStatus?.cacheSlots && (
<div className="card cache-slot-section" style={{ marginTop: '1rem' }}>
<h2>File cache slots</h2>
<p style={{ color: '#888', fontSize: '0.85rem', marginTop: '-0.5rem' }}>
Number of distinct files concurrently held by the WebDAV cache per phase. Caps how
many file fetches CORN/SMB sees at once, regardless of plugin fan-out.
</p>
<CacheSlotPanel label="Light (fast queue)" phase={status.queueStatus.cacheSlots.fast} />
<CacheSlotPanel label="Background" phase={status.queueStatus.cacheSlots.background} />
</div>
)}
<div className="grid grid-2"> <div className="grid grid-2">
{/* Metrics and Stats */} {/* Metrics and Stats */}
<div className="card"> <div className="card">
@@ -341,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">
@@ -363,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>
+28 -5
View File
@@ -75,6 +75,29 @@ export interface TaskSchedulerQueueStatus {
preProcessQueue?: TaskQueueStatus; preProcessQueue?: TaskQueueStatus;
fastQueue?: TaskQueueStatus; fastQueue?: TaskQueueStatus;
backgroundQueue?: TaskQueueStatus; backgroundQueue?: TaskQueueStatus;
cacheSlots?: CacheSlotsStatus;
}
export interface CacheSlotHolder {
fileHash: string;
filePath?: string;
currentPlugin?: string;
tasksRemaining: number;
}
export interface CacheSlotPhase {
capacity: number;
inUse: number;
waiting: number;
/** True when the underlying PQueue is paused — bg phase is paused while
* fast has work, so its slots can be "reserved" without active dispatch. */
paused: boolean;
holders: CacheSlotHolder[];
}
export interface CacheSlotsStatus {
fast: CacheSlotPhase;
background: CacheSlotPhase;
} }
export interface ProcessingStatus { export interface ProcessingStatus {
@@ -161,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';
@@ -0,0 +1,86 @@
import {CID} from 'multiformats/cid';
import {CID_ALGORITHM_CODES, CidAlgorithm} from '../hash-compute/MultiHashData';
/**
* Decoding a bare CIDv1 back into the algorithm that produced it.
*
* This is **the** TypeScript CID decoder for the platform. It exists because a
* bare-CID key-set (`cids/<cid>`) carries no field names — a consumer that wants
* "the sha2-256 of this file" has to ask the multicodec, not a string.
*
* Before this module, meta-dup carried a private hand-rolled base32+uvarint
* decoder, the meta-sort editor carried a second one with a *different* rank
* ladder, and meta-fuse had none at all (so it could only read the legacy
* `cid_midhash256` field, and silently failed on any record that didn't have
* one). One implementation, exported, replaces all three.
*
* ⚠ **Always match on the multihash code, never the content codec.** MetaMesh
* has two encoders that disagree about the codec: meta-hash writes
* `codec == algorithm code`, while the fullhash plugin and the gateway wrap
* every digest with the raw codec `0x55`. The multihash code is the algorithm in
* both. (`packages/meta-core/internal/cid/rank.go` documents the same trap.)
*/
/** The multihash function code of a bare CID, or `undefined` if it won't parse. */
export function cidMultihashCode(cid: string): number | undefined {
try {
return CID.parse(cid).multihash.code;
} catch {
return undefined;
}
}
/** The content codec of a bare CID, or `undefined` if it won't parse. */
export function cidCodec(cid: string): number | undefined {
try {
return CID.parse(cid).code;
} catch {
return undefined;
}
}
/**
* The first member of `cids` whose multihash code is `mhCode`.
*
* Takes the raw code so callers can ask for digests meta-hash doesn't itself
* compute (e.g. the gateway's per-file btih codes).
*/
export function pickCidByMulticodec(
cids: readonly string[] | undefined,
mhCode: number
): string | undefined {
return cids?.find(cid => cidMultihashCode(cid) === mhCode);
}
/** The member of `cids` produced by `algo`, if present. */
export function pickCidByAlgorithm(
cids: readonly string[] | undefined,
algo: CidAlgorithm
): string | undefined {
return pickCidByMulticodec(cids, CID_ALGORITHM_CODES[algo]);
}
/**
* Has `algo` already been computed for this cid set?
*
* The "skip if already computed" check. It used to be an O(1) property lookup
* (`metadata['cid_sha2-256']`); on a bare list it costs a decode per member.
* That is a handful of microseconds against a hash computation that streams the
* whole file — the trade is not close.
*/
export function hasAlgorithm(cids: readonly string[] | undefined, algo: CidAlgorithm): boolean {
return pickCidByAlgorithm(cids, algo) !== undefined;
}
/**
* Add `cid` to `cids` unless an equal string is already there.
*
* Dedup is on the exact string. Two encoders can spell the same digest
* differently (codec 0x55 vs codec == mh), and both spellings are legitimate
* members — meta-core's reverse index maps each to the same record.
*/
export function addCid(cids: string[], cid: string): void {
if (cid && !cids.includes(cid)) {
cids.push(cid);
}
}
@@ -1,12 +0,0 @@
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData";
export const codeTable = {
[CID_ALGORITHM_NAMES.crc32]: CID_ALGORITHM_CODES.crc32,
[CID_ALGORITHM_NAMES.md5]: CID_ALGORITHM_CODES.md5,
[CID_ALGORITHM_NAMES.sha1]: CID_ALGORITHM_CODES.sha1,
[CID_ALGORITHM_NAMES.sha256]: CID_ALGORITHM_CODES.sha256,
[CID_ALGORITHM_NAMES.sha3_256]: CID_ALGORITHM_CODES.sha3_256,
[CID_ALGORITHM_NAMES.sha3_384]: CID_ALGORITHM_CODES.sha3_384,
[CID_ALGORITHM_NAMES.midhash256]: CID_ALGORITHM_CODES.midhash256,
[CID_ALGORITHM_NAMES.btih_v2]: CID_ALGORITHM_CODES.btih_v2
}
@@ -1,21 +1,20 @@
import {CID_ALGORITHM_CODES, CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData"; import {CID_ALGORITHM_CODES, CidAlgorithm} from "../hash-compute/MultiHashData";
import {create} from "multiformats/hashes/digest"; import {create} from "multiformats/hashes/digest";
import {CID} from 'multiformats/cid'; import {CID} from 'multiformats/cid';
import {SimpleHash} from "./SimpleHash"; import {SimpleHash} from "./SimpleHash";
import {codeTable} from "@root/file-id/CodeTable";
/** /**
* Compute the CIDs of a file using specific algorithms * Compute the CIDs of a file using specific algorithms
* @param stream The Readable stream of the file * @param stream The Readable stream of the file
* @param algorithms Array of algorithms ('sha256', 'sha1', etc.) * @param algorithms Array of algorithms ('sha256', 'sha1', etc.)
* @returns Array of CIDs (in the order of the algorithms) * @returns Array of bare CIDv1 strings, in the order of `algorithms`
*/ */
export async function computeCIDs({stream, algorithms,createHasher}: { export async function computeCIDs({stream, algorithms, createHasher}: {
stream: ReadableStream<Uint8Array>; stream: ReadableStream<Uint8Array>;
algorithms: CID_ALGORITHM_NAMES[]; algorithms: CidAlgorithm[];
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash> createHasher: (algo: CidAlgorithm) => Promise<SimpleHash>
}): Promise<string[]> { }): Promise<string[]> {
const hashers = await hasherDefiner(algorithms,createHasher); const hashers = await hasherDefiner(algorithms, createHasher);
const reader = stream.getReader(); const reader = stream.getReader();
while (true) { while (true) {
const {done, value} = await reader.read(); const {done, value} = await reader.read();
@@ -28,19 +27,27 @@ export async function computeCIDs({stream, algorithms,createHasher}: {
return cidFinalize(hashers); return cidFinalize(hashers);
} }
async function hasherDefiner(algorithms: CID_ALGORITHM_NAMES[], async function hasherDefiner(algorithms: CidAlgorithm[],
createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>): Promise<{ createHasher: (algo: CidAlgorithm) => Promise<SimpleHash>): Promise<{
hasher: SimpleHash, hasher: SimpleHash,
code: CID_ALGORITHM_CODES code: CID_ALGORITHM_CODES
}[]> { }[]> {
const hashers = algorithms.filter(algo => Object.values(CID_ALGORITHM_NAMES).includes(algo)) const hashers = algorithms
.filter(algo => algo in CID_ALGORITHM_CODES)
.map(async algo => ({ .map(async algo => ({
hasher: await createHasher(algo), hasher: await createHasher(algo),
code: codeTable[algo] code: CID_ALGORITHM_CODES[algo]
})); }));
return await Promise.all(hashers); return await Promise.all(hashers);
} }
/**
* Encoder convention: meta-hash sets `codec == multihash code`. The fullhash
* plugin and the gateway instead use the raw codec (0x55) and carry the
* algorithm only in the multihash. Both are valid CIDs for the same digest, and
* both must decode to the same algorithm — which is why every consumer selects
* on the **multihash code**, never the codec. See `CidDecode.ts`.
*/
async function cidFinalize(hashers: { hasher: SimpleHash, code: number }[]): Promise<string[]> { async function cidFinalize(hashers: { hasher: SimpleHash, code: number }[]): Promise<string[]> {
return await Promise.all(hashers.map(async ({code, hasher}) => { return await Promise.all(hashers.map(async ({code, hasher}) => {
const hashBuffer = await hasher.digest(); const hashBuffer = await hasher.digest();
@@ -1,29 +1,30 @@
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData"; import {CidAlgorithm} from "../hash-compute/MultiHashData";
import {SimpleHash} from "./SimpleHash"; import {SimpleHash} from "./SimpleHash";
import {createHash} from "crypto"; import {createHash} from "crypto";
import {Crc32Hash} from "./Crc32Hash"; import {Crc32Hash} from "./Crc32Hash";
import {BtihV2Hasher} from "./BtihV2Hasher"; import {BtihV2Hasher} from "./BtihV2Hasher";
/** /**
* Compute the CIDs of a file using specific algorithms * Build the stream hasher for one algorithm.
* @returns Array of CIDs (in the order of the algorithms) *
* @param algo * `midhash256` is absent on purpose: it is not a stream digest (it hashes the
* file size plus the middle 1 MB), so it is produced by `FastHash.ts` instead.
*/ */
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> { export async function createHasher(algo: CidAlgorithm): Promise<SimpleHash> {
switch (algo) { switch (algo) {
case CID_ALGORITHM_NAMES.sha256: case 'sha256':
return createHash("sha256"); return createHash("sha256");
case CID_ALGORITHM_NAMES.sha1: case 'sha1':
return createHash("sha1"); return createHash("sha1");
case CID_ALGORITHM_NAMES.md5: case 'md5':
return createHash("md5"); return createHash("md5");
case CID_ALGORITHM_NAMES.sha3_256: case 'sha3_256':
return createHash("sha3-256"); return createHash("sha3-256");
case CID_ALGORITHM_NAMES.sha3_384: case 'sha3_384':
return createHash("sha3-384"); return createHash("sha3-384");
case CID_ALGORITHM_NAMES.crc32: case 'crc32':
return new Crc32Hash(); return new Crc32Hash();
case CID_ALGORITHM_NAMES.btih_v2: case 'btih_v2':
return new BtihV2Hasher(); return new BtihV2Hasher();
default: default:
throw new Error(`Unsupported algorithm: ${algo}`); throw new Error(`Unsupported algorithm: ${algo}`);
@@ -1,22 +1,27 @@
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData"; import {CidAlgorithm} from "../hash-compute/MultiHashData";
import {SimpleHash} from "./SimpleHash"; import {SimpleHash} from "./SimpleHash";
import {Crc32Hash} from "./Crc32Hash"; import {Crc32Hash} from "./Crc32Hash";
import {BrowserHasher} from "@root/file-id/BrowserHasher"; import {BrowserHasher} from "@root/file-id/BrowserHasher";
export async function createHasher(algo: CID_ALGORITHM_NAMES): Promise<SimpleHash> { /**
* Browser build. Note it supports fewer algorithms than the node one: there is
* no `btih_v2` (and no `midhash256`, which is never a stream hash) — asking for
* either throws.
*/
export async function createHasher(algo: CidAlgorithm): Promise<SimpleHash> {
const hasher = (() => { const hasher = (() => {
switch (algo) { switch (algo) {
case CID_ALGORITHM_NAMES.sha256: case 'sha256':
return new BrowserHasher('sha-256'); return new BrowserHasher('sha-256');
case CID_ALGORITHM_NAMES.sha1: case 'sha1':
return new BrowserHasher('sha-1'); return new BrowserHasher('sha-1');
case CID_ALGORITHM_NAMES.md5: case 'md5':
return new BrowserHasher('md5'); return new BrowserHasher('md5');
case CID_ALGORITHM_NAMES.sha3_256: case 'sha3_256':
return new BrowserHasher('sha3-256'); return new BrowserHasher('sha3-256');
case CID_ALGORITHM_NAMES.sha3_384: case 'sha3_384':
return new BrowserHasher('sha3-384'); return new BrowserHasher('sha3-384');
case CID_ALGORITHM_NAMES.crc32: case 'crc32':
return new Crc32Hash(); return new Crc32Hash();
default: default:
throw new Error(`Unsupported algorithm: ${algo}`); throw new Error(`Unsupported algorithm: ${algo}`);
@@ -1,5 +1,5 @@
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData"; import {CidAlgorithm} from "@root/hash-compute/MultiHashData";
export interface FileIDComputerInterface{ export interface FileIDComputerInterface{
computeCIDs(filePath: ReadableStream<Uint8Array>, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]>; computeCIDs(filePath: ReadableStream<Uint8Array>, algorithms: CidAlgorithm[]): Promise<string[]>;
} }
@@ -1,10 +1,16 @@
import {Piscina} from "piscina"; import {Piscina} from "piscina";
import {CID_ALGORITHM_NAMES} from "../hash-compute/MultiHashData"; import {CidAlgorithm} from "../hash-compute/MultiHashData";
export class FileIDComputerWorker{ export class FileIDComputerWorker{
private piscina: Piscina; private piscina: Piscina;
constructor(workerPath?:string) { constructor(workerPath?:string) {
// `WORKER_URL` is the escape hatch the tests have always set — it just
// wasn't read. Honour it before falling back to path derivation, which
// only works for a caller running from `dist/`.
if (!workerPath && process.env.WORKER_URL) {
workerPath = new URL(process.env.WORKER_URL, `file://${process.cwd()}/`).href;
}
if(!workerPath) { if(!workerPath) {
// Construct the URL for the current module // Construct the URL for the current module
let distFolder = import.meta.dirname; let distFolder = import.meta.dirname;
@@ -25,7 +31,7 @@ export class FileIDComputerWorker{
* @param algorithms Array of algorithms ('sha256', 'sha1') * @param algorithms Array of algorithms ('sha256', 'sha1')
* @returns Array of CIDs (in the order of the algorithms) * @returns Array of CIDs (in the order of the algorithms)
*/ */
public async computeCIDs(filePath: string, algorithms: CID_ALGORITHM_NAMES[]): Promise<string[]> { public async computeCIDs(filePath: string, algorithms: CidAlgorithm[]): Promise<string[]> {
return this.piscina.run({filePath, algorithms}); return this.piscina.run({filePath, algorithms});
} }
} }
@@ -1,4 +1,5 @@
import {MultiHashData} from '../hash-compute/MultiHashData'; import {MultiHashData} from '../hash-compute/MultiHashData';
import {pickCidByAlgorithm} from './CidDecode';
import {CID} from 'multiformats/cid'; import {CID} from 'multiformats/cid';
/** /**
@@ -22,13 +23,13 @@ export class MagnetLinkGenerator {
/** /**
* Generate BitTorrent v2 magnet link from metadata * Generate BitTorrent v2 magnet link from metadata
* *
* @param metadata - File metadata containing cid_btih_v2 * @param metadata - File metadata whose `cids` include a btih-v2 CID
* @param fileName - Original filename for display name * @param fileName - Original filename for display name
* @param fileSize - File size in bytes * @param fileSize - File size in bytes
* @returns Magnet link string, or null if btih_v2 not available * @returns Magnet link string, or null if btih_v2 not available
* *
* @example * @example
* const metadata = {cid_btih_v2: 'bafyb...'}; * const metadata = {cids: ['bafyb...']};
* const magnet = MagnetLinkGenerator.generate(metadata, 'video.mp4', 1024000); * const magnet = MagnetLinkGenerator.generate(metadata, 'video.mp4', 1024000);
* // Returns: magnet:?xt=urn:btmh:1220abc...&dn=video.mp4&xl=1024000 * // Returns: magnet:?xt=urn:btmh:1220abc...&dn=video.mp4&xl=1024000
*/ */
@@ -37,7 +38,9 @@ export class MagnetLinkGenerator {
fileName: string, fileName: string,
fileSize: number fileSize: number
): string | null { ): string | null {
const cidString = metadata.cid_btih_v2; // The btih-v2 CID is recovered from the bare list by its multicodec
// (0x10B7), not by a field name. See CidDecode.ts.
const cidString = pickCidByAlgorithm(metadata.cids, 'btih_v2');
if (!cidString) { if (!cidString) {
return null; // BitTorrent v2 hash not computed return null; // BitTorrent v2 hash not computed
} }
@@ -178,6 +181,6 @@ export class MagnetLinkGenerator {
fileName: string, fileName: string,
fileSize: number fileSize: number
): string | null { ): string | null {
return this.generate({cid_btih_v2: cidString}, fileName, fileSize); return this.generate({cids: [cidString]}, fileName, fileSize);
} }
} }
@@ -1,4 +1,4 @@
import {CID_ALGORITHM_NAMES} from "@root/hash-compute/MultiHashData"; import {CidAlgorithm} from "@root/hash-compute/MultiHashData";
import fs from "fs"; import fs from "fs";
import {Readable} from 'stream'; import {Readable} from 'stream';
import {computeCIDs as computeCIDsInternal} from "./ComputeHash"; import {computeCIDs as computeCIDsInternal} from "./ComputeHash";
@@ -12,7 +12,7 @@ import {createHasher} from "@root/file-id/CreateHasher";
*/ */
export default async function computeCIDs({filePath, algorithms}: { export default async function computeCIDs({filePath, algorithms}: {
filePath: string; filePath: string;
algorithms: CID_ALGORITHM_NAMES[] algorithms: CidAlgorithm[]
}): Promise<string[]> { }): Promise<string[]> {
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>; const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
return computeCIDsInternal({stream, algorithms, createHasher}); return computeCIDsInternal({stream, algorithms, createHasher});
@@ -1,30 +1,29 @@
import {CID_ALGORITHM_NAMES, MultiHashData} from "./MultiHashData"; import {CidAlgorithm, MultiHashData} from "./MultiHashData";
import {computeCIDs} from "@root/file-id/ComputeHash"; import {computeCIDs} from "@root/file-id/ComputeHash";
import {addCid, hasAlgorithm} from "@root/file-id/CidDecode";
import {SimpleHash} from "@root/file-id/SimpleHash"; import {SimpleHash} from "@root/file-id/SimpleHash";
export class HashComputer{ export class HashComputer {
constructor(private targetHash: CID_ALGORITHM_NAMES[], constructor(private targetHash: CidAlgorithm[],
private createHasher : (algo: CID_ALGORITHM_NAMES) => Promise<SimpleHash>) { private createHasher: (algo: CidAlgorithm) => Promise<SimpleHash>) {
} }
async computeMissingHash(stream: ReadableStream<Uint8Array>, metadata: MultiHashData): Promise<void> { async computeMissingHash(stream: ReadableStream<Uint8Array>, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed const cids = (metadata.cids ??= []);
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing // Which digests are still missing? On a bare-CID list this costs a decode
// per member rather than a property lookup — negligible against streaming
// the whole file through a hasher.
const neededHashes = this.targetHash.filter(algo => !hasAlgorithm(cids, algo));
if (neededHashes.length === 0) { if (neededHashes.length === 0) {
return; return;
} }
// Compute only the needed CIDs const computed = await computeCIDs({stream, algorithms: neededHashes, createHasher: this.createHasher});
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher:this.createHasher}); for (const cid of computed) {
addCid(cids, cid);
// Map the computed CIDs back to their respective metadata properties
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
} }
} }
} }
@@ -1,32 +1,28 @@
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData"; import {CidAlgorithm, ComputeInterface, MultiHashData} from "./MultiHashData";
import {Readable} from "stream"; import {Readable} from "stream";
import {computeCIDs} from "@root/file-id/ComputeHash"; import {computeCIDs} from "@root/file-id/ComputeHash";
import {addCid, hasAlgorithm} from "@root/file-id/CidDecode";
import fs from "fs"; import fs from "fs";
import {createHasher} from "@root/file-id/CreateHasher"; import {createHasher} from "@root/file-id/CreateHasher";
export class HashComputerFile implements ComputeInterface{ export class HashComputerFile implements ComputeInterface {
constructor(private targetHash: CID_ALGORITHM_NAMES[]) { constructor(private targetHash: CidAlgorithm[]) {
} }
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> { public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed const cids = (metadata.cids ??= []);
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing const neededHashes = this.targetHash.filter(algo => !hasAlgorithm(cids, algo));
if (neededHashes.length === 0) { if (neededHashes.length === 0) {
return; return;
} }
// Compute only the needed CIDs
const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>; const stream = Readable.toWeb(fs.createReadStream(filePath)) as ReadableStream<Uint8Array>;
const cids = await computeCIDs({stream, algorithms:neededHashes,createHasher}); const computed = await computeCIDs({stream, algorithms: neededHashes, createHasher});
for (const cid of computed) {
// Map the computed CIDs back to their respective metadata properties addCid(cids, cid);
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
} }
} }
} }
@@ -1,33 +1,59 @@
import {HashComputerWorker} from "./HashComputerWorker"; import {HashComputerWorker} from "./HashComputerWorker";
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData"; import {CidAlgorithm, ComputeInterface, MultiHashData} from "./MultiHashData";
import {HashIndexManager} from "./HashIndexManager"; import {HashIndexManager} from "./HashIndexManager";
import {addCid, hasAlgorithm, pickCidByAlgorithm} from "@root/file-id/CidDecode";
import {stat} from "fs/promises"; import {stat} from "fs/promises";
import path from "path"; import path from "path";
/**
* The translation point between the two shapes.
*
* `MultiHashData` carries **bare CIDs** (`cids: string[]`) — that is what gets
* persisted onto a record as the `cids/<cid>` key-set. The on-disk index is
* **per-algorithm columns**, because its job is the O(1) "already computed?"
* check. Neither shape leaks into the other; this class converts.
*/
export class HashComputerIndexCache implements ComputeInterface { export class HashComputerIndexCache implements ComputeInterface {
private hashIndexManager: HashIndexManager; private hashIndexManager: HashIndexManager;
private hashComputer: HashComputerWorker; private hashComputer: HashComputerWorker;
constructor(indexFolderPath: string,private targetHash: CID_ALGORITHM_NAMES[] = [CID_ALGORITHM_NAMES.sha1, CID_ALGORITHM_NAMES.sha256],workerPath?:string) { constructor(indexFolderPath: string, private targetHash: CidAlgorithm[] = ['sha1', 'sha256'], workerPath?: string) {
this.hashComputer = new HashComputerWorker(targetHash,workerPath); this.hashComputer = new HashComputerWorker(targetHash, workerPath);
this.hashIndexManager = new HashIndexManager(indexFolderPath,targetHash); this.hashIndexManager = new HashIndexManager(indexFolderPath, targetHash);
} }
public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> { public async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
await this.hashIndexManager.init(); await this.hashIndexManager.init();
let stats = await stat(filePath); const stats = await stat(filePath);
if (this.hashIndexManager.hasFileInCache(path.basename(filePath),stats)) { const cids = (metadata.cids ??= []);
// Cache hit: lift the cached CIDs into the bare list.
if (this.hashIndexManager.hasFileInCache(path.basename(filePath), stats)) {
const indexLine = this.hashIndexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString()); const indexLine = this.hashIndexManager.getCidForFile(filePath, stats.size, stats.mtime.toISOString());
if(indexLine) { if (indexLine) {
for (const hash of this.targetHash) { for (const algo of this.targetHash) {
if (!metadata[hash] && indexLine[hash]) { const cached = indexLine[algo];
metadata[hash] = indexLine[hash]; if (cached && !hasAlgorithm(cids, algo)) {
addCid(cids, cached);
} }
} }
} }
} }
// Compute whatever is still missing (a no-op on a full cache hit).
await this.hashComputer.computeMissingHash(filePath, metadata); await this.hashComputer.computeMissingHash(filePath, metadata);
this.hashIndexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), metadata);
// Write back: project the bare list onto the index's per-algorithm columns.
const columns: Partial<Record<CidAlgorithm, string>> = {};
for (const algo of this.targetHash) {
const cid = pickCidByAlgorithm(cids, algo);
if (cid) {
columns[algo] = cid;
}
}
if (Object.keys(columns).length > 0) {
this.hashIndexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), columns);
}
} }
async getHashIndexManager(): Promise<HashIndexManager> { async getHashIndexManager(): Promise<HashIndexManager> {
@@ -1,29 +1,25 @@
import {FileIDComputerWorker} from "../file-id/FileIDComputerWorker"; import {FileIDComputerWorker} from "../file-id/FileIDComputerWorker";
import {CID_ALGORITHM_NAMES, ComputeInterface, MultiHashData} from "./MultiHashData"; import {CidAlgorithm, ComputeInterface, MultiHashData} from "./MultiHashData";
import {addCid, hasAlgorithm} from "@root/file-id/CidDecode";
export class HashComputerWorker implements ComputeInterface { export class HashComputerWorker implements ComputeInterface {
private fileIDComputer: FileIDComputerWorker; private fileIDComputer: FileIDComputerWorker;
constructor(private targetHash: CID_ALGORITHM_NAMES[], workerPath?: string) { constructor(private targetHash: CidAlgorithm[], workerPath?: string) {
this.fileIDComputer = new FileIDComputerWorker(workerPath); this.fileIDComputer = new FileIDComputerWorker(workerPath);
} }
async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> { async computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void> {
// Dynamically determine which hashes are needed const cids = (metadata.cids ??= []);
const neededHashes = this.targetHash.filter(hashName => !metadata[hashName]);
// If all hashes are already computed, skip the processing const neededHashes = this.targetHash.filter(algo => !hasAlgorithm(cids, algo));
if (neededHashes.length === 0) { if (neededHashes.length === 0) {
return; return;
} }
// Compute only the needed CIDs const computed = await this.fileIDComputer.computeCIDs(filePath, neededHashes);
const cids = await this.fileIDComputer.computeCIDs(filePath, neededHashes); for (const cid of computed) {
addCid(cids, cid);
// Map the computed CIDs back to their respective metadata properties
for (const [index, cid] of cids.entries()) {
const hashType = neededHashes[index];
metadata[hashType] = cid;
} }
} }
} }
@@ -5,11 +5,28 @@ import {stringify} from 'csv-stringify/sync';
import {existsAsync} from "../utils/ExistsAsync"; import {existsAsync} from "../utils/ExistsAsync";
import path from "path"; import path from "path";
import {clearInterval} from "node:timers"; import {clearInterval} from "node:timers";
import {CID_ALGORITHM_NAMES} from "./MultiHashData"; import {CidAlgorithm} from "./MultiHashData";
import {stat} from "fs/promises"; import {stat} from "fs/promises";
import {Stats} from "node:fs"; import {Stats} from "node:fs";
interface IndexLine extends Partial<Record<CID_ALGORITHM_NAMES, string>> { /**
* One row of the on-disk hash-index cache.
*
* This is the one place a **per-algorithm column** is still the right shape, and
* it is not the deprecated one. The index answers "have I already computed sha256
* for this file?" on every scan, which a column makes an O(1) lookup and a bare
* CID list would make a decode-per-member. It is also strictly local: a wipeable
* cache under `/data/cache/hash-index/` (see
* `docs/project-architecture/cache-persistence.md`), never a record, never on the
* wire.
*
* What changed is the *naming*: columns are keyed by bare algorithm
* (`sha256`, `midhash256`) instead of by the old `cid_<algo>` record-field names,
* so the files are `index-sha256.csv`, not `index-cid_sha2-256.csv`. The record
* shape and the cache shape are now separate concerns that cannot leak into each
* other — which is the whole point of the split (METADATA_KEYS.md §14.13).
*/
interface IndexLine extends Partial<Record<CidAlgorithm, string>> {
path: string; path: string;
size: string; size: string;
mtime: string; mtime: string;
@@ -21,15 +38,15 @@ export class HashIndexManager {
private cache: Map<string, IndexLine> = new Map<string, IndexLine>();//the key is the file `${file_name}-${filesize}-${mtime_ISOstr}` private cache: Map<string, IndexLine> = new Map<string, IndexLine>();//the key is the file `${file_name}-${filesize}-${mtime_ISOstr}`
private intervalId: any; private intervalId: any;
private intervalTime: number = 30000; private intervalTime: number = 30000;
private lastIndexFileSize: { [key in CID_ALGORITHM_NAMES]?: number } = {}; //size of the index file last time it was read private lastIndexFileSize: { [key in CidAlgorithm]?: number } = {}; //size of the index file last time it was read
private lastCacheFile: { [key in CID_ALGORITHM_NAMES]?: IndexLine[] } = {}; //state of the file last time it was read private lastCacheFile: { [key in CidAlgorithm]?: IndexLine[] } = {}; //state of the file last time it was read
private indexOpsInProgress: boolean = false; private indexOpsInProgress: boolean = false;
private hasChanged: boolean = false; private hasChanged: boolean = false;
private initialLoad: Promise<void>; private initialLoad: Promise<void>;
private filePaths: { [key in CID_ALGORITHM_NAMES]?: string } = {}; private filePaths: { [key in CidAlgorithm]?: string } = {};
constructor(private indexFolderPath: string, constructor(private indexFolderPath: string,
private targetHash: CID_ALGORITHM_NAMES[]) { private targetHash: CidAlgorithm[]) {
} }
hasFileInCache(filePath: string,stats:Stats): boolean { hasFileInCache(filePath: string,stats:Stats): boolean {
@@ -81,7 +98,7 @@ export class HashIndexManager {
} }
// Function to check CSV headers // Function to check CSV headers
private checkCSVHeaders(csvContent: string, hash: CID_ALGORITHM_NAMES): boolean { private checkCSVHeaders(csvContent: string, hash: CidAlgorithm): boolean {
const records = parseSync(csvContent, { const records = parseSync(csvContent, {
bom: true, bom: true,
columns: true, columns: true,
@@ -116,7 +133,7 @@ export class HashIndexManager {
this.intervalId = setInterval(() => this.saveCacheToFile(), time); this.intervalId = setInterval(() => this.saveCacheToFile(), time);
} }
public async loadIndex(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> { public async loadIndex(hash: CidAlgorithm): Promise<IndexLine[]> {
if (await existsAsync(this.filePaths[hash])) { if (await existsAsync(this.filePaths[hash])) {
// check the file size and if it did not change, do not read the file // check the file size and if it did not change, do not read the file
const stats = await fs.stat(this.filePaths[hash]); const stats = await fs.stat(this.filePaths[hash]);
@@ -147,7 +164,7 @@ export class HashIndexManager {
return Array.from(this.cache.values()); return Array.from(this.cache.values());
} }
private async readCsv(hash: CID_ALGORITHM_NAMES): Promise<IndexLine[]> { private async readCsv(hash: CidAlgorithm): Promise<IndexLine[]> {
if (!(await existsAsync(this.filePaths[hash]))) { if (!(await existsAsync(this.filePaths[hash]))) {
return []; return [];
} }
@@ -178,10 +195,8 @@ export class HashIndexManager {
public async saveCacheToFile(): Promise<void> { public async saveCacheToFile(): Promise<void> {
if (this.indexOpsInProgress || !this.hasChanged) { if (this.indexOpsInProgress || !this.hasChanged) {
console.log(`[HashIndexManager] saveCacheToFile: Skipping (indexOpsInProgress=${this.indexOpsInProgress}, hasChanged=${this.hasChanged}, cacheSize=${this.cache.size})`);
return; return;
} }
console.log(`[HashIndexManager] saveCacheToFile: Starting save, cacheSize=${this.cache.size}`);
this.hasChanged = false; this.hasChanged = false;
this.indexOpsInProgress = true; this.indexOpsInProgress = true;
const start = performance.now(); const start = performance.now();
@@ -274,17 +289,15 @@ export class HashIndexManager {
return null; return null;
} }
public addFileCid(filePath: string, fileSize: number, mtime: string, hashs: Partial<Record<CID_ALGORITHM_NAMES, string>>): void { public addFileCid(filePath: string, fileSize: number, mtime: string, hashs: Partial<Record<CidAlgorithm, string>>): void {
if (!filePath || !fileSize || !mtime || !hashs) { if (!filePath || !fileSize || !mtime || !hashs) {
throw new Error('Invalid parameters'); throw new Error('Invalid parameters');
} }
// Check that at least one target hash is provided // Check that at least one target hash is provided
const providedHashes = this.targetHash.filter(hash => hashs[hash]); const providedHashes = this.targetHash.filter(hash => hashs[hash]);
if (providedHashes.length === 0) { if (providedHashes.length === 0) {
console.log(`[HashIndexManager] addFileCid: No target hashes provided for ${filePath}. targetHashes=${this.targetHash}, providedKeys=${Object.keys(hashs)}`);
throw new Error('At least one target hash must be provided'); throw new Error('At least one target hash must be provided');
} }
console.log(`[HashIndexManager] addFileCid: Adding ${filePath} with ${providedHashes.length} hashes: ${providedHashes.join(', ')}`);
const size = fileSize + ""; const size = fileSize + "";
const baseName = path.basename(filePath); const baseName = path.basename(filePath);
const cacheKey = `${baseName}-${size}-${mtime}`; const cacheKey = `${baseName}-${size}-${mtime}`;
@@ -1,21 +1,26 @@
export interface MultiHashData {
cid_crc32?: string; //0x0132 CRC32 (SFV)
cid_md5?: string; // 0xd5 md5
cid_sha1?: string; // 0x11 sha1
"cid_sha2-256"?: string;//0x12 sha2-256
"cid_sha3-256"?: string; // 0x16 sha3-256
"cid_sha3_384"?: string; // 0x16 sha3-256
cid_midhash256?: string; // 0x1000 midhash256 (SHA-256 of middle 1MB + file size) - CID v1 format
cid_btih_v2?: string; // 0x10B7 BitTorrent v2 info hash (BEP 52) - CID v1 format
}
export interface ComputeInterface {
computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void>;
}
/** /**
* according to * Multicodec / multihash codes for every digest meta-hash can compute.
* https://ipfs.io/ipfs/QmXec1jjwzxWJoNbxQF5KffL8q6hFXm9QwUGaa3wKGk6dT/#title=Multicodecs&src=https://raw.githubusercontent.com/multiformats/multicodec/master/table.csv *
* 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`
* - the `cid_rank.rs` in each of meta-search and meta-share
* - `/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`:
* `CID.createV1(code, create(code, digest))`), whereas the fullhash plugin and
* the gateway wrap every digest with the **raw** codec `0x55` and put the
* algorithm in the multihash code. The multihash code is the algorithm in both
* conventions — so anything selecting a CID by algorithm must read the
* **multihash code**, never the content codec. See `CidDecode.ts`.
*
* https://github.com/multiformats/multicodec/blob/master/table.csv
*/ */
export enum CID_ALGORITHM_CODES { export enum CID_ALGORITHM_CODES {
crc32 = 0x0132, crc32 = 0x0132,
@@ -24,28 +29,55 @@ export enum CID_ALGORITHM_CODES {
sha256 = 0x12, sha256 = 0x12,
sha3_256 = 0x16, sha3_256 = 0x16,
sha3_384 = 0x15, sha3_384 = 0x15,
midhash256 = 0x1000, // Custom code for midhash256 midhash256 = 0x1000, // Custom — SHA-256(size ‖ middle 1MB)
btih_v2 = 0x10B7, // Custom code for BitTorrent v2 info hash btih_v2 = 0x10B7, // Custom — BitTorrent v2 info hash (BEP 52)
} }
export enum CID_ALGORITHM_NAMES { /**
crc32 = 'cid_crc32', * An algorithm *selector*: which digests to compute, and which column of the
md5 = 'cid_md5', * local hash-index cache to read.
sha1 = 'cid_sha1', *
sha256 = 'cid_sha2-256', * This is deliberately **not** a metadata field name. The old
sha3_256 = 'cid_sha3-256', * `CID_ALGORITHM_NAMES` enum ('cid_sha2-256', 'cid_midhash256', …) did triple
sha3_384 = 'cid_sha3-384', * duty — selector, record property name, and CSV column — and the middle duty
midhash256 = 'cid_midhash256', * was the deprecated one: those `cid_<algo>` fields were stored on records but
btih_v2 = 'cid_btih_v2', * never reverse-indexed by meta-core, leaving records unresolvable by their own
* CID. meta-core now rejects them at the write boundary (400). See
* METADATA_KEYS.md §2/§14.13.
*
* The triple duty also rotted on its own: the interface declared `cid_sha3_384`
* (underscore) while the enum emitted `'cid_sha3-384'` (hyphen), and the doc
* comment on it named the wrong multicodec. A self-describing CID cannot drift
* from its own name that way.
*/
export type CidAlgorithm = keyof typeof CID_ALGORITHM_CODES;
/** Every algorithm meta-hash knows how to compute. */
export const CID_ALGORITHMS: CidAlgorithm[] = [
'crc32',
'md5',
'sha1',
'sha256',
'sha3_256',
'sha3_384',
'midhash256',
'btih_v2',
];
/**
* The output of a hash computation: **bare CIDv1 strings, nothing else**.
*
* A CIDv1 is self-describing — its algorithm is the multicodec — so there is no
* per-algorithm field name to keep in sync, and no `<algo>:` prefix. To find a
* specific digest, decode the members (`pickCidByAlgorithm` in `CidDecode.ts`).
*
* This is persisted onto a record as the bare-CID key-set
* (`cids/<cid> = "true"`), which is the only shape meta-core indexes.
*/
export interface MultiHashData {
cids?: string[];
} }
export enum CID_ALGORITHM { export interface ComputeInterface {
crc32 = 'crc32', computeMissingHash(filePath: string, metadata: MultiHashData): Promise<void>;
md5 = 'md5',
sha1 = 'sha1',
sha256 = 'sha2-256',
sha3_256 = 'sha3-256',
sha3_384 = 'sha3-384',
midhash256 = 'midhash256',
btih_v2 = 'btih-v2',
} }
@@ -4,6 +4,12 @@ export * from './file-id/ShaComputeWorker.js';//worker
export * from './file-id/FastHash.js'; // Fast-hash computation export * from './file-id/FastHash.js'; // Fast-hash computation
export * from './file-id/BtihV2Hasher.js'; // BitTorrent v2 info hash export * from './file-id/BtihV2Hasher.js'; // BitTorrent v2 info hash
export * from './file-id/MagnetLinkGenerator.js'; // Magnet link generation export * from './file-id/MagnetLinkGenerator.js'; // Magnet link generation
export * from './file-id/CidDecode.js'; // Bare-CID → algorithm decoding (THE shared TS CID decoder)
// The CID model: algorithm codes, the algorithm selector, and MultiHashData
// (bare `cids: string[]`). Exported because every consumer of a `cids/<cid>`
// key-set needs them to recover a specific digest — see CidDecode.
export * from './hash-compute/MultiHashData.js';
//hash-compute //hash-compute
export * from './hash-compute/HashComputerIndexCache.js'; export * from './hash-compute/HashComputerIndexCache.js';
@@ -1,6 +1,6 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {BtihV2Hasher} from '../lib/file-id/BtihV2Hasher'; import {BtihV2Hasher} from '../lib/file-id/BtihV2Hasher';
import {CID_ALGORITHM_NAMES, HashComputerWorker} from "../lib"; import {HashComputerWorker, pickCidByAlgorithm} from "../lib";
import {readFile} from 'fs/promises'; import {readFile} from 'fs/promises';
import {createHash} from 'crypto'; import {createHash} from 'crypto';
import * as process from "process"; import * as process from "process";
@@ -157,26 +157,20 @@ describe('BitTorrent v2 Info Hash (BEP 52)', () => {
}); });
it('should integrate with HashComputerWorker', async () => { it('should integrate with HashComputerWorker', async () => {
const hashComputer = new HashComputerWorker([ const hashComputer = new HashComputerWorker(['btih_v2', 'sha256']);
CID_ALGORITHM_NAMES.btih_v2,
CID_ALGORITHM_NAMES.sha256
]);
const metadata: any = {}; const metadata: any = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata); await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log('Computed metadata:', metadata); // The digests land in a bare CID list; each is recovered by its
// multicodec, not by a field name.
const btih = pickCidByAlgorithm(metadata.cids, 'btih_v2');
expect(btih).toBeDefined();
expect(typeof btih).toBe('string');
expect(btih!.startsWith('b')).toBe(true);
// Verify btih_v2 CID was computed // Should also have SHA-256 for comparison.
expect(metadata).toHaveProperty('cid_btih_v2'); expect(pickCidByAlgorithm(metadata.cids, 'sha256')).toBeDefined();
expect(typeof metadata.cid_btih_v2).toBe('string');
expect(metadata.cid_btih_v2.length).toBeGreaterThan(0);
// Verify CID format (should start with 'b' for base32 CIDv1)
expect(metadata.cid_btih_v2.startsWith('b')).toBe(true);
// Should also have SHA-256 for comparison
expect(metadata).toHaveProperty('cid_sha2-256');
}); });
it('should compute piece length correctly', () => { it('should compute piece length correctly', () => {
@@ -213,15 +207,16 @@ describe('BitTorrent v2 Info Hash (BEP 52)', () => {
}); });
it('should produce valid CID format with codec 0x10B7', async () => { it('should produce valid CID format with codec 0x10B7', async () => {
const hashComputer = new HashComputerWorker([ const hashComputer = new HashComputerWorker(['btih_v2']);
CID_ALGORITHM_NAMES.btih_v2
]);
const metadata: any = {}; const metadata: any = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata); await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
const cid = metadata.cid_btih_v2; const cid = pickCidByAlgorithm(metadata.cids, 'btih_v2');
console.log('BitTorrent v2 CID:', cid);
// The selector proves the codec: pickCidByAlgorithm('btih_v2') matches on
// multihash 0x10B7, so a hit here IS the "valid CID with codec 0x10B7"
// assertion this test is named for.
// Verify CID format // Verify CID format
expect(cid).toBeDefined(); expect(cid).toBeDefined();
@@ -1,8 +1,13 @@
import {CID_ALGORITHM_NAMES, HashComputerIndexCache, HashComputerWorker} from "../lib"; import {
CidAlgorithm,
HashComputerIndexCache,
HashComputerWorker,
pickCidByAlgorithm,
} from "../lib";
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import * as process from "process"; import * as process from "process";
import { mkdir, rm, rmdir } from "fs/promises"; import {mkdir, rm, stat} from "fs/promises";
process.env.WORKER_URL = "./dist/worker.js"; process.env.WORKER_URL = "./dist/worker.js";
@@ -11,87 +16,102 @@ process.env.WORKER_URL = "./dist/worker.js";
* Remember to build the worker each time you change the worker code. * Remember to build the worker each time you change the worker code.
*/ */
const ALGOS: CidAlgorithm[] = ['sha1', 'sha256', 'md5', 'sha3_256', 'crc32', 'sha3_384'];
/** `src/tests/test.txt`, checked out with LF endings (`.gitattributes`: text=auto eol=lf). */
const FIXTURE_BYTES = 490;
/**
* The expected digests of `src/tests/test.txt`, as bare CIDv1 strings.
*
* Independently reproducible — these are not "whatever the code printed":
*
* $ sha256sum src/tests/test.txt
* be25c28a7af0dfd58b3bd487858d0c40faf4c8fb3a721c9fe5d5db7fff3157ec
* $ md5sum → c8971ace120d1393b658eabb45d08c88
* $ sha1sum → fb6497eb882a7ce7773f4680fdd2b75cd73ebcf7
*
* and each CID below wraps exactly that digest under its multicodec (meta-hash's
* encoder sets `codec == multihash code`).
*
* ⚠ These values were **re-pinned**. The previous ones described a 499-byte
* CRLF copy of the fixture, from before `.gitattributes` normalised it to LF —
* so on any LF checkout the digests could never match and this suite was red.
* The mismatch was invisible because the worker path was also broken, so the
* assertions never even ran. Hence the explicit `FIXTURE_BYTES` check: if the
* fixture's bytes ever shift again, the test says *that*, instead of printing
* six inscrutable CID diffs.
*/
const EXPECTED: Record<string, string> = {
crc32: 'bagzafmqcat2hnot3',
md5: 'bahkqdvibcdejogwocigrhe5wldvlwroqrsea',
sha1: 'baeircfh3msl6xcbkpttxop2gqd65fn24247lz5y',
sha256: 'baejbeif6exbiu6xq37kywo6uq6cy2dca7l2mr6z2oioj7zov3n776mkx5q',
sha3_256: 'baelbmiemf56ripvp5zvx5rxljfr56fqfjpxv7xwyx3lphfyv6y5j2egdyi',
sha3_384: 'baekrkmatuj3wvbkizsyfe6bb3kk7eoiglmsa6qhrr2jehdboxbauep2mppu56xaxxtdztagn7adtswhrw7wq',
};
/**
* Every expected digest is present in the bare list, and each is recoverable by
* its algorithm **through its multicodec** — there is no field name to look up.
* That is the whole point of the `cids: string[]` shape: a CIDv1 is
* self-describing, so the record never carries a per-algorithm key that could
* drift from the CID it names (METADATA_KEYS.md §14.13).
*/
function expectAllDigests(cids: string[] | undefined) {
expect(cids).toBeDefined();
expect([...cids!].sort()).toEqual(Object.values(EXPECTED).sort());
for (const algo of ALGOS) {
expect(pickCidByAlgorithm(cids, algo)).toBe(EXPECTED[algo]);
}
}
describe('add', () => { describe('add', () => {
it('the fixture has the bytes these CIDs were pinned against', async () => {
const {size} = await stat('./src/tests/test.txt');
expect(size).toBe(FIXTURE_BYTES);
});
it('meta data compute', async () => { it('meta data compute', async () => {
let hashComputer = new HashComputerWorker([ const hashComputer = new HashComputerWorker(ALGOS);
CID_ALGORITHM_NAMES.sha1, const metadata = {};
CID_ALGORITHM_NAMES.sha256,
CID_ALGORITHM_NAMES.md5,
CID_ALGORITHM_NAMES.sha3_256,
CID_ALGORITHM_NAMES.crc32,
CID_ALGORITHM_NAMES.sha3_384
]);
let metadata = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata); await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log(metadata); expectAllDigests((metadata as { cids?: string[] }).cids);
/**
* test.txt CF20B62A (CRC32)
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
*/
expect(metadata).toEqual({
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
});
}); });
it('meta data compute with index', async () => { it('meta data compute with index', async () => {
await rm("./tmp",{recursive:true}); await rm("./tmp", {recursive: true, force: true});
await mkdir("./tmp",{recursive:true}); await mkdir("./tmp", {recursive: true});
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[ const hashComputer = new HashComputerIndexCache("./tmp/index.csv", ALGOS);
CID_ALGORITHM_NAMES.sha1, const metadata = {};
CID_ALGORITHM_NAMES.sha256,
CID_ALGORITHM_NAMES.md5,
CID_ALGORITHM_NAMES.sha3_256,
CID_ALGORITHM_NAMES.crc32,
CID_ALGORITHM_NAMES.sha3_384
]);
let metadata = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata); await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
console.log(metadata); expectAllDigests((metadata as { cids?: string[] }).cids);
/**
* test.txt CF20B62A (CRC32)
* 26f1157f4349a6fb5d207ac5f5995140 *test.txt (MD5)
* bb242b7b821074f4b9eb9a94d439ab668705aac4 ?SHA1*test.txt
* 2aa7a80d2fd1eb96d31afc7cdda9211a97524202c63f04151d6163fba35061a0 ?SHA256*test.txt
* 865a1ea4977b25e04b8ea84af93b21f1a031cb48359f3805089cdae1d89842b9a3faf87654989dcae418144a8bbb378c ?SHA3-384*test.txt
*/
expect(metadata).toEqual({
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra',
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua',
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa',
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga'
});
await (await hashComputer.getHashIndexManager()).saveCacheToFile(); await (await hashComputer.getHashIndexManager()).saveCacheToFile();
}); });
it('cache', async () => { it('cache', async () => {
await mkdir("./tmp",{recursive:true}); await mkdir("./tmp", {recursive: true});
let hashComputer = new HashComputerIndexCache("./tmp/index.csv",[ const hashComputer = new HashComputerIndexCache("./tmp/index.csv", ALGOS);
CID_ALGORITHM_NAMES.sha1,
CID_ALGORITHM_NAMES.sha256, // The on-disk index is the one place per-algorithm columns survive — it
CID_ALGORITHM_NAMES.md5, // answers "already computed?" in O(1) and never leaves this machine. Note
CID_ALGORITHM_NAMES.sha3_256, // the columns are bare algorithm names now (`sha256`), not the removed
CID_ALGORITHM_NAMES.crc32, // record-field names (`cid_sha2-256`).
CID_ALGORITHM_NAMES.sha3_384 const indexLine = await (await hashComputer.getHashIndexManager()).getCidForFileAsync('./src/tests/test.txt');
]);
let indexLine = await (await hashComputer.getHashIndexManager()).getCidForFileAsync('./src/tests/test.txt');
delete indexLine['mtime']; delete indexLine['mtime'];
expect(indexLine).toEqual({ expect(indexLine).toEqual({
"cid_sha1": 'baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra', ...EXPECTED,
"cid_sha2-256": 'baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua', path: "test.txt",
"cid_md5": 'bahkqdvibcatpcfl7ine2n625eb5ml5mzkfaa', size: String(FIXTURE_BYTES),
"cid_sha3-256": 'baelbmidner7x3dd7t2kldh6wiq4w6lsye7qrwo6uoqot2t52zkhqk56fnq',
"cid_crc32": "bagzafmqcathsbnrk",
"cid_sha3-384": 'baekrkmeglipkjf33exqexdvijl4twipruay4wsbvt44akce43lq5rgccxgr7v6dwksmj3sxedakevc53g6ga',
"path": "test.txt",
"size": "499",
}); });
}); });
it('a second pass reuses the cache and recomputes nothing', async () => {
const hashComputer = new HashComputerIndexCache("./tmp/index.csv", ALGOS);
const metadata = {};
await hashComputer.computeMissingHash('./src/tests/test.txt', metadata);
// Same digests, recovered from the index rather than by re-hashing.
expectAllDigests((metadata as { cids?: string[] }).cids);
});
}); });
@@ -4,13 +4,10 @@ export const FileMetaDataName = [
"byteSize", "byteSize",
] ]
// Sibling CIDs are stored as the bare-CID key-set `cids/<cid>` (see HashMeta),
// not as per-algorithm cid_* fields. The single member prefix is `cids`.
export const hashMetaDataName = [ export const hashMetaDataName = [
"cid_crc32", "cids",
"cid_md5",
"cid_sha1",
"cid_sha2-256",
"cid_sha3-256",
"cid_sha3_384",
]; ];
export const AnimeMetaData = [ export const AnimeMetaData = [
@@ -1,32 +1,23 @@
/** /**
* HashMeta - Content identification hashes * HashMeta - Content identification hashes.
* *
* Primary Global ID: cid_midhash256 (SHA-256 hash of middle 1MB + file size) * Sibling CIDs are NOT stored as per-algorithm fields anymore. Every digest
* - Format: CID v1 with custom multicodec 0x1000 * of the file (sha2-256, sha1, md5, crc32, sha3-*, midhash256, btih-v2, …)
* - Example: bafkr4ih5kapbjzqvmj7jxr... (base32 CID encoding) * lives in a single bare-CID key-set on the record:
* *
* Legacy IDs: cid_sha2-256, cid_sha1 (computed optionally for compatibility) * cids/<cidV1> = "true"
* *
* Migration Note: cid_midhash256 is the new primary identifier replacing full SHA-256. * A CIDv1 is self-describing — its algorithm is the multicodec — so there is
* Old hashes remain for backward compatibility and external system integration. * no per-algorithm field name and no `<algo>:` prefix. The canonical /
* All CIDs now use proper CID v1 format (IPFS-compatible structure). * network CID is derived by rank from this set on read, never stored. The
* midhash stays the file's address (hashId). See METADATA_KEYS.md §2/§14.13.
*
* Because the members are dynamic keys (`cids/<cid>`), they can't be typed as
* named fields here; consumers read them by prefix-scanning the flat record
* and recover the algorithm from each CID's multicodec.
*/ */
export interface HashMeta { export interface HashMeta {
cid_crc32?: string; //0x0132 CRC32 (SFV) // Intentionally empty: sibling CIDs are the `cids/<cid>` key-set (above),
cid_md5?: string; // 0xd5 md5 // not typed fields. Kept as a named type so existing `extends HashMeta`
cid_sha1?: string; // 0x11 sha1 // composition sites stay valid.
"cid_sha2-256"?: string;//0x12 sha2-256
"cid_sha3-256"?: string; // 0x16 sha3-256
"cid_sha3_384"?: string; // 0x16 sha3-256
cid_midhash256?: string; // 0x1000 midhash256 (CID v1 format with custom multicodec)
} }
export const HashMetaFields = [
"cid_crc32",
"cid_md5",
"cid_sha1",
"cid_sha2-256",
"cid_sha3-256",
"cid_sha3_384",
"cid_midhash256",
];
@@ -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';