AIOStreamsAIOStreams
Reference

Config Expression Language

Complete reference for config variants and the CEL instruction set used to adjust a configuration on the fly.

A variant is a named script that adjusts your configuration at request time. Variants live inside one config and are selected by the install URL, so a single UUID can serve several differently-behaving addons.

The scripts are written in the Config Expression Language (CEL). Where the Stream Expression Language is evaluated against a list of streams, CEL is declarative: a list of edits applied to your configuration, top to bottom.


Variants or a parent config?

Parent/child configVariant
Configs to set upTwo, each with its own UUIDOne
Written asMerge strategies chosen in the UIA few lines of CEL
Seeing the resultAfter saving, by looking at the streamsA diff, before you save
Whose config it isTheirs, with their own credentialsYours, viewed through a lens

Prefer a variant where you can. Most differences are a line or two, the preview shows the diff before you save, and there is no second configuration to create and keep track of.

Sharing does not settle it either way. Giving your config to someone whose TV cannot handle Dolby Vision is a variant: add excludedVisualTags "DV Only" and hand them /v/no-dv. They get your config exactly, minus the one thing their hardware cannot do, and it stays one config when you change it.

Use a parent/child config when the second configuration has to belong to somebody else — a child config is editable by whoever holds it and carries their own credentials, which you never see, where a variant only ever shows your config through a lens you wrote. The other reason is preference: a parent config is configured by clicking, at the cost of not being able to preview what a merge produces.


Quick start

In Miscellaneous → Variants (Advanced mode), add a variant with the id phone.

Write the script:

set addonName = "AIOStreams (Phone)"
set excludedResolutions = ["2160p", "1440p"]
set size.global.movies = [0, 8000000000]
set resultLimits.global = 10

Hit preview to see exactly which fields change, then save.

On Save/Install, pick the variant. Every install link gains /v/phone:

https://your-instance/stremio/<uuid>/<password>/v/phone/manifest.json

Where the selector goes

Selector location on the install page switches between two forms:

https://your-instance/stremio/<uuid>/<password>/v/phone/manifest.json
https://your-instance/stremio/<uuid>/<password>/manifest.json?v=phone

They do the same thing and both work everywhere, so the path form is the default: it is the one that survives a client rebuilding its request URLs from a base you gave it rather than following the manifest URL. A client keeping the query string is what the other form needs, and not all of them do.

The Search and Newznab/Torznab APIs take the query form only, as &v=, since they are single endpoints rather than a base a client extends.


Selecting variants

Comma-separate to combine. They apply left to right, so later instructions win:

.../v/phone,rd2/manifest.json

An unknown or disabled id is an error, surfaced as an error stream, rather than a silent fall back to the base config. Ids are not case-sensitive and repeats are ignored.

The selector also works on ChillLink and the Seanime extension URLs.

The configure page always edits the base config, even when a variant is selected.


Conditional activation

A variant can also decide for itself. Give it an activation condition and it applies whenever that condition matches the incoming request, with nothing added to the URL: one install serves a phone a different config from a TV. Conditions are checked on every request, whichever link it arrived on, so a match applies on top of whatever that link selected.

The condition is a stream expression that evaluates to true or false, using a limited set of functions:

includes(userAgent, 'android')

Naming a variant in the URL applies it whether or not its condition matches. What applies, in what order covers the case where both happen.

Selecting a conditional variant applies it to every request, not only those its condition matches. A "Real-Debrid down" variant picked on the install page keeps Real-Debrid disabled.

What a condition can read

NameIs
userAgentThe client's User-Agent header, or an empty string. On Jellyfin, the app's name, version and device are added to the end
resourcestream, manifest.json, catalog, meta, subtitles, search, ...
typemovie, series, ... where the request has one
idThe requested id, e.g. tt0111161:1:2
query('name')A query parameter, or an empty string
header('name')A request header, or an empty string
health('id')Whether a health check is currently passing
includes(a, b)Whether a contains b, ignoring case
matches(value, 'expr')Whether a regular expression matches. Follows this instance's regex rules

