npm package: @streamfox/plugin-sdk
Description: Unified Node SDK for StreamFox media plugins
Latest version: 0.7.2

# StreamFox Plugin SDK

`@streamfox/plugin-sdk` is the Node.js SDK for StreamFox remote plugins.

It includes:

- declarative plugin contract with `definePlugin(...)`
- runtime server with `createServer(...)` / `serve(...)`
- strict schema-major validation (`schemaVersion.major === 1`)
- built-in installer UI and typed settings parsing
- canonical contract parity with `swift-media-plugin-kit`

## Install

```bash
npm i @streamfox/plugin-sdk
```

## Quick Start

```ts
import { definePlugin, ids, serve } from "@streamfox/plugin-sdk";

const plugin = definePlugin({
  plugin: {
    id: ids.plugin("com.example.demo"),
    name: "Demo",
    version: "0.1.0",
  },
  resources: {
    stream: {
      mediaType: "movie",
      handler: async () => ({
        streams: [
          {
            transport: {
              kind: "http",
              url: "https://cdn.example.com/movie.mp4",
              mode: "stream",
            },
          },
        ],
      }),
    },
  },
});

const server = await serve(plugin, {
  port: 7000,
  integration: {
    installScheme: "streamfox",
    launchBaseURL: "https://streamfox.app/#",
    autoOpen: "none",
  },
});

console.log(server.url); // manifest URL
console.log(server.installURL); // install deeplink
console.log(server.launchURL); // launch URL
```

## Progressive Shorthand

`definePlugin(...)` accepts shorthand aliases and normalizes them to canonical manifest fields:

- `type` -> `mediaTypes: [type]`
- `mediaType` -> `mediaTypes: [mediaType]`

Conflict guard:

- equivalent canonical+alias values are accepted
- mismatched canonical+alias values throw `MANIFEST_INVALID`

Defaults (when omitted):

- `meta.mediaTypes`: `["movie", "series"]`
- `stream.mediaTypes`: `["movie", "series"]`
- `subtitles.mediaTypes`: `["movie", "episode"]`
- `stream.supportedTransports`: `["http"]`
- `subtitles.defaultLanguages`: `["en"]`
- `catalog.endpoint.mediaTypes`: `["movie"]`

## Validation Lifecycle

Validation runs automatically in three phases:

1. `createPlugin(...)` / `definePlugin(...)`: manifest + capability shape validation.
2. Incoming request handling (`createServer` routes): request validation against manifest capabilities.
3. Outgoing handler responses: response validation before JSON emission.

Redirect responses are validated too (`redirect.url`, `redirect.status`) before the redirect is returned.

## Canonical Routes

- `GET /manifest`
- `GET /studio-config`
- `GET /catalog/:mediaType/:catalogID`
- `GET /meta/:mediaType/:itemID`
- `GET /stream/:mediaType/:itemID`
- `GET /subtitles/:mediaType/:itemID`
- `GET /plugin_catalog/:catalogID/:pluginKind`

## GET Query Style

Resource routes use one HTTP style:

- path params for identity
- plain query aliases for request shaping

Examples:

- `/catalog/movie/browse?genre=action&year=2024&locale=el-GR&page=0&pageSize=20&orderBy=popular`
- `/catalog/movie/browse?year=2000..2024&rating=7..10`
- `/catalog/movie/browse?orderBy=rating&order=asc`
- `/meta/movie/tt0133093?locale=el-GR&regionCode=GR`
- `/stream/movie/tt0133093?videoID=trailer&startPositionSeconds=123&networkProfile=wifi`
- `/stream/movie/tt0133093?quality=1080p&videoID=trailer&startPositionSeconds=123&networkProfile=wifi`
- `/subtitles/movie/tt0133093?source=opensubtitles&hearingImpaired=true&videoHash=abc123&videoSize=1234567&filename=matrix.mkv&languagePreferences=en,el`
- `/plugin_catalog/featured/catalog?page=0&experimental=streamfox:beta`

Legacy structured query params such as `request`, `schemaVersion`, `context`, `experimental`, `filters`, `sort`, `playback`, and `videoFingerprint` are rejected on GET resource routes.

