Streamiva Plugins

A Streamiva plugin is a small HTTP server you run yourself. Streamiva calls it over HTTPS to ask for things it doesn't natively have — a curated or personalized row of titles, an alternate viewing source for a movie or episode, subtitle tracks, or the releases a title can be played from. Streamiva never runs your code; it only ever makes outbound HTTPS requests to endpoints you publish, and treats every response as untrusted input.

This document describes the contract as actually implemented. If you notice this file disagreeing with real server behavior, the server is the source of truth — please open an issue.

Getting listed

  1. Stand up your own HTTPS server that serves a manifest (see below) and the endpoints for whichever capabilities you're implementing.
  2. Add {"manifest_url": "https://your-server/manifest.json"} to plugin-registry/registry.json in the Streamiva repo and open a PR.
  3. Once merged, a project owner runs python -m app.registry_sync (or the equivalent scheduled job), which fetches your manifest live, validates it, and upserts it into the Store's plugin listing. This re-runs periodically, so editing your live manifest (adding a capability, changing your behavior_hints, rotating an endpoint URL) is picked up automatically — no new PR needed unless manifest_url itself changes.
  4. A malformed, unreachable, or non-HTTPS manifest is skipped, not fatal — your existing listing (from the last successful fetch) stays live until the next sync succeeds.

A user installs your plugin from the Store on their own account. Each install gets its own opaque request_id, generated by Streamiva, sent on every call your plugin receives — use it to distinguish installs if you need to (e.g. for your own rate limiting), but it never reveals anything about the underlying Streamiva account.

Manifest

Serve this as JSON from manifest_url (GET, no auth):

{
  "id": "yourname.subtitle-vault",
  "name": "Subtitle Vault",
  "version": "1.0.0",
  "description": "Finds Hebrew, English and Russian subtitle tracks on TheSubtitleDB and Wizdom.",
  "developer": "yourname",
  "manifest_url": "https://your-server/manifest.json",
  "capabilities": [
    { "type": "subtitle", "media_types": ["movie", "tv_episode"] }
  ],
  "endpoints": {
    "row": null,
    "viewing_source": null,
    "subtitle": "https://your-server/subtitle",
    "stream": null
  },
  "config_schema": [],
  "behavior_hints": {
    "cache_max_age_seconds": 3600
  }
}
Field Type Notes
id string Globally unique. Convention: developer.plugin-name.
name string Shown in the Store and in Settings.
version string Free-form (e.g. semver). Not currently diffed by Streamiva — informational.
description string Shown in the Store.
developer string Shown in the Store.
manifest_url string Must match the URL Streamiva fetched this document from. A mismatch falls under "malformed" in the Getting listed skip rule — Streamiva does not raise an error, it just leaves your last-known-good listing (or no listing at all, on a first sync) in place.
capabilities array One entry per capability you implement — see below. A plugin may declare more than one.
endpoints object {row, viewing_source, subtitle, stream}, each an HTTPS URL or null. Only endpoints matching a declared capability are ever called.
config_schema array Optional per-install settings fields a user fills in from Settings. [] if you have none.
behavior_hints.cache_max_age_seconds int | null How long Streamiva may cache your responses before calling you again. null means 15 minutes, and any value above 7 days is cut to 7 days, so a plugin can't make Streamiva serve indefinitely stale data. No minimum is enforced today. A value below 15 minutes is used as declared, but don't rely on that: declare 900 or more, because a low value only means more calls to your server. The cap is silent. Your own manifest response, the Store listing and GET /plugins/{id} all echo back the raw value you declared, not the one in effect.

Capability entries

{ "type": "row", "id": "trending-horror", "personalized": false, "label": "Trending Horror" }
{ "type": "viewing_source", "media_types": ["movie", "tv_episode"] }
{ "type": "subtitle", "media_types": ["movie", "tv_episode"] }
{ "type": "stream", "media_types": ["movie", "tv_episode"] }

config_schema entries

{ "key": "region", "type": "select", "label": "Region", "options": ["US", "UK", "CA"] }