Finding the right userAgent is guesswork, so the Variants tab lists the last five that actually reached your configuration. Only stream, catalogue, meta, subtitle and Jellyfin requests are counted.

The apps' own headers

The AIOStreams apps, on the web, the desktop, Android and TVs, send these with their Jellyfin requests to a server new enough to accept them. A header with nothing to report is left out.

HeaderValues
X-AIOStreams-ClientAIOStreams Web, AIOStreams Desktop, AIOStreams Android, AIOStreams Android TV, AIOStreams webOS or AIOStreams Tizen
X-AIOStreams-DeviceThe device's name, e.g. Pixel 7, or in a browser the browser and system, as Chrome on Windows. Characters outside plain ASCII arrive percent-encoded
X-AIOStreams-Platformandroid, ios, windows, macos, linux, webos or tizen
X-AIOStreams-Networkcellular, wifi, ethernet, bluetooth, wimax, mixed, other or none. Only Android reports one
X-AIOStreams-Purposedownload while finding a version to download, including the list the Download button opens
header('x-aiostreams-client') == 'AIOStreams Android' and header('x-aiostreams-network') == 'cellular'

Health checks

A health check is a URL this instance calls to decide whether something is up. Define them in Miscellaneous → Health Checks, then read one as health('id'):

not health('rd-up')
FieldDefaultMeaning
URLCalled with GET, or HEAD if you prefer
Expected status2xxA code (200), a class (2xx) or a range (200-299)
JSON pathA dotted path into a JSON body, e.g. debrid.rd
Expected valueCompared to the value at that path. Empty means any truthy value
Body containsText the response body must contain, ignoring case
Interval300sHow long a result is reused before it is checked again
Timeout3000msHow long to wait for a response
If the check failsdownWhat a timeout or unreachable URL counts as

health() is not limited to variant conditions. It works in group conditions, the dynamic addon fetching exit condition, stream expression filters and rankings, and the precache and preload selectors, so you can also skip a group or drop a service's results while it is down.

Every check a configuration defines is resolved once per request, so one request sees one answer everywhere. Results are cached for the interval you set and shared with everyone whose check names the same URL, which means a service that is down is not re-probed on every request. Once a result is older than the interval, the request that notices is served the old answer and the refresh happens behind it, so a slow endpoint never holds up a stream request twice.

Things worth knowing

  • A condition that fails counts as no match. A broken expression, an unreachable check, a typo: the variant does not apply and the request is served as it otherwise would be. Only a variant named in the URL is an error when it cannot be applied.
  • Automatic variants do not change your addon id. They share the install URL, which is the point. They are still part of the cache key, so two clients on one URL never see each other's results.
  • The manifest could be cached by your client, and Stremio syncs installs between devices, so a condition works best on things the manifest does not describe: filters, sorting, formatting, proxying and which service a stream is played through. Writing to presets, services, catalogModifications or mergedCatalogs changes which catalogues and resources the addon offers, and addonName, addonLogo, addonBackground and addonDescription change how it presents itself. Those are still served correctly on every request, but a client that cached the manifest at install time may keep showing what it saw then.

What applies, in what order

Several variants can apply to one request: every one you selected, and every one whose condition matched. They are layered onto a single copy of your configuration, in this order:

  1. Variants whose condition matched, in the order they are listed in Miscellaneous → Variants.
  2. Variants named in the URL, left to right.

Inside a script, instructions run top to bottom, and use variant runs that variant's instructions at the point it appears. The last write to a field wins, so set replaces whatever an earlier variant put there, while add, remove and insert build on it. Prefer those in a variant meant to combine with others.

RequestApplies
/v/phone,rd2phone, then rd2
No selector, phone and rd-down both matchwhichever is listed first, then the other
/v/no-dv while rd-down matchesrd-down, then no-dv
/v/rd-down while rd-down also matchesonce only, in its position in the URL

The variants on one request share a work budget. Each instruction costs one, plus one for every entry its path looks through and every value it writes, use variant included. Naming an entry by [key=value] is a lookup rather than a search, so it stays cheap however long the list. A request that runs out stops there and says so.

