diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9be425b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.pnpm-store +test/fixtures/*.mkv +test/fixtures/*.mp4 diff --git a/.gitignore b/.gitignore index abc15ad..7ffc8a7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ dist/ *.log .env .pnpm-store + +# Test fixtures are generated by test/fixtures/generate-test-fixtures.sh +# (run automatically in Dockerfile.test) — no need to carry binaries in git. +test/fixtures/*.mkv +test/fixtures/*.mp4 diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..c5c468b --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,34 @@ +# MetaMesh Plugin: subtitle-extractor - Test Image +# Builds the plugin and runs tests with ffmpeg available + +FROM node:20-slim + +# ffmpeg for fixture generation and the extraction tests +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy package files and install ALL dependencies (including devDependencies) +COPY package.json tsconfig.json vitest.config.ts ./ +RUN npm install + +# Copy source and test files +COPY src/ ./src/ +COPY test/ ./test/ + +# Build the plugin +RUN npm run build + +# Generate test fixtures +RUN chmod +x ./test/fixtures/generate-test-fixtures.sh && \ + ./test/fixtures/generate-test-fixtures.sh + +# Temp dir ffmpeg writes tracks into before upload +RUN mkdir -p /cache/temp && chmod 777 /cache + +ENV NODE_ENV=test +ENV CACHE_PATH=/cache + +CMD ["npm", "test"] diff --git a/README.md b/README.md index 3be413c..dd71c90 100644 --- a/README.md +++ b/README.md @@ -1,157 +1,58 @@ # MetaMesh Plugin: Subtitle Extractor -Extracts embedded subtitles from video files and saves them as standalone text files. The extracted subtitles are republished in the MetaMesh pipeline and linked to the original video via CID. +Extracts a video's embedded **text** subtitle streams into standalone files and links them to the video so clients (meta-watch) can offer them. -## Features +## What it writes (METADATA_KEYS.md §8 / §9) -- **Automatic extraction**: Extracts all text-based subtitle tracks from video files -- **Multiple formats**: Supports SRT, ASS, SSA, WebVTT, and MOV_TEXT codecs -- **Smart filtering**: Automatically skips image-based subtitles (PGS, DVD, DVB) that cannot be converted to text -- **Language preservation**: Maintains language metadata from video streams -- **Pipeline integration**: Extracted files are saved to `/output` and automatically picked up by meta-sort -- **CID linking**: Stores subtitle CIDs as metadata on the source video +On the **video record**, per extracted file `` (midhash256 of its bytes) in `` (ISO 639-2/B — `fre`, `ger`, `chi` — normalised the same way meta-watch does; `und` when the stream declares no language): -## Dependencies +| Key | Value | +|-----|-------| +| `subtitles//` | `"true"` — the leaf meta-watch lists | +| `extractedSubtitles//` | `"true"` — provenance: came out of the container | +| `subtitleLanguages/` | `"true"` (not for `und`) | +| `languages/` | `"true"` (not for `und`) | -This plugin requires the following plugins to run first: -- `file-info` - Determines file type -- `ffmpeg` - Provides subtitle stream information +On the **subtitle file's own record**: `videos/ = "true"` and `subtitleLanguage = ` (not for `und`). -## Installation +Writes are a single `PATCH /meta/{cid}` carrying only keys the record does not already hold; a failed write fails the task. Legacy comma-joined scalars written by the old version (`extractedSubtitles`, `subtitleLanguages`, `subtitles`) are deleted first — a scalar next to `field/...` keys breaks meta-sort's nested document. -### Build from source +## Behaviour -```bash -cd packages/plugins/metamesh-plugin-subtitle-extractor -npm install -npm run build -``` - -### Build Docker image - -```bash -docker build -t metamesh-plugin-subtitle-extractor:main . -``` - -### Configure in plugins.yml - -Add to `dev/plugins.yml`: - -```yaml -plugins: - subtitle-extractor: - enabled: true - image: metamesh-plugin-subtitle-extractor:main - instances: 1 - resources: - memory: 256m - cpus: 0.5 -``` +- Reads the ffmpeg plugin's stream table in any shape: meta-sort's nested `stream` array (JSON strings or objects), an index-keyed object, a JSON string, or meta-core's flat `stream/{n}`. +- **One ffmpeg pass** extracts every text track (one `-map`/output per track). Subtitle packets are interleaved through the whole container, so that pass is still one full read of the video over WebDAV — but one, not one per track. If the shared pass fails, tracks are retried one by one. +- Image codecs (PGS, VobSub, DVB, teletext, XSUB) are skipped explicitly; other unknown codecs are skipped and logged. +- Skips a video that already carries the `extractedSubtitles` key-set (unless `forceRecompute`), and reuses an output file that already exists instead of re-reading the video. +- Output: `/files/plugin/subtitle-extractor/ (<Year>)[<videoCid>]_subtitle.s<streamIndex>.<lang3>[.forced].<ext>` — the stream index keeps two tracks in one language apart. +- WebDAV endpoint resolved per request from the driving meta-core (`/urls` → `webdavUrlInternal`); `WEBDAV_URL` overrides. ## Configuration | Option | Type | Default | Description | |--------|------|---------|-------------| -| `forceRecompute` | boolean | `false` | Re-extract subtitles even if already processed | -| `outputFormat` | select | `srt` | Output format: `srt`, `vtt`, or `ass` | +| `forceRecompute` | boolean | `false` | Re-extract even if already processed | +| `outputFormat` | select | `native` | `native` keeps ASS as ASS (stream copy) and SubRip/WebVTT as-is, converts mov_text to SRT; `srt`, `vtt`, `ass` convert everything | +| `extractionTimeoutMs` | number | `270000` | Timeout of the single ffmpeg pass | -## Output +## Supported codecs -### Extracted Files +Text (extracted): `subrip`/`srt`, `ass`, `ssa`, `webvtt`, `mov_text`, `text`. +Image (skipped): `hdmv_pgs_subtitle`, `dvd_subtitle`, `dvb_subtitle`, `dvb_teletext`, `xsub`. -Subtitle files are saved to `/output` with the naming pattern: +## Tests +```bash +pnpm install && pnpm test # locally (ffmpeg tests need ffmpeg + ./test/fixtures/generate-test-fixtures.sh) +./test.sh # in Docker, fixtures generated in the image ``` -{Title} ({Year})[{VideoCID}]_subtitle.{lang}.{ext} -``` - -Example: `Sintel (2010)[bafk...abc]_subtitle.eng.srt` - -### Metadata - -The plugin stores the following metadata on the source video: - -| Field | Type | Description | -|-------|------|-------------| -| `extractedSubtitles` | array | CIDs of extracted subtitle files | -| `subtitleLanguages` | array | Language codes of extracted subtitles | - -## Supported Codecs - -### Text-based (supported) -- `subrip` / `srt` - SubRip -- `ass` - Advanced SubStation Alpha -- `ssa` - SubStation Alpha -- `webvtt` - WebVTT -- `mov_text` - QuickTime text - -### Image-based (skipped) -- `hdmv_pgs_subtitle` - Blu-ray PGS -- `dvd_subtitle` - DVD VOB -- `dvb_subtitle` - DVB -- `xsub` - DivX XSUB - -## Architecture - -``` -Video File (MKV/MP4/AVI) - │ - ▼ -┌───────────────────┐ -│ ffmpeg plugin │ ← Provides stream info -└───────────────────┘ - │ - ▼ -┌───────────────────┐ -│ subtitle-extractor│ -│ │ -│ 1. Parse streams │ -│ 2. Filter codecs │ -│ 3. Extract via │ -│ ffmpeg │ -│ 4. Save to │ -│ /output │ -│ 5. Compute CID │ -│ 6. Store in │ -│ Redis │ -└───────────────────┘ - │ - ▼ -┌───────────────────┐ -│ /output folder │ ← Watched by meta-sort -│ *.srt files │ -└───────────────────┘ - │ - ▼ -┌───────────────────┐ -│ meta-sort │ ← Processes extracted files -│ file watcher │ like any other media -└───────────────────┘ -``` - -## Container Mounts - -| Mount | Access | Purpose | -|-------|--------|---------| -| `/files` | READ-ONLY | Source video files | -| `/cache` | READ-WRITE | Plugin cache | -| `/output` | READ-WRITE | Extracted subtitle output | ## Environment Variables | Variable | Description | |----------|-------------| | `PORT` | HTTP server port (default: 8080) | -| `WEBDAV_URL` | WebDAV base URL for file access | - -## API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/health` | GET | Health check | -| `/manifest` | GET | Plugin manifest | -| `/configure` | POST | Update configuration | -| `/process` | POST | Process a video file | +| `WEBDAV_URL` | Optional WebDAV override (otherwise resolved from the meta-core `/urls`) | +| `CACHE_PATH` | Temp root (default `/cache`; files go to `$CACHE_PATH/temp`) | ## License diff --git a/package.json b/package.json index bcb75c8..cd7ee66 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,16 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "fastify": "^5.0.0" }, "devDependencies": { "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^1.6.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83a32ae..c1dfa5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,9 +18,150 @@ importers: typescript: specifier: ^5.3.0 version: 5.9.3 + vitest: + specifier: ^1.6.1 + version: 1.6.1(@types/node@20.19.33) packages: + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -39,15 +180,183 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@rollup/rollup-android-arm-eabi@4.63.2': + resolution: {integrity: sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.2': + resolution: {integrity: sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.2': + resolution: {integrity: sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.2': + resolution: {integrity: sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.2': + resolution: {integrity: sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.2': + resolution: {integrity: sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + resolution: {integrity: sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.63.2': + resolution: {integrity: sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.63.2': + resolution: {integrity: sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.63.2': + resolution: {integrity: sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.63.2': + resolution: {integrity: sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.63.2': + resolution: {integrity: sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.63.2': + resolution: {integrity: sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.63.2': + resolution: {integrity: sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.63.2': + resolution: {integrity: sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.63.2': + resolution: {integrity: sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.63.2': + resolution: {integrity: sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.63.2': + resolution: {integrity: sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.63.2': + resolution: {integrity: sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.63.2': + resolution: {integrity: sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.2': + resolution: {integrity: sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.2': + resolution: {integrity: sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.2': + resolution: {integrity: sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.2': + resolution: {integrity: sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.2': + resolution: {integrity: sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@20.19.33': resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -59,6 +368,13 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -66,14 +382,61 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -99,10 +462,36 @@ packages: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} @@ -112,10 +501,70 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} @@ -126,6 +575,17 @@ packages: resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==} hasBin: true + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -135,6 +595,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -154,6 +617,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rollup@4.63.2: + resolution: {integrity: sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + safe-regex2@5.0.0: resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} @@ -172,31 +640,225 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + thread-stream@4.0.0: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + snapshots: + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.17.1 @@ -220,14 +882,137 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.3.0 + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@pinojs/redact@0.4.0': {} + '@rollup/rollup-android-arm-eabi@4.63.2': + optional: true + + '@rollup/rollup-android-arm64@4.63.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.2': + optional: true + + '@rollup/rollup-darwin-x64@4.63.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.2': + optional: true + + '@sinclair/typebox@0.27.12': {} + + '@types/estree@1.0.9': {} + '@types/node@20.19.33': dependencies: undici-types: 6.21.0 + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + abstract-logging@2.0.1: {} + acorn-walk@8.3.5: + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + ajv-formats@3.0.1(ajv@8.17.1): optionalDependencies: ajv: 8.17.1 @@ -239,6 +1024,10 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-styles@5.2.0: {} + + assertion-error@1.1.0: {} + atomic-sleep@1.0.0: {} avvio@9.1.0: @@ -246,10 +1035,86 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + cac@6.7.14: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + confbox@0.1.8: {} + cookie@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + dequal@2.0.3: {} + diff-sequences@29.6.3: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -297,8 +1162,23 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 5.0.0 + fsevents@2.3.3: + optional: true + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + human-signals@5.0.0: {} + ipaddr.js@2.3.0: {} + is-stream@3.0.0: {} + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 @@ -311,8 +1191,60 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + merge-stream@2.0.0: {} + + mimic-fn@4.0.0: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + on-exit-leak-free@2.1.2: {} + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + pino-abstract-transport@3.0.0: dependencies: split2: 4.2.0 @@ -333,12 +1265,32 @@ snapshots: sonic-boom: 4.2.0 thread-stream: 4.0.0 + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + process-warning@4.0.1: {} process-warning@5.0.0: {} quick-format-unescaped@4.0.4: {} + react-is@18.3.1: {} + real-require@0.2.0: {} require-from-string@2.0.2: {} @@ -349,6 +1301,38 @@ snapshots: rfdc@1.4.1: {} + rollup@4.63.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.2 + '@rollup/rollup-android-arm64': 4.63.2 + '@rollup/rollup-darwin-arm64': 4.63.2 + '@rollup/rollup-darwin-x64': 4.63.2 + '@rollup/rollup-freebsd-arm64': 4.63.2 + '@rollup/rollup-freebsd-x64': 4.63.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.2 + '@rollup/rollup-linux-arm-musleabihf': 4.63.2 + '@rollup/rollup-linux-arm64-gnu': 4.63.2 + '@rollup/rollup-linux-arm64-musl': 4.63.2 + '@rollup/rollup-linux-loong64-gnu': 4.63.2 + '@rollup/rollup-linux-loong64-musl': 4.63.2 + '@rollup/rollup-linux-ppc64-gnu': 4.63.2 + '@rollup/rollup-linux-ppc64-musl': 4.63.2 + '@rollup/rollup-linux-riscv64-gnu': 4.63.2 + '@rollup/rollup-linux-riscv64-musl': 4.63.2 + '@rollup/rollup-linux-s390x-gnu': 4.63.2 + '@rollup/rollup-linux-x64-gnu': 4.63.2 + '@rollup/rollup-linux-x64-musl': 4.63.2 + '@rollup/rollup-openbsd-x64': 4.63.2 + '@rollup/rollup-openharmony-arm64': 4.63.2 + '@rollup/rollup-win32-arm64-msvc': 4.63.2 + '@rollup/rollup-win32-ia32-msvc': 4.63.2 + '@rollup/rollup-win32-x64-gnu': 4.63.2 + '@rollup/rollup-win32-x64-msvc': 4.63.2 + fsevents: 2.3.3 + safe-regex2@5.0.0: dependencies: ret: 0.5.0 @@ -361,18 +1345,122 @@ snapshots: set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-final-newline@3.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + thread-stream@4.0.0: dependencies: real-require: 0.2.0 + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + toad-cache@3.7.0: {} + type-detect@4.1.0: {} + typescript@5.9.3: {} + ufo@1.6.4: {} + undici-types@6.21.0: {} + + vite-node@1.6.1(@types/node@20.19.33): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.33) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@20.19.33): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.28 + rollup: 4.63.2 + optionalDependencies: + '@types/node': 20.19.33 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@20.19.33): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.33) + vite-node: 1.6.1(@types/node@20.19.33) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.33 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yocto-queue@1.2.2: {} diff --git a/src/langs.ts b/src/langs.ts new file mode 100644 index 0000000..102810e --- /dev/null +++ b/src/langs.ts @@ -0,0 +1,170 @@ +/** + * Language code normalisation — the `lang3` vocabulary meta-watch reads. + * + * Port of meta-watch's `MW_LANG.toLang3` (packages/meta-watch/ui/js/lang-codes.js), + * the same normaliser the `language` plugin copied. Every spelling folds to + * **ISO 639-2/B** — `fre` not `fra`, `ger` not `deu`, `chi` not `zho` — because + * that is what meta-watch writes (`subtitles/fre/<cid>`, routes/subtitles.rs) + * and what its readers compare against: + * + * ISO 639-1 "fr" -> fre + * ISO 639-2/B "fre" -> fre + * ISO 639-2/T "fra" -> fre (MP4 `mdhd` speaks T, Matroska speaks B) + * BCP-47 "pt-BR" -> por (primary subtag only) + * + * One deliberate difference: "no language" is `und`, not "" — the code becomes a + * key segment (`subtitles/und/<cid>`, METADATA_KEYS.md §8) and `und` is the + * registry's spelling for undetermined. + * + * Copied, not imported: plugins are independent submodules. This file is + * byte-identical in metamesh-plugin-subtitle and metamesh-plugin-subtitle-extractor. + */ + +// Canonical table keyed by 639-2/B: display name (lowercased for filename +// matching) and the 639-1 code that folds into it. +const LANGS: Record<string, { name: string; a1?: string }> = { + eng: { name: 'english', a1: 'en' }, + fre: { name: 'french', a1: 'fr' }, + ger: { name: 'german', a1: 'de' }, + spa: { name: 'spanish', a1: 'es' }, + ita: { name: 'italian', a1: 'it' }, + por: { name: 'portuguese', a1: 'pt' }, + dut: { name: 'dutch', a1: 'nl' }, + swe: { name: 'swedish', a1: 'sv' }, + nor: { name: 'norwegian', a1: 'no' }, + dan: { name: 'danish', a1: 'da' }, + fin: { name: 'finnish', a1: 'fi' }, + ice: { name: 'icelandic', a1: 'is' }, + pol: { name: 'polish', a1: 'pl' }, + cze: { name: 'czech', a1: 'cs' }, + slo: { name: 'slovak', a1: 'sk' }, + slv: { name: 'slovenian', a1: 'sl' }, + hun: { name: 'hungarian', a1: 'hu' }, + rum: { name: 'romanian', a1: 'ro' }, + bul: { name: 'bulgarian', a1: 'bg' }, + rus: { name: 'russian', a1: 'ru' }, + ukr: { name: 'ukrainian', a1: 'uk' }, + bel: { name: 'belarusian', a1: 'be' }, + srp: { name: 'serbian', a1: 'sr' }, + hrv: { name: 'croatian', a1: 'hr' }, + bos: { name: 'bosnian', a1: 'bs' }, + mac: { name: 'macedonian', a1: 'mk' }, + alb: { name: 'albanian', a1: 'sq' }, + gre: { name: 'greek', a1: 'el' }, + tur: { name: 'turkish', a1: 'tr' }, + heb: { name: 'hebrew', a1: 'he' }, + ara: { name: 'arabic', a1: 'ar' }, + per: { name: 'persian', a1: 'fa' }, + urd: { name: 'urdu', a1: 'ur' }, + hin: { name: 'hindi', a1: 'hi' }, + ben: { name: 'bengali', a1: 'bn' }, + tam: { name: 'tamil', a1: 'ta' }, + tel: { name: 'telugu', a1: 'te' }, + mal: { name: 'malayalam', a1: 'ml' }, + kan: { name: 'kannada', a1: 'kn' }, + mar: { name: 'marathi', a1: 'mr' }, + guj: { name: 'gujarati', a1: 'gu' }, + pan: { name: 'punjabi', a1: 'pa' }, + nep: { name: 'nepali', a1: 'ne' }, + sin: { name: 'sinhala', a1: 'si' }, + tha: { name: 'thai', a1: 'th' }, + lao: { name: 'lao', a1: 'lo' }, + khm: { name: 'khmer', a1: 'km' }, + bur: { name: 'burmese', a1: 'my' }, + vie: { name: 'vietnamese', a1: 'vi' }, + ind: { name: 'indonesian', a1: 'id' }, + may: { name: 'malay', a1: 'ms' }, + tgl: { name: 'tagalog', a1: 'tl' }, + fil: { name: 'filipino' }, + jpn: { name: 'japanese', a1: 'ja' }, + kor: { name: 'korean', a1: 'ko' }, + chi: { name: 'chinese', a1: 'zh' }, + mon: { name: 'mongolian', a1: 'mn' }, + kaz: { name: 'kazakh', a1: 'kk' }, + uzb: { name: 'uzbek', a1: 'uz' }, + aze: { name: 'azerbaijani', a1: 'az' }, + geo: { name: 'georgian', a1: 'ka' }, + arm: { name: 'armenian', a1: 'hy' }, + est: { name: 'estonian', a1: 'et' }, + lav: { name: 'latvian', a1: 'lv' }, + lit: { name: 'lithuanian', a1: 'lt' }, + cat: { name: 'catalan', a1: 'ca' }, + glg: { name: 'galician', a1: 'gl' }, + baq: { name: 'basque', a1: 'eu' }, + wel: { name: 'welsh', a1: 'cy' }, + gle: { name: 'irish', a1: 'ga' }, + afr: { name: 'afrikaans', a1: 'af' }, + swa: { name: 'swahili', a1: 'sw' }, + amh: { name: 'amharic', a1: 'am' }, + zul: { name: 'zulu', a1: 'zu' }, + lat: { name: 'latin', a1: 'la' }, + epo: { name: 'esperanto', a1: 'eo' }, + yid: { name: 'yiddish', a1: 'yi' }, + jav: { name: 'javanese', a1: 'jv' }, + mul: { name: 'multiple' }, +}; + +// ISO 639-2/T -> 639-2/B: the only codes where the two standards disagree. +const T2B: Record<string, string> = { + sqi: 'alb', hye: 'arm', eus: 'baq', mya: 'bur', ces: 'cze', + zho: 'chi', cym: 'wel', deu: 'ger', nld: 'dut', ell: 'gre', fas: 'per', + fra: 'fre', kat: 'geo', isl: 'ice', mkd: 'mac', msa: 'may', + ron: 'rum', slk: 'slo', +}; + +// "This track has no language" spellings. +const NONE = new Set(['und', 'zxx', 'mis', 'unknown', 'none']); + +// 639-1 -> 639-2/B, from the table plus legacy two-letter codes. +const A1: Record<string, string> = { nb: 'nor', nn: 'nor', iw: 'heb', in: 'ind', ji: 'yid', jw: 'jav' }; +const NAMES: Record<string, string> = {}; +for (const [b, entry] of Object.entries(LANGS)) { + if (entry.a1) A1[entry.a1] = b; + NAMES[entry.name] = b; +} + +/** + * franc-min answers in ISO 639-3 individual-language codes; a few of them are + * not 639-2 codes at all (Mandarin `cmn` is 639-2 `chi`). Folded before toLang3. + */ +const FRANC_TO_639_2: Record<string, string> = { + cmn: 'chi', arb: 'ara', pes: 'per', swh: 'swa', uzn: 'uzb', zlm: 'may', + npi: 'nep', azj: 'aze', plt: 'mlg', pbu: 'pus', ckb: 'kur', fuv: 'ful', qug: 'que', +}; + +/** Canonical lang3 (ISO 639-2/B) for any code; `und` when there is none. */ +export function toLang3(code: unknown): string { + const s = String(code ?? '').trim().toLowerCase(); + if (!s) return 'und'; + if (NAMES[s]) return NAMES[s]; + const p = s.split(/[-_]/)[0]; + if (NONE.has(p)) return 'und'; + if (p.length === 2) return A1[p] ?? 'und'; + if (p.length === 3 && /^[a-z]{3}$/.test(p)) return T2B[p] ?? p; + return 'und'; +} + +/** lang3 for a franc-min result (`und` stays `und`). */ +export function francToLang3(code: string): string { + return toLang3(FRANC_TO_639_2[code] ?? code); +} + +/** + * Strict variant for filename tokens (`video.fr.srt`, `video.English.srt`): + * only a token that is recognisably a language resolves, so `S01`, `x264` or + * `Extended` never read as one. Returns null for anything else. + */ +export function languageToken(token: string): string | null { + const t = token.trim().toLowerCase(); + if (!t) return null; + if (NAMES[t]) return NAMES[t]; + if (t === 'und') return 'und'; + const p = t.split(/[-_]/)[0]; + if (p !== t && !/^[a-z]{2,3}[-_][a-z0-9]{2,8}$/.test(t)) return null; + if (p.length === 2) return A1[p] ?? null; + if (p.length === 3) { + if (LANGS[p]) return p; + if (T2B[p]) return T2B[p]; + } + return null; +} diff --git a/src/meta-core-client.ts b/src/meta-core-client.ts index 6bdbad8..f293684 100644 --- a/src/meta-core-client.ts +++ b/src/meta-core-client.ts @@ -1,6 +1,10 @@ /** - * meta-core API Client for writing metadata - * Fails gracefully if meta-core is unavailable (logs warning, returns without throwing) + * meta-core API client for writing metadata. + * + * Writes report success so a failed write fails the task instead of being + * swallowed. Key-sets are written as individual `prefix/member = "true"` + * fields through PATCH — never via `_add`, which stores one comma-joined + * string and can never produce the key-set shape. */ export class MetaCoreClient { @@ -8,86 +12,57 @@ export class MetaCoreClient { private async safeFetch(url: string, options: RequestInit): Promise<Response | null> { try { - return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) }); + return await fetch(url, { ...options, signal: AbortSignal.timeout(10000) }); } catch (error) { - console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}`); + console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}: ${error instanceof Error ? error.message : String(error)}`); return null; } } - async setProperty(hashId: string, key: string, value: string): Promise<void> { - const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value }), - }); - if (response && !response.ok) { - console.warn(`[MetaCoreClient] Failed to set property: ${response.status}`); - } - } - async getProperty(hashId: string, key: string): Promise<string | null> { const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' }); - if (!response || response.status === 404) return null; - if (!response.ok) return null; - const data = await response.json() as { value?: string }; - return data.value ?? null; + if (!response || !response.ok) return null; + const data = await response.json() as { value?: unknown }; + return data.value === undefined || data.value === null ? null : String(data.value); } - async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<void> { + /** + * Whole record, in whatever form the endpoint serves (meta-sort's /meta is + * nested, meta-core's is flat) — callers flatten. `null` when the call + * itself failed, `{}` when the record does not exist. + */ + async getMetadata(hashId: string): Promise<Record<string, unknown> | null> { + const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' }); + if (!response) return null; + if (response.status === 404) return {}; + if (!response.ok) return null; + const data = await response.json() as { metadata?: Record<string, unknown> }; + return data.metadata ?? {}; + } + + /** PATCH — merge `metadata` into the record. True on success. */ + async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<boolean> { const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metadata), }); - if (response && !response.ok) { - console.warn(`[MetaCoreClient] Failed to merge metadata: ${response.status}`); + if (!response) return false; + if (!response.ok) { + console.warn(`[MetaCoreClient] Failed to merge metadata into ${hashId}: ${response.status}`); + return false; } + return true; } - async deleteProperty(hashId: string, key: string): Promise<void> { + /** DELETE one field. A missing field counts as deleted. */ + async deleteProperty(hashId: string, key: string): Promise<boolean> { const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' }); - if (response && !response.ok && response.status !== 404) { - console.warn(`[MetaCoreClient] Failed to delete property: ${response.status}`); - } - } - - async addToSet(hashId: string, key: string, value: string): Promise<void> { - const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value }), - }); - if (response && !response.ok) { - console.warn(`[MetaCoreClient] Failed to add to set: ${response.status}`); - } - } - - async getMetadata(hashId: string): Promise<Record<string, string>> { - const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' }); - if (!response || response.status === 404) return {}; - if (!response.ok) return {}; - const data = await response.json() as { metadata?: Record<string, string> }; - return data.metadata ?? {}; - } - - /** - * Compute the CID (Content Identifier) for a file - * Uses the meta-core /file/cid API endpoint - */ - async computeFileCID(filePath: string): Promise<string | null> { - try { - const response = await this.safeFetch(`${this.baseUrl}/file/cid`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: filePath }), - }); - if (!response || !response.ok) return null; - const data = await response.json() as { cid?: string }; - return data.cid ?? null; - } catch (error) { - console.debug(`[MetaCoreClient] Failed to compute CID for ${filePath}:`, error); - return null; + if (!response) return false; + if (!response.ok && response.status !== 404) { + console.warn(`[MetaCoreClient] Failed to delete ${hashId}/${key}: ${response.status}`); + return false; } + return true; } } diff --git a/src/meta-shape.ts b/src/meta-shape.ts new file mode 100644 index 0000000..5c41a2d --- /dev/null +++ b/src/meta-shape.ts @@ -0,0 +1,65 @@ +/** + * Reading a record regardless of the shape it arrives in, and writing only what + * is new. + * + * meta-sort's scheduler fills `existingMeta` from meta-sort's own GET /meta/{hash}, + * which returns the NESTED document (`fileinfo: {duration: 1433.089}`, + * `stream: [...]`, a key-set as `subtitles: {fre: {<cid>: true}}`). meta-core's + * backend serves the FLAT form (`fileinfo/duration`, `stream/0`, + * `subtitles/fre/<cid>`). A plugin that reads `existingMeta['x/y']` silently sees + * nothing in production — so everything here goes through `flattenMeta`. + * + * Byte-identical in metamesh-plugin-subtitle and metamesh-plugin-subtitle-extractor. + */ + +/** Flatten either form into `a/b/c -> string` (meta-sort's flattenMetadata, plus array indices). */ +export function flattenMeta(meta: Record<string, unknown> | undefined | null): Record<string, string> { + const out: Record<string, string> = {}; + const walk = (value: unknown, key: string) => { + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + value.forEach((v, i) => walk(v, key ? `${key}/${i}` : String(i))); + } else if (typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { + walk(v, key ? `${key}/${k}` : k); + } + } else if (key) { + out[key] = String(value); + } + }; + walk(meta ?? {}, ''); + return out; +} + +/** The subset of `wanted` the record does not already hold with that exact value. */ +export function onlyNewKeys(existingFlat: Record<string, string>, wanted: Record<string, string>): Record<string, string> { + const out: Record<string, string> = {}; + for (const [k, v] of Object.entries(wanted)) { + if (existingFlat[k] !== v) out[k] = v; + } + return out; +} + +/** + * Members of a LEGACY scalar csv-set (`subtitles = "cidA,cidB"`, written by the + * old `_add` path), or null when the field is absent or already a key-set. + * + * Such a scalar must be deleted before any `field/...` key is written: meta-sort + * rebuilds its nested document by walking the `/`-segments, and a string sitting + * where an object is needed either throws or silently hides the key-set. + */ +export function legacyCsvMembers(meta: Record<string, unknown> | undefined | null, field: string): string[] | null { + const v = meta?.[field]; + if (typeof v !== 'string' && typeof v !== 'number') return null; + return String(v).split(/[,|]/).map((s) => s.trim()).filter(Boolean); +} + +/** Every `<prefix>/<lang>/<cid>` leaf for `cid` whose language is not `lang3`. */ +export function staleLanguageLeaves(existingFlat: Record<string, string>, prefix: string, cid: string, lang3: string): string[] { + const stale: string[] = []; + for (const key of Object.keys(existingFlat)) { + const parts = key.split('/'); + if (parts.length === 3 && parts[0] === prefix && parts[2] === cid && parts[1] !== lang3) stale.push(key); + } + return stale; +} diff --git a/src/plugin.ts b/src/plugin.ts index 21773cb..54f3ea5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,125 +1,155 @@ /** * Subtitle Extractor Plugin * - * Extracts embedded subtitles from video files and saves them as text files. - * Republishes extracted subtitles in the pipeline (similar to TMDB plugin with posters). + * Extracts a video's embedded TEXT subtitle streams into standalone files under + * /files/plugin/subtitle-extractor/ and links them to the video so meta-watch can + * offer them. * * ============================================================================ - * PLUGIN FILE ACCESS ARCHITECTURE - WebDAV + * WHAT IT WRITES (METADATA_KEYS.md §8 / §9) * ============================================================================ + * On the video record, per extracted file `<subCid>` (midhash256 of its bytes) + * in language `<lang3>` (ISO 639-2/B, `und` when the stream declares none): + * subtitles/<lang3>/<subCid> = "true" what meta-watch lists + * extractedSubtitles/<lang3>/<subCid> = "true" provenance: came out of the container + * subtitleLanguages/<lang3> = "true" (not for `und`) + * languages/<lang3> = "true" (not for `und`) + * On the subtitle file's own record: + * videos/<videoCid> = "true" + * subtitleLanguage = <lang3> (not for `und`; the `subtitle` + * plugin's text sniff fills it) + * Writes are one PATCH carrying only keys the record does not already hold. + * Legacy scalar csv-sets from the old `_add` path (`extractedSubtitles`, + * `subtitleLanguages`, `subtitles`) are deleted first; see meta-shape.ts. * - * File access via WebDAV (WEBDAV_URL environment variable): - * - Read media files: GET /webdav/watch/... or /webdav/test/... - * - Write output: PUT /webdav/plugin/subtitle-extractor/... - * - Cache: Local /cache mount (for temporary files during extraction) - * - * Benefits: - * - No need for /files or /output volume mounts - * - Works across multiple hosts - * - Plugins are fully isolated + * Image codecs (PGS, VobSub, DVB) are skipped: turning them into text is OCR. * * ============================================================================ + * READ COST + * ============================================================================ + * Subtitle packets are interleaved through the whole container, so extracting + * even one track means demuxing the file end to end — over WebDAV, one full + * sequential read of the video. All text tracks therefore come out of ONE ffmpeg + * pass with one output per track (one read, not N). A per-track retry happens + * only when that pass fails outright (one broken track aborts every output). + * + * ============================================================================ + * FILE ACCESS - WebDAV + * ============================================================================ + * The WebDAV endpoint is resolved per request from the meta-core that drove the + * /process call (its /urls -> webdavUrlInternal; WEBDAV_URL overrides), so the + * bytes land in the same core the CIDs are written to. + * - Read media: GET /webdav/watch/... + * - Write output: PUT /webdav/plugin/subtitle-extractor/... + * - Temp: $CACHE_PATH/temp (default /cache/temp) */ import { spawn } from 'child_process'; -import { existsSync, mkdirSync, statSync, unlinkSync, readFileSync } from 'fs'; import { createHash } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; import * as path from 'path'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; -import { createWebDAVClient } from './webdav-client.js'; +import { getWebDAVClient } from './webdav-client.js'; +import { toLang3 } from './langs.js'; +import { flattenMeta, onlyNewKeys, legacyCsvMembers, staleLanguageLeaves } from './meta-shape.js'; -// Initialize WebDAV client if WEBDAV_URL is set -const webdavClient = createWebDAVClient(); -if (webdavClient) { - console.log('[subtitle-extractor] WebDAV client initialized for file access'); -} else { - console.warn('[subtitle-extractor] WARNING: WEBDAV_URL not set - plugin will not be able to write output files'); +const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/subtitle-extractor'; + +function tempDir(): string { + return path.join(globalThis.process.env.CACHE_PATH || '/cache', 'temp'); } -/** - * ============================================================================ - * PLUGIN OUTPUT PATH - Written via WebDAV - * ============================================================================ - * Output files (subtitles) are written via WebDAV PUT requests. - * Path: /files/plugin/subtitle-extractor/<filename> (accessible at WEBDAV_URL/plugin/subtitle-extractor/) - * TEMP_PATH (/cache/temp) - Local temp folder for ffmpeg extraction before WebDAV upload - * ============================================================================ - */ -const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/subtitle-extractor'; -const TEMP_PATH = '/cache/temp'; - -// Image-based subtitle codecs that cannot be converted to text -const UNSUPPORTED_SUBTITLE_CODECS = new Set([ +/** Bitmap subtitle codecs: no text to extract without OCR. */ +export const IMAGE_SUBTITLE_CODECS = new Set([ 'hdmv_pgs_subtitle', 'pgssub', 'dvd_subtitle', 'dvdsub', 'dvb_subtitle', 'dvbsub', + 'dvb_teletext', 'xsub', ]); -// Supported text-based subtitle codecs -const CODEC_EXTENSION_MAP: Record<string, string> = { - 'subrip': 'srt', - 'srt': 'srt', - 'ass': 'ass', - 'ssa': 'ssa', - 'webvtt': 'vtt', - 'mov_text': 'srt', - 'text': 'srt', +type SubtitleExt = 'srt' | 'ass' | 'ssa' | 'vtt'; + +/** Text codecs we extract, and the file type each one naturally becomes. */ +const TEXT_SUBTITLE_CODECS: Record<string, SubtitleExt> = { + subrip: 'srt', + srt: 'srt', + ass: 'ass', + ssa: 'ssa', + webvtt: 'vtt', + mov_text: 'srt', + text: 'srt', }; +/** Codecs whose packets can be stream-copied into their natural file type. */ +const COPYABLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt']); + +const ENCODER_FOR_EXT: Record<SubtitleExt, string> = { srt: 'srt', vtt: 'webvtt', ass: 'ass', ssa: 'ass' }; + +export type OutputFormat = 'native' | 'srt' | 'vtt' | 'ass'; +const OUTPUT_FORMATS = new Set<OutputFormat>(['native', 'srt', 'vtt', 'ass']); + export const manifest: PluginManifest = { id: 'subtitle-extractor', name: 'Subtitle Extractor', - version: '1.0.0', - description: 'Extracts embedded subtitles from video files and saves them as text files', + version: '1.1.0', + description: 'Extracts embedded text subtitles from video files and links them to the video', author: 'MetaMesh', + // file-info gives `fileType`; ffmpeg gives the `stream` table. dependencies: ['file-info', 'ffmpeg'], priority: 50, color: '#FF5722', + // One full read of the video per run. Never the fast queue. defaultQueue: 'background', - timeout: 300000, // 5 minutes for large files + timeout: 300000, schema: { - extractedSubtitles: { label: 'Extracted Subtitles', type: 'array', readonly: true }, - subtitleLanguages: { label: 'Subtitle Languages', type: 'array', readonly: true }, + subtitles: { label: 'Subtitles', type: 'json', readonly: true, hint: 'subtitles/<lang3>/<cid>' }, + extractedSubtitles: { label: 'Extracted Subtitles', type: 'json', readonly: true, hint: 'extractedSubtitles/<lang3>/<cid>' }, + subtitleLanguages: { label: 'Subtitle Languages', type: 'json', readonly: true, hint: 'subtitleLanguages/<lang3>' }, }, config: { - forceRecompute: { - type: 'boolean', - label: 'Force Recompute', - default: false, - }, + forceRecompute: { type: 'boolean', label: 'Force Recompute', default: false }, outputFormat: { type: 'select', - label: 'Output Format', - default: 'srt', + label: 'Output Format (native keeps ASS styling; srt/vtt/ass convert)', + default: 'native', + }, + extractionTimeoutMs: { + type: 'number', + label: 'Extraction timeout (ms) for the single ffmpeg pass', + default: 270000, }, }, }; -// Configuration let forceRecompute = false; -let outputFormat = 'srt'; +let outputFormat: OutputFormat = 'native'; +let extractionTimeoutMs = 270000; export function configure(config: Record<string, unknown>): void { forceRecompute = config.forceRecompute === true; - outputFormat = (config.outputFormat as string) || 'srt'; - console.log(`[subtitle-extractor] Config: forceRecompute=${forceRecompute}, outputFormat=${outputFormat}`); + const format = String(config.outputFormat ?? 'native') as OutputFormat; + outputFormat = OUTPUT_FORMATS.has(format) ? format : 'native'; + const timeout = config.extractionTimeoutMs === null || config.extractionTimeoutMs === undefined + ? NaN + : Number(config.extractionTimeoutMs); + extractionTimeoutMs = Number.isFinite(timeout) && timeout > 0 ? timeout : 270000; + console.log(`[subtitle-extractor] Config: forceRecompute=${forceRecompute}, outputFormat=${outputFormat}, extractionTimeoutMs=${extractionTimeoutMs}`); } /** - * Compute midhash256 CID from a Buffer (matches meta-hash algorithm) - * Used for computing CID before/after WebDAV upload + * midhash256 CID of a buffer (matches meta-hash's computeMidHash256Sync): + * sha256(size_u64_be ‖ middle 1 MiB), wrapped as CIDv1 multibase base32. */ -function computeMidHash256FromBuffer(data: Buffer): string { - const SAMPLE_SIZE = 1024 * 1024; // 1MB +export function computeMidHash256FromBuffer(data: Buffer): string { + const SAMPLE_SIZE = 1024 * 1024; const MIDHASH_VARINT = Buffer.from([0x80, 0x20]); const fileSize = data.length; - const sizeBuffer = Buffer.allocUnsafe(8); sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0); @@ -131,16 +161,8 @@ function computeMidHash256FromBuffer(data: Buffer): string { sampleData = data.subarray(middleOffset, middleOffset + SAMPLE_SIZE); } - const hashInput = Buffer.concat([sizeBuffer, sampleData]); - const hashBuffer = createHash('sha256').update(hashInput).digest(); - - const cidBytes = Buffer.concat([ - Buffer.from([0x01]), - MIDHASH_VARINT, - MIDHASH_VARINT, - Buffer.from([0x20]), - hashBuffer - ]); + const hashBuffer = createHash('sha256').update(Buffer.concat([sizeBuffer, sampleData])).digest(); + const cidBytes = Buffer.concat([Buffer.from([0x01]), MIDHASH_VARINT, MIDHASH_VARINT, Buffer.from([0x20]), hashBuffer]); const base32Chars = 'abcdefghijklmnopqrstuvwxyz234567'; let cid = 'b'; @@ -157,337 +179,480 @@ function computeMidHash256FromBuffer(data: Buffer): string { if (bits > 0) { cid += base32Chars[(value << (5 - bits)) & 0x1f]; } - return cid; } -/** - * Sanitize filename by removing invalid characters - */ -function sanitizeFilename(name: string): string { +/** Strip characters that are hostile to paths or to the WebDAV URL. */ +export function sanitizeFilename(name: string): string { return name - .replace(/[<>:"/\\|?*]/g, '') + .replace(/[<>:"/\\|?*#%\x00-\x1f]/g, '') .replace(/\s+/g, ' ') .trim(); } -/** - * Parse subtitle streams from ffmpeg plugin metadata - */ -interface SubtitleStream { - index: number; - codec: string; - language?: string; - title?: string; +// ---------------------------------------------------------------------------- +// Stream table +// ---------------------------------------------------------------------------- + +function numericKeyOrder(a: string, b: string): number { + return Number(a) - Number(b); } -function parseSubtitleStreams(existingMeta: Record<string, string>): SubtitleStream[] { - const streams: SubtitleStream[] = []; +/** + * The ffmpeg plugin's per-stream objects, in stream order, from any of the shapes + * a record can arrive in: + * - nested array of JSON strings stream: ["{...}", "{...}"] (meta-sort /meta today) + * - nested array of objects stream: [{...}, {...}] + * - nested object keyed by index stream: {"0": "{...}", "1": ...} + * - JSON string of an array stream: "[...]" + * - flat meta-core keys "stream/0": "{...}", "stream/10": ... (ordered numerically) + */ +export function readStreamEntries(existingMeta: Record<string, unknown> | undefined): Record<string, unknown>[] { + if (!existingMeta) return []; + const raw: unknown[] = []; - // ffmpeg plugin stores streams as 'stream' field - an array of JSON strings - // Each element is a stringified JSON object like: - // '{"type":"subtitle","codec":"ass","index":2,"language":"eng",...}' - const streamData = existingMeta['stream']; - if (streamData) { + const nested = existingMeta['stream']; + if (nested !== undefined && nested !== null && nested !== '') { try { - // Parse the outer array (it's already an array from meta-core) - const allStreams = Array.isArray(streamData) - ? streamData - : JSON.parse(streamData) as string[]; - - let subtitleIndex = 0; - for (const streamStr of allStreams) { - // Each stream is a JSON string - const stream = typeof streamStr === 'string' - ? JSON.parse(streamStr) as { - type?: string; - codec?: string; - index?: number; - language?: string; - title?: string; - } - : streamStr; - - if (stream.type === 'subtitle') { - streams.push({ - index: subtitleIndex, - codec: stream.codec || 'unknown', - language: stream.language, - title: stream.title, - }); - subtitleIndex++; - } + const parsed = typeof nested === 'string' ? JSON.parse(nested) : nested; + if (Array.isArray(parsed)) { + raw.push(...parsed); + } else if (parsed && typeof parsed === 'object') { + const obj = parsed as Record<string, unknown>; + raw.push(...Object.keys(obj).sort(numericKeyOrder).map((k) => obj[k])); } - } catch (e) { - console.error('[subtitle-extractor] Failed to parse stream metadata:', e); + } catch { + // fall through to the flat form } } - // Fallback: parse individual fields (legacy format) - if (streams.length === 0) { + if (raw.length === 0) { + const flat = Object.keys(existingMeta) + .filter((k) => /^stream\/\d+$/.test(k)) + .sort((a, b) => numericKeyOrder(a.slice(7), b.slice(7))); + raw.push(...flat.map((k) => existingMeta[k])); + } + + const entries: Record<string, unknown>[] = []; + for (const item of raw) { + try { + const obj = typeof item === 'string' ? JSON.parse(item) : item; + if (obj && typeof obj === 'object' && !Array.isArray(obj)) entries.push(obj as Record<string, unknown>); + } catch { + // skip an unparseable entry, keep the rest + } + } + return entries; +} + +export interface SubtitleStream { + /** Position among subtitle streams (ffmpeg `0:s:<ordinal>`). */ + ordinal: number; + /** Global ffmpeg stream index (`0:<streamIndex>`), when the record has it. */ + streamIndex?: number; + codec: string; + /** ISO 639-2/B, `und` when undeclared. */ + lang3: string; + title?: string; + forced: boolean; +} + +const isTrue = (v: unknown) => v === true || v === 'true'; + +/** Subtitle streams of a record, from the stream table or the legacy `subtitle_{i}_*` fields. */ +export function parseSubtitleStreams(existingMeta: Record<string, unknown> | undefined): SubtitleStream[] { + const streams: SubtitleStream[] = []; + let ordinal = 0; + for (const entry of readStreamEntries(existingMeta)) { + if (entry.type !== 'subtitle') continue; + const index = entry.index === undefined || entry.index === null ? NaN : Number(entry.index); + streams.push({ + ordinal: ordinal++, + streamIndex: Number.isInteger(index) && index >= 0 ? index : undefined, + codec: String(entry.codec ?? 'unknown').toLowerCase(), + lang3: toLang3(entry.language), + title: typeof entry.title === 'string' ? entry.title : undefined, + forced: isTrue(entry.forced), + }); + } + + if (streams.length === 0 && existingMeta) { for (let i = 0; i < 20; i++) { const codec = existingMeta[`subtitle_${i}_codec`]; - if (codec) { - streams.push({ - index: i, - codec, - language: existingMeta[`subtitle_${i}_language`], - title: existingMeta[`subtitle_${i}_title`], - }); - } + if (!codec) continue; + const title = existingMeta[`subtitle_${i}_title`]; + streams.push({ + ordinal: i, + codec: String(codec).toLowerCase(), + lang3: toLang3(existingMeta[`subtitle_${i}_language`]), + title: typeof title === 'string' ? title : undefined, + forced: false, + }); } } - return streams; } -/** - * Extract a subtitle track using ffmpeg - */ -async function extractSubtitle( - inputPath: string, - outputPath: string, - subtitleIndex: number, - codec: string -): Promise<boolean> { - return new Promise((resolve) => { - // Determine output codec based on format - let outputCodec = 'srt'; - if (outputFormat === 'vtt') { - outputCodec = 'webvtt'; - } else if (outputFormat === 'ass') { - outputCodec = 'ass'; +/** File type and ffmpeg encoder for a codec under an output format; null when not a text codec. */ +export function outputFor(codec: string, format: OutputFormat = outputFormat): { ext: SubtitleExt; encoder: string } | null { + const nativeExt = TEXT_SUBTITLE_CODECS[codec]; + if (!nativeExt) return null; + const ext: SubtitleExt = format === 'native' ? nativeExt : format; + const encoder = ext === nativeExt && COPYABLE_CODECS.has(codec) ? 'copy' : ENCODER_FOR_EXT[ext]; + return { ext, encoder }; +} + +export type TextSubtitleStream = SubtitleStream & { ext: SubtitleExt; encoder: string }; + +export function classifySubtitleStreams(streams: SubtitleStream[], format: OutputFormat = outputFormat): { + text: TextSubtitleStream[]; + image: SubtitleStream[]; + unsupported: SubtitleStream[]; +} { + const text: TextSubtitleStream[] = []; + const image: SubtitleStream[] = []; + const unsupported: SubtitleStream[] = []; + for (const s of streams) { + if (IMAGE_SUBTITLE_CODECS.has(s.codec)) { + image.push(s); + continue; } + const out = outputFor(s.codec, format); + if (out) text.push({ ...s, ...out }); + else unsupported.push(s); + } + return { text, image, unsupported }; +} - const args = [ - '-y', - '-hide_banner', - '-loglevel', 'error', - '-probesize', '1M', - '-analyzeduration', '1M', - '-i', inputPath, - '-map', `0:s:${subtitleIndex}`, - '-c:s', outputCodec, - outputPath - ]; +const scalar = (v: unknown): string => (typeof v === 'string' || typeof v === 'number' ? String(v) : ''); - console.log(`[subtitle-extractor] Running ffmpeg for subtitle ${subtitleIndex}`); +/** + * `Title (Year)[videoCid]_subtitle.s<streamIndex>.<lang3>[.forced].<ext>`. + * The stream index keeps two tracks in one language (full + signs, es-ES + + * es-419) from overwriting each other; the trailing `<lang3>` is what the + * `subtitle` plugin reads back as the file's language. + */ +export function subtitleFilename(existingMeta: Record<string, unknown>, videoCid: string, stream: SubtitleStream, ext: string): string { + const fileName = scalar(existingMeta.fileName); + const rawTitle = scalar(existingMeta.originalTitle) || scalar(existingMeta.title) || (fileName ? path.parse(fileName).name : ''); + const safeTitle = sanitizeFilename(rawTitle).slice(0, 100) || 'video'; + const year = scalar(existingMeta.movieYear); + const track = stream.streamIndex !== undefined ? `s${stream.streamIndex}` : `t${stream.ordinal}`; + return `${safeTitle}${year ? ` (${year})` : ''}[${videoCid}]_subtitle.${track}.${stream.lang3}${stream.forced ? '.forced' : ''}.${ext}`; +} - const ffmpeg = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] }); +// ---------------------------------------------------------------------------- +// Extraction +// ---------------------------------------------------------------------------- +export interface ExtractJob { + stream: TextSubtitleStream; + tempFile: string; +} + +/** ONE ffmpeg invocation writing every job's track to its own output (one read of the input). */ +export function buildExtractionArgs(input: string, jobs: ExtractJob[]): string[] { + const args = ['-y', '-hide_banner', '-nostdin', '-loglevel', 'error', '-i', input]; + for (const job of jobs) { + const map = job.stream.streamIndex !== undefined ? `0:${job.stream.streamIndex}` : `0:s:${job.stream.ordinal}`; + args.push('-map', map, '-c:s', job.stream.encoder, job.tempFile); + } + return args; +} + +/** Number of ffmpeg processes started — each one is a full read of the input. */ +export const extractionStats = { ffmpegPasses: 0 }; + +function runFfmpeg(args: string[], timeoutMs: number): Promise<{ code: number | null; stderr: string; timedOut: boolean }> { + extractionStats.ffmpegPasses++; + return new Promise((resolve) => { + const ffmpeg = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; - ffmpeg.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - ffmpeg.on('close', (code) => { - if (code === 0 && existsSync(outputPath)) { - const size = statSync(outputPath).size; - if (size > 10) { - console.log(`[subtitle-extractor] Extracted subtitle ${subtitleIndex} (${size} bytes)`); - resolve(true); - } else { - console.log(`[subtitle-extractor] Subtitle ${subtitleIndex} extraction produced empty file`); - try { unlinkSync(outputPath); } catch {} - resolve(false); - } - } else { - console.log(`[subtitle-extractor] Failed to extract subtitle ${subtitleIndex}: ${stderr.slice(0, 200)}`); - resolve(false); - } - }); - - ffmpeg.on('error', (err) => { - console.log(`[subtitle-extractor] ffmpeg error: ${err.message}`); - resolve(false); - }); - - // Timeout after 2 minutes per subtitle - setTimeout(() => { + let timedOut = false; + let settled = false; + const finish = (code: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, stderr, timedOut }); + }; + const timer = setTimeout(() => { + timedOut = true; ffmpeg.kill('SIGKILL'); - resolve(false); - }, 120000); + }, timeoutMs); + ffmpeg.stderr?.on('data', (d) => { if (stderr.length < 4000) stderr += d.toString(); }); + ffmpeg.on('close', (code) => finish(code)); + ffmpeg.on('error', (err) => { stderr += err.message; finish(-1); }); }); } +/** Does an extracted file carry at least one cue (an ASS header alone does not count)? */ +export function hasCues(data: Buffer, ext: string): boolean { + const text = data.toString('utf8'); + if (ext === 'ass' || ext === 'ssa') return /^Dialogue:/m.test(text); + return text.includes('-->'); +} + +function takeOutput(job: ExtractJob): Buffer | null { + if (!existsSync(job.tempFile)) return null; + const data = readFileSync(job.tempFile); + try { unlinkSync(job.tempFile); } catch { /* ignore */ } + return hasCues(data, job.stream.ext) ? data : null; +} + +function discard(job: ExtractJob): void { + try { if (existsSync(job.tempFile)) unlinkSync(job.tempFile); } catch { /* ignore */ } +} + +export interface ExtractResult { + outputs: Array<{ job: ExtractJob; data: Buffer | null }>; + passes: number; + timedOut: boolean; + error?: string; +} + +export async function extractTracks(input: string, jobs: ExtractJob[], timeoutMs: number = extractionTimeoutMs): Promise<ExtractResult> { + const first = await runFfmpeg(buildExtractionArgs(input, jobs), timeoutMs); + if (first.code === 0) { + return { outputs: jobs.map((job) => ({ job, data: takeOutput(job) })), passes: 1, timedOut: false }; + } + jobs.forEach(discard); + const error = first.timedOut ? `ffmpeg timed out after ${timeoutMs} ms` : first.stderr.slice(0, 300) || `ffmpeg exited ${first.code}`; + if (first.timedOut || jobs.length === 1) { + return { outputs: jobs.map((job) => ({ job, data: null })), passes: 1, timedOut: first.timedOut, error }; + } + + // One broken track aborts every output of the shared pass. Salvage the rest + // one track at a time — at the price of one more full read per track. + console.warn(`[subtitle-extractor] single-pass extraction failed (${error}); retrying ${jobs.length} track(s) one by one`); + const outputs: ExtractResult['outputs'] = []; + let passes = 1; + let timedOut = false; + for (const job of jobs) { + if (timedOut) { + outputs.push({ job, data: null }); + continue; + } + const r = await runFfmpeg(buildExtractionArgs(input, [job]), timeoutMs); + passes++; + timedOut = r.timedOut; + if (r.code === 0) { + outputs.push({ job, data: takeOutput(job) }); + } else { + discard(job); + outputs.push({ job, data: null }); + } + } + return { outputs, passes, timedOut, error }; +} + +// ---------------------------------------------------------------------------- +// Record keys +// ---------------------------------------------------------------------------- + +export interface ProducedSubtitle { + subCid: string; + lang3: string; +} + +/** True when this record already carries the `extractedSubtitles` key-set (nested or flat). */ +export function hasExtractedKeySet(existingMeta: Record<string, unknown> | undefined): boolean { + if (!existingMeta) return false; + const v = existingMeta['extractedSubtitles']; + if (v && typeof v === 'object') return true; + return Object.keys(existingMeta).some((k) => k.startsWith('extractedSubtitles/')); +} + +/** Keys on the video record for a set of extracted files (METADATA_KEYS.md §8, §9). */ +export function videoSubtitleKeys(produced: ProducedSubtitle[]): Record<string, string> { + const keys: Record<string, string> = {}; + for (const { subCid, lang3 } of produced) { + keys[`subtitles/${lang3}/${subCid}`] = 'true'; + keys[`extractedSubtitles/${lang3}/${subCid}`] = 'true'; + if (lang3 !== 'und') { + keys[`subtitleLanguages/${lang3}`] = 'true'; + keys[`languages/${lang3}`] = 'true'; + } + } + return keys; +} + +/** Keys on the subtitle file's own record. */ +export function subtitleRecordKeys(videoCid: string, lang3: string): Record<string, string> { + const keys: Record<string, string> = { [`videos/${videoCid}`]: 'true' }; + if (lang3 !== 'und') keys.subtitleLanguage = lang3; + return keys; +} + +/** + * Legacy scalar csv-sets the old version of this plugin wrote (and the old + * `subtitle` plugin's `subtitles`), turned into deletes plus the key-set leaves + * that preserve what they said. `extractedSubtitles` members are dropped: this + * run re-derives them with their language. + */ +export function legacySubtitleMigration(existingMeta: Record<string, unknown> | undefined): { deletes: string[]; sets: Record<string, string> } { + const deletes: string[] = []; + const sets: Record<string, string> = {}; + + if (legacyCsvMembers(existingMeta, 'extractedSubtitles')) deletes.push('extractedSubtitles'); + + const langs = legacyCsvMembers(existingMeta, 'subtitleLanguages'); + if (langs) { + deletes.push('subtitleLanguages'); + for (const l of langs.map(toLang3)) if (l !== 'und') sets[`subtitleLanguages/${l}`] = 'true'; + } + + const subs = legacyCsvMembers(existingMeta, 'subtitles'); + if (subs) { + deletes.push('subtitles'); + for (const cid of subs) sets[`subtitles/und/${cid}`] = 'true'; + } + return { deletes, sets }; +} + +/** Write the links for `produced` onto the video and each subtitle record. False on any failed write. */ +export async function linkSubtitles( + metaCore: MetaCoreClient, + videoCid: string, + existingMeta: Record<string, unknown>, + produced: ProducedSubtitle[], +): Promise<boolean> { + const { deletes, sets } = legacySubtitleMigration(existingMeta); + const flat = flattenMeta(existingMeta); + for (const key of deletes) delete flat[key]; + + for (const { subCid, lang3 } of produced) { + deletes.push( + ...staleLanguageLeaves(flat, 'subtitles', subCid, lang3), + ...staleLanguageLeaves(flat, 'extractedSubtitles', subCid, lang3), + ); + } + for (const key of deletes) { + if (!(await metaCore.deleteProperty(videoCid, key))) return false; + delete flat[key]; + } + + const fresh = onlyNewKeys(flat, { ...sets, ...videoSubtitleKeys(produced) }); + if (Object.keys(fresh).length > 0 && !(await metaCore.mergeMetadata(videoCid, fresh))) return false; + + for (const { subCid, lang3 } of produced) { + const current = await metaCore.getMetadata(subCid); + const wanted = onlyNewKeys(flattenMeta(current), subtitleRecordKeys(videoCid, lang3)); + if (Object.keys(wanted).length > 0 && !(await metaCore.mergeMetadata(subCid, wanted))) return false; + } + return true; +} + +// ---------------------------------------------------------------------------- +// Task +// ---------------------------------------------------------------------------- + export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise<void> ): Promise<void> { const startTime = Date.now(); + const { taskId, cid, filePath } = request; + const existingMeta = (request.existingMeta ?? {}) as Record<string, unknown>; const metaCore = new MetaCoreClient(request.metaCoreUrl); + const finish = (status: CallbackPayload['status'], extra: { reason?: string; error?: string } = {}) => + sendCallback({ taskId, status, duration: Date.now() - startTime, ...extra }); + const skip = (reason: string, quiet = false) => { + if (!quiet) console.log(`[subtitle-extractor] ${filePath}: skipped — ${reason}`); + return finish('skipped', { reason }); + }; + const fail = (error: string) => { + console.error(`[subtitle-extractor] ${filePath}: failed — ${error}`); + return finish('failed', { error }); + }; + try { - const { cid, filePath, existingMeta } = request; - - // Only process video files - if (existingMeta?.fileType !== 'video') { - await sendCallback({ - taskId: request.taskId, - status: 'skipped', - duration: Date.now() - startTime, - reason: 'Not a video file', - }); + if (existingMeta.fileType !== 'video') { + await skip('Not a video file', true); + return; + } + if (!forceRecompute && hasExtractedKeySet(existingMeta)) { + await skip('Subtitles already extracted', true); return; } - // Skip if already extracted (unless forceRecompute) - if (existingMeta?.extractedSubtitles && !forceRecompute) { - await sendCallback({ - taskId: request.taskId, - status: 'skipped', - duration: Date.now() - startTime, - reason: 'Subtitles already extracted', - }); + const streams = parseSubtitleStreams(existingMeta); + if (streams.length === 0) { + await skip('No subtitle streams found', true); return; } - // Parse subtitle streams from ffmpeg metadata - const subtitleStreams = parseSubtitleStreams(existingMeta || {}); - - if (subtitleStreams.length === 0) { - await sendCallback({ - taskId: request.taskId, - status: 'skipped', - duration: Date.now() - startTime, - reason: 'No subtitle streams found', - }); + const { text, image, unsupported } = classifySubtitleStreams(streams); + if (image.length > 0) { + console.log(`[subtitle-extractor] ${filePath}: skipping ${image.length} image-based subtitle stream(s) (${[...new Set(image.map((s) => s.codec))].join(', ')})`); + } + if (unsupported.length > 0) { + console.log(`[subtitle-extractor] ${filePath}: skipping ${unsupported.length} unsupported subtitle stream(s) (${[...new Set(unsupported.map((s) => s.codec))].join(', ')})`); + } + if (text.length === 0) { + await skip(image.length > 0 ? 'Only image-based subtitles (cannot convert to text)' : 'No supported text subtitle streams'); return; } - // Filter out unsupported (image-based) codecs - const textSubtitles = subtitleStreams.filter(s => !UNSUPPORTED_SUBTITLE_CODECS.has(s.codec)); - - if (textSubtitles.length === 0) { - console.log(`[subtitle-extractor] All ${subtitleStreams.length} subtitles are image-based, skipping`); - await sendCallback({ - taskId: request.taskId, - status: 'skipped', - duration: Date.now() - startTime, - reason: 'Only image-based subtitles (cannot convert to text)', - }); + const webdav = await getWebDAVClient(request.metaCoreUrl); + if (!webdav) { + await fail('No WebDAV endpoint available'); return; } - console.log(`[subtitle-extractor] Found ${textSubtitles.length} text-based subtitle(s) in ${filePath}`); + const produced: ProducedSubtitle[] = []; + const pending: Array<ExtractJob & { webdavPath: string }> = []; + const tmp = tempDir(); + mkdirSync(tmp, { recursive: true }); + const safeTask = taskId.replace(/[^A-Za-z0-9_-]/g, '_'); - // Require WebDAV client for writing output - if (!webdavClient) { - console.error('[subtitle-extractor] WebDAV client not available, cannot write output files'); - await sendCallback({ - taskId: request.taskId, - status: 'failed', - duration: Date.now() - startTime, - error: 'WebDAV client not configured (WEBDAV_URL not set)', - }); - return; - } - - // Get input path (WebDAV URL for reading) - let inputPath = webdavClient.toWebDAVUrl(filePath); - console.log(`[subtitle-extractor] Using WebDAV input: ${inputPath}`); - - // Ensure temp directory exists for ffmpeg extraction - if (!existsSync(TEMP_PATH)) { - mkdirSync(TEMP_PATH, { recursive: true }); - } - - // Get video title for output filename - const title = existingMeta?.originalTitle || existingMeta?.title || existingMeta?.fileName || 'video'; - const safeTitle = sanitizeFilename(title); - const year = existingMeta?.movieYear; - const yearStr = year ? ` (${year})` : ''; - - const extractedCids: string[] = []; - const extractedLanguages: string[] = []; - - // Extract each subtitle - for (const sub of textSubtitles) { - const langSuffix = sub.language ? `.${sub.language}` : `.${sub.index}`; - const ext = outputFormat; - - // Build output filename: Title (Year)[videoCID]_subtitle.lang.srt - const outputFilename = `${safeTitle}${yearStr}[${cid}]_subtitle${langSuffix}.${ext}`; - const webdavOutputPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${outputFilename}`; - const tempOutputPath = path.join(TEMP_PATH, outputFilename); - - // Check if already extracted via WebDAV - if (!forceRecompute) { - const exists = await webdavClient.exists(webdavOutputPath); - if (exists) { - console.log(`[subtitle-extractor] Subtitle already exists: ${outputFilename}`); - // Read existing file to get CID - try { - const existingData = await webdavClient.readFile(webdavOutputPath); - const subtitleCid = computeMidHash256FromBuffer(existingData); - extractedCids.push(subtitleCid); - if (sub.language && !extractedLanguages.includes(sub.language)) { - extractedLanguages.push(sub.language); - } - } catch (e) { - console.error(`[subtitle-extractor] Failed to read existing subtitle: ${e}`); - } - continue; - } - } - - // Extract subtitle to temp file - const extracted = await extractSubtitle(inputPath, tempOutputPath, sub.index, sub.codec); - - if (extracted) { + for (const stream of text) { + const webdavPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${subtitleFilename(existingMeta, cid, stream, stream.ext)}`; + if (!forceRecompute && await webdav.exists(webdavPath)) { try { - // Read extracted subtitle file - const subtitleData = readFileSync(tempOutputPath); - - // Compute CID from buffer - const subtitleCid = computeMidHash256FromBuffer(subtitleData); - console.log(`[subtitle-extractor] Subtitle CID: ${subtitleCid}`); - - // Upload to WebDAV - await webdavClient.writeFile(webdavOutputPath, subtitleData); - console.log(`[subtitle-extractor] Uploaded subtitle to WebDAV: ${webdavOutputPath}`); - - extractedCids.push(subtitleCid); - if (sub.language && !extractedLanguages.includes(sub.language)) { - extractedLanguages.push(sub.language); - } - - // Store subtitle CID as metadata on the video - await metaCore.addToSet(cid, 'extractedSubtitles', subtitleCid); - - // Store language if available - if (sub.language) { - await metaCore.addToSet(cid, 'subtitleLanguages', sub.language); - } - - // Clean up temp file - try { unlinkSync(tempOutputPath); } catch {} + // A previous run already wrote this track — reuse it, no read of the video. + produced.push({ subCid: computeMidHash256FromBuffer(await webdav.readFile(webdavPath)), lang3: stream.lang3 }); + continue; } catch (e) { - console.error(`[subtitle-extractor] Failed to process extracted subtitle: ${e}`); - // Clean up temp file on error - try { unlinkSync(tempOutputPath); } catch {} + console.warn(`[subtitle-extractor] could not reuse ${webdavPath}: ${e instanceof Error ? e.message : String(e)}`); } } + pending.push({ stream, webdavPath, tempFile: path.join(tmp, `${safeTask}-${stream.ordinal}.${stream.ext}`) }); + } + + let extraction: ExtractResult | undefined; + if (pending.length > 0) { + console.log(`[subtitle-extractor] ${filePath}: extracting ${pending.length} text track(s) in one pass`); + extraction = await extractTracks(webdav.toWebDAVUrl(filePath), pending); + for (const { job, data } of extraction.outputs) { + if (!data) continue; + const target = (job as ExtractJob & { webdavPath: string }).webdavPath; + await webdav.writeFile(target, data); + produced.push({ subCid: computeMidHash256FromBuffer(data), lang3: job.stream.lang3 }); + } } - if (extractedCids.length > 0) { - console.log(`[subtitle-extractor] Extracted ${extractedCids.length} subtitle(s) from ${filePath}`); - } else { - console.log(`[subtitle-extractor] No subtitles could be extracted from ${filePath}`); + if (produced.length === 0) { + if (extraction?.error) { + await fail(extraction.error); + } else { + await skip('No text subtitle track produced any cues'); + } + return; } - await sendCallback({ - taskId: request.taskId, - status: 'completed', - duration: Date.now() - startTime, - }); + if (!(await linkSubtitles(metaCore, cid, existingMeta, produced))) { + await fail('meta-core write failed'); + return; + } + + console.log( + `[subtitle-extractor] ${filePath}: linked ${produced.length} subtitle(s) ` + + `[${produced.map((p) => p.lang3).join(', ')}] in ${extraction?.passes ?? 0} ffmpeg pass(es)` + ); + await finish('completed'); } catch (error) { - console.error(`[subtitle-extractor] Error:`, error); - await sendCallback({ - taskId: request.taskId, - status: 'failed', - duration: Date.now() - startTime, - error: error instanceof Error ? error.message : String(error), - }); + await fail(error instanceof Error ? error.message : String(error)); } } diff --git a/src/types.ts b/src/types.ts index 4dbbfe2..c4f3212 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,7 +44,9 @@ export interface ProcessRequest { filePath: string; callbackUrl: string; metaCoreUrl: string; - existingMeta?: Record<string, string>; + // meta-sort sends its NESTED /meta document (`fileinfo: {...}`, `stream: [...]`); + // meta-core serves the flat `a/b` form. Top-level scalars match in both. + existingMeta?: Record<string, unknown>; } export interface ProcessResponse { diff --git a/src/webdav-client.ts b/src/webdav-client.ts index a2ceb1c..362a576 100644 --- a/src/webdav-client.ts +++ b/src/webdav-client.ts @@ -64,7 +64,8 @@ export class WebDAVClient { } /** - * Read bytes from a file via HTTP Range request + * Read first N bytes of a file (for magic byte detection) + * Uses HTTP Range request */ async readBytes(filePath: string, start: number, end: number): Promise<Buffer> { const url = this.toWebDAVUrl(filePath); @@ -85,7 +86,7 @@ export class WebDAVClient { } /** - * Read entire file as buffer (for small files) + * Read entire file as buffer (for small files only) */ async readFile(filePath: string): Promise<Buffer> { const url = this.toWebDAVUrl(filePath); @@ -106,12 +107,9 @@ export class WebDAVClient { async writeFile(filePath: string, data: Buffer | string): Promise<void> { const url = this.toWebDAVUrl(filePath); - // Convert Buffer to Uint8Array for fetch compatibility - const body = typeof data === 'string' ? data : new Uint8Array(data); - const response = await fetch(url, { method: 'PUT', - body, + body: data, headers: { 'Content-Type': 'application/octet-stream', }, @@ -135,40 +133,67 @@ export class WebDAVClient { return false; } } - - /** - * List directory contents via HTTP GET (returns JSON from nginx autoindex) - */ - async readdir(dirPath: string): Promise<string[]> { - const url = this.toWebDAVUrl(dirPath); - const finalUrl = url.endsWith('/') ? url : url + '/'; - - const response = await fetch(finalUrl, { - method: 'GET', - headers: { - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`WebDAV GET (dir) failed for ${dirPath}: ${response.status} ${response.statusText}`); - } - - const entries = await response.json() as Array<{ name: string; type: string }>; - return entries.map(e => e.name); - } } /** - * Create a WebDAV client from environment variables + * WebDAV client for the meta-core that drove a /process call. + * + * The endpoint is read from that core's own /urls (`webdavUrlInternal`), + * so blobs always land in the /files of the core we write metadata to. A feeder + * whose plugins run beside it but enrich a remote hub core needs no per-stack + * wiring, and it is impossible to write posters into one core while pointing + * CIDs at another. + * + * WEBDAV_URL still wins when set — an explicit override for tests and for + * topologies where the core's advertised internal URL isn't reachable from here. + * + * Resolutions are cached per meta-core URL; failures are not, so a core that is + * still starting up is retried on the next task. */ -export function createWebDAVClient(): WebDAVClient | null { - const webdavUrl = process.env.WEBDAV_URL; +const clientCache = new Map<string, Promise<WebDAVClient | null>>(); - if (!webdavUrl) { - console.warn('[webdav-client] WEBDAV_URL not set, WebDAV client unavailable'); - return null; +async function resolveWebDAVUrl(metaCoreUrl: string): Promise<string | null> { + // /urls, not /api/urls — metaCoreUrl points at the meta-core backend, whose + // discovery routes are unprefixed (same base the /meta/* writes use). + const base = metaCoreUrl.replace(/\/$/, ''); + const response = await fetch(`${base}/urls`, { signal: AbortSignal.timeout(5000) }); + + if (!response.ok) { + throw new Error(`GET ${base}/urls failed: ${response.status} ${response.statusText}`); } - return new WebDAVClient(webdavUrl); + const urls = await response.json() as { webdavUrlInternal?: string; webdavUrl?: string }; + return urls.webdavUrlInternal ?? urls.webdavUrl ?? null; +} + +export async function getWebDAVClient(metaCoreUrl: string): Promise<WebDAVClient | null> { + const override = process.env.WEBDAV_URL; + if (override) { + return new WebDAVClient(override); + } + + let pending = clientCache.get(metaCoreUrl); + if (!pending) { + pending = resolveWebDAVUrl(metaCoreUrl).then((webdavUrl) => { + if (!webdavUrl) { + console.warn(`[webdav-client] meta-core ${metaCoreUrl} advertises no WebDAV URL`); + return null; + } + console.log(`[webdav-client] Using WebDAV endpoint ${webdavUrl} (from ${metaCoreUrl})`); + return new WebDAVClient(webdavUrl); + }); + clientCache.set(metaCoreUrl, pending); + } + + try { + const client = await pending; + if (!client) { + clientCache.delete(metaCoreUrl); + } + return client; + } catch (error) { + clientCache.delete(metaCoreUrl); + console.warn(`[webdav-client] Failed to resolve WebDAV URL from ${metaCoreUrl}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } } diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..c14511a --- /dev/null +++ b/test.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# ============================================================================= +# Subtitle Extractor Plugin Test Runner +# +# Runs integration tests inside a Docker container with ffmpeg available. +# Usage: +# ./test.sh # Build and run tests +# ./test.sh --no-cache # Rebuild without cache +# ./test.sh --shell # Start shell in test container for debugging +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +IMAGE_NAME="metamesh-plugin-subtitle-extractor-test" +DOCKERFILE="Dockerfile.test" + +# Parse arguments +NO_CACHE="" +SHELL_MODE="" + +for arg in "$@"; do + case $arg in + --no-cache) + NO_CACHE="--no-cache" + ;; + --shell) + SHELL_MODE=1 + ;; + --help|-h) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --no-cache Rebuild Docker image without cache" + echo " --shell Start interactive shell in test container" + echo " --help Show this help message" + exit 0 + ;; + esac +done + +echo "==============================================" +echo " Subtitle Extractor Plugin Test Runner" +echo "==============================================" +echo "" + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "ERROR: Docker is not installed or not in PATH" + exit 1 +fi + +# Build the test image +echo "[1/2] Building test image..." +docker build $NO_CACHE -f "$DOCKERFILE" -t "$IMAGE_NAME" . + +if [ -n "$SHELL_MODE" ]; then + echo "" + echo "[2/2] Starting interactive shell..." + echo " Run 'npm test' to execute tests" + echo "" + docker run --rm -it \ + -v "$SCRIPT_DIR/test:/app/test:ro" \ + -v "$SCRIPT_DIR/src:/app/src:ro" \ + "$IMAGE_NAME" \ + /bin/bash +else + echo "" + echo "[2/2] Running tests..." + echo "" + + # Run tests and capture exit code + docker run --rm \ + -e CI=true \ + "$IMAGE_NAME" + + EXIT_CODE=$? + + echo "" + if [ $EXIT_CODE -eq 0 ]; then + echo "==============================================" + echo " All tests passed!" + echo "==============================================" + else + echo "==============================================" + echo " Tests failed (exit code: $EXIT_CODE)" + echo "==============================================" + fi + + exit $EXIT_CODE +fi diff --git a/test/extractor.test.ts b/test/extractor.test.ts new file mode 100644 index 0000000..d8cf9cf --- /dev/null +++ b/test/extractor.test.ts @@ -0,0 +1,541 @@ +/** + * Subtitle Extractor Plugin Tests + * + * Pure helpers run anywhere; extraction needs ffmpeg and the generated fixtures + * (./test/fixtures/generate-test-fixtures.sh, run by ./test.sh). + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { + manifest, + configure, + process as processFile, + computeMidHash256FromBuffer, + sanitizeFilename, + readStreamEntries, + parseSubtitleStreams, + classifySubtitleStreams, + outputFor, + subtitleFilename, + buildExtractionArgs, + extractTracks, + extractionStats, + hasCues, + hasExtractedKeySet, + videoSubtitleKeys, + subtitleRecordKeys, + legacySubtitleMigration, + linkSubtitles, + type ExtractJob, + type TextSubtitleStream, +} from '../src/plugin.js'; +import { toLang3, languageToken } from '../src/langs.js'; +import { flattenMeta, onlyNewKeys } from '../src/meta-shape.js'; +import { MetaCoreClient } from '../src/meta-core-client.js'; +import type { CallbackPayload } from '../src/types.js'; +import { startFakeCore, writesTo, type FakeCore } from './fake-core.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join(__dirname, 'fixtures'); +const TEMP = '/tmp/subtitle-extractor-test'; + +const fixture = (name: string) => path.join(FIXTURES, name); + +function hasFfmpeg(): boolean { + try { + execSync('ffmpeg -version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} +const ffmpegReady = hasFfmpeg() && existsSync(fixture('with-subs.mkv')) && existsSync(fixture('mov-text.mp4')); + +/** The nested `stream` array exactly as meta-sort's /meta serves it (JSON strings). */ +function nestedStreamsFor(file: string): string[] { + const probe = JSON.parse( + execSync(`ffprobe -v quiet -print_format json -show_streams "${file}"`, { encoding: 'utf-8' }) + ) as { streams: Array<Record<string, any>> }; + return probe.streams.map((s) => { + const entry: Record<string, unknown> = { + type: s.codec_type, + codec: s.codec_name, + index: s.index, + duration: 'N/A', + forced: s.disposition?.forced === 1, + default: s.disposition?.default === 1, + }; + if (s.tags?.language) entry.language = s.tags.language; + if (s.tags?.title) entry.title = s.tags.title; + return JSON.stringify(entry); + }); +} + +const SUB_ENG = { type: 'subtitle', codec: 'ass', index: 3, language: 'eng', title: 'English (Full)', forced: false }; +const SUB_ENG_SIGNS = { type: 'subtitle', codec: 'ass', index: 4, language: 'eng', title: 'Signs', forced: true }; +const SUB_FRE = { type: 'subtitle', codec: 'subrip', index: 10, language: 'fre' }; +const VIDEO = { type: 'video', codec: 'av1', index: 0 }; + +beforeEach(() => configure({})); + +describe('manifest', () => { + it('declares dependencies, queue and the keys it writes', () => { + expect(manifest.id).toBe('subtitle-extractor'); + expect(manifest.dependencies).toEqual(['file-info', 'ffmpeg']); + expect(manifest.defaultQueue).toBe('background'); + expect(Object.keys(manifest.schema ?? {})).toEqual(['subtitles', 'extractedSubtitles', 'subtitleLanguages']); + }); +}); + +describe('computeMidHash256FromBuffer', () => { + it('matches meta-hash for a known buffer', () => { + expect(computeMidHash256FromBuffer(Buffer.from('metamesh-still-extractor-cid-vector'))) + .toBe('bagacbabaecybg7wcyxl7su3dvjleuwgwil5tgeoybrwc35jasqtywb6bnjzk4'); + }); +}); + +describe('language normalisation (ISO 639-2/B, `und` when unknown)', () => { + it('folds 639-1, 639-2/T and BCP-47 onto 639-2/B', () => { + expect(toLang3('fr')).toBe('fre'); + expect(toLang3('fra')).toBe('fre'); + expect(toLang3('fre')).toBe('fre'); + expect(toLang3('deu')).toBe('ger'); + expect(toLang3('zho')).toBe('chi'); + expect(toLang3('pt-BR')).toBe('por'); + expect(toLang3('en')).toBe('eng'); + expect(toLang3('ENG')).toBe('eng'); + expect(toLang3('English')).toBe('eng'); + }); + + it('maps every "no language" spelling, and junk, to und', () => { + for (const v of [undefined, null, '', 'und', 'zxx', 'mis', 'xx', '12', 'toolong']) { + expect(toLang3(v)).toBe('und'); + } + }); + + it('passes an unlisted but well-formed 3-letter code through', () => { + expect(toLang3('tlh')).toBe('tlh'); + }); + + it('is strict about filename tokens', () => { + expect(languageToken('fr')).toBe('fre'); + expect(languageToken('S01')).toBeNull(); + expect(languageToken('x264')).toBeNull(); + expect(languageToken('Extended')).toBeNull(); + }); +}); + +describe('readStreamEntries — every payload shape', () => { + const expectOrder = (meta: Record<string, unknown>) => + expect(readStreamEntries(meta).map((s) => s.index)).toEqual([0, 3, 10]); + + it('nested array of JSON strings (meta-sort /meta)', () => { + expectOrder({ stream: [VIDEO, SUB_ENG, SUB_FRE].map((s) => JSON.stringify(s)) }); + }); + + it('nested array of objects', () => { + expectOrder({ stream: [VIDEO, SUB_ENG, SUB_FRE] }); + }); + + it('nested object keyed by index, ordered numerically', () => { + expectOrder({ stream: { '10': JSON.stringify(SUB_FRE), '0': JSON.stringify(VIDEO), '2': JSON.stringify(SUB_ENG) } }); + }); + + it('a JSON string of the array', () => { + expectOrder({ stream: JSON.stringify([VIDEO, SUB_ENG, SUB_FRE].map((s) => JSON.stringify(s))) }); + }); + + it('flat meta-core `stream/{n}` keys, stream/10 after stream/2', () => { + expectOrder({ + 'stream/10': JSON.stringify(SUB_FRE), + 'stream/2': JSON.stringify(SUB_ENG), + 'stream/0': JSON.stringify(VIDEO), + }); + }); + + it('skips an unparseable entry and keeps the rest', () => { + expect(readStreamEntries({ stream: ['not json', JSON.stringify(SUB_FRE)] }).length).toBe(1); + expect(readStreamEntries(undefined)).toEqual([]); + }); +}); + +describe('parseSubtitleStreams', () => { + it('keeps the global index, per-type ordinal, lang3 and forced flag', () => { + const streams = parseSubtitleStreams({ stream: [VIDEO, SUB_ENG, SUB_ENG_SIGNS, { ...SUB_FRE, language: 'fra' }] }); + expect(streams.map((s) => [s.ordinal, s.streamIndex, s.lang3, s.forced])).toEqual([ + [0, 3, 'eng', false], + [1, 4, 'eng', true], + [2, 10, 'fre', false], + ]); + }); + + it('uses und when a stream declares no language', () => { + expect(parseSubtitleStreams({ 'stream/0': JSON.stringify({ type: 'subtitle', codec: 'subrip', index: 0 }) })[0].lang3).toBe('und'); + }); + + it('falls back to the legacy subtitle_{i}_* fields', () => { + const streams = parseSubtitleStreams({ subtitle_0_codec: 'subrip', subtitle_0_language: 'ger' }); + expect(streams).toHaveLength(1); + expect(streams[0].lang3).toBe('ger'); + expect(streams[0].streamIndex).toBeUndefined(); + }); +}); + +describe('classifySubtitleStreams / outputFor', () => { + const s = (codec: string, i: number) => ({ ordinal: i, streamIndex: i, codec, lang3: 'eng', forced: false }); + + it('skips image codecs explicitly and keeps unknown ones apart', () => { + const { text, image, unsupported } = classifySubtitleStreams( + [s('hdmv_pgs_subtitle', 0), s('dvd_subtitle', 1), s('dvb_subtitle', 2), s('ass', 3), s('eia_608', 4)], + 'native', + ); + expect(image.map((x) => x.codec)).toEqual(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvb_subtitle']); + expect(text.map((x) => x.codec)).toEqual(['ass']); + expect(unsupported.map((x) => x.codec)).toEqual(['eia_608']); + }); + + it('native keeps ASS as ASS by stream copy, converts mov_text to SRT', () => { + expect(outputFor('ass', 'native')).toEqual({ ext: 'ass', encoder: 'copy' }); + expect(outputFor('subrip', 'native')).toEqual({ ext: 'srt', encoder: 'copy' }); + expect(outputFor('webvtt', 'native')).toEqual({ ext: 'vtt', encoder: 'copy' }); + expect(outputFor('mov_text', 'native')).toEqual({ ext: 'srt', encoder: 'srt' }); + }); + + it('an explicit format converts', () => { + expect(outputFor('ass', 'srt')).toEqual({ ext: 'srt', encoder: 'srt' }); + expect(outputFor('subrip', 'vtt')).toEqual({ ext: 'vtt', encoder: 'webvtt' }); + expect(outputFor('hdmv_pgs_subtitle', 'srt')).toBeNull(); + }); + + it('configure rejects an unknown outputFormat', () => { + configure({ outputFormat: 'pgs' }); + expect(classifySubtitleStreams([s('ass', 0)]).text[0].ext).toBe('ass'); + }); +}); + +describe('subtitleFilename', () => { + const meta = { title: 'Dead Dead Demons', movieYear: 2024 }; + const stream = (streamIndex: number, lang3: string, forced = false) => ({ ordinal: 0, streamIndex, codec: 'ass', lang3, forced }); + + it('two tracks in one language never share a file', () => { + const a = subtitleFilename(meta, 'bagvideo', stream(3, 'eng'), 'ass'); + const b = subtitleFilename(meta, 'bagvideo', stream(4, 'eng', true), 'ass'); + expect(a).not.toBe(b); + expect(a).toBe('Dead Dead Demons (2024)[bagvideo]_subtitle.s3.eng.ass'); + expect(b).toBe('Dead Dead Demons (2024)[bagvideo]_subtitle.s4.eng.forced.ass'); + }); + + it('falls back to the file name and strips URL-hostile characters', () => { + expect(subtitleFilename({ fileName: 'Show #1 100%?.mkv' }, 'c', stream(2, 'und'), 'srt')) + .toBe('Show 1 100[c]_subtitle.s2.und.srt'); + expect(sanitizeFilename('a/b:c*d?e#f%g')).toBe('abcdefg'); + }); +}); + +describe('buildExtractionArgs', () => { + it('is ONE ffmpeg invocation with one -map/output per track', () => { + const jobs = [3, 4, 10].map((i) => ({ + stream: { ordinal: i, streamIndex: i, codec: 'ass', lang3: 'eng', forced: false, ext: 'ass', encoder: 'copy' } as TextSubtitleStream, + tempFile: `/tmp/t-${i}.ass`, + })); + const args = buildExtractionArgs('http://core/webdav/watch/a.mkv', jobs); + expect(args.filter((a) => a === '-i')).toHaveLength(1); + expect(args.filter((a) => a === '-map')).toHaveLength(3); + expect(args.join(' ')).toContain('-map 0:3 -c:s copy /tmp/t-3.ass -map 0:4 -c:s copy /tmp/t-4.ass -map 0:10 -c:s copy /tmp/t-10.ass'); + }); + + it('maps by subtitle ordinal when the global index is unknown', () => { + const args = buildExtractionArgs('in.mkv', [{ + stream: { ordinal: 1, codec: 'subrip', lang3: 'ger', forced: false, ext: 'srt', encoder: 'copy' } as TextSubtitleStream, + tempFile: 'out.srt', + }]); + expect(args).toContain('0:s:1'); + }); +}); + +describe('hasCues', () => { + it('does not count a header-only ASS file', () => { + expect(hasCues(Buffer.from('[Script Info]\nTitle: x\n[Events]\n'), 'ass')).toBe(false); + expect(hasCues(Buffer.from('[Events]\nDialogue: 0,0:00:00.00,0:00:01.00,Default,,0,0,0,,Hi'), 'ass')).toBe(true); + expect(hasCues(Buffer.from('1\n00:00:00,000 --> 00:00:01,000\nHi\n'), 'srt')).toBe(true); + expect(hasCues(Buffer.from(''), 'srt')).toBe(false); + }); +}); + +describe('record keys (METADATA_KEYS.md §8)', () => { + it('writes subtitles/<lang3>/<cid> and extractedSubtitles/<lang3>/<cid> leaves', () => { + expect(videoSubtitleKeys([{ subCid: 'bagsubfre', lang3: 'fre' }, { subCid: 'bagsubund', lang3: 'und' }])).toEqual({ + 'subtitles/fre/bagsubfre': 'true', + 'extractedSubtitles/fre/bagsubfre': 'true', + 'subtitleLanguages/fre': 'true', + 'languages/fre': 'true', + 'subtitles/und/bagsubund': 'true', + 'extractedSubtitles/und/bagsubund': 'true', + }); + }); + + it('never writes a bare subtitles/<lang3> node or an und facet', () => { + const keys = Object.keys(videoSubtitleKeys([{ subCid: 'c', lang3: 'und' }])); + expect(keys.every((k) => k.split('/').length === 3)).toBe(true); + }); + + it('subtitle record carries the back-pointer and a determined language only', () => { + expect(subtitleRecordKeys('bagvideo', 'fre')).toEqual({ 'videos/bagvideo': 'true', subtitleLanguage: 'fre' }); + expect(subtitleRecordKeys('bagvideo', 'und')).toEqual({ 'videos/bagvideo': 'true' }); + }); +}); + +describe('hasExtractedKeySet', () => { + it('recognises the key-set in both payload forms', () => { + expect(hasExtractedKeySet({ extractedSubtitles: { fre: { c: true } } })).toBe(true); + expect(hasExtractedKeySet({ 'extractedSubtitles/fre/c': 'true' })).toBe(true); + }); + + it('does not treat the legacy csv scalar as done — that record needs migrating', () => { + expect(hasExtractedKeySet({ extractedSubtitles: 'cidA,cidB' })).toBe(false); + expect(hasExtractedKeySet({})).toBe(false); + }); +}); + +describe('legacySubtitleMigration', () => { + it('deletes legacy scalars and keeps what they said as key-set leaves', () => { + expect(legacySubtitleMigration({ + extractedSubtitles: 'cidA,cidB', + subtitleLanguages: 'eng,fra', + subtitles: 'cidS', + })).toEqual({ + deletes: ['extractedSubtitles', 'subtitleLanguages', 'subtitles'], + sets: { 'subtitleLanguages/eng': 'true', 'subtitleLanguages/fre': 'true', 'subtitles/und/cidS': 'true' }, + }); + }); + + it('leaves key-sets alone', () => { + expect(legacySubtitleMigration({ subtitles: { fre: { c: true } }, subtitleLanguages: { fre: true } })) + .toEqual({ deletes: [], sets: {} }); + }); +}); + +describe('meta-shape helpers', () => { + it('flattens the nested document to the same keys meta-core stores', () => { + const nested = { fileType: 'video', fileinfo: { duration: 1433.089 }, subtitles: { fre: { bagsub: true } }, stream: ['{"a":1}'] }; + expect(flattenMeta(nested)).toEqual({ + fileType: 'video', + 'fileinfo/duration': '1433.089', + 'subtitles/fre/bagsub': 'true', + 'stream/0': '{"a":1}', + }); + }); + + it('onlyNewKeys drops what the record already holds', () => { + expect(onlyNewKeys({ 'a/b': 'true', c: 'x' }, { 'a/b': 'true', c: 'y', d: 'true' })).toEqual({ c: 'y', d: 'true' }); + }); +}); + +describe('process — skip logic (no I/O)', () => { + const collect = () => { + const seen: CallbackPayload[] = []; + return { seen, cb: async (p: CallbackPayload) => { seen.push(p); } }; + }; + const req = (existingMeta: Record<string, unknown>) => ({ + taskId: 't1', + cid: 'bagvideo', + filePath: '/files/watch/test.mkv', + callbackUrl: 'http://localhost/callback', + metaCoreUrl: 'http://127.0.0.1:9', // unreachable: every case must bail first + existingMeta, + }); + + it('skips non-video files', async () => { + const { seen, cb } = collect(); + await processFile(req({ fileType: 'subtitle' }), cb); + expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Not a video file' }); + }); + + it('skips a record whose extractedSubtitles key-set is already there (nested form)', async () => { + const { seen, cb } = collect(); + await processFile(req({ fileType: 'video', extractedSubtitles: { eng: { c: true } }, stream: [JSON.stringify(SUB_ENG)] }), cb); + expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Subtitles already extracted' }); + }); + + it('skips a video with no subtitle streams', async () => { + const { seen, cb } = collect(); + await processFile(req({ fileType: 'video', stream: [JSON.stringify(VIDEO)] }), cb); + expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'No subtitle streams found' }); + }); + + it('skips a video whose only subtitles are image-based', async () => { + const { seen, cb } = collect(); + await processFile(req({ + fileType: 'video', + stream: [VIDEO, { type: 'subtitle', codec: 'hdmv_pgs_subtitle', index: 1, language: 'eng' }].map((s) => JSON.stringify(s)), + }), cb); + expect(seen[0]).toMatchObject({ status: 'skipped', reason: 'Only image-based subtitles (cannot convert to text)' }); + }); +}); + +describe.skipIf(!ffmpegReady)('extractTracks (ffmpeg)', () => { + beforeAll(() => mkdirSync(TEMP, { recursive: true })); + + const jobsFor = (file: string, tag: string): ExtractJob[] => + classifySubtitleStreams(parseSubtitleStreams({ stream: nestedStreamsFor(file) }), 'native').text + .map((stream) => ({ stream, tempFile: path.join(TEMP, `${tag}-${stream.ordinal}.${stream.ext}`) })); + + it('extracts every text track of the MKV in a single pass', async () => { + const jobs = jobsFor(fixture('with-subs.mkv'), 'single'); + expect(jobs.map((j) => [j.stream.lang3, j.stream.ext, j.stream.forced])).toEqual([ + ['eng', 'srt', false], + ['fre', 'ass', true], + ['und', 'srt', false], + ]); + const before = extractionStats.ffmpegPasses; + const result = await extractTracks(fixture('with-subs.mkv'), jobs, 60000); + expect(extractionStats.ffmpegPasses - before).toBe(1); + expect(result.passes).toBe(1); + const [eng, fre, und] = result.outputs.map((o) => o.data?.toString('utf8') ?? ''); + expect(eng).toContain('quick brown fox'); + expect(fre).toContain('[Script Info]'); + expect(fre).toMatch(/^Dialogue:.*renard brun/m); + expect(und).toContain('-->'); + }); + + it('converts mov_text to SRT', async () => { + const jobs = jobsFor(fixture('mov-text.mp4'), 'movtext'); + expect(jobs.map((j) => [j.stream.lang3, j.stream.ext, j.stream.encoder])).toEqual([['ger', 'srt', 'srt']]); + const result = await extractTracks(fixture('mov-text.mp4'), jobs, 60000); + expect(result.outputs[0].data?.toString('utf8')).toContain('schnelle braune Fuchs'); + }); + + it('salvages good tracks one by one when the shared pass fails', async () => { + const jobs = jobsFor(fixture('with-subs.mkv'), 'salvage'); + const broken: ExtractJob = { ...jobs[0], stream: { ...jobs[0].stream, streamIndex: 42 }, tempFile: path.join(TEMP, 'salvage-broken.srt') }; + const result = await extractTracks(fixture('with-subs.mkv'), [jobs[0], broken, jobs[1]], 60000); + expect(result.passes).toBe(4); + expect(result.outputs.map((o) => o.data !== null)).toEqual([true, false, true]); + }); +}); + +describe.skipIf(!ffmpegReady)('process — end to end against a fake core', () => { + let core: FakeCore; + const VIDEO_CID = 'bagacbabaevideo'; + const videoPath = '/files/watch/show/with-subs.mkv'; + let nestedMeta: Record<string, unknown>; + + beforeAll(async () => { + core = await startFakeCore(); + core.files.set('/watch/show/with-subs.mkv', readFileSync(fixture('with-subs.mkv'))); + process.env.WEBDAV_URL = core.webdavUrl; + process.env.CACHE_PATH = TEMP; + nestedMeta = { + fileType: 'video', + title: 'Test Show', + fileName: 'with-subs.mkv', + fileinfo: { duration: 3, formatName: 'matroska,webm' }, + stream: nestedStreamsFor(fixture('with-subs.mkv')), + // Legacy scalars from the `_add` era, which must not survive next to key-sets. + extractedSubtitles: 'bagold1,bagold2', + subtitleLanguages: 'eng', + }; + core.records.set(VIDEO_CID, flattenMeta(nestedMeta)); + }); + + afterAll(async () => { + delete process.env.WEBDAV_URL; + await core.close(); + }); + + const run = async (existingMeta: Record<string, unknown>) => { + const seen: CallbackPayload[] = []; + await processFile({ + taskId: 'task-e2e', + cid: VIDEO_CID, + filePath: videoPath, + callbackUrl: 'http://unused', + metaCoreUrl: core.url, + existingMeta, + }, async (p) => { seen.push(p); }); + return seen[0]; + }; + + it('extracts in one pass, uploads, and links subtitles/<lang3>/<cid>', async () => { + const passes = extractionStats.ffmpegPasses; + const result = await run(nestedMeta); + expect(result.status).toBe('completed'); + expect(extractionStats.ffmpegPasses - passes).toBe(1); + + const uploads = [...core.files.keys()].filter((p) => p.startsWith('/plugin/subtitle-extractor/')).sort(); + expect(uploads).toEqual([ + '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s1.eng.srt', + '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s2.fre.forced.ass', + '/plugin/subtitle-extractor/Test Show[bagacbabaevideo]_subtitle.s3.und.srt', + ]); + const cidOf = (suffix: string) => computeMidHash256FromBuffer(core.files.get(uploads.find((u) => u.endsWith(suffix))!)!); + const eng = cidOf('eng.srt'); + const fre = cidOf('fre.forced.ass'); + const und = cidOf('und.srt'); + + const video = core.records.get(VIDEO_CID)!; + expect(video).toMatchObject({ + [`subtitles/eng/${eng}`]: 'true', + [`subtitles/fre/${fre}`]: 'true', + [`subtitles/und/${und}`]: 'true', + [`extractedSubtitles/eng/${eng}`]: 'true', + [`extractedSubtitles/fre/${fre}`]: 'true', + [`extractedSubtitles/und/${und}`]: 'true', + 'subtitleLanguages/eng': 'true', + 'subtitleLanguages/fre': 'true', + 'languages/eng': 'true', + 'languages/fre': 'true', + }); + expect(video.extractedSubtitles).toBeUndefined(); + expect(video.subtitleLanguages).toBeUndefined(); + expect(video['subtitleLanguages/und']).toBeUndefined(); + + expect(core.records.get(fre)).toEqual({ [`videos/${VIDEO_CID}`]: 'true', subtitleLanguage: 'fre' }); + expect(core.records.get(und)).toEqual({ [`videos/${VIDEO_CID}`]: 'true' }); + + // The video PATCH carried only new keys — nothing the record already had. + const patch = writesTo(core, VIDEO_CID).find((c) => c.method === 'PATCH')!; + expect(Object.keys(patch.body as object).some((k) => ['fileType', 'title', 'stream/0'].includes(k))).toBe(false); + }); + + it('skips once the key-set is on the record', async () => { + const result = await run(core.records.get(VIDEO_CID)!); + expect(result).toMatchObject({ status: 'skipped', reason: 'Subtitles already extracted' }); + }); + + it('re-links from existing outputs without reading the video again', async () => { + // Metadata lost, output files still there. + const bare = flattenMeta({ ...nestedMeta, extractedSubtitles: undefined, subtitleLanguages: undefined }); + const passes = extractionStats.ffmpegPasses; + const gets = core.fileGets.get('/watch/show/with-subs.mkv'); + const result = await run(bare); + expect(result.status).toBe('completed'); + expect(extractionStats.ffmpegPasses).toBe(passes); + expect(core.fileGets.get('/watch/show/with-subs.mkv')).toBe(gets); + }); + + it('a second link over an up-to-date record writes nothing', async () => { + const before = core.calls.length; + const video = core.records.get(VIDEO_CID)!; + const produced = Object.keys(video) + .filter((k) => k.startsWith('subtitles/')) + .map((k) => ({ lang3: k.split('/')[1], subCid: k.split('/')[2] })); + expect(await linkSubtitles(new MetaCoreClient(core.url), VIDEO_CID, video, produced)).toBe(true); + expect(core.calls.length).toBe(before); + }); + + it('fails the task when meta-core rejects the write', async () => { + core.failPatch = true; + try { + const result = await run(flattenMeta({ fileType: 'video', title: 'Test Show', stream: nestedStreamsFor(fixture('with-subs.mkv')) })); + expect(result).toMatchObject({ status: 'failed', error: 'meta-core write failed' }); + } finally { + core.failPatch = false; + } + }); +}); diff --git a/test/fake-core.ts b/test/fake-core.ts new file mode 100644 index 0000000..fbd3a4d --- /dev/null +++ b/test/fake-core.ts @@ -0,0 +1,147 @@ +/** + * In-process stand-in for the two HTTP surfaces a plugin talks to: + * - WebDAV at /webdav/... (JSON directory listing, HEAD, Range GET, PUT) + * - meta API at /meta/... (GET record / property, PATCH merge, DELETE property) + * Records are stored FLAT (`subtitles/fre/<cid>`), the way meta-core keeps them. + */ + +import http from 'http'; +import type { AddressInfo } from 'net'; + +export interface FakeCall { + method: string; + path: string; + body?: unknown; +} + +export interface FakeCore { + url: string; + webdavUrl: string; + /** Keyed by path under /files, e.g. `/watch/show/a.mkv`. */ + files: Map<string, Buffer>; + records: Map<string, Record<string, string>>; + calls: FakeCall[]; + /** GET requests per file path (each ffmpeg pass opens the file at least once). */ + fileGets: Map<string, number>; + failPatch: boolean; + close(): Promise<void>; +} + +function readBody(req: http.IncomingMessage): Promise<Buffer> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +export async function startFakeCore(): Promise<FakeCore> { + const core = { + files: new Map<string, Buffer>(), + records: new Map<string, Record<string, string>>(), + calls: [] as FakeCall[], + fileGets: new Map<string, number>(), + failPatch: false, + }; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', 'http://x'); + const pathname = decodeURIComponent(url.pathname); + const send = (status: number, body?: unknown, headers: Record<string, string> = {}) => { + if (body === undefined) { + res.writeHead(status, headers); + res.end(); + } else if (Buffer.isBuffer(body)) { + res.writeHead(status, headers); + res.end(body); + } else { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); + } + }; + + if (pathname.startsWith('/webdav')) { + const p = pathname.slice('/webdav'.length) || '/'; + if (req.method === 'PUT') { + core.files.set(p, await readBody(req)); + return send(201); + } + if (p.endsWith('/')) { + const dir = p.replace(/\/$/, ''); + const entries = new Map<string, { name: string; type: string; size: number }>(); + for (const [fp, data] of core.files) { + if (!fp.startsWith(`${dir}/`)) continue; + const rest = fp.slice(dir.length + 1); + const slash = rest.indexOf('/'); + if (slash === -1) entries.set(rest, { name: rest, type: 'file', size: data.length }); + else entries.set(rest.slice(0, slash), { name: rest.slice(0, slash), type: 'directory', size: 512 }); + } + return send(200, [...entries.values()]); + } + const data = core.files.get(p); + if (!data) return send(404); + if (req.method === 'HEAD') { + return send(200, undefined, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' }); + } + core.fileGets.set(p, (core.fileGets.get(p) ?? 0) + 1); + const range = /^bytes=(\d+)-(\d*)$/.exec(req.headers.range ?? ''); + if (range) { + const start = Number(range[1]); + const end = range[2] ? Math.min(Number(range[2]), data.length - 1) : data.length - 1; + if (start >= data.length) return send(416, undefined, { 'Content-Range': `bytes */${data.length}` }); + return send(206, data.subarray(start, end + 1), { + 'Content-Range': `bytes ${start}-${end}/${data.length}`, + 'Content-Length': String(end - start + 1), + 'Accept-Ranges': 'bytes', + }); + } + return send(200, data, { 'Content-Length': String(data.length), 'Accept-Ranges': 'bytes' }); + } + + const m = /^\/meta\/([^/]+)(?:\/(.+))?$/.exec(pathname); + if (m) { + const [, hash, key] = m; + const raw = await readBody(req); + const body = raw.length ? JSON.parse(raw.toString('utf8')) : undefined; + if (req.method !== 'GET') core.calls.push({ method: req.method ?? '', path: pathname, body }); + const record = core.records.get(hash); + + if (req.method === 'GET' && !key) return record ? send(200, { metadata: record }) : send(404, { error: 'not found' }); + if (req.method === 'GET' && key) { + return record && key in record ? send(200, { value: record[key] }) : send(404, { error: 'Property not found' }); + } + if (req.method === 'PATCH' && !key) { + if (core.failPatch) return send(500, { error: 'boom' }); + core.records.set(hash, { ...(record ?? {}), ...(body as Record<string, string>) }); + return send(200, { status: 'ok' }); + } + if (req.method === 'DELETE' && key) { + if (record) delete record[key]; + return send(200, { status: 'ok' }); + } + } + send(404, { error: 'no route' }); + }); + + await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + const url = `http://127.0.0.1:${port}`; + + return { + ...core, + get failPatch() { return core.failPatch; }, + set failPatch(v: boolean) { core.failPatch = v; }, + url, + webdavUrl: `${url}/webdav`, + close: () => new Promise<void>((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + +/** Mutating calls (PATCH / DELETE) against one record. */ +export function writesTo(core: FakeCore, hash: string): FakeCall[] { + return core.calls.filter((c) => c.path === `/meta/${hash}` || c.path.startsWith(`/meta/${hash}/`)); +} diff --git a/test/fixtures/generate-test-fixtures.sh b/test/fixtures/generate-test-fixtures.sh new file mode 100644 index 0000000..ca59c9f --- /dev/null +++ b/test/fixtures/generate-test-fixtures.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Generate media for subtitle-extractor integration tests. +# Run inside the test container (ffmpeg required); safe to run locally too. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Generating test fixtures..." + +# 1. MKV with three text tracks — the shape real releases have: +# s:0 SubRip, language eng +# s:1 ASS, language fre, forced (must stay ASS under outputFormat=native) +# s:2 SubRip, language und (nothing declared) +if [ ! -f "with-subs.mkv" ]; then + echo "Creating with-subs.mkv..." + cat > eng.srt << 'EOF' +1 +00:00:00,000 --> 00:00:01,000 +The quick brown fox jumps over the lazy dog. + +2 +00:00:01,000 --> 00:00:02,000 +Every good story starts somewhere. +EOF + cat > fre.srt << 'EOF' +1 +00:00:00,000 --> 00:00:01,000 +Le renard brun saute par-dessus le chien paresseux. + +2 +00:00:01,000 --> 00:00:02,000 +Chaque bonne histoire commence quelque part. +EOF + cat > und.srt << 'EOF' +1 +00:00:00,500 --> 00:00:01,500 +~ ~ ~ +EOF + + ffmpeg -y -f lavfi -i testsrc=duration=3:size=160x120:rate=12 \ + -i eng.srt -i fre.srt -i und.srt \ + -map 0:v -map 1:s -map 2:s -map 3:s \ + -c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p \ + -c:s:0 srt -c:s:1 ass -c:s:2 srt \ + -metadata:s:s:0 language=eng \ + -metadata:s:s:1 language=fre -disposition:s:1 forced \ + -metadata:s:s:2 language=und \ + with-subs.mkv + rm -f eng.srt fre.srt und.srt +fi + +# 2. MP4 with a mov_text track tagged in ISO 639-2/T (`deu`) — must be converted +# to SRT and normalised to `ger`. +if [ ! -f "mov-text.mp4" ]; then + echo "Creating mov-text.mp4..." + cat > ger.srt << 'EOF' +1 +00:00:00,000 --> 00:00:01,000 +Der schnelle braune Fuchs springt über den faulen Hund. +EOF + ffmpeg -y -f lavfi -i testsrc=duration=2:size=160x120:rate=12 \ + -i ger.srt \ + -map 0:v -map 1:s \ + -c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p \ + -c:s mov_text -metadata:s:s:0 language=deu \ + mov-text.mp4 + rm -f ger.srt +fi + +echo "Test fixtures generated:" +ls -la *.mkv *.mp4 2>/dev/null || true diff --git a/tsconfig.json b/tsconfig.json index 0017704..95ac5c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,15 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "dist", - "rootDir": "src", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, "declaration": true, - "resolveJsonModule": true + "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..193a0ef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'], + testTimeout: 30000, // 30s timeout for FFprobe tests + hookTimeout: 60000, // 60s for setup/teardown + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/types.ts'], + }, + }, +});