type is one of "string", "number", "boolean", "select" ("select" requires options). A user's saved values are stored per-install and validated against this schema on save, but are not currently forwarded on any capability call. If your endpoint needs configuration (an API key, a region), your server needs its own copy of it (e.g. its own .env) — don't rely on Streamiva sending config back to you. This applies just as much to Subtitle plugins as to Row or Viewing Source — a region or preferred-language selector is a natural thing to want to declare in config_schema for a subtitle plugin, and it still won't be forwarded; your server needs its own default or its own copy of that setting.

Trust boundary baseline

Every outbound call Streamiva makes to your server — fetching your manifest or calling a capability endpoint — is bounded the same way:

Because failures are silent by design, test your endpoint directly (curl) rather than relying on Streamiva's UI to tell you why nothing showed up.

Capabilities

Every capability call is a POST to the endpoint you declared, with a JSON body and a JSON response. Every request body includes request_id (the opaque, per-install string described above; personalized rows use a different opaque token — see below).

Row

Endpoint: endpoints.row. Called from the home/movies/TV pages.

Request:

{
  "request_id": "b7e2b6b1b1c94e9c9c6a6a6a6a6a6a6a",
  "row_id": "trending-horror"
}

row_id echoes the capability's own id field, letting one endpoint serve multiple declared rows. If the capability is personalized: true, the request also carries the viewer's interaction history, reusing the same fields as the account interactions model exactly:

{
  "request_id": "5e2c...opaque-per-profile-token",
  "row_id": "for-you",
  "interactions": [
    { "tmdb_id": 550, "media_type": "movie", "watchlisted": true, "watched": false, "rating": "like" }
  ]
}

For a personalized row, request_id is itself the opaque per-(install, profile) token — generated once by Streamiva and reused on every subsequent call for that profile, so you can tell repeat visits from the same profile apart without ever learning Streamiva's real account or profile id. rating is one of "like", "dislike", "neutral", or null.

Response:

{
  "items": [
    { "tmdb_id": 550, "media_type": "movie" },
    { "tmdb_id": 1399, "media_type": "tv" }
  ]
}

Streamiva resolves each tmdb_id/media_type against its own Catalog and silently drops any it can't resolve — you can only ever surface titles Streamiva already has.

Caching: a curated row's cache key is the bare row_id, shared across every account and profile that has your plugin installed. A personalized row's cache key is {row_id}:{profile_token}, scoped to one profile. Either way, TTL is your behavior_hints value (15 minutes when unset, at most 7 days).

Viewing Source

Endpoint: endpoints.viewing_source. Called when rendering a title or episode's watch providers.

Request, movie:

{
  "request_id": "b7e2...",
  "media_type": "movie",
  "tmdb_id": 550,
  "imdb_id": "tt0137523",
  "title": "Fight Club",
  "release_year": 1999
}

Request, TV episode:

{
  "request_id": "b7e2...",
  "media_type": "tv_episode",
  "tmdb_id": 1399,
  "imdb_id": "tt0944947",
  "title": "Game of Thrones",
  "release_year": 2011,
  "season_number": 1,
  "episode_number": 1,
  "episode_title": "Winter Is Coming",
  "air_date": "2011-04-17"
}

imdb_id may be null if Streamiva's Catalog doesn't have one for this title.

title and episode_title are in English, whatever language the viewer is browsing in. Your answer is cached by tmdb_id alone and served to every viewer, so the request never depends on who asked. A title the Catalog has no English name for comes in Russian, else Hebrew, else as "".

Response, a match:

{ "match": true, "url": "https://example.com/watch/fight-club", "label": "Watch on Example" }

Response, no match:

{ "match": false }

A match appends one extra watch-provider entry (tagged as third-party in the UI, linking to your url) after Streamiva's own native providers. match: false — or a timeout, or a malformed response — contributes nothing; it's never shown as an error.

Caching: cache key is the bare tmdb_id for a movie, or {tmdb_id}:{season_number}:{episode_number} for an episode. TTL is your behavior_hints value (15 minutes when unset, at most 7 days).

Subtitle

Endpoint: endpoints.subtitle. Called when a signed-in viewer opens the player on a title or an episode, so the subtitle menu can list your tracks next to the ones embedded in the file. Uses the exact same request body as Viewing Source (same movie/episode identifying fields above, same request_id), plus two optional fields describing the release being played, when known:

Response:

{
  "tracks": [
    { "language": "en", "format": "srt", "url": "https://example.com/subs/en.srt", "label": "English",
      "release_name": "Fight.Club.1999.1080p.BluRay.x264-CtrlHD", "hearing_impaired": false },
    { "language": "he", "format": "srt", "url": "https://example.com/subs/he.srt" }
  ]
}

An empty tracks: [], a timeout, or a malformed top-level response contributes nothing. As with stream candidates, and unlike the other capabilities, each entry in tracks is validated on its own — one bad track is dropped, the rest of the list still renders.

Caching: same cache-key formula as Viewing Source (bare tmdb_id for a movie, {tmdb_id}:{season_number}:{episode_number} for an episode), same TTL rule.

Stream

Endpoint: endpoints.stream. Called only when a signed-in viewer asks where a movie or episode can be played from. It is never called while a page renders. A stream plugin says which releases of a title exist and where to get them. Streamiva then plays the one the viewer picks, in its own player. Your plugin carries metadata only and is never in the media path.

A worked example, a small plugin you can run, is in TUTORIAL-STREAM.md.

A manifest for a stream-only plugin:

{
  "id": "yourname.open-films",
  "name": "Open Films",
  "version": "1.0.0",
  "description": "Plays openly licensed films hosted by the Internet Archive.",
  "developer": "yourname",
  "manifest_url": "https://your-server/manifest.json",
  "capabilities": [
    { "type": "stream", "media_types": ["movie", "tv_episode"] }
  ],
  "endpoints": {
    "row": null,
    "viewing_source": null,
    "subtitle": null,
    "stream": "https://your-server/stream"
  },
  "config_schema": [],
  "behavior_hints": {
    "cache_max_age_seconds": 900
  }
}

Request

The request body is the same as for Viewing Source, and it is posted to your stream endpoint. Points that matter more here:

Response

{
  "candidates": [
    {
      "kind": "magnet",
      "infohash": "0e876ce2a1a504f849ca72a5e2bc07347b3bc957",
      "label": "Big Buck Bunny (2008) 720p AVI",
      "source": "example-tracker",
      "seeders": 412,
      "size_bytes": 332243668,
      "container": "avi",
      "resolution": "720p"
    },
    {
      "kind": "torrent",
      "url": "https://archive.org/download/BigBuckBunny_124/BigBuckBunny_124_archive.torrent",
      "label": "Big Buck Bunny (2008) Internet Archive",
      "source": "archive.org"
    },
    {
      "kind": "direct",
      "url": "https://archive.org/download/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4",
      "label": "Big Buck Bunny (2008) 720p",
      "source": "archive.org",
      "size_bytes": 61878609,
      "language": "en",
      "container": "MP4",
      "video_codec": "H.264",
      "audio_codec": "AC3 5.1",
      "resolution": "720p",
      "audio": [
        { "codec": "ac3", "language": "en", "channels": 5.1 },
        { "codec": "aac", "language": "fr" }
      ]
    }
  ],
  "complete": true
}

Candidate kinds

Each candidate has exactly one identifier, chosen by its kind:

kind Identifier What Streamiva does with it
magnet infohash: 40 hex characters, either case. A full magnet URI, or the 32-character base32 form, is also accepted in infohash and reduced to hex. A magnet URI in url drops the candidate. Its Torrent Streamer joins the swarm with Streamiva's own trackers. Any trackers or web seeds in a magnet URI are discarded, and the URI goes no further.
torrent url of a .torrent file The backend fetches the file as soon as your answer arrives, under the url rules. It reduces the file to its infohash, and from then on the candidate is a magnet. Trackers inside the file are discarded.
direct url of a video file The Torrent Streamer relays it when the viewer presses play, under the same url rules. It requests the bytes with Range, a few seconds of video at a time.

Candidate fields