A variant that use variant pulls in runs again even if it already applied on its own. Most instructions land the same way twice, so this is usually invisible.


Who gets an addon or catalog

Giving an addon or catalog to some people only needs no script. On the configure page, the people button beside each addon in My Addons and each catalog in Catalogs, and Choose who gets them… in their Actions menus, list who could get it:

  • the base config, which is what an install link without a variant serves;
  • each variant you install from a link, one with no activation condition that no user picks;
  • on a media server, the primary user and each other user.

Ticking or unticking a row changes only that row. Behind it, the picker writes ordinary instructions:

  • The base config gets the item's own on/off switch.
  • A variant you install from a link gets an enable or disable line at its end.
  • A user gets the same line in a variant of their own, which has the user's id, or primary for the primary user. It is created and added to the end of their variant list the first time, and removed again once it holds nothing.
enable presets[instanceId="8ae"] # Torrentio
disable catalogModifications[id="8ae1a2b.top"][type="movie"] # Popular

A line is only written where a row would otherwise come out wrong, and dropped once it changes nothing. The picker reads each row by applying that row's variants, so lines you wrote yourself count: a disable presets[type=torrentio] in a user's variants shows them without Torrentio. Only enable and disable lines naming that exact item are rewritten.

Some rows can't be changed there: a user whose list puts another variant after their own, when that variant also turns the item on or off, and a user whose id another variant already has. The picker says which. Variants with an activation condition have no row, as they depend on the request, but the picker names any that change the item. Deleting an addon removes its lines, and those of its catalogs, from every variant, and removing a user removes their own variant unless something else uses it.


Syntax

One instruction per line. # starts a comment, except inside a string. Object and array literals may span lines.

# Two comments and a multi-line literal
set resultLimits.global = 10

merge deduplicator = {
  "enabled": true,
  "keys": ["filename", "infoHash"]
}

Instructions

InstructionEffect
set <path> = <value>Assign a value. Missing intermediate objects are created.
merge <path> = { ... }Deep merge. Objects recurse, arrays are replaced, a null member deletes that key.
unset <path>Delete a key, or remove a list element when the path ends in an index or selector.
clear <path>Empty a list or object.
add <path> <value>, ...Append to a list, skipping values already present. Creates the list if absent.
prepend <path> <value>, ...The same, inserted at the front.
insert <path> = <value>, ...Insert at the position the path names. The value lands there and the rest shift down.
insert after <path> = <value>The same, placed just past the addressed entry. insert before spells out the default.
insert <n> after <path> = <value>n places from the addressed entry: 2 after leaves one entry in between. before counts backwards.
remove <path> <value>, ...Remove matching values from a list.
remove <path>With no values, the path itself selects the elements to remove.
enable <path> / disable <path>Shorthand for set <path>.enabled = true / false.
use formatter <name>Load one of your saved formatters.
use variant <id>Apply another variant's instructions at this point.

Values

Strings (single or double quoted), numbers, true, false, null, arrays and objects. Strings support \n, \t, \r, \b, \f, \\, \", \', \/ and \uXXXX.

set addonName = "Living room"
set resultLimits.global = 10
set excludeUncached = true
set sortCriteria.global = [
  { "key": "cached", "direction": "desc" },
  { "key": "resolution", "direction": "desc" },
  { "key": "size", "direction": "desc" }
]

Paths

A path starts with a configuration field and drills in.

SegmentMeaningExample
.nameAn object propertydeduplicator.enabled
[0]A list element by positionsortCriteria.global[0]
[-1]Counting back from the endsortCriteria.global[-1]
[*]Every element of a listpresets[*].options.timeout
[*]Every value of an objectsortCriteria[*]
[key=value]Elements whose property equals a valueservices[id=realdebrid]
[key!=value]Elements whose property does not equal itpresets[type!=torrentio]
[key*=value]Elements whose property contains the textrankedRegexPatterns[name*="german"]
[key!*=value]Elements whose property does not contain itpresets[type!*=torrent]
[=value]Plain list entries, comparing the entry itselfexcludedKeywords[="cam"]

