Skip to content

Adding your own sources

This is the boundary of these archives. The public Crimson Haven stack ships with no streaming sources — the backend scrapes nothing, and the client bundles an engine that, by default, knows how to find nothing. To get real playback you provide your own private sources repository and bundle it into the client.

This page documents the mechanism — the contract your engine must satisfy and how to wire it in. It deliberately says nothing about what providers to build or how to scrape any particular site; that is entirely yours to decide and yours to keep private.

The client bundles your engine as a git submodule at vendor/crimson-sources and imports its public API through a Vite alias (crimson-sourcesvendor/crimson-sources/src/index.ts). The TypeScript is transpiled inline at build time — there’s no separate build step for the engine.

The import is direct, but the build never fails when the submodule is missing: a built-in safeguard swaps in a no-op stub so the site builds with no sources (see No sources? The build handles it for you). Provide a real engine to get playback.

Your src/index.ts must export the following. This is the entire surface the client depends on:

// The factory the client calls once per watch session.
export async function createEngine(env: EngineEnv): Promise<Engine>;
// Companion-extension detection (return null when absent — playback still works).
export function getExtensionBridge(): ExtensionBridge | null;
export async function waitForExtensionBridge(): Promise<ExtensionBridge | null>;

EngineEnv — what the client hands your engine

Section titled “EngineEnv — what the client hands your engine”
interface EngineEnv {
extension: ExtensionBridge | null; // E3 — the companion, or null
signProxyUrl?: (f: SignFields) => Promise<string>; // E2 — mints a signed proxy link via the backend /sign grant
resolveGrant?: (r: GrantRequest) => Promise<GrantStream[]>; // backend /resolve grant for secret-bound sources
debug?: boolean;
}

The client supplies signProxyUrl and resolveGrant for you — they call the backend’s grant endpoints with the session token. Your engine just calls them when a source needs the edge proxy (E2) or a server-held secret. You never see PROXY_SECRET or any backend secret.

interface Engine {
// Which sources could run for this request, given the current environment.
capabilities(ctx: { mediaType: "tv" | "movie" }): Record<string, unknown>;
// Quick yes/no: is there at least one runnable source? If false, the client
// doesn't even start the local engine and relies entirely on the backend.
canRunAny(ctx: { mediaType: "tv" | "movie" }): boolean;
// The heart: resolve sources for an episode/movie and YIELD stream lines as they
// come, exactly like the backend's /watch NDJSON.
streamEpisode(
ctx: MediaCtx,
opts: { signal?: AbortSignal },
): AsyncIterable<StreamLine>;
// Tear down any installed extension media rules / resources.
dispose(): Promise<void>;
}

MediaCtx — what identifies the thing to play

Section titled “MediaCtx — what identifies the thing to play”
interface MediaCtx {
tmdbId: number;
mediaType: "tv" | "movie";
season?: number;
episode?: number;
// Enriched by the client from the backend /scrape-meta grant (server TMDB key):
title?: string;
titleEnglish?: string;
titleRomaji?: string;
titleNative?: string;
synonyms?: string[];
releaseYear?: number;
imdbId?: string;
}

The single most important rule: yield the same line shape the backend’s /watch emits, so a locally-resolved stream is indistinguishable from a backend one and the player needs no changes.

interface StreamLine {
type: "stream";
source: string; // display label, e.g. "Example (1080p)"
streamType: "hls" | "mp4" | "iframe";
url: string; // a URL the player can load directly
language?: string | null; // dub/sub label, or null
subtitles?: SubtitleTrack[] | null;
}

How you turn a raw CDN URL into a player-ready url (a direct CDN link plus extension media rules, or a signed proxy link via signProxyUrl) is up to your engine — the New System describes the E1/E2/E3 delivery options.

Section titled “Capability flags drive routing (optional but recommended)”

If your engine declares, per source, which constraints it needs, it can route each source to the cheapest environment that can serve it:

interface SourceFlags {
needsCORSBypass?: boolean; // C1 → proxy (E2) or extension (E3)
needsHeaderInjection?: boolean; // C2 → proxy or extension
needsJA3?: boolean; // C3 → extension only (never the edge)
needsResidentialIP?: boolean; // C4 → extension only
needsServerSecret?: boolean; // C5 → backend /resolve grant
needsEdgeSecret?: boolean; // → proxy edge only (e.g. an edge-held token)
}