Field Type Notes
kind string "magnet", "torrent" or "direct". Required.
infohash string magnet only. See the table above.
url string torrent and direct only. http:// or https://. Plain http:// is allowed here, unlike your own endpoints, which must be https://.
label string? The release name as you found it. Shown to the viewer as-is, and parsed by Streamiva (see Merging).
source string? A short name for where this came from ("yts", "eztv", …), so a viewer can tell two near-identical releases apart.
seeders int? The single most useful optional field: the best predictor of whether a torrent will play at all.
size_bytes int? The whole torrent's size, as your source lists it, including for a season pack.
language string? The release's main spoken language. It is shown to the viewer as you send it and isn't normalized, so "en" and "English" are not matched as one. Prefer an ISO 639-1 code ("en", "he").
container, video_codec, audio_codec, resolution string? Free strings that Streamiva normalizes; see below. They carry more weight than they look like they do. A TV may decode one release and not the one beside it, and the viewer needs these fields to choose.
audio list? Every audio track of the release, the default one included (audio_codec and language repeat the default track). Each track is {codec, language, channels?}. codec and language are required. channels is a number such as 2, 5.1 or 7.1, and a numeric string such as "5.1" is accepted. A malformed track (no language, or "channels": "2ch") is dropped on its own; it does not take the candidate with it.
file_index int? The 0-based position of the file to play in a multi-file torrent. Today it is only a hint, and you can leave it out. Streamiva makes its own choice: the largest video for a movie, and the file whose name states the episode for an episode.

Integers must not be negative.

Free strings are normalized, never rejected. Send what the release says, in any spelling. Streamiva lowercases the value and ignores spaces, dots, hyphens and underscores before matching it against a short alias list, so "H.264", "x264" and "AVC" are all h264. This is the whole list:

Field Value Also matched
container mp4 m4v
mkv matroska
webm, avi, mov, wmv, m2ts
ts mpegts
video_codec h264 x264, avc, avc1
hevc h265, x265, hvc1, hev1
mpeg4 xvid, divx
av1, vp9, vp8, mpeg2
audio_codec (and audio[].codec) aac aac lc, he-aac
ac3 dd, dolby digital
eac3 e-ac-3, ddp, dd+, ddplus, dolby digital plus
dts dts lossy
dts-hd dts-hd ma, dts-ma, dts lossless
pcm lpcm
truehd, flac, mp3, opus, vorbis
resolution 2160p, 1440p, 1080p, 720p, 576p, 480p, 360p, 240p The number alone (720) or with i (1080i). 4K and UHD count as 2160p, and FHD as 1080p.

A value it doesn't recognize becomes "unknown", and the candidate is kept and shown. Anything added to a value counts as part of it, so these all become "unknown":

Send the bare codec, container or resolution when you can tell it. A field you leave out stays empty; it is never guessed.

Each candidate is validated on its own. One that fails costs only itself: a row that isn't an object, an unknown kind, a bad infohash, or a negative size. The rest of your answer survives. Unknown fields are ignored, so you can add fields without breaking an older Streamiva. There is no version field.

URLs are fetched by Streamiva, never by a viewer

No url you return ever reaches a viewer's app. Streamiva never redirects a viewer to your host, and a viewer's IP address never reaches it. What that means for you:

Limits

The trust boundary baseline applies: a 3-second ceiling with no retries, and a 256 KB cap on the response body. On top of that:

complete: saying "more is coming"

complete is optional, and leaving it out means true. A value other than true or false also counts as absent. A plugin that asks one fast source never has to think about it.

A plugin that asks several sources can answer early with "complete": false, then keep going. Here is what happens next:

  1. Streamiva shows the viewer what you sent, and tells the viewer's app to fetch again once, about 2.5 s after the app received the partial list. Counting your 0.8 s answer, the re-fetch reaches you roughly 3.3 s after the first request.
  2. That second request reaches you, with the same body. Streamiva keeps a partial answer for 45 s at most, and never serves it in place of asking you again. It uses that copy only if you fail to answer the second time.
  3. So by then your own cache should hold the full list, marked complete. If the second answer is partial too, the viewer sees it as it is, and there is no third fetch. Sources still running later than that serve the next viewer instead.

TUTORIAL-STREAM.md shows this pattern in about 60 lines.

Merging with other plugins

A viewer may have several stream plugins installed. Their answers are merged into one list:

Caching