A selector may match several elements, in which case the instruction applies to all of them: disable presets[type=torrentio] disables every Torrentio instance.

Selectors written back to back narrow the same list, so an element has to match all of them: presets[type=torrentio][options.name*="4K"] is only the Torrentio instances with 4K in their name.

[*] is the one segment that also walks an object, which is how a single instruction reaches all twelve sort lists: add sortCriteria[*] { "key": "resolution", "direction": "desc" } appends the same criterion to every one you have defined. The [key=value] selectors stay list-only, since an object's members are addressed by name.

= and != are exact and case-sensitive. *= and !*= are substring matches that ignore case.

Lists of plain values

Drop the key to compare the entry itself. This is how you reach a list of strings such as excludedKeywords or syncedExcludedRegexUrls, where there is no property to name. All four operators work this way:

remove excludedKeywords[="CAM"]           # exactly "CAM", nothing else
remove excludedKeywords "CAM"             # the same thing, shorter
remove excludedKeywords[*="cam"]          # "CAM", "hdcam" and "camrip"
remove syncedExcludedRegexUrls[*="example.com"]   # every URL from one host

Targeting one entry in a list

Different lists identify their entries differently.

Services are keyed by id:

set services[id=realdebrid].credentials.apiKey = "SECOND_ACCOUNT_KEY"

Addons have an instanceId, but it is a short random hex string such as 8ae, so prefer the addon's type or the name you gave it. A selector key may be dotted, which is how you reach the name inside options:

disable presets[type=torrentio]                  # every Torrentio instance
disable presets[options.name*="4K"]              # by the name shown in the UI
disable presets[instanceId=8ae]                  # one exact instance