You decide the flags; the engine decides the placement. A source that can’t run in the current environment is simply skipped, and the backend remains the floor.

Once your private repository implements the contract above, add it to the client:

Terminal window
cd crimson-client
git submodule add ../crimson-sources vendor/crimson-sources
git commit -m "Bundle private sources engine"

Notes:

  • Use a relative URL (../crimson-sources). The client’s .gitmodules already expects this so the submodule resolves to a sibling repo under the same organisation/owner as the client. Keep all your repos under one org.
  • You can pin a branch (e.g. dev for staging, main for production) in .gitmodules.

Making CI bundle a private sources repo (env-driven)

Section titled “Making CI bundle a private sources repo (env-driven)”

The client’s build workflow fetches the sources repo by name from a secret, so the repo is never hardcoded into the pipeline — and if the secret is unset (or the clone fails), the build still succeeds with no sources. Two repository Actions secrets:

SecretValuePurpose
CRIMSON_SOURCES_REPOyour-org/your-sources-repoWhich repo to bundle. Unset ⇒ build with no sources.
SUBMODULES_TOKENa PAT with read on that repoAuth for the clone (a fork’s default token can’t read a different private repo).

The workflow clones CRIMSON_SOURCES_REPO (at @dev on a dev push, @main on a release) using SUBMODULES_TOKEN, bakes it into the image, and never fails the build if it can’t — it just falls back to the no-op stub. (The companion extension used to be fetched the same way, but it now ships on the Chrome Web Store, so the client no longer bundles it — that fetch step is left commented in the workflow.)

You do not need to provide anything to build a sources-free site. The client ships a built-in safeguard (src/sourcesStub.js): when vendor/crimson-sources is absent, vite.config.js aliases the crimson-sources import to that no-op automatically, so the build succeeds and the in-browser engine cleanly resolves nothing. The site serves whatever the backend owns (your Local / Cache / Jellyfin sources).

So a fresh git clone of the client builds out of the box — no stub to write, no submodule to initialise. Add your private sources repo (above) whenever you’re ready; until then, playback falls back to the backend.

The no-op contract the stub implements is exactly the public API documented above (createEngine → an engine whose canRunAny() is false, plus getExtensionBridge / waitForExtensionBridge). Your real engine just makes those do something.

Your engine can lean on three backend endpoints without ever seeing a secret:

  • /scrape-meta — the client calls this for you and enriches MediaCtx with titles, localized synonyms, release year and IMDb id (these need the server’s TMDB key).
  • /signenv.signProxyUrl(...) mints a signed edge-proxy link (E2).
  • /resolveenv.resolveGrant(...) runs a secret-bound resolve on the backend and returns a raw stream URL for your engine to deliver.

The backend ships a small, documented operator-only grant for secret-bound sources; if you run such a source on your own instance, see Operator-owned sources.

Section titled “Advanced (and not recommended): backend-side E0 sources”

Everything above keeps the actual stream-finding off your server — it runs in the viewer’s browser (E1), at the edge proxy (E2) or in the companion extension (E3). That split is deliberate, and it’s the recommended way: your backend stays light, your server’s IP never touches a third-party host, and you don’t pay the bandwidth.

It is, however, possible to bake sources directly into the backend image as well — E0 sources that scrape and resolve on the server — using the very same “named-by-a-secret” trick the client uses. The backend build looks for two Actions secrets:

SecretValuePurpose
SOURCES_REPOyour-org/your-backend-sourcesWhich private repo to overlay. Unset ⇒ a plain image with operator-owned sources only.
SOURCES_PATa PAT with read on that repoAuth for the clone (mounted as a BuildKit secret — never baked into a layer).

When both are set, the build clones that repo and drops its modules into the backend’s scrapers/ and resolvers/ packages; they’re auto-discovered and registered at boot. A runtime kill-switch, PRIVATE_SOURCES_ENABLED=0, disables them without a rebuild. Like the client, the same public backend builds cleanly for everyone — a fork with no secret simply gets the base image.