# Myspoti extensions: API for authors An extension is a small JavaScript file plus a `manifest.json`. Myspoti runs it in a sandbox. What it may do is a list of **actions** from a closed catalogue the app defines (section 4): provide lyrics, their romanization or song details, add Home sections, search results, menu entries or player buttons (as native blocks the app draws itself, section 3), give a song's audio or download files, show its own web page, sign in on a website, react to the song playing, and, if the user allows it, read or change the signed-in Spotify account. Each action has a fixed icon, a short label, a level (normal or critical) and the API it unlocks; the user sees them as icons, each with your reason, before installing. This page documents what the build ships (API version 1). Nothing here is a promise about anything else. Look-only extensions (colours, hidden parts, tab order, text size, fixed Home blocks, all declared in the manifest and applied by the app) are described in [UI-LOOKS.md](UI-LOOKS.md). Feature-gated media transforms and concurrent source resolution are described in [SOURCE-PIPELINES.md](SOURCE-PIPELINES.md). Providers and recording matching remain extension responsibilities; the host keeps its existing safe media transport. ## 1. A minimal extension `manifest.json`: ```json { "id": "io.github.you.lyrics", "name": "My Lyrics", "version": "1.0.0", "author": "You", "description": "Lyrics from example.com.", "actions": [ { "name": "lyrics.provide", "why": "Synced lyrics from example.com" }, { "name": "network", "hosts": ["lyrics.example.com"], "why": "Asks example.com for the lyrics" } ] } ``` `main.js`: ```js myspoti.provide('lyrics', function (track) { var r = myspoti.http.get('https://lyrics.example.com/api?' + myspoti.query({ artist: track.artist, title: track.title })); if (!r.ok) return null; // null: "I have nothing", the app tries its other sources var lines = r.json().lines; // [{ ms: 1200, text: "..." }, ...] return { provider: 'example.com', lines: lines.map(function (l) { return { time: l.ms, text: l.text }; }) }; }); ``` Package it (a zip with the manifest and the script at its root, nothing else but the optional files below): ``` myspoti-ext pack ./my-lyrics # writes io.github.you.lyrics-1.0.0.zip, prints its sha256 ``` A package may hold `manifest.json`, the `main` script, `icon.png` (or the PNG named by the manifest's `icon`), `LICENSE`, `LICENSE.txt`, `README.md` and nothing more (any other file refuses the package). At most 30 files, 2 MB for the zip, 1 MB for the script, 256 KB for the icon. The zip is built the same way every time (fixed order and dates), so anyone can rebuild it and compare the sha256. How an extension reaches people: its own GitHub repository, found by the store through the topic `myspoti-extension` (section 9), the official repository's optional list of verified extensions or a repository the user adds at their own risk. A repository is a folder of packages and an `index.json` (`myspoti-ext index ` writes it). The index can also list extensions every Myspoti must turn off (`blocked`: an id, optionally some package sha256, a reason). An index entry is the manifest's fields (`id`, `name`, `version`, `versionCode`, `apiVersion`, `author`, `description`, `actions` (or the old `provides` and `permissions`), `homepage`, `replaces`, `conflicts_with`) plus `repo` (optional: `owner/name` on GitHub, the package is then read from there as in section 9 and kept updated from there; `sha256`, when not empty, is checked against the release's package or the package built from the archive) and the package's `url` (absolute or relative to the index), `sha256` and `size`, and these optional store fields (an older Myspoti ignores them, an index without them still reads): | Field | Type | Shown | |---|---|---| | `icon_url` | string | The icon before download: a PNG, absolute or relative to the index (`myspoti-ext index` writes `icons/.png`). `icon`, the older name, is still read. | | `updated_at` | string | ISO 8601 date the version was listed (`2026-10-23`); `index` keeps it while the package's sha256 is unchanged. | | `category`, `tags`, `changelog` | | As in the manifest. | | `min_app_version` | string | As the manifest's `minAppVersion`: the row says "Requires Myspoti X or newer" and its Install button is off. | In the app, Extensions has two tabs. **Store**: each repository with its trust note (verified by the owner, or added by the user), a search field and category chips, and one row per extension (icon, name, version, one line, its actions as icons, critical ones in the warning colour, one button: Install, Update, Installed; "Incompatible with X (installed)" when `conflictsWith` or `replaces` meets an installed one). A row opens the store page (description, changelog, tags, each action's icon, label and your `why`, repository, sha256) before anything is downloaded; Install then shows the install prompt: name, author and source, every action with your `why` (critical ones marked), what it collides with (the user picking which to keep, section 4.2), the third-party notice ("Third-party extension, not reviewed by Myspoti, under its author's responsibility.") and one box, "I understand this extension is made by a third party and I install it on my own responsibility", which enables Install. There is no per-action choice: an extension gets every action it lists, or is not installed. The official repository is suggested, never added without the user. **Installed** is organised **by area**: Playback, Lyrics & song info, Audio & downloads, Home & search, Menus & player, Library & Spotify, Pages & more. Each area opens with a **Built-in** row (a dropdown of what Myspoti does there by itself, e.g. Lyrics: Spotify, LRCLIB, NetEase, KuGou, Lyrics+, Genius; built-in sources are not extensions), then the extensions acting there with their switch and action icons, in the user's order (arrows). A newly installed extension is placed first in each area and in the audio and download source orders it gives; an update keeps the user's order. That order is the one the area asks them in (section 3) and the one the playback arbiter uses. The Built-in row says in one plain sentence what the app already does there; an area no extension acts in says "No extension — the app already does: …". Nothing is ever installed without the install prompt, and the samples in `docs/extensions/samples` are for authors only. Audio and download sources are ordered in Settings (Playback, Downloads). Under each area, the last things extensions did there ("Lyrics of X from Y", "Skipped by Z") with a one-tap Turn off. ## 2. Manifest `manifest.json` at the root of the package. Unknown keys are ignored. | Field | Type | Default | Rule | |---|---|---|---| | `id` | string | required | Lower case, reverse-domain, at least two parts: `^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$`. Never changes between versions. | | `name` | string | required | 1 to 40 characters. | | `version` | string | required | 1 to 20 characters, shown to people (`"1.2.0"`). | | `versionCode` | int | `1` | 1 or more. Higher is newer; this is what is compared. | | `apiVersion` | int | `1` | Between 1 and 1 (the version this Myspoti speaks). | | `author` | string | required | 1 to 60 characters. | | `description` | string | `""` | 500 characters at most. | | `homepage` | string | none | Not checked. | | `main` | string | `"main.js"` | A `.js` file at the root of the package (no `/`, no `\`). | | `actions` | object[] | required | What it does, from the catalogue of section 4: `{ "name", "why", "hosts"? }`. At least one, each once. | | `settings` | object[] | `[]` | Options the user sets on the extension's page. A key at most once. Needs the `settings.page` action. | | `replaces` | string[] | `[]` | Extension ids this one takes the place of (10 at most): installing it offers to turn them off. | | `conflictsWith` | string[] | `[]` | Extension ids that must not run with this one (10 at most): the user picks which to keep. Either side declaring it is enough. | | `icon` | string | none | A `.png` at the root of the package (square, 256 px or less). Without it, `icon.png` is used if the package has one, else a tile with the name's first letter. | | `category` | string | `other` | Where the store files it, one of a fixed list: `lyrics` (needs `lyrics.provide`, `lyrics.romanize` or `trackinfo.provide`), `playback` (Playback & audio: a `playback.*`, `audio.source` or `player.buttons` action), `library` (`library.read`, `library.write` or `playlists.write`), `discovery` (`home.sections`, `search.provide` or `spotify.catalog`), `stats` (`playback.events` or `library.read`), `look` (Look & feel: a blocks action, `webview.page` or `ui.style`), `social` (`network`, `library.read` or `spotify.catalog`), `tools`, `other`. A category the actions do not fit, or an unknown one, is filed under `other` (`myspoti-ext check` says so); an extension with `download.source` is always under `tools`. `tags` are shown and found by the search, not filtered on. | | `tags` | string[] | `[]` | 8 at most, each 1 to 24 characters: lower case letters, digits, spaces, `-`. The store's search finds them. | | `minAppVersion` | string | none | The oldest Myspoti it works on (`"2.0.2"`, `"2.0.2-beta.4"`), compared like semantic versions (a release is newer than its betas). An older Myspoti shows "Requires Myspoti 2.0.2 or newer" and does not install it. | | `changelog` | string | `""` | What changed in this version, 2000 characters at most: shown on the store page and before an update. | | `ui` | object | none | The look the extension gives the app, with the `ui.style` action: see [UI-LOOKS.md](UI-LOOKS.md). Ignored without that action (and by an older app). | | `welcome` | object | none | `{ "message": "...", "steps": ["...", "..."] }`, shown once right after the first install (not on updates): what the extension does (600 characters at most) and what is left to do (5 steps at most, 160 characters each). Older apps ignore it. | | `translations` | object | `{}` | The texts people see, in other languages, by language tag (`"fr"`, `"pt-BR"`; 80 at most). Below. Older apps ignore it. | `translations` lets the store, the install prompt and the extension's page speak the user's language, with no action to ask. Each language may give any of `name`, `description`, `changelog`, `welcome`, `actions` (an action's name → its reason, 80 characters) and `settings` (a setting's key → `{ "label", "description", "options" }`, `options` being the labels of a choice's options in their order). What a language leaves out stays the manifest's own text. Myspoti shows the language picked in its settings (the phone's when it follows the phone): `pt-BR` takes `pt-BR`, then `pt`, then another `pt-…`, then the manifest's own texts. A choice's values do not change: `myspoti.settings.get('quality')` still gives `"lossless"` when the user ticked "Sans perte". The reasons the user accepted are weighed on the manifest's own `why`, so a new translation never asks the user again. An action or a setting the manifest does not have, or a number of option labels that is not the number of options, refuses the package. ```json "translations": { "fr": { "description": "Des paroles synchronisées d'example.com.", "actions": { "lyrics.provide": "Paroles synchronisées d'example.com" }, "settings": { "quality": { "label": "Qualité", "options": ["Sans perte", "Haute"] } } }, "pt-BR": { "description": "Letras sincronizadas do example.com." } } ``` Texts your script makes (blocks, outcomes, `notify`, your page) follow `myspoti.locale` (section 3), also with no action to ask. A sign-in page opened with `myspoti.auth.open` is asked in the same language (`Accept-Language`), so a site that has it shows it. An action entry: `{ "name": "...", "why": "...", "hosts": [...] }`. - `name` is an action key (section 4). Unknown names, or a name twice, refuse the package. - `why` is **required for every action**, 1 to 80 characters, in your words: it is shown next to the action's icon in the install prompt and on the store page. A missing or longer `why` refuses the package. - `hosts` is for `network` only, and `network` needs at least one. Each host is lower case: a name (`api.example.com`), `*.example.com` (the domain itself and every subdomain), or `*` (any host, a critical action shown as "Use any website"). Names have at least two labels: no port, no scheme, no path, no `localhost`. **Old manifests** (before actions: `provides` + `permissions`) still load and install, mapped to actions (section 4.1); `myspoti-ext check` says so. `actions` cannot be mixed with them. A setting: `{ "key", "type", "label", "description", "default", "options", "section"?, "role"?, "perNetwork"? }`. - `key`: letters, digits, `_`, 1 to 40 characters. - `type`: `text` (default), `secret` (a password field, never logged), `toggle`, `number`, `choice`, `button`. - `label`: required. `description`: optional. `default`: any JSON value. `options`: strings, required (non-empty) for `choice`. - `button`: an action, not a value. It has no `default` (the check refuses one) and no value, so `myspoti.settings.get` gives `undefined`. It is shown as a button with its `label` and `description`. Pressing it sends the `setting` event with `pressed: true` (section 3). Typical uses: pair again with a server, clear a cache, sign in again. - Read it with `myspoti.settings.get(key)`: the user's value, else `default`, else `undefined`. - `section` (optional): `"playback"` or `"downloads"`. The setting is also shown in the app's Settings → Playback or Settings → Downloads, in a card headed " settings", drawn as on your page (same value: changing it in one place changes it in the other). Only the **first source** of the user's order shows its settings there: - Playback: the first source of the audio order (section 3, `audio-source`), when it is your extension, above Spotify, and turned on. Spotify's own Wi-Fi and mobile data quality then leave that screen (they keep their value, and Spotify still uses it for the songs it plays); moving Spotify back to the top brings them back and your settings stay on your page only. - Downloads: the first extension of the download order, turned on. The app's own download quality leaves that screen only for your `role: quality` setting; the app still sends its own value as `quality` to every extension in `download-source`. An extension below the first one, turned off or not installed shows nothing there. Without `section`, a setting is on your page only, as always. An unknown value is ignored (your page only); `myspoti-ext check` warns. A section is only ever shown for an extension that asks the matching action (`audio.source`, `download.source`). - `role` (optional): `"quality"`, on a `choice` with a `section`, one per section at most: your extension's quality. Shown first-class in that section (in the place of the app's), and its value when the song was played or downloaded is cited in "Why this source?" and in Stats for nerds ("Quality: FLAC", your label and option). Any other value is ignored (check warns). - `perNetwork` (optional, `true`; for `choice`, `toggle`, `number`, `text`): one value on Wi-Fi and one on mobile data, both shown ("