Plugin reference
這一頁暫時只有英文版。
The file
Section titled “The file”A plugin is one .js file.
Its manifest is a comment that opens the file, and is everything up to the first comment terminator.
Yonto Player reads the manifest as text and never runs the file to learn it, which is why a permission lives there and not in an export.
The file is an ES module and may await at the top level.
It must not read yonto while it is being evaluated, so read it inside a function.
The manifest
Section titled “The manifest”| Key | |
|---|---|
kind |
"content-source". |
id |
2 to 32 characters: lowercase letters, digits and hyphens, starting with a letter or digit. Installing a plugin with the id of a built-in one replaces it, and removing it brings the built-in back. |
name, version |
What the viewer sees. version is three numbers, 1.2.3, and a new build needs a new version. |
contractVersion |
The newest part of the plugin interface the plugin uses. lint works it out: declaring less is refused, and declaring more is warned about, since it would turn away apps that could run the plugin. |
provides |
"source" is a plugin that is one source, and installing it switches to it. "source-type" is a kind of source a viewer configures, and appears in Add source; it needs a configSchema. |
allowedHosts |
The hosts the plugin may reach, as bare hosts. A *. entry matches the subdomains. An entry that cannot be a host is refused by lint. |
configSchema |
The fields of the Add source form, below. |
description, probeQuery |
A sentence for the install dialog, and a word doctor searches for. |
deviceProfile |
true hands getStream what the device plays, so your server can convert what it cannot (below). It has no contract version, and lint refuses it without a getStream export. |
A key lint does not know is refused, and the message names the nearest key it does know.
A television ignores a key it does not know, so a plugin written for a later interface still installs.
Settings
Section titled “Settings”Each entry of configSchema has an id, a label and a type, and may be required or have a default.
The types are text, secret, url, choice and bool.
- Every answer arrives in
yonto.configas a string, trimmed. - A field left empty is not there, so
yonto.config.x || FALLBACKis how to read one. - A
boolis the string'true'or'false', and'false'is truthy, so compare it:yonto.config.x === 'true'. - A
requiredfield left empty is refused before the first call, naming the field. - A
choicelists itsoptions. lintrefuses aprovides: "source"plugin with a required field and nodefault, since a source that asks nothing has no question.
The host of a url field the viewer fills in is allowed in addition to allowedHosts, which is how a plugin for a server the viewer owns can leave allowedHosts empty.
hostsFromConfig turns the list off entirely.
It is for plugins whose whole job is to read addresses the viewer supplies, and a plugin that declares it asks the viewer to confirm first.
Do not use it to avoid writing a host down.
The functions
Section titled “The functions”Four are required, and lint refuses a plugin without them.
| Function | Called with | Answers |
|---|---|---|
getCategories() |
nothing | [{ id, name }], the tabs along Home. |
getMediaList(categoryId, { page, filters, cursor }) |
the category’s id, then one options object: page counts from 1, filters maps a filter id to the chosen option id, cursor is only there if you handed one out |
A list of summaries, or { items, nextCursor } when the source pages by cursor. An empty list ends the listing. |
getMediaDetail(id) |
an id you handed out |
A detail: the summary plus synopsis, genres and playbackOptions. |
search(query) |
the query, verbatim | A list of summaries. |
A category’s first page with no filters is its shelf on Home, so list it latest first where your source can: what was added or updated last, a series by its newest episode.
To offer other orders, add a filter group whose init is that order, and answer a listing that names no choice for the group as its init, since Home names none.
A summary is { id, title } and may have type, posterUrl, backdropUrl, year and rating, all strings, and ids.
ids is the title’s identity in the public databases, { tmdb, imdb, tvdb }, each optional and a string: digits, or tt and digits for imdb.
Give the ones your source knows, since two sources listing the same work can then be told apart from two works that share a name; a TMDB id is unique only within a type.
The app drops a value of any other shape.
type is a plain string, and an unrecognised one falls back to a safe default, so write "movie" and not "MOVIE".
A playback option is { label, stream: { url, mimeType, headers, live } }.
mimeType is application/x-mpegURL for HLS, video/mp4 for a file as stored that claims no container, or a container’s own type such as video/x-matroska where your source knows it and the address does not say, so a player that cannot open it hands it on at once.
Set live: true on a stream with no end, a television channel: the app saves no place in it, draws no timeline or speed, and starts it again if it ends or drops.
It also treats a stream whose player reports no length as live, so the key is for what a player cannot tell, a looped channel that is a short file ending every minute.
An app older than contract 22 ignores the key and plays the stream as a title with a length, so declare 21 to run there too, or 22 to require it.
Instead of stream it may carry pan, a cloud-drive share that the app redeems, or track, a string you will be handed back in getStream.
A summary, a detail and a playback option may also say where the viewer is in the title, as the server knows it: played (a boolean), position (seconds, at least 0) and playedAt (an RFC 3339 date-time).
Leave them out when the server does not say, and send played: false only when it says not watched.
A playback option may carry an id, the server’s own id for it, 1 to 512 characters, which the app sends back in a playback report.
A stream may carry subtitles, at most 32 of { url, label, language, mimeType, forced, default }: url and label are required, language is a BCP 47 tag, and mimeType is application/x-subrip, text/vtt or text/x-ssa.
A subtitle is fetched with the stream’s headers and has none of its own, and its URL must be on the stream’s host or a host in allowedHosts, or it is dropped.
An app older than contract 23 ignores all of these, so a plugin that only sends them can declare 21 or 22 and run there too, or 23 to require them.
A stream may also carry segments, at most 32 of { type, start, end }: type is intro, recap, credits, preview or commercial, and start and end are seconds.
start is required; leave end out when the segment runs to the end of the video.
The app offers to skip an intro, a recap or the credits, and drops a segment with an unknown type, a negative start or an end that is not after its start.
An app older than contract 24 ignores segments, so a plugin that only sends them can declare 21 to 23 and run there too, or 24 to require them.
A playback option of a series may carry season and episode, both integers: season heads the title page’s sections, and with both the app can ask a skip-times database for the intro and credits of an episode your source doesn’t mark.
Send the numbers the server keeps, and leave episode out rather than guess it; the label still says which episode it is.
An app older than contract 25 ignores episode, so a plugin that only sends it can declare 21 to 24 and run there too, or 25 to require it.
A stream may also carry session, a string of 1 to 2048 characters naming this one play on your server, which the app hands back as session on every reportPlayback about it (below).
An app older than contract 26 drops it and reports as it did, so a plugin that sends one can declare 23 and run there too.
A playback option may carry video, what the copy it plays is as stored: width and height in pixels and range, one of sdr, hdr10, hdr10plus, hlg and dolbyVision, each optional.
The title page shows it after the title’s facts (4K, Dolby Vision): a film the copy Play plays, a series the largest picture its episodes have.
Send only what your source says, and the file as stored, never what a device ends up playing; the app drops a size past 16384 or a range it doesn’t know.
An app older than contract 27 ignores video, so a plugin that sends it can declare 23 and run there too.
These are optional.
doctor says which a plugin has.
| Function | What it is for |
|---|---|
getFilters(categoryId) |
Groups of filters for a category. Do not export one that returns []; a source with no filters just has none. |
getRecommendations() |
The featured titles on Home. |
checkHealth() |
A status line for Settings. Without it, a source is healthy if getCategories answers. |
getImageHeaders() |
Headers to send with poster requests. |
getSubSources() |
When one source is several libraries, a picker. |
getStream(token, request) |
Redeem a track. request is { device, start? }, only for a deviceProfile plugin and only when the app is about to play. |
getContinueWatching() |
The server’s own continue-watching list, { items: [summary plus optionId] }, where optionId is the id of the option to resume. Contract 23. |
reportPlayback(report) |
Tells the server where the viewer is: { mediaId, optionId, event, paused, position, duration, finished, session }, with event one of start, progress and stop, and session the played stream’s own, when it had one (contract 26). Answers nothing. Contract 23. |
setPlayed(change) |
Marks a title watched or unwatched on the server: { mediaId, optionId, played }. Answers nothing. Contract 23. |
reportPlayback and setPlayed write to the viewer’s real server, so doctor never calls them, while it does call getContinueWatching.
A plugin that exports any of the three declares contract 23, and an app older than that will not install it.
A source that cannot search still exports search, and throws yonto.error.noSearch() from it (contract 28): the app says the source has no search, in the viewer’s language, and offers no Retry.
What the device plays
Section titled “What the device plays”A media server can convert a file the device cannot play, as its own apps ask it to: a 4K file for a 1080p box, or HDR tone-mapped for a screen without it.
A plugin whose manifest says deviceProfile: true is handed what the device plays as getStream’s second argument, and only when the app is about to play the stream:
// getStream(track, { device }), where device is:{ video: [{ codec: 'h264', maxWidth: 1920, maxHeight: 1080, maxBitDepth: 8 }, { codec: 'hevc', maxWidth: 3840, maxHeight: 2160, maxBitDepth: 10 }], audio: ['aac', 'mp3', 'ac3', 'eac3'], maxAudioChannels: 8, containers: ['mp4', 'mkv', 'webm', 'ts', 'hls'], hdr: ['hdr10', 'hlg'], subtitles: ['srt', 'vtt', 'ass'], softwarePlayer: { containers: ['mkv'], video: ['h264', 'hevc'], audio: ['aac', 'ac3', 'eac3', 'dts', 'truehd', 'flac'], hdr: ['hdr10', 'hlg'] },}video[].codecish264,hevc,vp9orav1;audiois fromaac,mp3,ac3,eac3,dts,truehd,flac,opus,vorbisandalac;containersfrommp4,mkv,webm,tsandhls;subtitlesfromsrt,vttandass. Ignore a value you do not know, since a new one comes without a version.- The lists are what the device plays in hardware, never what a software player could struggle through, so the server converts a file a box would stutter on.
softwarePlayer, where present, is what the device’s software player also plays as stored: a file in one of itscontainerswhose video is one of itsvideo, decoded in the same hardware withinvideo[]’s sizes, whose sound is one of itsaudio, and whose picture is SDR or one of itshdr, the ranges it shows. It shows no Dolby Vision, sohdrnever names it: leave a Dolby Vision title, or an HDR one it does not name, to the server. The Apple apps send it for VLCKit.hdris what the screen shows:hdr10,hdr10plus,hlgordolbyVision. An empty list is an SDR screen, which a server answers by tone-mapping.start, where present, is where the play starts, in seconds: the viewer’s saved place, which the player seeks to. Hand it to a server that can begin its conversion there (Plex’soffset), so it does not convert from 0 and then again from the place.- Without the second argument, answer the file as stored. The app calls with the track alone for a download, for a download’s size, and when the viewer turned Let the server convert videos off for the source. An app too old to know
deviceProfiledrops the key and does the same, which is why the key has no contract version. - A stream the server decided carries
session, your own name for that one play. The app hands it back verbatim on everyreportPlaybackabout it, so the server can end its conversion on the stop, and keeps it for that play only. - Anything the server’s decision cannot answer plays the file as stored: a decision that fails is never worse than not asking.
- The key is declared so that no other plugin learns the device’s decoders. Converting is the viewer’s own server’s work, on its own settings, and nothing passes through anybody else.
The yonto global
Section titled “The yonto global”A plugin reaches the host through yonto and declares contract 21: no app runs a plugin declaring less.
yonto.config |
The viewer’s settings, an object of strings. |
yonto.fetch(url, { method, headers, body, encoding, redirect }) |
Resolves with { status, url, headers, body, bodyBase64 } for every status. It rejects only when it has no answer, with a code: REQUEST_INVALID, HOST_NOT_ALLOWED, REDIRECT_REFUSED, TIMEOUT, RESPONSE_TOO_LARGE or REQUEST_FAILED. |
yonto.text.decode(…) |
Decode bytes that are not UTF-8, such as GBK, since there is no TextDecoder. |
yonto.html.load(markup), yonto.xml.load(markup) |
cheerio’s $ over a page or an XML document. |
yonto.store.get / set / remove / clear |
A cache kept for this source. set(key, value, ttlSeconds). |
yonto.now() |
The clock. Use it, and never Date.now(), for elapsed time. |
yonto.sleep(ms) |
There is no setTimeout. |
yonto.log(message) |
A log line. |
yonto.partial(reason) |
Says the answer you are about to return is incomplete, such as a search that reached three of four sites. The app shows the sentence under the result. |
yonto.installId(), yonto.subSource() |
Which install this is, and the library the viewer picked. |
yonto.crypto, yonto.encoding |
md5, sha1, sha256, hmacSha256, aesCbcDecrypt, and base64 and hex conversions, with strings in and strings out. |
yonto.cryptoJs(), yonto.jsEncrypt() |
The whole CryptoJS or JSEncrypt library, for a site that needs more. They are slow to start and not secure random, so never make a key with them. |
Requests follow the WHATWG Fetch rules.
A GET with a body, credentials in the URL, and headers the host owns, such as Host or Content-Length, are refused before anything is sent.
Cookie, Origin and Referer you may set.
Errors
Section titled “Errors”Throw one of these, and the app shows the right screen:
| Means | |
|---|---|
yonto.error.notFound(message) |
The title does not exist. |
yonto.error.unauthenticated(message) |
The server refused the credentials the viewer gave. |
yonto.error.unauthenticated(message, { signIn: false }) |
The same, when the app runs a login for your plugin and that login would not help, such as a token the viewer typed for their own server while signed out: the app shows your sentence and offers no login for it. |
yonto.error.misconfigured(reason) |
A field of your own form is blank or wrong, and no retry could help. |
yonto.error.unreachable(reason) |
The server did not answer. The app may rest it. |
yonto.error.noSearch() |
From search: the source has no search at all, so no retry would help. Contract 28. |
yonto.error.unavailable(reason) |
Anything else that went wrong. |
reason and message are shown to the viewer verbatim, in the language of the site you read, since nothing translates them.
Name the part that failed and not the source, because the app’s headline already says Can't reach <source>.
Keep it to one sentence.
Put a status or a code in yonto.log, and never a URL, a key or what the viewer searched for.
An error thrown with an unknown code, or no code, is a METHOD_THREW, which shows as a plugin fault.
Limits
Section titled “Limits”- A call’s own JavaScript may run for 20 seconds in all; time spent waiting on
yonto.fetchoryonto.sleepdoes not count. Once 20 seconds have passed, no newyonto.fetch,yonto.sleeporyonto.storecall may start, and a call that never finishes is ended at 85 seconds. One source runs one call at a time, so a slow call makes everything behind it wait. Fetch in the call that needs the data, and cache what you can. - A response body is at most 16 MB, and less for a body that is not plain text.
Read
bodyBase64in the call that made the request. - The store holds 256 keys, each up to 1 MiB of JSON, per source.
A write past that rejects with
STORE_REFUSED, so catch it and carry on. - A plugin file is at most 1 MiB to install, and the download has 30 seconds.
- A plugin cannot read a file, open a socket, run a timer, or reach a host the viewer was not told about.
- The private-address floor refuses loopback,
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, link-local and*.localaddresses for every plugin, whatever its manifest says. The one exception is an address a person typed into aurlfield.
The exact definition
Section titled “The exact definition”This page is the short version.
The interface’s full text, with the reasons and every edge case, is contracts/content-source-http.md in yonto-app/yonto-player-plugins, and its schemas are the files beside it.