The cache key is tmdb_id for a movie, or {tmdb_id}:{season_number}:{episode_number} for an episode. imdb_id is never part of it. A request with preferred_audio_language adds :audio={code}, so an answer shaped by one hint is never served to a request with another, or with none.

Your answer How long Streamiva keeps it
Complete, including a complete empty answer Your cache_max_age_seconds. A missing value means 15 minutes, and the ceiling is 7 days.
complete: false 45 s at most, and only as a stand-in (see above).
A timeout, an error status or an invalid body Not kept. You'll be asked again on the next request.

An empty answer is a valid answer. {"candidates": []} means "nothing found", and it is kept as long as any other complete answer.

So don't say "complete" when your own code failed. The usual advice is to answer every failure with a 200 and an empty body. For stream, a catch-all that returns {"candidates": [], "complete": true} turns a bug or a down source into "nothing exists", cached for your full TTL. Answer your own failures with {"candidates": [], "complete": false} instead. That is still a 200 with a valid body, and Streamiva keeps it for 45 s at most. A source that fails while others succeed costs only itself, so that answer can still be complete.

Take the TTL seriously. A stream plugin usually queries live third-party sites on every miss. The cache is the only thing standing between a popular title and a burst of identical searches leaving one IP address, both Streamiva's and yours. Declare 900 or more. Streamiva doesn't enforce a minimum today, but don't count on that staying true.

Failure semantics, summarized

Nothing your server does can raise a visible error in Streamiva. A timeout, a 500, an oversized body, invalid JSON, or a response that fails schema validation are all treated identically: no row, no viewing source, no subtitles, no stream candidates from that plugin for that request. Debug by calling your own endpoint directly, not by watching Streamiva's UI for an error message.

Building a plugin: Subtitle Vault

This is a worked walkthrough of building a real Streamiva plugin end to end: a subtitle capability plugin backed by the OpenSubtitles REST API. Read README.md first for the full contract — this doc only re-explains what's needed to follow along.

This is a first-pass draft. If you hit friction following it, that's exactly the signal this doc needs — please report it.

What you're building

A standalone HTTP server, entirely separate from Streamiva's own codebase, that:

  1. Serves a manifest declaring one subtitle capability.
  2. Exposes a subtitle endpoint that, given a movie or episode's identifying fields, looks up subtitles on OpenSubtitles and returns them in Streamiva's tracks[] shape.

Streamiva never runs your code or sees your OpenSubtitles credentials — your server is a standalone service you host, configure, and hold the keys to yourself.

1. Get OpenSubtitles API access

Sign up for an API consumer account at OpenSubtitles and get an API key. You'll need it as an Api-Key header on every call to https://api.opensubtitles.com/api/v1/..., plus a User-Agent header in the form "YourAppName vX.Y.Z" on every call — OpenSubtitles' own docs require it, and requests without one risk being rate-limited. Keep both, and your login credentials below, in your own plugin server's .env — none of this ever gets sent to or stored by Streamiva.

