Plugin System Overview
Obscura's metadata is plugin-driven. Posters, descriptions, performers, studios, tags, episode breakdowns — none of it is hard-coded. Every provider is a plugin with a manifest, a declared capability set, and an execution envelope.
This page is the bird's-eye view: what kinds of plugins exist, how they relate, and what to read next.
Three runtimes
| Runtime | What it is | When to use |
|---|---|---|
| TypeScript | A compiled JS module loaded into the worker process via dynamic import(). | New providers when you want type safety, npm ecosystem, in-process speed. |
| Python | A standalone script invoked as a subprocess; talks JSON over stdin/stdout. | New providers where Python libraries make life easier (some scrapers, some data formats). |
| Stash-compat | A wrapper around a Stash YAML scraper. The community has hundreds of these; the adapter maps Stash actions onto Obscura's envelope. | Pulling in existing Stash scrapers without rewriting them. |
All three speak the same protocol at the executePlugin() boundary:
┌──────────────────────┐
identify request │ PluginExecutionInput│
─────────────────►│ { action, input, │
│ auth, batch } │
└──────────┬───────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ TypeScript │ │ Python │ │ Stash │
│ loader │ │ subprocess │ │ adapter │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
└────────┬──────┴───────┬──────┘
▼ ▼
┌──────────────────────────────┐
│ PluginExecutionOutput<T> │
│ { ok, result | results, │
│ error } │
└──────────────┬───────────────┘
│
▼
Normalizers → scrape_results row
Whether you write TypeScript or Python, your code answers a single question: "given this input and these credentials, what metadata do you have?"
A separate concept: StashBox
StashBox endpoints are not plugins. They use a fingerprint-based GraphQL protocol (PHash, OSHash, MD5) to look up content. Configuration lives in stashbox_endpoints rows; queries go through the client in packages/stash-import/src/stashbox/.
StashBox results land in the same UI as plugin results — scrape_results rows reviewed and accepted in the same drawer — but the path that produced them is different. The dedicated docs are at Advanced · StashBox.
What a plugin produces
A plugin returns a normalized result matched to the action it ran:
| Action category | Result type |
|---|---|
videoByURL, videoByName, videoByFragment | NormalizedVideoResult |
folderByName, folderByFragment, folderCascade | NormalizedFolderResult (with optional episodeMap) |
galleryByURL, galleryByFragment | NormalizedGalleryResult |
imageByURL | NormalizedImageResult |
audioByURL, audioByFragment, audioLibraryByName | NormalizedAudioTrackResult / NormalizedAudioLibraryResult |
performer* | Performer result (via Stash adapter for legacy parity) |
movieByName, movieByURL, movieByFragment | NormalizedMovieResult |
seriesByName, seriesByURL, seriesByFragment | NormalizedSeriesResult (with optional disambig candidates[]) |
seriesCascade | NormalizedSeriesResult with full seasons[].episodes[] tree |
episodeByName, episodeByFragment | NormalizedEpisodeResult |
These shapes are documented in detail in Capabilities.
The application normalizer trims strings, validates URLs, deduplicates names case-insensitively, coerces numbers, and accepts both singular and plural field names. Returning slightly-the-wrong shape isn't fatal — the normalizer is forgiving. Returning nothing useful is fine too: just null.
What happens after a result lands
plugin → normalized result
→ scrape_results row written (status = pending,
proposed_* fields populated)
→ user reviews in the cascade drawer
→ on Accept: missing performers/tags/studios created,
images downloaded to /data/cache/metadata/,
entity row updated, status = accepted
The cascade drawer is what turns one TMDB series result into a fully-populated series + N seasons + M episodes in your library. The plugin returns the tree; Obscura walks it.
First-party plugins
The CHANGELOG mentions The Movie Database (TMDB), TVDB, YouTube, and MusicBrainz. They live in the obscura-community-plugins sister repo, not in the main repo. They're the easiest reference reads for "what does a real plugin look like."
You install them from Plugins → Obscura Index in the web app. One click downloads, verifies, and registers them.
Where plugin code lives
| Path | What it is |
|---|---|
packages/plugins/src/types.ts | The wire-protocol types — OscuraPlugin, PluginExecutionInput, PluginExecutionOutput, PluginCapabilities, every Normalized*Result. The contract. |
packages/plugins/src/manifest-parser.ts | YAML manifest reader and validator. |
packages/plugins/src/ts-loader.ts | TypeScript runtime loader. |
packages/plugins/src/executor.ts | Python subprocess executor. |
packages/plugins/src/normalizer.ts | Output normalizers. |
packages/stash-import/src/stash-adapter.ts | Stash-compat YAML adapter. |
packages/app-core/src/plugin-execution.ts | Web-side glue: resolve manifest, decrypt auth, dispatch into the runtime, normalize result. |
packages/app-core/src/plugin-registry.ts | Discover installed plugins from the plugin_packages table. |
packages/app-core/src/plugin-proposed-result.ts | Convert a plugin output into a scrape_results row. |
If you're going to read source, start with packages/plugins/src/types.ts. Everything else makes sense once you know the wire format.
What to read next
- Building one yourself: Manifest → Capabilities → TypeScript Plugin or Python Plugin.
- Bringing in a Stash YAML scraper: Stash Compatibility.
- Publishing for others: Publishing.