The editor autocompletes real instance ids after presets[instanceId=, annotated with each addon's name.

Catalogs are keyed by id and type together, since one addon can offer the same catalog for movies and for series. Their ids start with the addon's instance id:

disable catalogModifications[id="8ae1a2b.top"][type=movie]

Ranked regex patterns and regex overrides carry a name:

set rankedRegexPatterns[name="HDR boost"].score = 500
remove rankedRegexPatterns[name*="cam"]

Stream expressions have no name field. Their display name lives inside the expression as a /* Name */ comment, so match the expression text with *=:

disable excludedStreamExpressions[expression*="Low seeders"]
disable rankedStreamExpressions[expression*="4K bonus"]

Position works too, but breaks when you reorder the list:

disable excludedStreamExpressions[0]

enable and disable set an enabled property. regexOverrides and selOverrides use an inverted disabled flag instead, so for those write set regexOverrides[name*="hdr"].disabled = true.


Inserting at a position

add and prepend reach the ends of a list. insert reaches everywhere else: the path names a position and the value lands there.

insert excludedStreamExpressions[5] = { "expression": "/*No 4K*/ resolution(streams, '2160p')", "enabled": true }

The new entry becomes number 5 and the one that was there moves down. insert before is the same instruction spelled out.

Positions shift when you reorder the list, so anchor on a selector instead. after is usually the one you want, because it reads in the order the list runs:

insert after excludedStreamExpressions[expression*="Torbox Download Limit"] = {
  "expression": "/*No 4K*/ resolution(streams, '2160p')",
  "enabled": true
}

Count further out when the neighbouring slot is not the place you mean. 1 after is the slot next to the entry, 2 after leaves one entry in between, and before counts the same way backwards. A count has to say which way it goes, so it always comes with one of the two keywords:

insert 2 after excludedStreamExpressions[expression*="Torbox Download Limit"] = { "expression": "/*No 4K*/ resolution(streams, '2160p')" }
insert 3 before rankedStreamExpressions[expression*="4K bonus"] = { "expression": "visualTag(streams, 'HDR')", "score": 100 }

[-1] keeps the meaning it has everywhere else, the last entry, so the two ends are:

insert excludedKeywords[0] = "cam"          # the front, where prepend puts it
insert after excludedKeywords[-1] = "cam"   # the end, where add puts it

Several values at once keep their order. Unlike add, nothing is skipped for being already present, which is what makes moving an entry work:

unset excludedKeywords[="cam"]
insert excludedKeywords[0] = "cam"

An insert names one place per list. A path that reaches several lists, such as presets[*].options.<list>, writes one value into each of them, the way add does. Within a list, a selector that matches several entries uses the first and says so in the editor preview. A position that falls outside a list is skipped rather than clamped, and a trailing [*] is rejected.


Recipes

Phone on mobile data. Cap the file size, skip 4K, return fewer results.

set addonName = "AIOStreams (Phone)"
set excludedResolutions = ["2160p", "1440p"]
set size.global.movies = [0, 8000000000]
set size.global.series = [0, 3000000000]
set resultLimits.global = 10

A second debrid account.

set services[id=realdebrid].credentials.apiKey = "SECOND_ACCOUNT_KEY"

Instant playback only. Cached results, no failover attempts.

set excludeUncached = true
set failover.enabled = false

A different formatter. use formatter copies one of your saved formatters into the custom slot and switches to it, so a whole template never has to go in the script.

use formatter "Minimal"

Or pick a built-in one:

set formatter.id = "minimalisticgdrive"

Debugging. Show the statistics streams and stop hiding addon errors.

set hideErrors = false
set statistics = { "enabled": true, "position": "top", "statsToShow": ["addon", "filter", "timing"] }

Only a couple of addons.

disable presets[*]
enable presets[type=comet]
enable presets[type=easynews]

A different language. Language values are the ones the filter menu offers, plus Original, Dual Audio, Multi, Dubbed and Unknown.

set requiredLanguages = ["German", "Multi", "Dual Audio"]
prepend preferredLanguages "German"

A different language, with confirmed media info only.

set requiredLanguages = ["German"]
add excludedStreamExpressions { "expression": "mediaInfoQuality(streams, 'unknown')", "enabled": true }

Reordering the sort. Put resolution above cache status for a machine that does not mind waiting.

set sortCriteria.global = [
  { "key": "resolution", "direction": "desc" },
  { "key": "cached", "direction": "desc" },
  { "key": "streamExpressionScore", "direction": "desc" },
  { "key": "size", "direction": "desc" }
]

Or slot one criterion into second place in every sort list you have defined, leaving the rest of each list alone:

insert sortCriteria[*][1] = { "key": "resolution", "direction": "desc" }

Layering. Either reference one variant from another:

use variant phone
add excludedVisualTags "3D"

or keep them independent and combine at install time with /v/phone,no3d. Either way, order decides which one wins where both write to the same field.


Limits

Operators can tune these; defaults shown. Scripts have no length limit of their own: the configuration as a whole is capped by MAX_JSON_BODY_SIZE.

LimitDefaultEnvironment variable
AvailabilityeveryoneVARIANT_ACCESS (all, trusted, none)
Variants per config50MAX_VARIANTS
Work per request100000MAX_VARIANT_WORK
Health checks: availabilityeveryoneHEALTH_CHECK_ACCESS (all, trusted, none)
Health checks per config5MAX_HEALTH_CHECKS
Shortest health check interval60sHEALTH_CHECK_MIN_TTL
Longest health check timeout10000msHEALTH_CHECK_MAX_TIMEOUT
Health check response read64KBHEALTH_CHECK_MAX_BYTES
Private health check URLsallowedALLOW_PRIVATE_URLS

Behaviour notes

  • An instruction that matches nothing is skipped and only logged. Remove Real-Debrid later and a variant that swapped its credentials keeps working instead of breaking every install URL you handed out. The editor preview shows these as warnings while you write.
  • Syntax errors and forbidden fields are rejected on save, so they never reach a live request.
  • A variant's name is a label for the configuration UI and the install page. Use set addonName to change what your client shows.
  • Variants are not inherited from a parent config: they reference this config's own addon ids and saved formatter names. Neither are health checks.
  • Health check URLs cannot reach private addresses when the instance owner has turned those off, and can never point back at the instance itself.

On this page