Api-Key alone is enough to get a 200 back from /download, but don't stop there. In practice, an Api-Key-only call to /download succeeds against a shared, undocumented anonymous quota rather than the quota belonging to your own account — you get subtitles back, so nothing looks wrong, but you have no visibility into how much of that shared quota is left or when you'll start silently losing downloads to other anonymous callers. To draw against your own account's dedicated daily quota instead, POST /login with your account's username/password to get a short-lived Bearer token, and send it as Authorization: Bearer <token> on /download calls (/subtitles search doesn't need it). Cache the token and re-login when it's close to expiry or a call comes back 401. Sign up for both an API key and a regular account if you want your own quota tracked separately from anyone else on the shared one.

2. Scaffold the server

Any language/framework works — Streamiva only cares that you serve HTTPS JSON matching the contract. This example uses Python + FastAPI, in its own directory with its own dependencies, independent of Streamiva's own backend:

subtitle-vault/
  .env              # OPENSUBTITLES_API_KEY=..., OPENSUBTITLES_APP_NAME=..., OPENSUBTITLES_LOGIN_NAME=..., OPENSUBTITLES_LOGIN_PASSWORD=...
  app.py
  requirements.txt

OPENSUBTITLES_APP_NAME is your own app name for the User-Agent header OpenSubtitles requires ("AppName vX.Y.Z" — see step 4). It's a free-form string you choose, not something OpenSubtitles issues you; give it its own env var rather than a literal in code, since OpenSubtitles ties rate limits to it and you'll want to change it without a code edit.

Pin your dependencies rather than taking bare latest — this example was last verified against fastapi>=0.115, httpx>=0.27, uvicorn[standard]>=0.32, python-dotenv>=1.0. Bare unpinned names in requirements.txt will drift over time; treat these as a known-good floor, not a ceiling.

3. Serve the manifest

# app.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/manifest.json")
def manifest():
    return {
        "id": "yourname.subtitle-vault",
        "name": "Subtitle Vault",
        "version": "1.0.0",
        "description": "Finds subtitle tracks via OpenSubtitles.",
        "developer": "yourname",
        "manifest_url": "https://your-host/manifest.json",
        "capabilities": [
            {"type": "subtitle", "media_types": ["movie", "tv_episode"]}
        ],
        "endpoints": {
            "row": None,
            "viewing_source": None,
            "subtitle": "https://your-host/subtitle",
            "stream": None,
        },
        "config_schema": [],
        "behavior_hints": {"cache_max_age_seconds": 3600},
    }

manifest_url must exactly match the URL you'll later register — Streamiva refetches from that address, not from whatever id you chose.

4. Implement the subtitle endpoint

The request body (see README.md) gives you imdb_id, title, release_year, and — for episodes — season_number/episode_number too. OpenSubtitles searches by imdb_id when you have one:

import asyncio
import os
import time

import httpx
from fastapi import Request

OPENSUBTITLES_API_KEY = os.environ["OPENSUBTITLES_API_KEY"]  # from your own .env, not from Streamiva
OPENSUBTITLES_APP_NAME = os.environ.get("OPENSUBTITLES_APP_NAME", "subtitle-vault")
OPENSUBTITLES_LOGIN_NAME = os.environ.get("OPENSUBTITLES_LOGIN_NAME")
OPENSUBTITLES_LOGIN_PASSWORD = os.environ.get("OPENSUBTITLES_LOGIN_PASSWORD")
BASE_URL = "https://api.opensubtitles.com/api/v1"
USER_AGENT = f"{OPENSUBTITLES_APP_NAME} v1.0.0"  # OpenSubtitles requires "AppName vX" on every call

# Streamiva gives you 3 seconds total with no retries on its side (see README.md's trust
# boundary baseline). A login + search + download round trip, each with a possible retry,
# adds up fast - budget well inside 3s, not the tutorial's original 1-second backoff. These
# numbers are a starting point measured once, from one network/location, against OpenSubtitles'
# live API - not a guarantee for yours. Measure your own login+search+download round trip and
# tighten OVERALL_DEADLINE_SECONDS if your own latency runs higher.
RETRY_BACKOFF_SECONDS = 0.25
OVERALL_DEADLINE_SECONDS = 2.5

# OpenSubtitles' /login response carries both a `token` and a `base_url` - the latter can be a
# load-balanced host distinct from api.opensubtitles.com, and their own docs say to use it for
# every subsequent call in the session. Cache both; fall back to BASE_URL if base_url is absent.
_token_cache: dict = {"token": None, "base_url": BASE_URL, "fetched_at": 0.0}
TOKEN_TTL_SECONDS = 20 * 60


async def _login(client: httpx.AsyncClient) -> str | None:
    """Logging in trades the shared anonymous /download quota for your own account's
    dedicated one - see section 1. Skipped automatically if no login credentials are set."""
    if not OPENSUBTITLES_LOGIN_NAME or not OPENSUBTITLES_LOGIN_PASSWORD:
        return None
    try:
        resp = await client.post(
            f"{BASE_URL}/login",
            json={"username": OPENSUBTITLES_LOGIN_NAME, "password": OPENSUBTITLES_LOGIN_PASSWORD},
        )
    except httpx.RequestError:
        return None
    if resp.status_code != 200:
        return None
    data = resp.json()
    token = data.get("token")
    base_url = data.get("base_url")
    if token:
        _token_cache["token"] = token
        _token_cache["base_url"] = f"https://{base_url}/api/v1" if base_url else BASE_URL
        _token_cache["fetched_at"] = time.monotonic()
    return token


async def _get_token(client: httpx.AsyncClient) -> str | None:
    fresh = (time.monotonic() - _token_cache["fetched_at"]) < TOKEN_TTL_SECONDS
    if _token_cache["token"] and fresh:
        return _token_cache["token"]
    return await _login(client)


async def _request_with_retry(
    client: httpx.AsyncClient, method: str, url: str, **kwargs
) -> httpx.Response | None:
    """One retry with backoff on a transient upstream error - OpenSubtitles occasionally
    returns a 503 under load."""
    response: httpx.Response | None = None
    for attempt in range(2):
        try:
            response = await client.request(method, url, **kwargs)
        except httpx.RequestError:
            response = None
        if response is not None and response.status_code < 500:
            return response
        if attempt == 0:
            await asyncio.sleep(RETRY_BACKOFF_SECONDS)
    return response


def _normalize_imdb_id(imdb_id: str) -> str | None:
    """Strip "tt" AND leading zeros. OpenSubtitles 301-redirects a leading-zero id (e.g.
    `imdb_id=0137523`, exactly what `imdb_id.removeprefix("tt")` alone produces for
    `tt0137523` - Fight Club's own real id) to the canonical zero-stripped form, and a plain
    `httpx.AsyncClient` does not follow redirects by default - so the naive version of this
    function makes every request for a leading-zero id come back as a redirect response
    instead of JSON, silently returning no subtitles for it every time."""
    digits = imdb_id[2:] if imdb_id.lower().startswith("tt") else imdb_id
    digits = digits.lstrip("0")
    return digits or None


async def _lookup_tracks(body: dict) -> list:
    imdb_id = body.get("imdb_id")
    if imdb_id is None:
        return []
    numeric_imdb_id = _normalize_imdb_id(imdb_id)
    if numeric_imdb_id is None:
        return []

    headers = {"Api-Key": OPENSUBTITLES_API_KEY, "User-Agent": USER_AGENT}
    params = {"imdb_id": numeric_imdb_id, "languages": "en"}
    if body.get("media_type") == "tv_episode":
        if "season_number" in body:
            params["season_number"] = body["season_number"]
        if "episode_number" in body:
            params["episode_number"] = body["episode_number"]

    async with httpx.AsyncClient(headers=headers, timeout=10.0, follow_redirects=True) as client:
        token = await _get_token(client)
        auth_headers = {"Authorization": f"Bearer {token}"} if token else {}
        api_base = _token_cache["base_url"] if token else BASE_URL

        search = await _request_with_retry(client, "GET", f"{api_base}/subtitles", params=params)
        if search is None or search.status_code != 200:
            return []

        results = search.json().get("data", [])
        if not results:
            return []

        # OpenSubtitles' default sort is relevance-ranked by its own algorithm (download count,
        # rating, and recency all factor in) - taking results[0] as-is, as this example does, is
        # the simple route. If subtitle quality/accuracy matters more to you, sort `results`
        # yourself first (e.g. by `attributes.download_count` or `attributes.ratings`) before
        # picking one, or filter by `attributes.release` against the title's known release group.
        files = results[0].get("attributes", {}).get("files", [])
        if not files:
            return []

        download = await _request_with_retry(
            client, "POST", f"{api_base}/download",
            json={"file_id": files[0]["file_id"], "sub_format": "srt"},
            headers=auth_headers,
        )
        if download is None or download.status_code != 200:
            return []

        link = download.json().get("link")
        if not link:
            return []

        return [{
            "language": results[0]["attributes"]["language"],
            "format": "srt",
            "url": link,
            "label": results[0]["attributes"].get("release"),
        }]


@app.post("/subtitle")
async def subtitle(request: Request):
    # Every exit from this handler must return 200 - a raised exception (e.g. malformed JSON
    # from request.json(), or a body missing a field this code accesses) becomes a 4xx/5xx by
    # default, which breaks the "always 200, never an error status" rule below.
    try:
        body = await request.json()
    except Exception:
        return {"tracks": []}
    try:
        tracks = await asyncio.wait_for(_lookup_tracks(body), timeout=OVERALL_DEADLINE_SECONDS)
    except Exception:
        tracks = []
    return {"tracks": tracks}

A few things worth calling out, since they trip people up:

5. Test it directly, locally, before you have a public domain

Don't rely on Streamiva's UI while iterating — a failure there is silent by design (see "Trust boundary baseline" in README.md). Call your own endpoint first, directly, on your own machine.

You don't need a public domain or a real certificate authority to do this: run your server on 127.0.0.1 with a self-signed TLS certificate (e.g. openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365 -subj "/CN=127.0.0.1" -addext "subjectAltName=IP:127.0.0.1", then uvicorn app:app --ssl-certfile cert.pem --ssl-keyfile key.pem), and use curl -k to skip certificate verification against your own box. The -addext flag needs a reasonably modern OpenSSL (3.x, or the one bundled with Git for Windows) — if your openssl doesn't support it, or you don't have one on PATH at all, generate the same self-signed pair with PowerShell's New-SelfSignedCertificate (then export it to PEM), or with Python's cryptography package, instead:

curl -k -X POST https://127.0.0.1:8443/subtitle \
  -H 'Content-Type: application/json' \
  -d '{"request_id": "test", "media_type": "movie", "tmdb_id": 550, "imdb_id": "tt0137523", "title": "Fight Club", "release_year": 1999}'

You should get back {"tracks": [...]} with a real, working download URL.

A self-signed certificate is only good for testing your own server with curl -k like this. Streamiva itself does not skip certificate verification — when it's time to actually register your plugin (next section), your manifest_url needs a certificate from a publicly trusted CA (the kind you get automatically from most hosting platforms, or via Let's Encrypt), on a real public host. A self-signed cert on 127.0.0.1 will never be fetchable by Streamiva; it's purely a local iteration tool.

