initial commit

This commit is contained in:
Pierre
2026-06-06 15:20:36 +02:00
commit 62a2b796e2
16 changed files with 2001 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Build and Push Docker Image
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
branches: [main, master]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
dist-test/
*.log
.env
.DS_Store
.pnpm-store
+42
View File
@@ -0,0 +1,42 @@
# MetaMesh Plugin: opensubtitles
# Fetches subtitle sidecars from OpenSubtitles
FROM node:20-slim AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@latest && corepack enable
WORKDIR /app
# Copy package files first for layer caching
COPY package.json pnpm-lock.yaml ./
RUN corepack install
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Copy source and build
COPY tsconfig.json ./
COPY src/ ./src/
RUN pnpm run build
# Production image
FROM node:20-slim
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g corepack@latest && corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack install
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --prod
COPY --from=builder /app/dist ./dist
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "dist/index.js"]
+94
View File
@@ -0,0 +1,94 @@
# MetaMesh Plugin: OpenSubtitles
A MetaMesh plugin that **fetches** subtitle sidecars from
[OpenSubtitles](https://www.opensubtitles.com) (REST API v1) and links them to
videos. This is the network-download counterpart to `subtitle-extractor`, which
only pulls subtitles already embedded in the media file.
## Description
For each video, the plugin:
1. Computes the OpenSubtitles **moviehash** (size + first/last 64 KiB, read over
WebDAV) for exact-match accuracy.
2. Searches `/subtitles` by `moviehash` + `imdb_id`/`tmdb_id` (+ season/episode
for TV), falling back to a title/year query only when no structured
identifier is available.
3. Picks the single best subtitle per wanted language (exact moviehash match
first, then popularity).
4. Logs in (JWT) and downloads each chosen file, writes it via WebDAV to
`/files/plugin/opensubtitles/`, and registers the resulting CID on the video.
**Note**: Runs on the **background** queue (external API calls, download
quota). Depends on `tmdb` so `imdbid`/`tmdbid` are populated before it runs.
## Metadata Fields (written on the video record)
| Field | Format | Description |
|-------|--------|-------------|
| `subtitles` | csv-set of `cid` | Fetched subtitle file CIDs (external/sidecar provenance, per METADATA_KEYS.md §8) |
| `subtitleLanguages` | csv-set of `lang3` | Denormalized union of available subtitle languages |
| `languages/<lang3>` | key-set | Adds the subtitle language to the file's language set (§9) |
Each downloaded subtitle is itself indexed as a first-class record under its
own `midhash256` CID — only the CID is stored on the video, never a path.
> **Migration note:** `subtitles` / `subtitleLanguages` are written as legacy
> `csv-set` via `addToSet` to stay compatible with current consumers. They are
> slated to migrate to `key-set` (METADATA_KEYS.md §14.12) in lockstep with the
> other subtitle writers (`subtitle`, `subtitle-extractor`).
## Dependencies
- Requires `file-info`, `filename-parser`, and `tmdb` (for `imdbid`/`tmdbid`).
## Configuration
Set at runtime via the meta-sort REST API (never in `plugins.yml`):
```bash
curl -k -X PUT https://metasort-dev.localhost:8180/api/plugins/opensubtitles/config \
-H "Content-Type: application/json" \
-d '{"apiKey":"YOUR_API_KEY","username":"YOUR_USER","password":"YOUR_PASS","languages":"en,fr"}'
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `apiKey` | string | Yes | OpenSubtitles API key (Consumer key from your account) |
| `username` | string | For downloads | OpenSubtitles account username |
| `password` | string | For downloads | OpenSubtitles account password |
| `languages` | string | No | CSV of ISO 639-1 codes (default `en`) |
| `userAgent` | string | No | User-Agent registered with OpenSubtitles |
| `forceRecompute` | boolean | No | Re-fetch even if subs already present |
> The API key alone permits **search**; **downloading** requires a logged-in
> account (username + password → JWT). The free tier has a small daily download
> quota — the plugin stops early and reports when quota is exhausted.
## API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/health` | GET | Health check |
| `/manifest` | GET | Plugin manifest |
| `/configure` | POST | Update configuration |
| `/process` | POST | Process a file |
## Docker
```bash
# From the plugin directory
docker build -t metamesh-plugin-opensubtitles:main .
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | HTTP server port |
| `WEBDAV_URL` | — | meta-sort WebDAV base (provided by the orchestrator) |
| `CACHE_DIR` | `/cache` | Plugin cache mount |
## License
MIT
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@metazla/plugin-opensubtitles",
"version": "1.0.0",
"packageManager": "[email protected]",
"description": "MetaMesh plugin: Fetches subtitle sidecars from OpenSubtitles",
"type": "module",
"main": "dist/index.js",
"scripts": { "build": "tsc", "start": "node dist/index.js", "test": "tsc -p tsconfig.test.json && node --test \"dist-test/*.test.js\"" },
"dependencies": { "fastify": "^5.0.0", "axios": "^1.6.0" },
"devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.3.0" }
}
+568
View File
@@ -0,0 +1,568 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
axios:
specifier: ^1.6.0
version: 1.13.5
fastify:
specifier: ^5.0.0
version: 5.7.4
devDependencies:
'@types/node':
specifier: ^20.0.0
version: 20.19.33
typescript:
specifier: ^5.3.0
version: 5.9.3
packages:
'@fastify/[email protected]':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/[email protected]':
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
'@fastify/[email protected]':
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
'@fastify/[email protected]':
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
'@fastify/[email protected]':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@pinojs/[email protected]':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@types/[email protected]':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
[email protected]:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
[email protected]:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
[email protected]:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
[email protected]:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
[email protected]:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
[email protected]:
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
[email protected]:
resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
[email protected]:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
[email protected]:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
[email protected]:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
[email protected]:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
[email protected]:
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
[email protected]:
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
[email protected]:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
[email protected]:
resolution: {integrity: sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==}
[email protected]:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
[email protected]:
resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
[email protected]:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
[email protected]:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
engines: {node: '>= 10'}
[email protected]:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
[email protected]:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
[email protected]:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
[email protected]:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
[email protected]:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
[email protected]:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
[email protected]:
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
[email protected]:
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
[email protected]:
resolution: {integrity: sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
[email protected]:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
[email protected]:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
[email protected]:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
[email protected]:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
[email protected]:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
[email protected]:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
[email protected]:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
[email protected]:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
[email protected]:
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
[email protected]:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
[email protected]:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
snapshots:
'@fastify/[email protected]':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1([email protected])
fast-uri: 3.1.0
'@fastify/[email protected]': {}
'@fastify/[email protected]':
dependencies:
fast-json-stringify: 6.3.0
'@fastify/[email protected]': {}
'@fastify/[email protected]':
dependencies:
dequal: 2.0.3
'@fastify/[email protected]':
dependencies:
'@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0
'@pinojs/[email protected]': {}
'@types/[email protected]':
dependencies:
undici-types: 6.21.0
[email protected]: {}
[email protected]([email protected]):
optionalDependencies:
ajv: 8.17.1
[email protected]:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@fastify/error': 4.2.0
fastq: 1.20.1
[email protected]:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
[email protected]:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
[email protected]:
dependencies:
delayed-stream: 1.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
es-errors: 1.3.0
[email protected]:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
'@fastify/merge-json-schemas': 0.2.1
ajv: 8.17.1
ajv-formats: 3.0.1([email protected])
fast-uri: 3.1.0
json-schema-ref-resolver: 3.0.0
rfdc: 1.4.1
[email protected]:
dependencies:
fast-decode-uri-component: 1.0.1
[email protected]: {}
[email protected]:
dependencies:
'@fastify/ajv-compiler': 4.0.5
'@fastify/error': 4.2.0
'@fastify/fast-json-stringify-compiler': 5.0.3
'@fastify/proxy-addr': 5.1.0
abstract-logging: 2.0.1
avvio: 9.1.0
fast-json-stringify: 6.3.0
find-my-way: 9.4.0
light-my-request: 6.6.0
pino: 10.3.0
process-warning: 5.0.0
rfdc: 1.4.1
secure-json-parse: 4.1.0
semver: 7.7.4
toad-cache: 3.7.0
[email protected]:
dependencies:
reusify: 1.1.0
[email protected]:
dependencies:
fast-deep-equal: 3.1.3
fast-querystring: 1.1.2
safe-regex2: 5.0.0
[email protected]: {}
[email protected]:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
[email protected]: {}
[email protected]:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
[email protected]:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
has-symbols: 1.1.0
[email protected]:
dependencies:
function-bind: 1.1.2
[email protected]: {}
[email protected]:
dependencies:
dequal: 2.0.3
[email protected]: {}
[email protected]:
dependencies:
cookie: 1.1.1
process-warning: 4.0.1
set-cookie-parser: 2.7.2
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
mime-db: 1.52.0
[email protected]: {}
[email protected]:
dependencies:
split2: 4.2.0
[email protected]: {}
[email protected]:
dependencies:
'@pinojs/redact': 0.4.0
atomic-sleep: 1.0.0
on-exit-leak-free: 2.1.2
pino-abstract-transport: 3.0.0
pino-std-serializers: 7.1.0
process-warning: 5.0.0
quick-format-unescaped: 4.0.4
real-require: 0.2.0
safe-stable-stringify: 2.5.0
sonic-boom: 4.2.0
thread-stream: 4.0.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
ret: 0.5.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
atomic-sleep: 1.0.0
[email protected]: {}
[email protected]:
dependencies:
real-require: 0.2.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
+59
View File
@@ -0,0 +1,59 @@
/**
* MetaMesh Plugin: OpenSubtitles
*
* ============================================================================
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
* ============================================================================
*
* Each plugin container has exactly 3 mounts:
*
* 1. /files (READ-ONLY) - Shared media files, read access only
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
* 3. /files/plugin/<name> (READ-WRITE for this plugin, READ-ONLY for others)
* - Plugin output folder for generated files
*
* SECURITY: Plugins must NEVER write to /files directly.
* - Use /cache for temporary/cache data
* - Use /files/plugin/<name> for output files (e.g., posters, generated assets)
*
* This architecture ensures:
* - Plugins cannot corrupt user's media files
* - Plugins can share generated assets with other plugins (read-only)
* - Each plugin has isolated cache storage
*
* ============================================================================
*/
import Fastify from 'fastify';
import type { HealthResponse, ProcessRequest, ProcessResponse, CallbackPayload, ConfigureRequest, ConfigureResponse } from './types.js';
import { manifest, process as processFile, configure } from './plugin.js';
const app = Fastify({ logger: true });
let ready = false;
app.get('/health', async (): Promise<HealthResponse> => ({ status: 'healthy', ready, version: manifest.version }));
app.get('/manifest', async () => manifest);
app.post<{ Body: ConfigureRequest }>('/configure', async (request): Promise<ConfigureResponse> => {
try {
configure(request.body.config || {});
console.log(`[${manifest.id}] Configuration updated`);
return { status: 'ok' };
} catch (error) {
return { status: 'error', error: error instanceof Error ? error.message : String(error) };
}
});
app.post<{ Body: ProcessRequest }>('/process', async (request, reply) => {
const { taskId, cid, filePath, callbackUrl, metaCoreUrl } = request.body;
if (!taskId || !cid || !filePath || !callbackUrl || !metaCoreUrl) {
return reply.send({ status: 'rejected', error: 'Missing required fields' } as ProcessResponse);
}
processFile(request.body, async (payload: CallbackPayload) => {
try { await fetch(callbackUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); }
catch (error) { console.error(`[${manifest.id}] Callback error:`, error); }
}).catch(console.error);
return reply.send({ status: 'accepted' } as ProcessResponse);
});
const port = parseInt(process.env.PORT || '8080', 10);
app.listen({ port, host: '0.0.0.0' }).then(() => { ready = true; console.log(`[${manifest.id}] Listening on port ${port}`); });
process.on('SIGTERM', async () => { ready = false; await app.close(); process.exit(0); });
+72
View File
@@ -0,0 +1,72 @@
/**
* Language code normalization.
*
* OpenSubtitles speaks ISO 639-1 (2-letter, e.g. "en", "pt-br") while the
* MetaMesh metadata store uses ISO 639-3 (`lang3`, e.g. "eng") everywhere
* (see METADATA_KEYS.md §"Value formats" and §9). This maps the common
* subtitle languages OpenSubtitles serves to their 639-3 equivalents.
*
* Special case mirrored from the rest of the store: `jpl` is reserved for
* romaji transliterations of Japanese — OpenSubtitles has no such code, so
* it never appears here.
*/
// ISO 639-1 → ISO 639-3 for the languages OpenSubtitles commonly serves.
const ISO1_TO_ISO3: Record<string, string> = {
ab: 'abk', af: 'afr', am: 'amh', ar: 'ara', as: 'asm', az: 'aze',
be: 'bel', bg: 'bul', bn: 'ben', br: 'bre', bs: 'bos',
ca: 'cat', cs: 'ces', cy: 'cym', da: 'dan', de: 'deu',
el: 'ell', en: 'eng', eo: 'epo', es: 'spa', et: 'est', eu: 'eus',
fa: 'fas', fi: 'fin', fr: 'fra', ga: 'gle', gl: 'glg',
he: 'heb', hi: 'hin', hr: 'hrv', hu: 'hun', hy: 'hye',
id: 'ind', is: 'isl', it: 'ita', ja: 'jpn', ka: 'kat', kk: 'kaz',
km: 'khm', kn: 'kan', ko: 'kor', ku: 'kur', lb: 'ltz', lt: 'lit',
lv: 'lav', mk: 'mkd', ml: 'mal', mn: 'mon', mr: 'mar', ms: 'msa',
my: 'mya', nb: 'nob', ne: 'nep', nl: 'nld', nn: 'nno', no: 'nor',
oc: 'oci', pa: 'pan', pl: 'pol', pt: 'por', ro: 'ron', ru: 'rus',
si: 'sin', sk: 'slk', sl: 'slv', sq: 'sqi', sr: 'srp', sv: 'swe',
sw: 'swa', ta: 'tam', te: 'tel', th: 'tha', tl: 'tgl', tr: 'tur',
uk: 'ukr', ur: 'urd', uz: 'uzb', vi: 'vie', zh: 'zho',
};
// ISO 639-2/B (bibliographic) → ISO 639-3 (terminological) aliases. These show
// up in OpenSubtitles responses and release filenames; canonicalize them so the
// store never holds two codes for one language (e.g. `fre` and `fra`). Ported
// from the gateway's torznab subtitles.rs::iso3_canonical.
const ISO3_ALIASES: Record<string, string> = {
fre: 'fra', ger: 'deu', dut: 'nld', chi: 'zho', cze: 'ces',
gre: 'ell', rum: 'ron', baq: 'eus', wel: 'cym', arm: 'hye',
geo: 'kat', ice: 'isl', mac: 'mkd', may: 'msa', bur: 'mya',
per: 'fas', tib: 'bod', slo: 'slk', alb: 'sqi',
};
/**
* Normalize an OpenSubtitles language code to ISO 639-3 (`lang3`).
*
* Handles region suffixes ("pt-BR" → "por", "zh-CN" → "zho") by taking the
* primary subtag, and canonicalizes 639-2/B bibliographic codes ("fre" → "fra").
* Already-canonical 3-letter codes pass through lowercased. Returns "und"
* (undetermined) for anything unrecognized so the value still satisfies the
* `lang3` contract.
*/
export function toLang3(code: string | undefined | null): string {
if (!code) return 'und';
const primary = code.toLowerCase().split(/[-_]/)[0];
if (primary.length === 3) return ISO3_ALIASES[primary] ?? primary;
return ISO1_TO_ISO3[primary] ?? 'und';
}
/**
* Parse a user-supplied CSV of language codes (e.g. "en,fr,pt-BR") into the
* deduped lowercase 2-letter (or region-tagged) codes OpenSubtitles expects
* in its `languages` query parameter.
*/
export function parseWantedLanguages(csv: string | undefined): string[] {
if (!csv) return ['en'];
const seen = new Set<string>();
for (const raw of csv.split(',')) {
const code = raw.trim().toLowerCase();
if (code) seen.add(code);
}
return seen.size > 0 ? [...seen] : ['en'];
}
+89
View File
@@ -0,0 +1,89 @@
/**
* meta-core API Client for writing metadata
* Fails gracefully if meta-core is unavailable (logs warning, returns without throwing)
*/
export class MetaCoreClient {
constructor(private baseUrl: string) {}
private async safeFetch(url: string, options: RequestInit): Promise<Response | null> {
try {
return await fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
} catch (error) {
console.warn(`[MetaCoreClient] Warning: meta-core unavailable at ${this.baseUrl}`);
return null;
}
}
async setProperty(hashId: string, key: string, value: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to set property: ${response.status}`);
}
}
async getProperty(hashId: string, key: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'GET' });
if (!response || response.status === 404) return null;
if (!response.ok) return null;
const data = await response.json() as { value?: string };
return data.value ?? null;
}
async mergeMetadata(hashId: string, metadata: Record<string, string>): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to merge metadata: ${response.status}`);
}
}
async deleteProperty(hashId: string, key: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/${key}`, { method: 'DELETE' });
if (response && !response.ok && response.status !== 404) {
console.warn(`[MetaCoreClient] Failed to delete property: ${response.status}`);
}
}
async addToSet(hashId: string, key: string, value: string): Promise<void> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}/_add/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
});
if (response && !response.ok) {
console.warn(`[MetaCoreClient] Failed to add to set: ${response.status}`);
}
}
async getMetadata(hashId: string): Promise<Record<string, string>> {
const response = await this.safeFetch(`${this.baseUrl}/meta/${hashId}`, { method: 'GET' });
if (!response || response.status === 404) return {};
if (!response.ok) return {};
const data = await response.json() as { metadata?: Record<string, string> };
return data.metadata ?? {};
}
/**
* Compute CID for a file
* @param path - Path relative to FILES_PATH (e.g., "poster/Movie (2024)[tmdb12345]_poster.jpg")
* @returns CID string or null if failed
*/
async computeFileCID(path: string): Promise<string | null> {
const response = await this.safeFetch(`${this.baseUrl}/file/cid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
if (!response || !response.ok) return null;
const data = await response.json() as { cid?: string };
return data.cid ?? null;
}
}
+222
View File
@@ -0,0 +1,222 @@
/**
* OpenSubtitles REST API client (api.opensubtitles.com / API v1).
*
* Auth is two-tier:
* - The `Api-Key` header + a registered `User-Agent` are enough to call
* `/subtitles` (search).
* - The `/download` endpoint additionally needs a JWT obtained from
* `POST /login` (username + password). The token is cached and reused
* until the API rejects it, then re-fetched once.
*
* Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api
*/
import axios, { AxiosInstance, isAxiosError } from 'axios';
const DEFAULT_BASE_URL = 'https://api.opensubtitles.com/api/v1';
export interface OpenSubtitlesConfig {
apiKey: string;
username?: string;
password?: string;
userAgent: string;
}
export interface SubtitleFile {
fileId: number;
fileName?: string;
}
export interface SubtitleResult {
/** ISO 639-1 (or region-tagged) language code as OpenSubtitles reports it. */
language: string;
moviehashMatch: boolean;
/** Transient popularity signal — used only to rank candidates, never stored. */
downloadCount: number;
files: SubtitleFile[];
release?: string;
}
export interface SearchParams {
imdbId?: string; // numeric, no "tt" prefix
tmdbId?: string;
query?: string;
year?: string;
seasonNumber?: string;
episodeNumber?: string;
moviehash?: string;
/** CSV of 2-letter codes, e.g. "en,fr". */
languages: string;
}
export interface DownloadResult {
link: string;
fileName?: string;
/** Downloads remaining in the current quota window (per OpenSubtitles). */
remaining: number;
}
/**
* Compute the OpenSubtitles "moviehash" — a 64-bit hash of the file size plus
* the 64-bit little-endian words across the first and last 64 KiB. Returned
* as a zero-padded 16-char lowercase hex string. Caller supplies the size and
* the two 64 KiB chunks (read over WebDAV Range requests).
*
* Returns null if the file is smaller than 128 KiB (the algorithm is undefined
* there and OpenSubtitles ignores undersized hashes anyway).
*/
export function computeMovieHash(size: number, head: Buffer, tail: Buffer): string | null {
const CHUNK = 65536;
if (size < CHUNK * 2 || head.length < CHUNK || tail.length < CHUNK) return null;
const mask = (1n << 64n) - 1n;
let hash = BigInt(size) & mask;
const addWords = (buf: Buffer) => {
for (let i = 0; i + 8 <= buf.length; i += 8) {
hash = (hash + buf.readBigUInt64LE(i)) & mask;
}
};
addWords(head.subarray(0, CHUNK));
addWords(tail.subarray(tail.length - CHUNK));
return hash.toString(16).padStart(16, '0');
}
export class OpenSubtitlesClient {
private http: AxiosInstance;
private token: string | null = null;
constructor(private cfg: OpenSubtitlesConfig) {
this.http = axios.create({
baseURL: DEFAULT_BASE_URL,
timeout: 30000,
headers: {
'Api-Key': cfg.apiKey,
'User-Agent': cfg.userAgent,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
}
/** Lazily log in and cache the JWT. No-op if already authenticated. */
private async ensureToken(): Promise<string> {
if (this.token) return this.token;
if (!this.cfg.username || !this.cfg.password) {
throw new Error('OpenSubtitles username/password required for downloads');
}
const resp = await this.http.post('/login', {
username: this.cfg.username,
password: this.cfg.password,
});
const token = resp.data?.token as string | undefined;
if (!token) throw new Error('OpenSubtitles login returned no token');
this.token = token;
return token;
}
/**
* Search for subtitles. Best-effort: returns [] on any API error so a
* single bad lookup never fails the whole pipeline task.
*/
async search(params: SearchParams): Promise<SubtitleResult[]> {
const query: Record<string, string> = { languages: params.languages };
if (params.imdbId) query.imdb_id = params.imdbId;
if (params.tmdbId) query.tmdb_id = params.tmdbId;
if (params.moviehash) query.moviehash = params.moviehash;
if (params.query) query.query = params.query;
if (params.year) query.year = params.year;
if (params.seasonNumber) query.season_number = params.seasonNumber;
if (params.episodeNumber) query.episode_number = params.episodeNumber;
try {
const resp = await this.http.get('/subtitles', { params: query });
const data = (resp.data?.data ?? []) as any[];
return data.map((item) => {
const attr = item.attributes ?? {};
return {
language: attr.language ?? '',
moviehashMatch: attr.moviehash_match === true,
downloadCount: Number(attr.download_count ?? 0),
release: attr.release,
files: (attr.files ?? [])
.map((f: any) => ({ fileId: Number(f.file_id), fileName: f.file_name }))
.filter((f: SubtitleFile) => Number.isFinite(f.fileId) && f.fileId > 0),
} as SubtitleResult;
}).filter((r) => r.files.length > 0);
} catch (err) {
console.warn(`[opensubtitles] search failed: ${describeError(err)}`);
return [];
}
}
/**
* Request a download link for a subtitle file. Retries once on 401/403 by
* dropping the cached token and re-authenticating. Throws on quota
* exhaustion (406) so the caller can stop early.
*/
async requestDownload(fileId: number): Promise<DownloadResult> {
const doRequest = async (token: string) => {
const resp = await this.http.post(
'/download',
{ file_id: fileId },
{ headers: { Authorization: `Bearer ${token}` } },
);
return resp.data;
};
let token = await this.ensureToken();
let data: any;
try {
data = await doRequest(token);
} catch (err) {
if (isAxiosError(err) && (err.response?.status === 401 || err.response?.status === 403)) {
// Stale token — re-auth once.
this.token = null;
token = await this.ensureToken();
data = await doRequest(token);
} else if (isAxiosError(err) && err.response?.status === 406) {
throw new QuotaExceededError(describeError(err));
} else {
throw err;
}
}
if (!data?.link) throw new Error('OpenSubtitles download returned no link');
return {
link: data.link as string,
fileName: data.file_name as string | undefined,
remaining: Number(data.remaining ?? -1),
};
}
/** Fetch the actual subtitle bytes from a (short-lived) download link. */
async fetchSubtitleBytes(link: string): Promise<Buffer> {
const resp = await axios.get(link, {
responseType: 'arraybuffer',
timeout: 30000,
headers: { 'User-Agent': this.cfg.userAgent },
});
return Buffer.from(resp.data);
}
}
export class QuotaExceededError extends Error {
constructor(message: string) {
super(message);
this.name = 'QuotaExceededError';
}
}
function describeError(err: unknown): string {
if (isAxiosError(err)) {
const status = err.response?.status;
const body = err.response?.data;
const detail = typeof body === 'object' && body
? (body as any).message ?? JSON.stringify(body)
: String(body ?? '');
return `HTTP ${status ?? '?'}${detail ? ` — ${detail}` : ''}`;
}
return err instanceof Error ? err.message : String(err);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Unit tests for the pure helpers, ported from the gateway's torznab
* subtitles.rs tests (lang mapping, region-stripping) plus the moviehash.
* Run with `pnpm test` (uses Node's built-in test runner — no extra deps).
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { toLang3, parseWantedLanguages } from './langs.js';
import { computeMovieHash } from './opensubtitles.js';
test('toLang3 maps ISO 639-1 to 639-3', () => {
assert.equal(toLang3('en'), 'eng');
assert.equal(toLang3('fr'), 'fra');
assert.equal(toLang3('pt'), 'por');
});
test('toLang3 strips region suffixes', () => {
assert.equal(toLang3('pt-BR'), 'por');
assert.equal(toLang3('zh-CN'), 'zho');
assert.equal(toLang3('en_US'), 'eng');
});
test('toLang3 canonicalizes 639-2/B bibliographic codes', () => {
assert.equal(toLang3('fre'), 'fra');
assert.equal(toLang3('ger'), 'deu');
assert.equal(toLang3('chi'), 'zho');
assert.equal(toLang3('fra'), 'fra'); // already canonical
});
test('toLang3 falls back to und', () => {
assert.equal(toLang3('xx'), 'und');
assert.equal(toLang3(undefined), 'und');
assert.equal(toLang3(''), 'und');
});
test('parseWantedLanguages dedupes and lowercases, defaults to en', () => {
assert.deepEqual(parseWantedLanguages('en,fr'), ['en', 'fr']);
assert.deepEqual(parseWantedLanguages(' EN , Fr , en '), ['en', 'fr']);
assert.deepEqual(parseWantedLanguages(undefined), ['en']);
assert.deepEqual(parseWantedLanguages(''), ['en']);
});
test('computeMovieHash returns null for files smaller than 128 KiB', () => {
const small = Buffer.alloc(1000);
assert.equal(computeMovieHash(1000, small, small), null);
});
test('computeMovieHash sums size + 64KiB head/tail words (zero chunks => size)', () => {
// All-zero head/tail → hash == size, as 16-char zero-padded lowercase hex.
const zeros = Buffer.alloc(65536);
const size = 0x20000; // 131072 = 2 * 64 KiB
assert.equal(computeMovieHash(size, zeros, zeros), '0000000000020000');
});
test('computeMovieHash folds in non-zero words', () => {
const head = Buffer.alloc(65536);
head.writeBigUInt64LE(1n, 0); // one word = 1
const tail = Buffer.alloc(65536);
const size = 0x20000;
// hash = size + 1
assert.equal(computeMovieHash(size, head, tail), '0000000000020001');
});
+470
View File
@@ -0,0 +1,470 @@
/**
* OpenSubtitles Plugin
*
* Fetches subtitle sidecars from OpenSubtitles (api.opensubtitles.com) for a
* video and registers them as first-class subtitle records, the same way
* `subtitle-extractor` registers subs pulled out of embedded streams — except
* the bytes come from the network instead of ffmpeg.
*
* ============================================================================
* PLUGIN FILE ACCESS ARCHITECTURE - WebDAV
* ============================================================================
*
* File access via WebDAV (WEBDAV_URL environment variable):
* - Read media files (for moviehash): GET (Range) /webdav/watch/...
* - Write output: PUT /webdav/plugin/opensubtitles/...
* - Cache: Local /cache mount (unused for now)
*
* Output subtitle files become their own records under their midhash256 CID;
* we only stamp the CID onto the video's `subtitles` set — no path is stored
* (the watcher indexes /files/plugin/opensubtitles/ as a first-class root).
*
* ============================================================================
* METADATA (see METADATA_KEYS.md §8/§9)
* ============================================================================
* Fetched subs are external/sidecar provenance, which §8 maps to `subtitles`
* (NOT `extractedSubtitles`, which is embedded-stream-only). We write the
* language-nested **key-set** target shape (matching the gateway's torznab
* enricher, which this plugin supersedes):
* - companion record on the subtitle CID: fileType=subtitle, subtitleLanguage,
* videos/<videoCid>=true (written explicitly — no watcher dependency)
* - on the video: subtitles/<lang3>/<cid>=true, subtitleLanguages/<lang3>=true,
* languages/<lang3>=true (all via PATCH/merge, path-safe like tmdb plot/eng)
*/
import { createHash } from 'crypto';
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
import { MetaCoreClient } from './meta-core-client.js';
import { WebDAVClient } from './webdav-client.js';
import {
OpenSubtitlesClient,
QuotaExceededError,
computeMovieHash,
type SearchParams,
type SubtitleResult,
} from './opensubtitles.js';
import { toLang3, parseWantedLanguages } from './langs.js';
const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/opensubtitles';
const HASH_CHUNK = 65536; // 64 KiB head/tail for the OpenSubtitles moviehash
/**
* Resolve the WebDAV target from the meta-core URL handed to us in the request,
* rather than a static WEBDAV_URL env. This keeps the subtitle BYTES in the same
* meta-core whose METADATA we update — critical when the gateway drives this
* plugin against its own dedicated meta-core (otherwise we'd split-brain: bytes
* to meta-sort's volume, metadata to the gateway's). Falls back to the
* WEBDAV_URL env when the urls endpoint is unreachable. Cached per meta-core.
*/
const webdavCache = new Map<string, WebDAVClient | null>();
async function resolveWebdav(metaCoreUrl: string): Promise<WebDAVClient | null> {
const cached = webdavCache.get(metaCoreUrl);
if (cached !== undefined) return cached;
let client: WebDAVClient | null = null;
const base = metaCoreUrl.replace(/\/$/, '');
for (const path of ['/urls', '/api/urls']) {
try {
const resp = await fetch(`${base}${path}`, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) continue;
const data = (await resp.json()) as { webdavUrlInternal?: string; webdavUrl?: string };
const url = data.webdavUrlInternal || data.webdavUrl;
if (url) {
client = new WebDAVClient(url);
console.log(`[opensubtitles] WebDAV resolved from ${base}${path}: ${url}`);
break;
}
} catch {
/* try next path / fall back to env */
}
}
if (!client) {
// NB: this module exports a function named `process`, which shadows the
// Node global at module scope — reach the real one via globalThis.
const env = globalThis.process.env.WEBDAV_URL;
if (env) {
client = new WebDAVClient(env);
console.log(`[opensubtitles] WebDAV from WEBDAV_URL env: ${env}`);
} else {
console.warn(`[opensubtitles] WARNING: could not resolve WebDAV from ${base} and WEBDAV_URL unset`);
}
}
webdavCache.set(metaCoreUrl, client);
return client;
}
export const manifest: PluginManifest = {
id: 'opensubtitles',
name: 'OpenSubtitles Fetcher',
version: '1.0.0',
description: 'Fetches subtitle sidecars from OpenSubtitles and links them to videos',
author: 'MetaMesh',
dependencies: ['file-info', 'filename-parser', 'tmdb'],
priority: 55, // after tmdb (30) so imdbid/tmdbid are populated
color: '#1B9335',
defaultQueue: 'background', // network-bound, quota-limited
timeout: 120000,
schema: {
subtitles: { label: 'Subtitles', type: 'array', readonly: true },
subtitleLanguages: { label: 'Subtitle Languages', type: 'array', readonly: true },
},
config: {
apiKey: {
type: 'string',
label: 'OpenSubtitles API Key',
required: true,
secret: true,
},
username: {
type: 'string',
label: 'OpenSubtitles Username (required for downloads)',
},
password: {
type: 'string',
label: 'OpenSubtitles Password',
secret: true,
},
languages: {
type: 'string',
label: 'Wanted Languages (CSV of ISO 639-1 codes, e.g. "en,fr")',
default: 'en',
},
userAgent: {
type: 'string',
label: 'User-Agent (registered with OpenSubtitles)',
default: 'MetaMesh-OpenSubtitles-Plugin/1.0',
},
forceRecompute: {
type: 'boolean',
label: 'Force Recompute',
default: false,
},
},
};
let apiKey: string | null = null;
let username: string | undefined;
let password: string | undefined;
let wantedLanguages: string[] = ['en'];
let userAgent = 'MetaMesh-OpenSubtitles-Plugin/1.0';
let forceRecompute = false;
export function configure(config: Record<string, unknown>): void {
console.log(`[opensubtitles] Received config keys: ${Object.keys(config).join(', ')}`);
apiKey = (config.apiKey as string) || null;
username = (config.username as string) || undefined;
password = (config.password as string) || undefined;
wantedLanguages = parseWantedLanguages(config.languages as string | undefined);
userAgent = (config.userAgent as string) || 'MetaMesh-OpenSubtitles-Plugin/1.0';
forceRecompute = config.forceRecompute === true;
console.log(
`[opensubtitles] Configured: apiKey=${apiKey ? 'set' : 'missing'}, ` +
`login=${username ? 'set' : 'missing'}, languages=${wantedLanguages.join(',')}`,
);
}
/**
* Compute the midhash256 CID of a buffer (matches the meta-hash algorithm;
* identical to subtitle-extractor's helper).
*/
function computeMidHash256FromBuffer(data: Buffer): string {
const SAMPLE_SIZE = 1024 * 1024;
const MIDHASH_VARINT = Buffer.from([0x80, 0x20]);
const fileSize = data.length;
const sizeBuffer = Buffer.allocUnsafe(8);
sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0);
let sampleData: Buffer;
if (fileSize <= SAMPLE_SIZE) {
sampleData = data;
} else {
const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2);
sampleData = data.subarray(middleOffset, middleOffset + SAMPLE_SIZE);
}
const hashInput = Buffer.concat([sizeBuffer, sampleData]);
const hashBuffer = createHash('sha256').update(hashInput).digest();
const cidBytes = Buffer.concat([
Buffer.from([0x01]),
MIDHASH_VARINT,
MIDHASH_VARINT,
Buffer.from([0x20]),
hashBuffer,
]);
const base32Chars = 'abcdefghijklmnopqrstuvwxyz234567';
let cid = 'b';
let bits = 0;
let value = 0;
for (const byte of cidBytes) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
cid += base32Chars[(value >> bits) & 0x1f];
}
}
if (bits > 0) {
cid += base32Chars[(value << (5 - bits)) & 0x1f];
}
return cid;
}
function sanitizeFilename(name: string): string {
return name.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim();
}
/** Derive a subtitle file extension from a remote filename, defaulting to srt. */
function subtitleExt(fileName: string | undefined): string {
if (!fileName) return 'srt';
const m = fileName.toLowerCase().match(/\.(srt|ass|ssa|vtt|sub|smi)$/);
return m ? m[1] : 'srt';
}
/** Best-effort OpenSubtitles moviehash for a video, read over WebDAV. */
async function computeVideoMovieHash(client: WebDAVClient, filePath: string): Promise<string | null> {
try {
const stats = await client.stat(filePath);
const size = stats.size;
if (!size || size < HASH_CHUNK * 2) return null;
const head = await client.readBytes(filePath, 0, HASH_CHUNK - 1);
const tail = await client.readBytes(filePath, size - HASH_CHUNK, size - 1);
return computeMovieHash(size, head, tail);
} catch (e) {
console.warn(`[opensubtitles] moviehash unavailable: ${e instanceof Error ? e.message : String(e)}`);
return null;
}
}
/**
* Negative cache of search misses, keyed by the search signature. Ported from
* the gateway's redb `(tmdb_id, lang)` negative cache (opensubtitles.rs §quota):
* a title OpenSubtitles has no subs for must never re-burn the (rate-limited)
* search budget on a rescan. In-memory per process — good enough for the
* plugin's lifetime; cleared on restart.
*/
const searchMissCache = new Set<string>();
/** True once OpenSubtitles reports its download quota spent — short-circuits the
* rest of this process's downloads until restart (mirrors the gateway's
* "skip OS for the rest of this run" hard stop). */
let downloadQuotaSpent = false;
/** Rank: prefer an exact moviehash match, then higher (transient) popularity. */
function rankResults(a: SubtitleResult, b: SubtitleResult): number {
if (a.moviehashMatch !== b.moviehashMatch) return a.moviehashMatch ? -1 : 1;
return b.downloadCount - a.downloadCount;
}
export async function process(
request: ProcessRequest,
sendCallback: (payload: CallbackPayload) => Promise<void>,
): Promise<void> {
const startTime = Date.now();
const metaCore = new MetaCoreClient(request.metaCoreUrl);
const done = (status: CallbackPayload['status'], extra: Partial<CallbackPayload> = {}) =>
sendCallback({ taskId: request.taskId, status, duration: Date.now() - startTime, ...extra });
try {
const { cid, filePath, existingMeta } = request;
if (!apiKey) return void (await done('skipped', { reason: 'No API key configured' }));
if (existingMeta?.fileType !== 'video') return void (await done('skipped', { reason: 'Not a video file' }));
if (downloadQuotaSpent && !forceRecompute) {
return void (await done('skipped', { reason: 'OpenSubtitles download quota spent for this run' }));
}
// Resolve WebDAV from the meta-core we were handed (not a static env) so
// bytes and metadata land in the same store. Required for gateway use.
const webdavClient = await resolveWebdav(request.metaCoreUrl);
if (!webdavClient) return void (await done('failed', { error: 'Could not resolve a WebDAV target' }));
// Which lang3 codes do we still need? Read both the legacy csv-set
// (`subtitleLanguages`) and the key-set (`subtitleLanguages/<lang3>`).
const wantedLang3 = new Map<string, string>(); // lang3 -> original 2-letter code
for (const code of wantedLanguages) wantedLang3.set(toLang3(code), code);
const haveLang3 = new Set<string>();
for (const l of (existingMeta?.subtitleLanguages ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
haveLang3.add(l);
}
for (const key of Object.keys(existingMeta ?? {})) {
if (key.startsWith('subtitleLanguages/')) {
const l = key.slice('subtitleLanguages/'.length);
if (l) haveLang3.add(l);
}
}
if (!forceRecompute) {
for (const lang3 of [...wantedLang3.keys()]) {
if (haveLang3.has(lang3)) wantedLang3.delete(lang3);
}
}
if (wantedLang3.size === 0) {
return void (await done('skipped', { reason: 'Subtitles already present for all wanted languages' }));
}
// Build the search. Prefer structured IDs + moviehash for accuracy.
const moviehash = await computeVideoMovieHash(webdavClient, filePath);
const imdbRaw = existingMeta?.imdbid;
const imdbId = imdbRaw ? imdbRaw.replace(/^tt/i, '').replace(/^0+/, '') : undefined;
const tmdbId = existingMeta?.tmdbid;
const isEpisode = !!(existingMeta?.season || existingMeta?.episode)
|| existingMeta?.videoType === 'tvshow' || existingMeta?.videoType === 'tv';
let title = existingMeta?.originalTitle || existingMeta?.title || existingMeta?.fileName;
const year = existingMeta?.movieYear;
if (title && year) {
title = title.replace(new RegExp(`\\s*[\\(\\[]?${year}[\\)\\]]?\\s*$`), '').trim();
}
const search: SearchParams = { languages: [...wantedLang3.values()].join(',') };
if (moviehash) search.moviehash = moviehash;
if (imdbId) search.imdbId = imdbId;
if (tmdbId) search.tmdbId = tmdbId;
if (isEpisode && existingMeta?.season) search.seasonNumber = existingMeta.season;
if (isEpisode && existingMeta?.episode) search.episodeNumber = existingMeta.episode;
// Title query only as a fallback when we have no structured identifier.
if (!imdbId && !tmdbId && !moviehash && title) {
search.query = title;
if (year) search.year = year;
}
// Negative cache: skip a search we already know returns nothing (no
// moviehash, since that's video-specific). Saves the search budget.
const missKey = [
moviehash ? '' : (tmdbId ?? ''),
moviehash ? '' : (imdbId ?? ''),
moviehash ? '' : (search.query ?? ''),
search.seasonNumber ?? '',
search.episodeNumber ?? '',
[...wantedLang3.keys()].sort().join('+'),
].join('|');
if (!moviehash && !forceRecompute && searchMissCache.has(missKey)) {
return void (await done('skipped', { reason: 'Known OpenSubtitles miss (negative cache)' }));
}
const client = new OpenSubtitlesClient({ apiKey, username, password, userAgent });
const results = await client.search(search);
if (results.length === 0) {
if (!moviehash) searchMissCache.add(missKey);
return void (await done('completed', { reason: 'No OpenSubtitles match' }));
}
// Pick the single best subtitle per still-wanted language.
const bestByLang3 = new Map<string, SubtitleResult>();
for (const r of results) {
const lang3 = toLang3(r.language);
if (!wantedLang3.has(lang3)) continue;
const existing = bestByLang3.get(lang3);
if (!existing || rankResults(r, existing) < 0) bestByLang3.set(lang3, r);
}
if (bestByLang3.size === 0) {
if (!moviehash) searchMissCache.add(missKey);
return void (await done('completed', { reason: 'No results in wanted languages' }));
}
const displayTitle = existingMeta?.title || existingMeta?.originalTitle || title || undefined;
const safeTitle = sanitizeFilename(title || existingMeta?.fileName || 'video');
const yearStr = year ? ` (${year})` : '';
let fetched = 0;
let quotaHit = false;
for (const [lang3, result] of bestByLang3) {
const file = result.files[0];
if (!file) continue;
try {
const download = await client.requestDownload(file.fileId);
const ext = subtitleExt(download.fileName || file.fileName);
const outputFilename = `${safeTitle}${yearStr}[${cid}]_subtitle.${lang3}.${ext}`;
const webdavOutputPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${outputFilename}`;
const opts = { extension: ext, fileName: download.fileName || file.fileName, parentTitle: displayTitle };
// Reuse an already-downloaded sidecar unless forcing.
if (!forceRecompute && (await webdavClient.exists(webdavOutputPath))) {
const existingData = await webdavClient.readFile(webdavOutputPath);
await registerSubtitle(metaCore, cid, computeMidHash256FromBuffer(existingData), lang3, opts);
fetched++;
continue;
}
const bytes = await client.fetchSubtitleBytes(download.link);
if (bytes.length < 10) {
console.warn(`[opensubtitles] empty subtitle for ${lang3}, skipping`);
continue;
}
const subtitleCid = computeMidHash256FromBuffer(bytes);
await webdavClient.writeFile(webdavOutputPath, bytes);
await registerSubtitle(metaCore, cid, subtitleCid, lang3, opts);
fetched++;
console.log(`[opensubtitles] fetched ${lang3} subtitle -> ${subtitleCid} (${webdavOutputPath})`);
if (download.remaining === 0) {
console.warn('[opensubtitles] download quota exhausted, stopping');
downloadQuotaSpent = true;
quotaHit = true;
break;
}
} catch (e) {
if (e instanceof QuotaExceededError) {
console.warn(`[opensubtitles] quota exceeded: ${e.message}`);
downloadQuotaSpent = true;
quotaHit = true;
break;
}
console.error(`[opensubtitles] failed to fetch ${lang3}: ${e instanceof Error ? e.message : String(e)}`);
}
}
await done('completed', {
reason: quotaHit
? `Fetched ${fetched} subtitle(s); stopped on quota`
: `Fetched ${fetched} subtitle(s)`,
});
} catch (error) {
await done('failed', { error: error instanceof Error ? error.message : String(error) });
}
}
/** Companion metadata for a fetched subtitle's own record. */
interface SubtitleOpts {
extension: string;
fileName?: string;
parentTitle?: string;
}
/**
* Register a fetched subtitle. Mirrors the gateway's torznab subtitle enricher
* (subtitles.rs): writes the subtitle's own companion record (keyed by its CID)
* AND stamps the language-nested key-set onto the video. See METADATA_KEYS.md
* §8/§9. We write the companion record explicitly (not via the watcher) so this
* works when the gateway drives the plugin against a meta-core with no watcher.
*/
async function registerSubtitle(
metaCore: MetaCoreClient,
videoCid: string,
subtitleCid: string,
lang3: string,
opts: SubtitleOpts,
): Promise<void> {
// 1. Companion subtitle-file record (fileType=subtitle, lang, video backref).
const companion: Record<string, string> = {
fileType: 'subtitle',
extension: opts.extension,
subtitleLanguage: lang3,
[`videos/${videoCid}`]: 'true',
};
if (opts.fileName) companion.fileName = opts.fileName;
if (opts.parentTitle) companion.title = `${opts.parentTitle} (${lang3} subtitles)`;
await metaCore.mergeMetadata(subtitleCid, companion);
// 2. Language-nested key-set on the video (target shape, METADATA_KEYS.md §8).
await metaCore.mergeMetadata(videoCid, {
[`subtitles/${lang3}/${subtitleCid}`]: 'true',
[`subtitleLanguages/${lang3}`]: 'true',
[`languages/${lang3}`]: 'true',
});
}
+70
View File
@@ -0,0 +1,70 @@
/**
* MetaMesh Plugin Types
*/
export interface PluginManifest {
id: string;
name: string;
version: string;
description?: string;
author?: string;
dependencies?: string[];
schema?: Record<string, SchemaField>;
config?: Record<string, ConfigField>;
priority?: number;
color?: string;
defaultQueue?: 'fast' | 'background';
timeout?: number;
}
export interface SchemaField {
label: string;
type?: 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid';
readonly?: boolean;
hint?: string;
}
export interface ConfigField {
type: 'string' | 'number' | 'boolean' | 'select';
label?: string;
required?: boolean;
default?: unknown;
secret?: boolean;
}
export interface HealthResponse {
status: 'healthy' | 'unhealthy';
ready: boolean;
version: string;
}
export interface ProcessRequest {
taskId: string;
cid: string;
filePath: string;
callbackUrl: string;
metaCoreUrl: string;
existingMeta?: Record<string, string>;
}
export interface ProcessResponse {
status: 'accepted' | 'rejected';
error?: string;
}
export interface CallbackPayload {
taskId: string;
status: 'completed' | 'failed' | 'skipped';
duration: number;
error?: string;
reason?: string;
}
export interface ConfigureRequest {
config: Record<string, unknown>;
}
export interface ConfigureResponse {
status: 'ok' | 'error';
error?: string;
}
+150
View File
@@ -0,0 +1,150 @@
/**
* WebDAV Client for Plugin File Access
*
* Provides HTTP-based file access to meta-sort's WebDAV endpoint.
* Replaces direct filesystem access for containerized plugins.
*/
export interface FileStats {
size: number;
mtime?: Date;
}
export class WebDAVClient {
private baseUrl: string;
constructor(baseUrl: string) {
// Remove trailing slash if present
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Convert absolute file path to WebDAV URL
* Strips /files prefix and appends to base URL
*/
toWebDAVUrl(filePath: string): string {
// filePath is like "/files/watch/movie.mkv"
// We need to convert to "http://meta-sort-dev/webdav/watch/movie.mkv"
let relativePath = filePath;
// Strip /files prefix if present
if (relativePath.startsWith('/files/')) {
relativePath = relativePath.substring(6); // Remove "/files"
} else if (relativePath.startsWith('/files')) {
relativePath = relativePath.substring(6);
}
// Ensure path starts with /
if (!relativePath.startsWith('/')) {
relativePath = '/' + relativePath;
}
return this.baseUrl + relativePath;
}
/**
* Get file stats (size, mtime) via HTTP HEAD request
*/
async stat(filePath: string): Promise<FileStats> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, { method: 'HEAD' });
if (!response.ok) {
throw new Error(`WebDAV HEAD failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const contentLength = response.headers.get('content-length');
const lastModified = response.headers.get('last-modified');
return {
size: contentLength ? parseInt(contentLength, 10) : 0,
mtime: lastModified ? new Date(lastModified) : undefined,
};
}
/**
* Read first N bytes of a file (for magic byte detection)
* Uses HTTP Range request
*/
async readBytes(filePath: string, start: number, end: number): Promise<Buffer> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, {
method: 'GET',
headers: {
'Range': `bytes=${start}-${end}`,
},
});
if (!response.ok && response.status !== 206) {
throw new Error(`WebDAV Range GET failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Read entire file as buffer (for small files only)
*/
async readFile(filePath: string): Promise<Buffer> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, { method: 'GET' });
if (!response.ok) {
throw new Error(`WebDAV GET failed for ${filePath}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Write file via HTTP PUT
*/
async writeFile(filePath: string, data: Buffer | string): Promise<void> {
const url = this.toWebDAVUrl(filePath);
const response = await fetch(url, {
method: 'PUT',
body: data,
headers: {
'Content-Type': 'application/octet-stream',
},
});
if (!response.ok) {
throw new Error(`WebDAV PUT failed for ${filePath}: ${response.status} ${response.statusText}`);
}
}
/**
* Check if file exists via HTTP HEAD
*/
async exists(filePath: string): Promise<boolean> {
const url = this.toWebDAVUrl(filePath);
try {
const response = await fetch(url, { method: 'HEAD' });
return response.ok;
} catch {
return false;
}
}
}
/**
* Create a WebDAV client from environment variables
*/
export function createWebDAVClient(): WebDAVClient | null {
const webdavUrl = process.env.WEBDAV_URL;
if (!webdavUrl) {
console.warn('[webdav-client] WEBDAV_URL not set, WebDAV client unavailable');
return null;
}
return new WebDAVClient(webdavUrl);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist-test",
"sourceMap": false,
"declaration": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "dist-test"]
}