跳到內容

Plugin reference

這一頁暫時只有英文版。

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.

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.

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.config as a string, trimmed.
  • A field left empty is not there, so yonto.config.x || FALLBACK is how to read one.
  • A bool is the string 'true' or 'false', and 'false' is truthy, so compare it: yonto.config.x === 'true'.
  • A required field left empty is refused before the first call, naming the field.
  • A choice lists its options.
  • lint refuses a provides: "source" plugin with a required field and no default, 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.

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.

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[].codec is h264, hevc, vp9 or av1; audio is from aac, mp3, ac3, eac3, dts, truehd, flac, opus, vorbis and alac; containers from mp4, mkv, webm, ts and hls; subtitles from srt, vtt and ass. 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 its containers whose video is one of its video, decoded in the same hardware within video[]’s sizes, whose sound is one of its audio, and whose picture is SDR or one of its hdr, the ranges it shows. It shows no Dolby Vision, so hdr never 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.
  • hdr is what the screen shows: hdr10, hdr10plus, hlg or dolbyVision. 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’s offset), 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 deviceProfile drops 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 every reportPlayback about 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.

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.

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.

  • A call’s own JavaScript may run for 20 seconds in all; time spent waiting on yonto.fetch or yonto.sleep does not count. Once 20 seconds have passed, no new yonto.fetch, yonto.sleep or yonto.store call 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 bodyBase64 in 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 *.local addresses for every plugin, whatever its manifest says. The one exception is an address a person typed into a url field.

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.