6. List it and install it

  1. Deploy your server somewhere with a public HTTPS URL and a publicly trusted certificate (not the self-signed one from the previous step). Any host works — a small VM, a PaaS, a container platform. If you'd rather not stand up a public host at all yet, an authenticated Cloudflare Tunnel gets you a real, publicly-trusted HTTPS URL pointed at your existing local server, with no separate hosting account:
    1. Create a tunnel in the Cloudflare dashboard (Zero Trust → Networks → Tunnels) or via cloudflared tunnel login && cloudflared tunnel create subtitle-vault — this requires a Cloudflare account (a free one works) and, for a stable hostname, a domain added to it. This is different from cloudflared tunnel --url ...'s anonymous "quick tunnel": a named, authenticated tunnel gets you a stable hostname across restarts, and doesn't depend on a short-lived, unauthenticated trycloudflare.com URL.
    2. Run it against your local server: cloudflared tunnel run --url https://127.0.0.1:8443 --no-tls-verify subtitle-vault (the --no-tls-verify only affects the local hop to your self-signed cert; Cloudflare still terminates a real, publicly-trusted certificate for inbound traffic).
    3. Point manifest_url and your subtitle endpoint at the hostname Cloudflare gives the tunnel, and re-serve your manifest with that as its own manifest_url value (see the mismatch note in README.md).
    • Run this yourself, outside any agent sandbox. An automated coding agent may be blocked from creating outbound tunnels or completing this dashboard flow at all (both creating a tunnel and Cloudflare's own login/Turnstile step require a human) — this step is meant to be done by you, by hand, not delegated.
  2. Add {"manifest_url": "https://your-host/manifest.json"} to plugin-registry/registry.json and open a PR against the Streamiva repo.
  3. Once merged and synced (see README.md), find your plugin in the Store and install it on a test account.
  4. Open a title page for a movie your plugin covers (e.g. Fight Club) and confirm the subtitle dropdown shows your language with a working download link.

Next steps

The same shape extends to the row, viewing_source and stream capabilities. See README.md for their request and response schemas. A single plugin can declare more than one capability from the same server.

stream has a different rhythm from the others. It is called only when a viewer explicitly asks where something can be played from, never as part of rendering a page, and its answer can say "more is coming". TUTORIAL-STREAM.md walks through a small working stream plugin. It queries its sources at once, answers within 0.8 seconds, and finishes slow sources in the background.