## Strong ID Helpers

Use `ids.*` helpers when authoring manifests and responses:

- `ids.plugin("com.example.demo")`
- `ids.catalog("browse")`
- `ids.item("tt0133093")`
- `ids.video("main")`
- `ids.imdb("tt0133093")` for strict IMDb format (`tt` + digits)

## IMDb-Only IDs

This SDK enforces IMDb-only media IDs:

- Item/media IDs must match `tt<digits>`
- Episodic video IDs must match `tt<digits>:<season>:<episode>` when colon format is used
- ID prefix configuration is not part of the SDK API

Examples:

- valid item ID: `tt0133093`
- invalid item ID: `tmdb:603`
- valid episodic video ID: `tt0944947:1:1`
- invalid episodic video ID: `episode-1`

## Migration Note

If older code used custom ID prefixes, migrate to IMDb IDs (`tt...`) and episodic video IDs in `tt...:season:episode` format.

## Unified Filter Ergonomics

Catalog filters support:

- reusable `filterSets` shared across endpoints
- reusable `sortSets` shared across endpoints
- richer filter metadata for UI and docs
- richer sort metadata for UI and docs
- `filters.*` and `sorts.*` helpers for authoring plain manifest-compatible specs
- query alias normalization through `options[].aliases`
- ordering aliases through `orderBy`
- `isRequired` for required (always-visible) controls
- `index` (`>= 0`) for deterministic UI ordering
- `optionsLimit` and `maxSelected` for bounded multi-selects
- `dynamicOptions` for provider-driven filter options with caching/fallback behavior
- `visibleWhen` / `enabledWhen` for conditional filter UX
- the same `FilterSpec` model on `catalog`, `stream`, and `subtitles`

Example:

```ts
import { definePlugin, filters, ids, sorts } from "@streamfox/plugin-sdk";

const plugin = definePlugin({
  plugin: {
    id: ids.plugin("com.example.catalog"),
    name: "Catalog Demo",
    version: "0.1.0",
  },
  resources: {
    catalog: {
      filterSets: {
        commonCatalogFilters: [
          filters.select("language", {
            label: "Language",
            group: "regional",
            options: [
              { label: "Japanese", value: "ja", aliases: ["Japanese (ja)"] },
              { label: "English", value: "en", aliases: ["English (en)"] },
            ],
          }),
          filters.select("genre", {
            options: [
              { label: "Action", value: "action", aliases: ["Action"] },
              { label: "Drama", value: "drama" },
            ],
          }),
        ],
      },
      sortSets: {
        browseSorts: [
          sorts.desc("popularity", {
            label: "Popular",
            aliases: ["popular"],
          }),
          sorts.choice("rating", {
            label: "Top Rated",
            aliases: ["top-rated"],
            directions: ["descending", "ascending"],
            defaultDirection: "descending",
          }),
        ],
      },
      endpoints: [
        {
          id: ids.catalog("browse"),
          name: "Browse",
          mediaTypes: ["movie"],
          filterSetRefs: ["commonCatalogFilters"],
          sortSetRefs: ["browseSorts"],
          filters: [filters.intOrRange("year"), filters.range("rating")],
        },
        {
          id: ids.catalog("episodes"),
          name: "Episodes",
          mediaTypes: ["series"],
          filters: [
            filters.number("season", {
              label: "Season",
              group: "episodes",
            }),
          ],
        },
      ],
      handler: async () => ({ items: [] }),
    },
  },
});
```

Prefer semantic endpoint IDs such as `browse`, `discover`, and `search`. Keep variable controls in the query string:

- `/catalog/movie/browse?language=ja`
- `/catalog/movie/browse?year=2024`
- `/catalog/movie/browse?year=2000..2024`
- `/catalog/movie/browse?query=matrix`
- `/catalog/movie/browse?orderBy=popular`
- `/catalog/series/episodes?season=1`
- `/catalog/series/episodes` to return all episodes when no season is provided

`stream` and `subtitles` can declare their own `filters` too, while still using dedicated aliases like `videoID`, `videoHash`, and `languagePreferences`.

Reserved filter keys are rejected across all resource kinds:

- `query`, `page`, `pageSize`, `orderBy`, `order`
- `videoID`, `videoHash`, `videoSize`, `filename`, `languagePreferences`
- `locale`, `regionCode`, `traceID`, `experimental`

## Manifest Policy + Quality Metadata

Manifest now supports:

- `safety` (`adult`, `p2p`) for install-time trust hints
- `configuration` (`required`, `fields`) as the first-class plugin config schema
- `capabilityConstraints` (`accountRequired`, `bandwidth`, geo allow/block regions)
- `qualitySignals` (`providerSuccessRate`, `timeoutRatio`, `freshnessTimestamp`)

Capability-level guardrails:

- IMDb-only ID enforcement for `meta`, `stream`, and `subtitles` capabilities
- `meta.embeddedVideoStreamStrategy` (`exclusive` | `merge` | `prefer_external`)
- catalog `discovery` metadata for UI defaults (`mode`, `defaultSort`, `defaultFilters`)

## Rich Media Details

`MediaSummary` stays lightweight for browse/cards, but supports a few presentation fields:

- `background`
- `runtime`
- `yearLabel`
- `logoURL`
- `releasedAt`
- `slug`
- `imdbRating`
- `sourceRatings`
- `popularity`

`MediaDetail` is the richer app-detail model and supports:

- `releasedAt`
- `dvdReleaseAt`
- `logoURL`
- `language`
- `country`
- `awards`
- `slug`
- `imdbRating`
- `sourceRatings`
- `popularity`
- `popularityBySource`
- `cast`, `directors`, `writers`
- `behaviorHints`
- `videos`
- `trailers`
- `similarItems`

`behaviorHints` follows the Cinemeta-style shape:

```ts
behaviorHints?: {
  defaultVideoId?: VideoID | null;
  hasScheduledVideos?: boolean;
}
```

Video entries support both:

- `releasedAt`: generic availability date
- `firstAiredAt`: schedule/broadcast date for upcoming-episode UX
- `rating`: optional video/episode rating

There is no separate `trailerStreams` field; trailers are represented only through `trailers`.

## ID Model

StreamFox uses typed IDs in the SDK contract:

- `MediaSummary.id`
- `MediaDetail.summary.id`
- `similarItems[].id`
- `VideoUnit.id`

ID semantics depend on the entity:

- media/title IDs identify the title itself, for example `tt0133093`
- video IDs identify the video resource itself, for example `main` or `tt8599532:1:4`

Recommended episodic video ID format:

- `{parentMediaID}:{season}:{episode}`

`defaultVideoID` always points to one `videos[].id`.

Use `ids.item(...)` for media IDs, `ids.video(...)` for video IDs, and `ids.imdb(...)` when strict IMDb format is required.

## Custom Frontends

There are two supported ways to own the installer/frontend experience:

1. Headless mode:

```ts
await serve(plugin, {
  frontend: false,
});
```

Use your own app against `GET /manifest`, `GET /studio-config`, and the resource routes.

2. Custom static bundle served by the SDK:

```ts
await serve(plugin, {
  frontend: {
    mountPath: "/installer",
    distPath: "/absolute/path/to/frontend-dist",
    assetsMountPath: "/installer/assets",
  },
});
```

`/studio-config` is the frontend contract for installer metadata, field definitions, deeplink scheme, and `configurationRequired` (derived from `manifest.configuration.required`).

## Docs

- [Plugin Contract](./docs/plugin-contract.md)
- [Runtime + Serve Options](./docs/runtime-options.md)
- [Install + Settings](./docs/install-settings.md)

## Advanced API

```ts
import {
  createPlugin,
  parseJsonWithLimits,
  maximumJsonNestingDepth,
  validateManifest,
  validateRequest,
  validateResponse,
  ProtocolError,
} from "@streamfox/plugin-sdk";
```

JSON payload size/depth limit controls live in the schema utilities above, not in `createServer(...)` or `serve(...)`.

Subpath export is also available:

```ts
import {
  validateManifest,
  validateRequest,
  validateResponse,
} from "@streamfox/plugin-sdk/advanced";
```

## Development

```bash
npm install
npm run format
npm run build
npm test
```